From d875806a8cf3792a441616090c29c4e128d6d914 Mon Sep 17 00:00:00 2001 From: zichun Date: Thu, 28 May 2026 13:37:22 +0800 Subject: [PATCH] fix: dual-reviewer audit (Claude+Codex) — workflows/lib/skills fixes --- .claude-plugin/plugin.json | 2 +- README.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++----------------------- agents/code-reviewer.md | 30 +++++++++++++++++++++++++----- lib/merge-gitignore.mjs | 22 ++++++++++++++++------ lib/merge-gitignore.test.mjs | 24 ++++++++++++++++++++++++ lib/validate-ddl.mjs | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------- lib/validate-ddl.test.mjs | 252 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- skills/coding-start/SKILL.md | 14 +++++++------- skills/downstream-gen/SKILL.md | 30 ++++++++++++++---------------- skills/downstream-gen/templates/docs-10-header-template.md | 2 +- skills/frontend-scope-lock/SKILL.md | 6 +++++- skills/plan-start/SKILL.md | 33 +++++++++++++++++++-------------- skills/project-init/templates/CLAUDE-template.md | 2 +- skills/project-init/templates/docs-08-initial-template.md | 3 ++- skills/skeleton-gen/SKILL.md | 2 +- skills/skeleton-gen/templates/docs-06-static-template.md | 2 +- skills/skeleton-gen/templates/env-local-template | 4 ++-- workflows/coding.mjs | 1031 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------------------------------------------------------------------------------------------------------- 18 files changed, 1471 insertions(+), 277 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 88063f4..ff22faa 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "erp-workflow", - "description": "ERP 项目全流程框架:阶段 A 计划(交互式 skill 链,9 个 skill + 4 个前移闸门) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag)。", + "description": "ERP 项目全流程框架:阶段 A 计划(plan-start 入口 + A0~A6 共 7 个 skill + B 阶段瘦入口 coding-start = 9 个 skill;plan-start 终结闸 5 项前移硬校验) + 阶段 B 编码(单个静默 Workflow 脚本 coding.mjs,子代理自动跑后端+前端功能循环、测试闸门、本地里程碑 tag)。", "version": "0.2.0", "skills": ["./skills"] } diff --git a/README.md b/README.md index cdb1f36..d934e9a 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 -把"从零到 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 为页面权威)。 +把"从零到 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 为页面权威)。 ## 这个插件做什么 ``` -📋 阶段 A:规划(一次性,交互式 9 skill,入口 /erp-workflow:plan-start) +📋 阶段 A:规划(一次性,交互式;入口 /erp-workflow:plan-start 派发 A0~A6 共 7 个 skill) A0 project-init → A1 scope-lock(结构化 REQ 卡片 + secrets/commands 锁) ↓ @@ -33,12 +33,20 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 coding.mjs Router → 解析 docs/08 § 二/§ 三 + git tag,列出待跑模块 │ - ├─ B-后端(按模块循环,每模块一个里程碑 tag) - │ featureLoop(后端):spec → plan → tdd → verify → review(有界 5 轮修复) - │ testGate(backend) → 跨模块记录 → 模块报告 → milestone(本地 merge --no-ff + tag) + ├─ B-后端(按模块循环,每模块一个里程碑 tag;功能链顺序 for-await,单工作树串行 commit) + │ runBranchSetup(module-) ← JS 编排:detect default → wt clean → exists? → + │ checkout/create → confirm HEAD(5 微 agent) + │ → featureLoop(后端):spec → plan → tdd → verify → review(有界 5 轮修复, + │ throw 自然冒泡到模块主循环 try → fail-fast) + │ → testGate(backend) → runCrossModule(JS 编排:diff → 分类 → 写日志) + │ → reportPrompt(LLM 12 节叙述) + │ → runMilestone(JS 编排:wt → default → 已合入? → merge → 字段当前值? + │ → 写字段 → tag 已存在? → 打 tag → 报告 § ⑫ 当前值? → 替换占位; + │ 10+ 微 agent,全部跳过/分支条件由 JS 判定,幂等) │ └─ B-前端(后端全部打里程碑后,整体 1 个里程碑 tag) - featureLoop(前端,FE-NN,路径限 frontend/) → testGate(frontend) → milestone + runBranchSetup(frontend-phase) → featureLoop(前端,FE-NN,路径限 frontend/) + → testGate(frontend) → runMilestone(milestone/frontend-phase) 子代理无法弹窗 → 缺值即写阻塞点并 halt(终止态,非对话框);fail-fast 后等人工修复重跑 coding-start ``` @@ -73,10 +81,10 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 **`coding.mjs` 的阶段(子代理执行,无弹窗)**: - - **Router**:扫描 docs/08 § 二/§ 三 里程碑字段 + 本地 `git tag -l`,产出结构化模块清单(`{id, done, reqs[], feItems[]}`),过滤出待跑模块。docs/08 字段与 git tag 不一致 → halt 报错(绝不静默假设完成状态)。 - - **后端模块循环**(顶层 `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/` tag + 回写 docs/08 § 二)。 - - **前端阶段**(后端全部打里程碑后):`featureLoop(feItems, 'frontend')`(FE-NN,路径限 `frontend/`,review 调统一 `code-reviewer` agent 附加前端 7 维 checklist)→ `testGate(frontend)` → milestone(docs/08 § 三 整体里程碑)。 - - **halt 终止态**:子代理缺值不弹窗 → 写阻塞点并抛错;整阶段 fail-fast,halt 后停下等人工修复,修好重跑 `/erp-workflow:coding-start` 从断点续跑。 + - **Router**:扫描 docs/08 § 二/§ 三 里程碑字段 + 本地 `git tag -l`,产出结构化模块清单(`{id, done, reqs[], feItems[]}`),过滤出待跑模块。docs/08 字段与 git tag 不一致 → halt 报错(绝不静默假设完成状态)。Router 后做运行时互斥断言(后端模块 `feItems` 必空、`frontend-phase` 聚合模块 `reqs` 必空),契约违例直接 halt。 + - **后端模块循环**(顶层 `for module`,fail-fast):每模块依次 `runBranchSetup(module-)`(**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/` tag + 回写 docs/08 § 二 + 替换报告 § ⑫ 占位;跨重入幂等)。 + - **前端阶段**(后端全部打里程碑后):`runBranchSetup(frontend-phase)` → `featureLoop(feItems, 'frontend')`(FE-NN,路径限 `frontend/`,review 调统一 `code-reviewer` agent 附加前端 7 维 checklist)→ `testGate(frontend)` → `runMilestone`(docs/08 § 三 整体里程碑 `milestone/frontend-phase`)。 + - **halt 终止态**:子代理缺值不弹窗 → 写阻塞点并抛错;整阶段 fail-fast,halt 后停下等人工修复,修好重跑 `/erp-workflow:coding-start` 从断点续跑(`runBranchSetup` / `runMilestone` 内的 read-then-decide 幂等支持续跑)。 `docs/08 § 二` 每后端模块占一行 bullet,`§ 三` 是前端阶段整体段,完成信号统一由本地 `git tag -l 'milestone/'` 判定。 @@ -121,7 +129,7 @@ erp-workflow-plugin/ | Skill | 作用 | 谁调用 | |---|---|---| -| `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` | +| `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` | | `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` | ### Plan 阶段 A skill(7 个 = A0~A6,均由 `plan-start` 按 docs/08 § 一 顺序派发) @@ -129,7 +137,7 @@ erp-workflow-plugin/ | # | Skill | 作用 | 流程中谁调用 | |---|---|---|---| | A0 | `project-init` | • **依赖检查**:检测 git / mysql / node 是否在 PATH,缺失则按 OS(darwin/win32/linux)打印安装指引并 halt(**不自动 brew/apt 安装**)
• 空目录初始化:用 Read/Write/Glob 工具拷模板创建 CLAUDE.md / docs/01/index.md / docs/08
• `git init` | `plan-start` | -| A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引
• 按 `docs/01-需求清单//{_module.md, REQ-*.md}` 子目录结构生成**结构化** REQ 卡片(每字段一行:字段名/类型/必填/校验/业务规则/示例值,示例值必须替换为真实约束)
• **A1 终结校验**:REQ 字段非空且非占位、docs/07 secret/account/包名/namespace 字段清单已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清
• 用 `node ${CLAUDE_PLUGIN_ROOT}/lib/render.mjs` 渲染模板
• **停下**等人工审阅,审阅完毕用 `/plan-start` 续进 A2 | A0 | +| A1 | `scope-lock` | • 引导填项目概述 / 技术栈 / 需求索引
• 按 `docs/01-需求清单//{_module.md, REQ-*.md}` 子目录结构生成**结构化** REQ 卡片(每字段一行:字段名/类型/必填/校验/业务规则/示例值,示例值必须替换为真实约束)
• **A1 终结校验**:REQ 字段非空且非占位、`config-vars.yaml` 配置字段(包名 / 端口 / 初始账号等)+ `secrets_ref` 键名(引用 `.env.local`)已锁、各 stack 的 build/lint/unit/e2e 命令写入 docs/04 § 零;缺失则在此(Plan 期)用 `AskUserQuestion` 问清
• 用 `node ${CLAUDE_PLUGIN_ROOT}/lib/render.mjs` 渲染模板
• **停下**等人工审阅,审阅完毕用 `/plan-start` 续进 A2 | A0 | | A2 | `skeleton-gen` | • 生成架构文档:docs/04 § 一+ / docs/06 / docs/07 / docs/09
• 生成跨平台工具脚本:`scripts/*.mjs`、.env.local(**无 chmod**)
• 创建 `sql/migrations/` 空目录(Flyway 准备)
• 用 `node ${CLAUDE_PLUGIN_ROOT}/lib/merge-gitignore.mjs` 合并 .gitignore(逐行判重) | `plan-start` | | A3 | `db-design-gen` | • A3 起始用 `AskUserQuestion` 确认 ERP 约定(主键策略 / 租户列 / 列前缀规则,默认值可覆盖),结果写 docs/04 + CLAUDE.md
• 从 docs/01 REQ 卡片正向设计 `docs/03-数据库设计文档.md`(schema SSoT)
• 回填 REQ 卡片依赖表(`TBD(A3 自动补)` → 实际表名)
• **停下**等人工审阅 docs/03,审阅完毕用 `/plan-start` 续进 A4 | A2 | | A4 | `db-init` | • LLM 解析 docs/03 → `sql/migrations/V1__initial_schema.sql`(DDL only)
• `node ${CLAUDE_PLUGIN_ROOT}/lib/validate-ddl.mjs` 校验 DDL ↔ docs/03(5 维:表/列名/列类型/索引/FK),fail-closed
• `node ${CLAUDE_PLUGIN_ROOT}/lib/apply-ddl.mjs .env.local V1.sql`(安全 env 解析 + mysql2 apply,**无 shell-source**) | A3 | @@ -145,27 +153,46 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf │ ▼ coding.mjs(各 stage 派 agent 子代理执行,无弹窗) Router ── 解析 docs/08 § 二/§ 三 + git tag → 结构化模块清单(schema 校验) - │ docs/08 字段与 git tag 不一致 → halt + │ docs/08 字段与 git tag 不一致 → halt;运行时断言 reqs/feItems 互斥 │ 顶层 for module(fail-fast,halt 后 break): │ - ├─ 后端:featureLoop(module.reqs, 'backend') + ├─ 后端: + │ runBranchSetup(module-) ← JS 编排(5 微 agent) + │ → featureLoop(module.reqs, 'backend') ← 顺序 for-await │ spec → plan → tdd → verify → reviewWithFixLoop(有界 5 轮: │ review(code-reviewer) approve → 过;request-changes → fix → 重审; - │ 第 5 轮仍未过 → throw HALT) + │ 第 5 轮仍未过 → throw HALT,由 for-await 冒泡到主循环) │ → testGate(backend)(红色自动重试 1 次防 flaky,仍红 → HALT) - │ → 跨模块改动记录 → 模块报告 - │ → milestone(git merge --no-ff 进默认分支 + tag milestone/ + 回写 docs/08 § 二) + │ → runCrossModule(module) ← JS 编排(4 微 agent:默认分支 → diff → 分类 → 写日志) + │ → reportPrompt(LLM 12 节叙述报告) + │ → runMilestone(module) ← JS 编排(10+ 微 agent,跨重入幂等) + │ wt → default → 已合入? → merge → 字段当前值? → 写字段 + commit + │ → tag 存在? → 打 tag → 报告 § ⑫ 当前值? → 替换占位 + commit │ └─ 前端(module.feItems 非空时,后端全部打里程碑后): - featureLoop(feItems, 'frontend')(FE-NN,路径限 frontend/, - review 调 code-reviewer + 前端 7 维 checklist) - → testGate(frontend) → milestone(docs/08 § 三 整体里程碑) + runBranchSetup(frontend-phase) + → featureLoop(feItems, 'frontend')(FE-NN,路径限 frontend/, + review 调 code-reviewer + 前端 7 维 checklist) + → testGate(frontend) → runMilestone(docs/08 § 三 整体里程碑) ``` -`coding.mjs` 内部用 `pipeline` 把后端/前端功能链同构为一个 `featureLoop(items, phase)`(替代旧 10 个克隆 skill);`reviewWithFixLoop` 有界 5 轮;`testGate` 失败自动重试 1 次。完成信号统一由本地 `git tag -l 'milestone/'` 判定,**不依赖任何远程仓库 / push**。 +#### featureLoop 顺序 for-await(非 pipeline) -> 旧 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)。 +`coding.mjs` 把后端/前端功能链同构为一个 `featureLoop(items, phase)`(替代旧 10 个克隆 skill),实现上采用**顺序 for-await**(**非 pipeline**):tdd/fix stage 共享单工作树 + 同一功能分支做 git commit,并发会争 `.git/index.lock` 且撞 migration `V` 版本号;同时 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 次。 + +#### JS 编排 vs LLM prompt:哪些是 `run*` 哪些是 `*Prompt` + +`coding.mjs` 里两种 stage 实现形态,按"步骤是否需要 LLM 判断"分: + +| 形态 | 适用 | 示例 stage | 工作方式 | +|---|---|---|---| +| **`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 再判一次、得到同一决策。 | +| **`*Prompt`(LLM 叙述)** | 真正需要 LLM 判断 / 上下文综合 / 文本生成的 stage | `routerPrompt` / `deriveSpecPrompt` / `planPrompt` / `tddPrompt` / `verifyPrompt` / `gatePrompt` / `reviewPrompt` / `fixPrompt` / `reportPrompt` | 一段较长的中文 prompt 文本,子代理读完后自由发挥实现意图(写 spec / 写 plan / 跑 TDD / 出 12 节报告 等)。结构化的部分仍走 schema(router / review / gate 都用 schema 约束最终结论)。 | + +> 完成信号统一由本地 `git tag -l 'milestone/'` 判定,**不依赖任何远程仓库 / push**。 + +> 旧 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)。 ## Agent 清单(1 个) @@ -216,6 +243,6 @@ coding-start(skill)校验 Plan 终结闸 → Workflow({scriptPath:"…/workf ## 设计原则 -参见 `project-init/templates/CLAUDE-template.md` 末尾的「🧭 通用工作准则」4 条:① Think Before Coding ② Simplicity First ③ Surgical Changes ④ Goal-Driven Execution。 +参见 `skills/project-init/templates/CLAUDE-template.md` 末尾的「🧭 通用工作准则」4 条:① Think Before Coding ② Simplicity First ③ Surgical Changes ④ Goal-Driven Execution。 最关键的 1 条:"**所有测试与验证派发到全新子会话执行,主会话只接收结构化结论**"——避免主会话被测试输出污染,并让测试结果作为独立证据存档。 diff --git a/agents/code-reviewer.md b/agents/code-reviewer.md index 4941779..2c6f0bc 100644 --- a/agents/code-reviewer.md +++ b/agents/code-reviewer.md @@ -1,11 +1,27 @@ --- name: code-reviewer description: | - 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. + 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. model: inherit --- -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. +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. + +## Domain phase resolution (important: naming collision with harness) + +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. + +Your **domain phase** (`backend` vs `frontend`) is encoded inside the prompt body as a bolded line: + +``` +**phase = backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。** +``` +or +``` +**phase = frontend → 附加前端 7 维 checklist。...** +``` + +Parse this line first. The round number comes from the prompt header `第 N 轮`. ## Output contract (required) @@ -13,9 +29,13 @@ Return a structured result matching the workflow's `REVIEW_SCHEMA`: - `verdict`: `approve` or `request-changes` - `round`: the integer round number you were given -- `issues`: array of strings — each a concrete, actionable must-fix (empty when `verdict` is `approve`) - -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. +- `issues`: array of **structured objects** (not strings) — each must-fix is `{ summary, locator, severity }`: + - `summary`: one Chinese sentence describing what is wrong + - `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:` to validate before editing. A finding without a concrete file locator is a Suggestion, not a must-fix — do NOT put it in `issues`. + - `severity`: `blocker` | `high` | `medium` | `low` +- Empty array `[]` when `verdict` is `approve`; non-empty when `request-changes`. +- An audit report is written by the workflow's review prompt instructions (path `docs/superpowers/reviews/-.md`); use that report for rich prose / suggestions / praise. The `issues` array is reserved for hard must-fixes only. +- **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. ## Decision discipline (avoid non-deterministic loops) diff --git a/lib/merge-gitignore.mjs b/lib/merge-gitignore.mjs index 2024344..7f68573 100644 --- a/lib/merge-gitignore.mjs +++ b/lib/merge-gitignore.mjs @@ -1,17 +1,27 @@ // lib/merge-gitignore.mjs +// 合并两份 .gitignore,对**规则行**逐行判重并集合并;注释行透传(相邻去重),空行丢弃(节由注释头承担)。 +// 之所以不对注释去重:两段分组各自的同名注释头(如多次出现的 `# generated`)是分节标题, +// 全局去重会把第二段的标题吞掉,让 add 文件的规则被并入第一段的注释下、破坏分节语义。 export function mergeGitignore(baseText, addText) { - const seen = new Set() + const seenRules = new Set() const out = [] const push = (line) => { - const key = line.trim() - if (!key) return // drop blank lines - if (seen.has(key)) return // dedupe by trimmed content - seen.add(key) + const trimmed = line.trim() + if (!trimmed) return // drop blank lines (输出靠 join('\n')+尾换行;分节由注释行承担) + if (trimmed.startsWith('#')) { + // 注释:仅折叠**相邻**完全相同的注释,避免分节标题被吞 + if (out.length && out[out.length - 1].trim() === trimmed) return + out.push(line) + return + } + // 规则行:全局去重(含 negation `!pattern`,按原文比对,顺序保留首次出现位置) + if (seenRules.has(trimmed)) return + seenRules.add(trimmed) out.push(line) } for (const l of baseText.split('\n')) push(l) for (const l of addText.split('\n')) push(l) - let text = out.join('\n').replace(/\n+$/,'') + '\n' + let text = out.join('\n').replace(/\n+$/, '') + '\n' return text } diff --git a/lib/merge-gitignore.test.mjs b/lib/merge-gitignore.test.mjs index a8458cf..b7aca4a 100644 --- a/lib/merge-gitignore.test.mjs +++ b/lib/merge-gitignore.test.mjs @@ -12,3 +12,27 @@ test('union dedupes and preserves base order, appends new', () => { test('blank lines and comments in add are ignored for dedupe but kept once', () => { assert.equal(mergeGitignore('a\n', '\n# c\nb\n'), 'a\n# c\nb\n') }) + +// 回归:两段不同区块共用同一注释标题(如 `# generated`)时,第二段的标题不应被去重吞掉。 +test('cross-section duplicate comment headers are preserved (no global dedupe on comments)', () => { + const base = '# generated\na\n' + const add = '# generated\nb\n' + assert.equal(mergeGitignore(base, add), '# generated\na\n# generated\nb\n') +}) + +// 相邻完全重复的注释会折叠成一行(避免无意义连续重复)。 +test('adjacent duplicate comments are folded', () => { + assert.equal(mergeGitignore('# x\n# x\n', 'a\n'), '# x\na\n') +}) + +// negation 规则 (!pattern) 按原文比对、顺序保留。 +test('negation patterns are deduped by literal and order is preserved', () => { + const merged = mergeGitignore('node_modules\n!node_modules/keep\n', '!node_modules/keep\ndist\n') + assert.equal(merged, 'node_modules\n!node_modules/keep\ndist\n') +}) + +// 输出始终以单个 \n 结尾,即便 base 末尾无换行。 +test('output always ends with exactly one newline', () => { + assert.equal(mergeGitignore('a', 'b'), 'a\nb\n') + assert.equal(mergeGitignore('a\n\n\n', 'b\n\n'), 'a\nb\n') +}) diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs index 689f98c..c875d10 100644 --- a/lib/validate-ddl.mjs +++ b/lib/validate-ddl.mjs @@ -1,5 +1,8 @@ // lib/validate-ddl.mjs — docs/03 表格 ↔ DDL(V1.sql)一致性 5 维校验 // 替换 db-init/scripts/validate.sh(跨平台、纯 Node、零外部依赖)。 +// 语法基线偏向 MySQL 8(int/varchar/json 等 ANSI + MySQL 类型;KEY/UNIQUE KEY 索引语法)。 +// 厂商扩展(Postgres `bytea`、Oracle `nvarchar2` 等)未列入 SQL_TYPE_RE,下游解析可能退化为 +// 跳过整项(fix #2 起 KEY/INDEX 项遇未知类型保留字会跳过而非误判为列)。 // // 用法(CLI):node lib/validate-ddl.mjs // 退出码 0 = 一致;1 = 存在差异(diff 明细打印到 stderr);2 = 用法/路径错误。 @@ -74,25 +77,66 @@ export function parseDocsTables(text) { // type 为 PRIMARY(不分大小写)→ 记 'PRIMARY'(匹配 parseDDL 对主键的归一化); // 否则记索引名 name(匹配 parseDDL 对命名索引存 name)。 function parseIndexBullet(line, indexes) { - const m = line.match(/^\s*-\s+`?([^`():]+)`?\s*(?:\(([^)]*)\))?\s*:?/) + // 真正的索引 bullet 必须有 `(type)` 或 `: cols`(或两者皆有);纯散文 bullet 拒绝匹配。 + const m = line.match(/^\s*-\s+`?([^`():]+)`?\s*(?:\(([^)]*)\))?\s*(?::\s*(.+))?$/) if (!m) return const name = m[1].trim() const type = (m[2] || '').trim() + const colsRaw = (m[3] || '').trim() if (!name) return - if (/^primary$/i.test(type) || /^primary$/i.test(name)) indexes.add('PRIMARY') - else indexes.add(name) + // 散文 bullet 守门:没有括号也没有冒号列段 → 不是索引项 + if (!type && !colsRaw) return + if (/^primary$/i.test(type) || /^primary$/i.test(name)) { + indexes.add('PRIMARY') + return + } + // 列与 UNIQUE/INDEX 类别一并参与等价比较(fix #10) + const cols = colsRaw + .split(',') + .map(c => c.replace(/`/g, '').trim()) + .filter(c => /^[A-Za-z0-9_]+$/.test(c)) + .join(',') + const kind = /^unique$/i.test(type) ? 'UNIQUE' : 'INDEX' + indexes.add(`${name}:${kind}:${cols}`) } // 解析外键 bullet: - `name`: from_col → to_table.to_col (on_delete) -// 归一化为 parseDDL 同形的 `${fromCol}->${toTable}(${toCol})`(注意 docs 用 unicode → / DDL 用 ->)。 +// 归一化为 parseDDL 同形的 `${fromCols}->${toTable}(${toCols})`(注意 docs 用 unicode → / DDL 用 ->)。 +// 复合外键的两种合法 docs 写法都支持,避免与 DDL 侧的 `(idA, idB)` 形态不对称: +// - `fk`: colA, colB → other.idA, idB ← 平铺,到列也是逗号分隔 +// - `fk`: colA, colB → other.(idA, idB) ← 目标列括起 +// - `fk`: colA, colB → other.`idA`,`idB` ← 各列各带反引号 function parseForeignKeyBullet(line, foreignKeys) { - const m = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*`?([A-Za-z0-9_,\s]+?)`?\s*(?:→|->|>)\s*`?([A-Za-z0-9_]+)`?\.`?([A-Za-z0-9_]+)`?/) - if (!m) return - const fromCols = m[1].replace(/`/g, '').replace(/\s+/g, '') - const toTable = m[2] - const toCols = m[3].replace(/`/g, '').replace(/\s+/g, '') + // 1) 先把头部 `- `name`: ... → table` 抠出来,保留"目标表后剩余的尾段"用于解析目标列(可能是 + // `.idA`、`.idA, idB`、`.(idA, idB)` 或 `.`idA`,`idB``)。 + const head = line.match(/^\s*-\s+`?[^`:]+`?\s*:\s*([^→>\n]+?)\s*(?:→|->)\s*`?([A-Za-z0-9_]+)`?\s*\.\s*(.+)$/) + if (!head) return + const fromRaw = head[1] + const toTable = head[2] + let toRaw = head[3] + if (!fromRaw || !toTable || !toRaw) return + + const fromCols = fromRaw.replace(/`/g, '').replace(/\s+/g, '') + + // 2) 目标列:剥掉一对外层圆括号(如果有),按逗号切分,去反引号 / 空白;遇到第一个非 + // `[A-Za-z0-9_]` 列分隔符以外的字符(如 `(CASCADE)`、` on delete ...`)就停止收集。 + toRaw = toRaw.trim() + // 在分列前先尝试抓取尾部的 on-delete 标记:(CASCADE) / (RESTRICT) / (SET NULL) / (NO ACTION) / + // (SET DEFAULT);docs 模板规约把 action 写在一对独立括号里,紧跟在目标列之后。 + const onDeleteMatch = toRaw.match(/\((CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION)\)\s*$/i) + const onDelete = onDeleteMatch ? onDeleteMatch[1].toUpperCase().replace(/\s+/g, ' ') : 'RESTRICT' + // 剥外层括号:(idA, idB) → idA, idB + const paren = toRaw.match(/^\(([^)]*)\)/) + let toBody = paren ? paren[1] : toRaw + // 截断到第一个 `(`(如 `(CASCADE)`)或行尾。 + toBody = toBody.split('(')[0] + const toCols = toBody + .split(',') + .map(s => s.replace(/`/g, '').trim()) + .filter(s => /^[A-Za-z0-9_]+$/.test(s)) + .join(',') if (!fromCols || !toTable || !toCols) return - foreignKeys.add(`${fromCols}->${toTable}(${toCols})`) + foreignKeys.add(`${fromCols}->${toTable}(${toCols}):${onDelete}`) } // ── 解析 CREATE TABLE DDL ──────────────────────────────────────── @@ -101,8 +145,9 @@ export function parseDDL(text) { const tables = new Map() // 先剥离 SQL 注释,避免被注释掉的 CREATE TABLE 被当成真实表(幽灵表假阳性)。 const src = stripSqlComments(String(text)) - // 抓取 CREATE TABLE ( ) ;name 可带反引号;body 到匹配的右括号 - const createRe = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([A-Za-z0-9_]+)`?\s*\(/gi + // 抓取 CREATE TABLE ( ) ;name 可带反引号;body 到匹配的右括号。 + // 支持可选 schema 限定名 `db`.`t` / db.t(取末段为表名,与 docs/03 一致)。 + 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 let m while ((m = createRe.exec(src)) !== null) { const tableName = m[1] @@ -128,12 +173,14 @@ function parseTableBody(body) { // 外键约束(可带前缀 CONSTRAINT ) if (/\bFOREIGN\s+KEY\b/i.test(item)) { - const fk = item.match(/FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) + // REFERENCES 支持 schema 限定 `db`.`t` / db.t(取末段为表名,与 CREATE TABLE 一致)。 + 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) if (fk) { const fromCols = fk[1].replace(/`/g, '').replace(/\s+/g, '') const refTable = fk[2] const toCols = fk[3].replace(/`/g, '').replace(/\s+/g, '') - foreignKeys.add(`${fromCols}->${refTable}(${toCols})`) + const onDelete = (fk[4] || 'RESTRICT').toUpperCase().replace(/\s+/g, ' ') + foreignKeys.add(`${fromCols}->${refTable}(${toCols}):${onDelete}`) } else { foreignKeys.add(item) } @@ -146,10 +193,24 @@ function parseTableBody(body) { continue } // UNIQUE [KEY|INDEX] (...) / KEY (...) / INDEX (...) + // 启发式消歧:若 ` (...)` 中 ident 是 SQL 标量类型关键字(如 + // `key varchar(10)`),更可能是未加反引号的保留字列名 + 类型,回退到普通列解析避免漏列; + // 但下游列正则会显式排斥以 KEY/INDEX/UNIQUE/FULLTEXT/SPATIAL 开头的整项,避免 fix #2 的幽灵列。 if (/^(UNIQUE\s+(KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\b/i.test(item)) { - const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?/i) - indexes.add(nameMatch ? nameMatch[1] : item) - continue + const nameMatch = item.match(/^(?:UNIQUE\s+(?:KEY|INDEX)|KEY|INDEX|FULLTEXT\s+KEY|SPATIAL\s+KEY)\s+`?([A-Za-z0-9_]+)`?\s*\(([^)]*)\)/i) + 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 + if (nameMatch && !SQL_TYPE_RE.test(nameMatch[1])) { + const kind = /^UNIQUE/i.test(item) ? 'UNIQUE' : 'INDEX' + const cols = nameMatch[2] + .split(',') + .map(c => c.replace(/`/g, '').trim()) + .filter(Boolean) + .join(',') + indexes.add(`${nameMatch[1]}:${kind}:${cols}`) + continue + } + // 命名是类型关键字 / 无法定位 → 回退到列定义解析; + // 列正则下游会拒绝以保留字开头的列名(fix #2)。 } // CONSTRAINT 但非外键(如 UNIQUE/CHECK 约束)→ 当索引/约束记 if (/^CONSTRAINT\b/i.test(upper)) { @@ -161,10 +222,13 @@ function parseTableBody(body) { if (/^CHECK\b/i.test(upper)) continue // 普通列: ... name 可带反引号;type 取到第一个属性关键字/逗号前 - const col = item.match(/^`?([A-Za-z0-9_]+)`?\s+(.+)$/s) + const col = item.match(/^(`?)([A-Za-z0-9_]+)\1\s+(.+)$/s) if (!col) continue - const name = col[1] - const type = extractType(col[2]) + const quoted = col[1] === '`' + const name = col[2] + // 未加反引号时拒绝索引保留字开头的"列",避免把 `UNIQUE KEY foo (c)` 等误吃成列(fix #2)。 + if (!quoted && /^(KEY|INDEX|UNIQUE|FULLTEXT|SPATIAL|PRIMARY|CONSTRAINT|CHECK|FOREIGN)$/i.test(name)) continue + const type = extractType(col[3]) columns.set(name, type) } return { columns, indexes, foreignKeys } @@ -176,10 +240,13 @@ function extractType(rest) { // 类型形如 varchar(100) / decimal(10,2) / int unsigned / bigint const m = s.match(/^([A-Za-z]+(?:\s+(?:unsigned|signed|zerofill))*)\s*(\([^)]*\))?/i) if (!m) return s.split(/\s+/)[0] - let type = m[1].trim() - // 仅保留基础类型词 + 括号;丢弃 unsigned/signed 这类修饰以贴近 docs/03 写法(docs 一般只写基础类型) + const type = m[1].trim() const base = type.split(/\s+/)[0] - return base + (m[2] ? m[2].replace(/\s+/g, '') : '') + const paren = m[2] ? m[2].replace(/\s+/g, '') : '' + // 保留 unsigned / signed 修饰,避免与 docs/03 写法(如 `int unsigned`)产生假阳性类型 mismatch。 + // zerofill 较罕见且 docs 通常不写,仍丢弃。 + const mod = /\bunsigned\b/i.test(type) ? ' unsigned' : /\bsigned\b/i.test(type) ? ' signed' : '' + return base + paren + mod } // ── 5 维 diff ──────────────────────────────────────────────────── @@ -244,12 +311,54 @@ export function diffSchema(docsTables, ddlTables) { // ── 工具函数 ───────────────────────────────────────────────────── // 剥离 SQL 注释:-- 行注释(到行尾)、# 行注释(到行尾)、/* */ 块注释。 -// 保守起见不解析字符串字面量内的注释符(DDL 极少在标识符/默认值里出现裸 -- 或 /*)。 +// **字符串字面量感知**:单引号 / 双引号 / 反引号字面量内部的注释符按原文保留(DEFAULT 'a--b' / +// DEFAULT '#tag' 之类不会被错剥成"列丢失")。转义引号支持 SQL 标准的 '' 与反斜杠 \\'。 function stripSqlComments(sql) { - return sql - .replace(/\/\*[\s\S]*?\*\//g, ' ') // 块注释 - .replace(/--.*$/gm, '') // -- 行注释 - .replace(/#.*$/gm, '') // # 行注释 + const s = String(sql) + let out = '' + let i = 0 + while (i < s.length) { + const ch = s[i] + const next = s[i + 1] + // 进入字符串 / 反引号:原样吐出整个字面量 + if (ch === "'" || ch === '"' || ch === '`') { + const q = ch + out += ch + i++ + while (i < s.length) { + const c = s[i] + // SQL 标准的双引号转义:'' 或 "" + if (c === q && s[i + 1] === q) { out += c + c; i += 2; continue } + // 反斜杠转义:\' / \" / \\ 等(MySQL 默认开启 NO_BACKSLASH_ESCAPES 才禁,保守按开启处理) + if (c === '\\' && i + 1 < s.length && q !== '`') { out += c + s[i + 1]; i += 2; continue } + out += c + i++ + if (c === q) break + } + continue + } + // /* ... */ 块注释(吞到下一个 */) + if (ch === '/' && next === '*') { + i += 2 + while (i < s.length && !(s[i] === '*' && s[i + 1] === '/')) i++ + i += 2 + out += ' ' + continue + } + // -- 行注释(吞到行尾,不含换行) + if (ch === '-' && next === '-') { + while (i < s.length && s[i] !== '\n') i++ + continue + } + // # 行注释(吞到行尾,不含换行) + if (ch === '#') { + while (i < s.length && s[i] !== '\n') i++ + continue + } + out += ch + i++ + } + return out } function stripTicks(s) { @@ -274,32 +383,65 @@ function isHeaderLabel(cell) { return ['列', '字段', '字段名', '列名', '类型', 'name', 'type', 'column'].includes(cell.trim()) } +// 推进字符串字面量游标:从指针指向开引号开始,返回字面量结束后(含闭引号)的下标。 +// 支持 '' / "" 转义与反斜杠转义(反引号字面量不支持反斜杠转义)。 +function advanceLiteral(src, i) { + const q = src[i] + i++ + while (i < src.length) { + const c = src[i] + if (c === q && src[i + 1] === q) { i += 2; continue } + if (c === '\\' && i + 1 < src.length && q !== '`') { i += 2; continue } + i++ + if (c === q) return i + } + return i +} + // 提取从 openIdx(指向 '(')开始的平衡括号内部内容(不含最外层括号)。 +// **字符串字面量感知**:DEFAULT ')' / DEFAULT '(a,b)' 等不会让 depth 提前减为 0 截断表体。 function extractBalancedParens(src, openIdx) { if (src[openIdx] !== '(') return null let depth = 0 - for (let i = openIdx; i < src.length; i++) { + let i = openIdx + while (i < src.length) { const ch = src[i] - if (ch === '(') depth++ - else if (ch === ')') { + if (ch === "'" || ch === '"' || ch === '`') { + i = advanceLiteral(src, i) + continue + } + if (ch === '(') { depth++; i++; continue } + if (ch === ')') { depth-- if (depth === 0) return src.slice(openIdx + 1, i) + i++ + continue } + i++ } return null } -// 在顶层(括号深度 0)按逗号切分 DDL body,保护 varchar(100) / decimal(10,2) 内的逗号。 +// 在顶层(括号深度 0、字符串字面量外)按逗号切分 DDL body。 +// 保护 varchar(100) / decimal(10,2) 内的逗号,也保护 DEFAULT 'a,b' / COMMENT '..., ...' 内的逗号。 function splitTopLevelCommas(body) { const out = [] let depth = 0 let buf = '' - for (let i = 0; i < body.length; i++) { + let i = 0 + while (i < body.length) { const ch = body[i] - if (ch === '(') { depth++; buf += ch } - else if (ch === ')') { depth--; buf += ch } - else if (ch === ',' && depth === 0) { out.push(buf); buf = '' } - else buf += ch + if (ch === "'" || ch === '"' || ch === '`') { + const end = advanceLiteral(body, i) + buf += body.slice(i, end) + i = end + continue + } + if (ch === '(') { depth++; buf += ch; i++; continue } + if (ch === ')') { depth--; buf += ch; i++; continue } + if (ch === ',' && depth === 0) { out.push(buf); buf = ''; i++; continue } + buf += ch + i++ } if (buf.trim()) out.push(buf) return out diff --git a/lib/validate-ddl.test.mjs b/lib/validate-ddl.test.mjs index 49ad3e3..eac3d01 100644 --- a/lib/validate-ddl.test.mjs +++ b/lib/validate-ddl.test.mjs @@ -86,7 +86,7 @@ const DDL_FULL = [ ' `sUserId` varchar(100) NOT NULL,', ' PRIMARY KEY (`iId`),', ' KEY `idx_user` (`sUserId`),', - ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`)', + ' CONSTRAINT `fk_user` FOREIGN KEY (`sUserId`) REFERENCES `t_user` (`sId`) ON DELETE CASCADE', ') ENGINE=InnoDB;', ].join('\n') @@ -94,8 +94,10 @@ test('parseDocsTables: parses ### 索引 / ### 外键 bullets into sets (C2 regr const t = parseDocsTables(DOCS_FULL).get('t_order') assert.ok(t) assert.ok(t.indexes.has('PRIMARY'), 'PRIMARY index normalized') - assert.ok(t.indexes.has('idx_user'), 'named index by name') - assert.ok(t.foreignKeys.has('sUserId->t_user(sId)'), 'FK normalized to parseDDL form') + assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), + 'named index normalized to name:kind:cols — got: ' + [...t.indexes]) + assert.ok(t.foreignKeys.has('sUserId->t_user(sId):CASCADE'), + 'FK normalized to parseDDL form with on-delete — got: ' + [...t.foreignKeys]) }) test('full chain: matching docs/03 (with indexes+FK) ↔ DDL yields no diff (C2 regression)', () => { @@ -115,7 +117,7 @@ test('full chain: a real FK present in docs but missing from DDL is caught', () ') ENGINE=InnoDB;', ].join('\n') const d = diffSchema(parseDocsTables(DOCS_FULL), parseDDL(ddlNoFk)) - assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId)')) + assert.ok(d.foreignKeyMismatches.some(m => m.side === 'docs' && m.foreignKey === 'sUserId->t_user(sId):CASCADE')) assert.equal(d.hasDiff, true) }) @@ -164,8 +166,8 @@ test('parseDDL: columns, types, indexes, foreign keys (backtick-quoted)', () => assert.deepEqual([...t.columns.keys()], ['iIncrement', 'sId', 'sUserId']) assert.equal(t.columns.get('sId'), 'varchar(100)') // index keys (named) collected; PRIMARY collected too - assert.ok(t.indexes.has('uk_sid')) - assert.ok(t.indexes.has('idx_user')) + assert.ok(t.indexes.has('uk_sid:UNIQUE:sId'), 'unique index normalized — got: ' + [...t.indexes]) + assert.ok(t.indexes.has('idx_user:INDEX:sUserId'), 'named index normalized — got: ' + [...t.indexes]) assert.ok([...t.indexes].some(i => i.toUpperCase().includes('PRIMARY'))) // foreign key collected assert.ok([...t.foreignKeys].some(fk => fk.includes('sUserId') && fk.includes('t_user'))) @@ -209,10 +211,10 @@ test('diffSchema: extra column in DDL reported as columnMismatch', () => { }) test('diffSchema: index dimension diff reported', () => { - const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c']), foreignKeys: new Set() }]]) + const docs = new Map([['t', { columns: new Map([['c', 'int']]), indexes: new Set(['idx_c:INDEX:c']), foreignKeys: new Set() }]]) const ddl = parseDDL('CREATE TABLE t ( c int );') // no indexes const d = diffSchema(docs, ddl) - assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c')) + assert.ok(d.indexMismatches.some(m => m.table === 't' && m.index === 'idx_c:INDEX:c')) }) test('diffSchema: foreign-key dimension diff reported', () => { @@ -228,3 +230,237 @@ test('diffSchema: hasDiff is false when everything matches, true otherwise', () const bad = diffSchema(parseDocsTables(DOCS), parseDDL('CREATE TABLE t_user ( iId bigint );')) assert.equal(bad.hasDiff, true) }) + +// ── 字符串字面量感知(回归)────────────────────────────────────── +test('parseDDL: DEFAULT \'a--b\' 字面量中的 -- 不应被当行注释剥离', () => { + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT 'a--b', x int );" + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 -- 不应吞掉后续列 x') +}) + +test('parseDDL: DEFAULT \'#tag\' 字面量中的 # 不应被当行注释剥离', () => { + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT '#tag', x int );" + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 # 不应吞掉后续列 x') +}) + +test('parseDDL: DEFAULT \')\' 字面量中的右括号不应提前截断表体', () => { + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT ')', x int );" + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['s', 'x'], '字面量内 ) 不应让 depth 提前归零截断表体') +}) + +test('parseDDL: DEFAULT \'(a,b)\' 字面量中的逗号不应被当顶层分隔', () => { + const ddl = "CREATE TABLE t ( s varchar(10) DEFAULT '(a,b)', x int );" + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['s', 'x']) +}) + +// ── schema 限定表名(回归)─────────────────────────────────────── +test('parseDDL: CREATE TABLE db.t 与 `db`.`t` 都应解析(取末段为表名)', () => { + const tables1 = parseDDL('CREATE TABLE mydb.t_user ( id int );') + assert.deepEqual([...tables1.keys()], ['t_user']) + const tables2 = parseDDL('CREATE TABLE `mydb`.`t_user` ( id int );') + assert.deepEqual([...tables2.keys()], ['t_user']) +}) + +// ── 复合外键 docs↔DDL 对称(回归)──────────────────────────────── +test('parseDocsTables: 复合外键 - colA, colB → other.idA, idB 应平铺成 colA,colB->other(idA,idB)', () => { + const docs = [ + '## `t_link`', + '### 字段', + '| 列 | 类型 |', + '|---|---|', + '| `colA` | int |', + '| `colB` | int |', + '### 外键', + '- `fk_x`: colA, colB → other.idA, idB (CASCADE)', + ].join('\n') + const t = parseDocsTables(docs).get('t_link') + assert.ok(t) + assert.ok(t.foreignKeys.has('colA,colB->other(idA,idB):CASCADE'), + 'docs-side composite FK should normalize the same way as parseDDL — got: ' + [...t.foreignKeys]) +}) + +test('full chain: 复合外键 docs ↔ DDL 一致时不应误报双向 mismatch', () => { + const docs = [ + '## `t_link`', + '### 字段', + '| 列 | 类型 |', + '|---|---|', + '| `colA` | int |', + '| `colB` | int |', + '### 外键', + '- `fk_x`: colA, colB → other.(idA, idB)', + ].join('\n') + const ddl = [ + 'CREATE TABLE `t_link` (', + ' `colA` int NOT NULL,', + ' `colB` int NOT NULL,', + ' CONSTRAINT `fk_x` FOREIGN KEY (`colA`, `colB`) REFERENCES `other` (`idA`, `idB`)', + ') ENGINE=InnoDB;', + ].join('\n') + const d = diffSchema(parseDocsTables(docs), parseDDL(ddl)) + assert.deepEqual(d.foreignKeyMismatches, [], + '复合 FK 一致时不应误报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) +}) + +// ── 未加引号的保留字列名(回归)───────────────────────────────── +test('parseDDL: 未加引号的保留字列名 `key varchar(...)` 不应被误判为索引也不应制造幽灵列(fix #2)', () => { + // 列名 key 未加反引号,且后面跟的是 `varchar(`(一个类型而非 `key (`)。 + // 新策略:未加反引号的保留字列名一律被跳过;用户需用反引号包裹保留字列名。 + const ddl = 'CREATE TABLE t ( id int, key varchar(10) );' + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.equal(t.columns.has('key'), false, '未加反引号的 key 应被跳过,不入 columns') + assert.equal(t.indexes.size, 0, '也不应被当索引') +}) + +test('parseDDL: 反引号包裹的保留字列名应正常解析(fix #2)', () => { + const ddl = 'CREATE TABLE t ( id int, `key` varchar(10) );' + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.ok(t.columns.has('key'), '加了反引号的 key 应被解析为普通列') + assert.equal(t.columns.get('key'), 'varchar(10)') +}) + +// ── #2 KEY/INDEX 启发式 fallthrough 不应制造幽灵列 ────────────── +test('parseDDL: `KEY varchar (id)` 不应制造名为 `KEY` 的幽灵列(fix #2)', () => { + const ddl = 'CREATE TABLE t ( id int, KEY varchar (id) );' + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['id'], '不应出现 KEY 列') + // varchar 是类型关键字,启发式跳过该项 → 既不入列也不入索引 + assert.equal(t.indexes.size, 0, '保留字 + 类型名时该项应被跳过') +}) + +test('parseDDL: `UNIQUE KEY double (c)` 不应被解析为列(fix #2/#20)', () => { + const ddl = 'CREATE TABLE t ( c int, UNIQUE KEY double (c) );' + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['c'], '不应出现 UNIQUE/KEY 列') +}) + +test('parseDDL: `KEY decimal (c)` 不应被解析为列(fix #2/#20)', () => { + const ddl = 'CREATE TABLE t ( c int, KEY decimal (c) );' + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.deepEqual([...t.columns.keys()], ['c']) +}) + +// ── #3 REFERENCES schema-qualified table ───────────────────────── +test('parseDDL: FK REFERENCES mydb.users(id) 归一化为 uid->users(id)(fix #3)', () => { + const ddl = [ + 'CREATE TABLE t (', + ' uid int NOT NULL,', + ' FOREIGN KEY (uid) REFERENCES mydb.users(id)', + ');', + ].join('\n') + const t = parseDDL(ddl).get('t') + assert.ok(t) + assert.ok(t.foreignKeys.has('uid->users(id):RESTRICT'), + 'FK 表名应取末段 users 并附默认 on-delete — got: ' + [...t.foreignKeys]) +}) + +// ── #4 extractType 保留 unsigned/signed 修饰 ───────────────────── +test('extractType: `int unsigned` vs `int unsigned` 匹配,`int` vs `int unsigned` 报 mismatch(fix #4)', () => { + const docsOk = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') + const ddlOk = parseDDL('CREATE TABLE t ( id int unsigned );') + const ok = diffSchema(docsOk, ddlOk) + assert.deepEqual(ok.typeMismatches, [], 'unsigned 两侧一致不应报错 — got: ' + JSON.stringify(ok.typeMismatches)) + + const docsMix = parseDocsTables('## `t`\n| 列 | 类型 |\n|---|---|\n| id | int unsigned |\n') + const ddlMix = parseDDL('CREATE TABLE t ( id int );') + const bad = diffSchema(docsMix, ddlMix) + assert.ok(bad.typeMismatches.some(m => m.column === 'id' && m.docsType === 'int unsigned' && m.ddlType === 'int'), + '一侧带 unsigned 一侧不带应报 mismatch — got: ' + JSON.stringify(bad.typeMismatches)) +}) + +// ── #9 散文 bullet 不应被当 FK / 索引 ──────────────────────────── +test('parseDocsTables: ### 外键 下的散文 bullet (含 `>`) 不应被当外键(fix #9)', () => { + const docs = '## `t`\n### 外键\n- note: a > users.id\n' + const t = parseDocsTables(docs).get('t') + assert.ok(t) + assert.equal(t.foreignKeys.size, 0, 'bare `>` 不再作为外键箭头 — got: ' + [...t.foreignKeys]) +}) + +test('parseDocsTables: ### 索引 下纯散文 bullet 不应被当索引(fix #9)', () => { + const docs = '## `t`\n### 索引\n- This bullet is not an index entry\n' + const t = parseDocsTables(docs).get('t') + assert.ok(t) + assert.equal(t.indexes.size, 0, '散文 bullet 不再制造幽灵索引 — got: ' + [...t.indexes]) +}) + +// ── #10 索引比较包含列与 UNIQUE-ness ──────────────────────────── +test('diffSchema: 同名索引列不同应报 mismatch(fix #10)', () => { + const docs = parseDocsTables([ + '## `t`', + '### 字段', + '| 列 | 类型 |', + '|---|---|', + '| user_id | int |', + '| wrong_col | int |', + '### 索引', + '- `idx_user` (index): user_id', + ].join('\n')) + const ddl = parseDDL([ + 'CREATE TABLE `t` (', + ' `user_id` int,', + ' `wrong_col` int,', + ' KEY `idx_user` (`wrong_col`)', + ') ENGINE=InnoDB;', + ].join('\n')) + const d = diffSchema(docs, ddl) + assert.ok(d.indexMismatches.length > 0, '同名但列不同应报 — got: ' + JSON.stringify(d.indexMismatches)) +}) + +test('diffSchema: 同名索引 UNIQUE vs 非 UNIQUE 应报 mismatch(fix #10)', () => { + const docs = parseDocsTables([ + '## `t`', + '### 字段', + '| 列 | 类型 |', + '|---|---|', + '| c | int |', + '### 索引', + '- `uk_c` (unique): c', + ].join('\n')) + const ddl = parseDDL([ + 'CREATE TABLE `t` (', + ' `c` int,', + ' KEY `uk_c` (`c`)', + ') ENGINE=InnoDB;', + ].join('\n')) + const d = diffSchema(docs, ddl) + assert.ok(d.indexMismatches.length > 0, 'UNIQUE vs INDEX 应报 — got: ' + JSON.stringify(d.indexMismatches)) +}) + +// ── #11 ON DELETE actions differentiated ───────────────────────── +test('diffSchema: FK ON DELETE CASCADE vs 缺省 RESTRICT 应报 mismatch(fix #11)', () => { + const docs = parseDocsTables([ + '## `t`', + '### 字段', + '| 列 | 类型 |', + '|---|---|', + '| `uid` | int |', + '### 外键', + '- `fk_uid`: uid → users.id (CASCADE)', + ].join('\n')) + const ddl = parseDDL([ + 'CREATE TABLE `t` (', + ' `uid` int,', + ' FOREIGN KEY (`uid`) REFERENCES `users`(`id`)', + ') ENGINE=InnoDB;', + ].join('\n')) + const d = diffSchema(docs, ddl) + assert.ok(d.foreignKeyMismatches.length > 0, 'CASCADE vs RESTRICT 应报 — got: ' + JSON.stringify(d.foreignKeyMismatches)) +}) + +// ── #16 CREATE TEMPORARY TABLE 也应被识别 ───────────────────────── +test('parseDDL: CREATE TEMPORARY TABLE 也应被解析(fix #16)', () => { + const tables = parseDDL('CREATE TEMPORARY TABLE t_tmp ( id int );') + assert.deepEqual([...tables.keys()], ['t_tmp'], 'TEMPORARY 表应入 Map — got: ' + [...tables.keys()]) +}) diff --git a/skills/coding-start/SKILL.md b/skills/coding-start/SKILL.md index b6867bc..69c2b0d 100644 --- a/skills/coding-start/SKILL.md +++ b/skills/coding-start/SKILL.md @@ -1,6 +1,6 @@ --- name: coding-start -description: B 阶段(Coding)瘦入口。校验 Plan 终结闸(docs/08 §一 A0~A6 全勾、已在本地默认分支、工作树干净)后,读取 docs/08 §二/§三 概述模块/前端进度,然后调用 workflows/coding.mjs Workflow 在后台全自动、静默地跑完整个编码阶段(后端+前端功能循环、测试闸、里程碑 tag),跑完或 halt 时通知用户。本入口不写任何文件、不做编码决策。 +description: B 阶段(Coding)瘦入口。校验 Plan 终结闸(docs/08 §一 A0~A6 全勾、已在本地默认分支、工作树干净)后,读取 docs/08 §二/§三 概述模块/前端进度,然后调用 workflows/coding.mjs Workflow 全自动、静默地跑完整个编码阶段(后端+前端功能循环、测试闸、里程碑 tag),跑完或 halt 时返回最终状态。本入口不写任何文件、不做编码决策。 user-invocable: true allowed-tools: Read Glob Workflow --- @@ -84,22 +84,22 @@ allowed-tools: Read Glob Workflow ### 步骤 4:启动 Coding Workflow -用 `Workflow` 工具调用编码编排脚本(`` 替换为当前项目根的绝对路径): +用 `Workflow` 工具调用编码编排脚本。`projectRoot` **必须是绝对路径**(POSIX 形如 `/Users/.../my-erp`,Windows 形如 `C:\\Users\\...\\my-erp`),从你当前会话的工作目录读取——**绝不传相对路径如 `.`**。`coding.mjs` 顶部对相对路径做硬校验,传 `.` 会立即 halt(避免子代理在错误 cwd 上执行 `git -C .` 把 tag 打到错处)。 ``` Workflow({ scriptPath: "${CLAUDE_PLUGIN_ROOT}/workflows/coding.mjs", - args: { projectRoot: "" } + args: { projectRoot: "<当前项目根绝对路径>" } }) ``` -### 步骤 5:告知用户已后台启动 +### 步骤 5:告知用户 Workflow 已启动 启动后向用户输出: ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - [coding-start] ✅ Coding Workflow 已在后台启动 + [coding-start] ✅ Coding Workflow 已启动 进度概述:<步骤 3 概述,如「待跑 3 模块 + 前端阶段」> @@ -107,8 +107,8 @@ Workflow({ • 当前已在本地默认分支(main / master) • 工作树干净,Plan 产物(docs/* + skeleton + DDL)已 commit - Workflow 将按模块顺序全自动、静默推进;跑完所有模块或在某模块 - halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时会通知你。 + Workflow 将按模块顺序全自动、静默推进,跑完所有模块或在某模块 + halt(测试闸持续 RED / review 5 轮未过 / 缺值阻塞等)时返回最终状态。 halt 后请按诊断修复,再重新运行 /erp-workflow:coding-start 续跑。 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` diff --git a/skills/downstream-gen/SKILL.md b/skills/downstream-gen/SKILL.md index 1487c32..54ad39c 100644 --- a/skills/downstream-gen/SKILL.md +++ b/skills/downstream-gen/SKILL.md @@ -16,20 +16,20 @@ allowed-tools: Read Write Edit Glob Grep Skill AskUserQuestion **清单颗粒度**:一行一个 REQ,同一模块的 REQ 必须**连续排列**。 1. 构建**模块依赖 DAG**。 -3. 对**每个模块内部**构建 REQ 间依赖,得到模块内 REQ 顺序。 -4. 合成 `req_order[]`:按 `module_topo_order[]` 依次铺开每个模块内的 REQ 序列(**同模块 REQ 连续**)。 -5. **环依赖打破**: +2. 对**每个模块内部**构建 REQ 间依赖,得到模块内 REQ 顺序。 +3. 合成 `req_order[]`:按 `module_topo_order[]` 依次铺开每个模块内的 REQ 序列(**同模块 REQ 连续**)。 +4. **环依赖打破**: - **模块级**:若模块 DAG 存在环(module_A ↔ module_B),按启发式(字母序 / 被依赖次数多者先)破环排出 `module_topo_order`,并在**参与环的模块里第一个 REQ** 的 `note` 字段填入原因(如 "A↔B 互依赖:先做 A 的骨架")。 - **REQ 级(同模块内)**:若模块内 REQ 互依赖,同样破环,`note` 填原因。 - 非环 REQ `note` 留 `—`。 -6. 为 `req_order[]` 每项生成字段: +5. 为 `req_order[]` 每项生成字段: - `index`:行号(从 1 开始) - `req_id`:如 `REQ-SYS-001` - `module_id`:该 REQ 所属模块,如 `module_sys` - `rationale`(**选中理由**):依赖驱动的简短描述,如 `所属模块无依赖,基础模块` / `依赖 REQ-SYS-001 已在前` / `所属模块依赖 module_sys 已在前` - `note`(**备注**):默认 `—`;仅环依赖打破场景填原因 -7. 读取并填充 `${CLAUDE_SKILL_DIR}/templates/docs-02-template.md`。 -8. 写入 `docs/02-开发计划.md`。 +6. 读取并填充 `${CLAUDE_SKILL_DIR}/templates/docs-02-template.md`。 +7. 写入 `docs/02-开发计划.md`。 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 中勾选: - ` - [ ] docs/02 开发计划已生成` @@ -120,21 +120,19 @@ allowed-tools: Read Write Edit Glob Grep Skill AskUserQuestion 4. 完成后,用 `Edit` 在 `docs/08-模块任务管理.md` 勾选 A5 父项: - `- [ ] A5 下游文档生成 — downstream-gen` -5. 打印 Plan 阶段终止横幅并**停下**(不自动进入 B 阶段): +5. 打印 A5 完成横幅并**停下**(A6 仍未跑——A5 之后由 plan-start 派发 A6 frontend-scope-lock,A6 完成后再由 plan-start 跑 5 项终结闸;只有终结闸全过才会提示运行 coding-start): ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - [downstream-gen] ✅ Plan 阶段(A0~A5)全部完成 + [downstream-gen] ✅ A5 下游文档生成完成(A0~A5) - 所有规划文档已就绪,docs/08 § 一 全部勾选。 + docs/02 / docs/05 / docs/06 § 三 / docs/08 § 二 / docs/10 已就绪; + docs/05 + docs/02 评审闸已通过;docs/08 § 一 A0~A5 已全勾。 - ⚠️ 进入 B 阶段前必须完成: - 1. 审核 docs/01~10 + CLAUDE.md + sql/migrations/V1 + 各 scripts/* - - 2. 把全部 Plan 产物 commit 到本地默认分支(main / master): - git add -A && git commit -m "chore: plan phase done" - - 3. 运行 /erp-workflow:coding-start 进入 B 阶段 + ⏭️ 下一步:运行 /erp-workflow:plan-start + plan-start 会派发到 A6 frontend-scope-lock 锁定前端 scope, + A6 完成后再由 plan-start 跑 5 项终结闸校验; + 全过才会提示运行 /erp-workflow:coding-start 进入 B 阶段。 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` diff --git a/skills/downstream-gen/templates/docs-10-header-template.md b/skills/downstream-gen/templates/docs-10-header-template.md index dc62c40..4440bff 100644 --- a/skills/downstream-gen/templates/docs-10-header-template.md +++ b/skills/downstream-gen/templates/docs-10-header-template.md @@ -2,7 +2,7 @@ 通用验收项(全项目适用): -- [ ] `scripts/test.sh` 本地全绿 +- [ ] `node scripts/test.mjs` 本地全绿 - [ ] 所有 schema 改动都有对应 `sql/migrations/V_n__.sql` - [ ] 所有新接口在 `docs/05` 中有契约定义 - [ ] 所有新功能代码注释含 REQ-XXX-NNN diff --git a/skills/frontend-scope-lock/SKILL.md b/skills/frontend-scope-lock/SKILL.md index b610215..1fa8942 100644 --- a/skills/frontend-scope-lock/SKILL.md +++ b/skills/frontend-scope-lock/SKILL.md @@ -42,7 +42,11 @@ A6 是 **Plan 阶段最后一个前端守门 skill**,由 `plan-start` 在 A5 - **至少 1 个 `.html`** → 通过,记下文件清单,进入步骤 2。 - **0 个** → 这是 Plan 期,**可以问**。用 `AskUserQuestion` 告知用户「未在 prototype/ 找到任何 .html 原型,前端范围锁定依赖原型作为页面骨架权威」,给「我已补齐原型,请重新检查」和「本项目无前端,跳过 A6」两个选项。 - 选「已补齐」→ 重新 `Glob`:命中则进入步骤 2,仍为 0 则重复本问。 - - 选「无前端」→ 在 docs/08 § 一 勾选 A6 父项并注明「无前端,A6 跳过」,打印步骤 6 的终止横幅(产出标注「跳过」),**停止**,不写 docs/06 / docs/04。 + - 选「无前端」→ 在 docs/08 § 一 把 A6 的**父项 + 全部 3 个子项**一并勾选并在父项行尾注明「(无前端,A6 跳过)」,打印步骤 6 的终止横幅(产出标注「跳过」),**停止**,不写 docs/06 / docs/04。 + > 必须同时勾子项:`plan-start` 的分发依据是「§ 一 第一个未勾 [ ] 子项」,若只勾父项会让下一次 plan-start 重复派发回本 skill,无前端项目无法满足 Plan 终结闸。具体要勾的子项: + > - ` - [x] docs/06 项目级 UI 约定 + Design Tokens + 组件库已锁定(无前端跳过)` + > - ` - [x] docs/04 § 二 前端栈已锁定(引用 docs/06)(无前端跳过)` + > - ` - [x] 各 FE-NN 设计决策表已生成(docs/06 § 三之后 / docs/08 § 三)(无前端跳过)` ### 步骤 2:收集证据(只读,不问) diff --git a/skills/plan-start/SKILL.md b/skills/plan-start/SKILL.md index 814cf92..67bac3b 100644 --- a/skills/plan-start/SKILL.md +++ b/skills/plan-start/SKILL.md @@ -21,17 +21,22 @@ docs/08 § 一 是**Plan 阶段进度追踪**(A0~A6 的 checkbox)。§ 二 2. **根据 § 一 找到当前进度** -| `进度` | `后续` | `阶段` | + **判定算法(务必按此执行,不要凭"表里含 Axx"判断;docs/08 § 一 模板始终列出 A0~A6 全部行,"含"恒为真)**: + - 用 Read / Grep 把 `docs/08-模块任务管理.md § 一` 全文读出,按文件顺序自上而下扫描,找到**第一个 `- [ ]` 未勾子项**(仅看 § 一,§ 二 / § 三 不参与判定)。 + - 该子项归属的父项 `Axx`(A0~A6)即为当前阶段,按下表派发到对应 skill。 + - 若 § 一 所有 `[ ]` 都已变成 `[x]`(含父项与全部子项)→ 进入 §2.1 Plan 终结闸。 + +| `第一个未勾子项归属` | `后续` | `阶段` | |---|---|---| -| 无 docs/08 | `project-init` | `A0` | -| 含 `A0` / `A0 子项` | `project-init` | `A0` | -| 含 `A1` / `A1 子项` | `scope-lock` | `A1` | -| 含 `A2` / `A2 子项` | `skeleton-gen` | `A2` | -| 含 `A3` / `A3 子项` | `db-design-gen` | `A3` | -| 含 `A4` / `A4 子项` | `db-init` | `A4` | -| 含 `A5` / `A5 子项` | `downstream-gen` | `A5` | -| 含 `A6` / `A6 子项` | `frontend-scope-lock` | `A6` | -| `A` 全勾,Plan 阶段结束 | **无分发** | - | +| 无 docs/08(文件不存在) | `project-init` | `A0` | +| A0 父项或其任一子项 | `project-init` | `A0` | +| A1 父项或其任一子项 | `scope-lock` | `A1` | +| A2 父项或其任一子项 | `skeleton-gen` | `A2` | +| A3 父项或其任一子项 | `db-design-gen` | `A3` | +| A4 父项或其任一子项 | `db-init` | `A4` | +| A5 父项或其任一子项 | `downstream-gen` | `A5` | +| A6 父项或其任一子项 | `frontend-scope-lock` | `A6` | +| § 一 全部 `[x]` | **无分发** → §2.1 Plan 终结闸 | - | ## 第二步:分发通知 + 调用目标 skill @@ -46,8 +51,8 @@ A 阶段所有 checkbox 均 `[x]` 时**不代表可以进 B 阶段**。Coding 1. **REQ 卡片真实数据**(来自 A1 scope-lock) - `Glob` 找出全部 REQ 卡片(如 `docs/01-需求清单/**/*.md`)。 - 对每张卡片 `Grep` 残留占位:命中任一即缺口 — - `【人工填写`、`TBD`、`待补`、`<示例`(用有区分度的 `<示例` 而非裸 `示例值`,避免误命中卡片合法表头行 `| ... | 示例值 |`;与 scope-lock E.1 写法一致)。 - - 缺口表述示例:`REQ-USER-001 仍含 TBD / 示例值未替换为真实约束`。 + `【人工填写`、`TBD`、`待补`、`<示例`、`【示例行`(与 scope-lock E.1 同强度——`<示例` 兜底 `<示例值>`,`【示例行` 兜底未删的模板示例行 `【示例行,替换为真实字段】`;避免半填卡片绕过本闸)。 + - 缺口表述示例:`REQ-USER-001 仍含 TBD / 示例值未替换为真实约束 / 示例行未删除`。 2. **secrets / 项目配置全锁**(来自 A1 收集的 secret/account/package-name/namespace 清单) - `Read` `.env.local`(真实 secret 值所在;gitignored,docs/07 只记规则不记值):校验 `config-vars.yaml` 的 `secrets_ref` 列出的每个 secret 键(如 `DB_PASSWORD` / `JWT_SECRET`)均有真实值,无 `【人工填写`/`TBD`/空值。 @@ -63,8 +68,8 @@ A 阶段所有 checkbox 均 `[x]` 时**不代表可以进 B 阶段**。Coding - 校验:(a) docs/05 每个端点都有请求/响应 schema、无 `【人工填写`/`TBD`;(b) docs/02 每个 REQ 都在构建顺序 DAG 中、cycle-breaking 顺序有 `note` 说明。缺任一即缺口。(A5 父项已勾本身即蕴含 downstream-gen 评审闸已过——downstream-gen 在用户未确认时禁止勾 A5,故无需独立的「已评审」标记。) 5. **A6 前端 scope 已锁**(来自 A6 frontend-scope-lock) - - `Read` `docs/06-UI交互规范.md`。 - - 校验:项目级 UI 约定 / design tokens / 组件库选型已确认;每个 FE-NN 的设计决策表非占位;prototype 闸门已过(docs/08 § 一 A6 勾选即代表此项已由 A6 skill 锁定,但仍核对 docs/06 无 `【人工填写`/`TBD` 残留)。缺任一即缺口。 + - **无前端项目分支**:先 `Read` `docs/08-模块任务管理.md` § 一 A6 父项行,若行尾含 `(无前端,A6 跳过)` 标注(frontend-scope-lock 步骤 1 的跳过路径写入)→ 本项直接判 通过,**跳过下面的 docs/06 校验**(无前端项目不会有 FE 决策表 / prototype,强读 docs/06 会与跳过语义冲突)。 + - 否则(有前端):`Read` `docs/06-UI交互规范.md`,校验项目级 UI 约定 / Design Tokens / 组件库选型已确认;每个 FE-NN 的设计决策表非占位;prototype 闸门已过(docs/08 § 一 A6 勾选即代表此项已由 A6 skill 锁定,但仍核对 docs/06 无 `【人工填写`/`TBD` 残留)。缺任一即缺口。 #### 第 2 步(A):全部通过 → 放行 diff --git a/skills/project-init/templates/CLAUDE-template.md b/skills/project-init/templates/CLAUDE-template.md index beb88a6..776a923 100644 --- a/skills/project-init/templates/CLAUDE-template.md +++ b/skills/project-init/templates/CLAUDE-template.md @@ -60,7 +60,7 @@ B 阶段整体是**一个静默 Workflow 脚本 `workflows/coding.mjs`**(由 每个后端模块在 docs/08 § 二 中长这样: ```markdown -- module_0 系统管理 +- module_sys 系统管理 - 依赖: — - 路径: backend/module/sys/ - 里程碑: — diff --git a/skills/project-init/templates/docs-08-initial-template.md b/skills/project-init/templates/docs-08-initial-template.md index ee9ca3c..61fc530 100644 --- a/skills/project-init/templates/docs-08-initial-template.md +++ b/skills/project-init/templates/docs-08-initial-template.md @@ -20,6 +20,7 @@ - [ ] A2 骨架生成 — skeleton-gen - [ ] 架构文档已生成(docs/04 § 一+、docs/06、docs/07、docs/09) - [ ] 工具脚本已生成(scripts/*.mjs、.env.local) + - [ ] 样式 token 骨架已生成(src/styles/tokens.css) - [ ] .gitignore 已配置 - [ ] A3 DB 设计 + REQ 回填 — db-design-gen @@ -51,7 +52,7 @@ (A5 填入后,每行一个后端模块。每个模块的 `里程碑:` 字段在 `—` 和 `milestone/` 之间变化,完成由本地 `git tag -l` 判定。`coding-start` 每次按 docs/02 REQ 序扫每模块的里程碑 tag 决定派发。后端模块全部打里程碑后自动进入 § 三 前端阶段。) diff --git a/skills/skeleton-gen/templates/env-local-template b/skills/skeleton-gen/templates/env-local-template index c16e67f..43a3bd2 100644 --- a/skills/skeleton-gen/templates/env-local-template +++ b/skills/skeleton-gen/templates/env-local-template @@ -4,7 +4,7 @@ # 1. 值含 `$`、反引号、空格、`!` 等 shell 特殊字符时,必须用单引号包裹: # DB_PASSWORD='p@ss$w0rd!' # 否则 `set -a; . .env.local; set +a` 会做变量展开导致密码错乱。 -# 2. DB_HOST 建议保持 localhost / 127.0.0.1;非本地 host 默认会被 scripts/setup-test-db.sh 防护拒绝。 +# 2. DB_HOST 建议保持 localhost / 127.0.0.1;非本地 host 默认会被 scripts/setup-test-db.mjs 防护拒绝。 # 若必须用远程测试库,把 host 列入下方 TEST_DB_ALLOWED_HOSTS。 # 3. DB_SCHEMA 建议命名含 test / _dev / _local / _ci,避免与生产库同名。 @@ -19,7 +19,7 @@ JWT_SECRET=【人工填写:JWT 签名密钥,256+ bit 随机串】 # 非本地服务器时填写;留空表示只允许 localhost / 127.0.0.1 / ::1。 # 示例:TEST_DB_ALLOWED_HOSTS="118.178.19.35 test-mysql.internal" # -# ⚠️ 列入后该 host 每次 test.sh 都会被 DROP CREATE(无二次确认)。 +# ⚠️ 列入后该 host 每次 test.mjs 都会被 DROP CREATE(无二次确认)。 # 仅用于你完全可控的测试库;生产/共享库/多人共享的 staging 库**千万别列**。 # (防护 2 还会检查 schema 名须含 test/_dev/_local/_ci,独立兜底。) TEST_DB_ALLOWED_HOSTS= diff --git a/workflows/coding.mjs b/workflows/coding.mjs index ff1c4f2..be14804 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -2,15 +2,22 @@ // // 整个 ERP Coding(B 阶段)= 一个静默、全自动的 Workflow 脚本。 // -// 设计原则(见 docs/superpowers/specs/2026-05-26-workflow-migration-design.md): +// 设计原则(详见仓库根 README.md 「阶段 B」 节 与 「设计原则」 节): // - 所有 stage 都是 agent() 子代理,物理上无法 AskUserQuestion → 编码期结构性静默。 // - 缺值不再问人:派生 stage 把具体阻塞点写进产物并 throw(fail-fast,合法 halt → 终止态,非对话框)。 // - 后端 / 前端功能循环由同一份 featureLoop(items, phase) 驱动;phase 切换 reviewer checklist、 // 测试命令、路径作用域(backend/ vs frontend/)、id 格式(REQ-XXX-NNN vs FE-NN)。 +// - **featureLoop 采用顺序 for-await**(不是 pipeline)。两条理由: +// (1) tdd/fix stage 会在共享工作树 + 同一功能分支上 git commit / 编辑源码;并发会争 .git/index.lock +// 并撞 migration 版本号; +// (2) pipeline 的语义是"stage 抛异常 → 该 item 掉 null、pipeline 永不 reject",会把 +// reviewWithFixLoop / verify / tdd 的 HALT throw 静默吞掉,使 fail-fast 在功能链层级失效, +// 残缺模块照样会被 testGate/report/milestone 推进。顺序 for-await 让 throw 自然冒泡到 +// 模块主循环的 try/catch,被捕获后整阶段 fail-fast break。 // - 状态账本 = docs/08 §二/§三 + git tag;halt 后重跑 coding-start,router 从账本+tag 重算进度。 // - reviewer 统一为 agents/code-reviewer.md,review stage 用 agentType:'code-reviewer'。 // -// 运行时约束:Workflow 运行时禁用非确定性内建(Date.now / Math.random 等)。本脚本不调用它们; +// 运行时约束:Workflow 运行时禁用非确定性内建(取当天日期 / 随机数的 API)。本脚本不调用它们; // 凡需要"当天日期"的产物路径(-.md),一律由子代理在其自身上下文中解析并落盘, // 脚本只负责编排,不计算日期 / 随机数。 @@ -31,21 +38,128 @@ const ROUTER_SCHEMA = { type:'object', additionalProperties:false, reqs:{type:'array',items:{type:'string'}}, feItems:{type:'array',items:{type:'string'}} } } } } } +// REVIEW_SCHEMA:reviewer 返回的裁决。issues 改为结构化对象,避免"模糊一行 must-fix" +// 让 fix stage 无从下手就空转 5 轮(见 reviewWithFixLoop 的 must-fix 闸门)。 +// - summary:人类可读的问题摘要(一句)。 +// - locator:必须能让 fix stage 定位到文件(含 `` 或 `:`), +// 否则在 reviewWithFixLoop 里直接判违约 HALT。 +// - severity:blocker/high/medium/low,方便后续把 low/medium 降级为 suggestion 而不卡循环。 const REVIEW_SCHEMA = { type:'object', additionalProperties:false, required:['verdict','round','issues'], properties:{ verdict:{type:'string',enum:['approve','request-changes']}, - round:{type:'integer'}, issues:{type:'array',items:{type:'string'}} } } + round:{type:'integer'}, + issues:{ type:'array', items:{ + type:'object', additionalProperties:false, + required:['summary','locator','severity'], + properties:{ + summary:{type:'string'}, + locator:{type:'string'}, + severity:{type:'string', enum:['blocker','high','medium','low']} } } } } } + +// STAGE_RESULT_SCHEMA:派生 stage(spec/plan/tdd/verify/fix/report)的统一结构化返回。 +// - status=ok:本步骤产出可用,artifactPath 必填(spec/plan/verify/report 的落盘文件), +// summary 可放给下游用作 prompt 上下文。tdd/fix 没有单一 artifact,artifactPath 可省。 +// - status=halt:sub-agent 已经决定无法继续(缺值 / 越界 / 重试到顶),把阻塞点写进 reason, +// JS 端读到立即 throw `HALT …`,让 fail-fast 顺序 for-await 冒泡到模块主循环 try。 +// - 无 schema 时,sub-agent 可以"写一段散文说我跑不下去了,但仍然算成功返回"——这是真正的 +// fail-fast 漏洞;加 schema 后所有派生 stage 都有显式 halt 通道。 +const STAGE_RESULT_SCHEMA = { type:'object', additionalProperties:false, + required:['status'], properties:{ + status:{type:'string', enum:['ok','halt']}, + reason:{type:'string'}, + artifactPath:{type:'string'}, + summary:{type:'string'} } } const GATE_SCHEMA = { type:'object', additionalProperties:false, required:['status'], properties:{ status:{type:'string',enum:['green','red']}, failures:{type:'array',items:{type:'string'}} } } +// ── 微步骤 schemas(runBranchSetup / runMilestone / runCrossModule 用)───────── +// 这三个阶段是纯机械的 git/文件操作 + 条件跳过;与其让子代理读"1. 2. 3. 若 X 则跳过"的散文 +// 流程,不如把"observe → JS branch → execute"切成多个 agent 微步骤,每步带强 schema 返回。 +// 这样:(a) 跳过/分支条件由 JS 判定(不再依赖 LLM 读散文条件),idempotency 一致; +// (b) 每步语义单一、prompt 短,schema 校验阻断畸形返回; +// (c) action 步统一返回 ACTION_RESULT_SCHEMA(success/error),失败可由 JS 抛错 halt。 +const WT_SCHEMA = { type:'object', additionalProperties:false, + required:['clean'], properties:{ + clean:{type:'boolean'}, + dirty:{type:'array', items:{type:'string'}} } } + +const DEFAULT_BRANCH_SCHEMA = { type:'object', additionalProperties:false, + required:['branch'], properties:{ branch:{type:'string'} } } + +const EXISTS_SCHEMA = { type:'object', additionalProperties:false, + required:['exists'], properties:{ exists:{type:'boolean'} } } + +const CURRENT_BRANCH_SCHEMA = { type:'object', additionalProperties:false, + required:['branch'], properties:{ branch:{type:'string'} } } + +const FIELD_VALUE_SCHEMA = { type:'object', additionalProperties:false, + required:['found','value'], properties:{ + found:{type:'boolean'}, + value:{type:'string'}, + lineNumber:{type:'integer'} } } + +// CHECKBOX_STATE_SCHEMA:docs/08 中 `- [ ] REQ-XXX-NNN ...` / `- [x] FE-NN ...` 这类功能行 +// 的勾选态。把"审阅 approve 后 flip checkbox"从 reviewPrompt 的隐式 side-effect 改为可观测 +// 的 read-then-write micro step(参见 reviewWithFixLoop 的 approve 分支)。 +// 注意:state 必填——schema 只 require found 时,sub-agent 返回 {found:true} 而漏掉 state 仍合法, +// 上层 if (cb.state === 'unchecked') 会静默落入"已 checked"分支,docs/08 与 review 裁决悄悄背离。 +const CHECKBOX_STATE_SCHEMA = { type:'object', additionalProperties:false, + required:['found','state'], properties:{ + found:{type:'boolean'}, + state:{type:'string', enum:['checked','unchecked']}, + lineNumber:{type:'integer'} } } + +// TAG_REPORT_FRESHNESS_SCHEMA:当 milestone tag 已存在(resume 或前一轮残留)时,校验 +// tag 指向的 commit 是否包含已落地的 § ⑫ 值。旧版 bug:tag → § ⑫ commit 的错序会让 tag +// 指向 \`{{milestone_tag}}\` 占位符 commit;新顺序(report → tag)下不会再产生,但保留 +// freshness 自检以发现历史残留。 +const TAG_REPORT_FRESHNESS_SCHEMA = { type:'object', additionalProperties:false, + required:['fresh'], properties:{ + fresh:{type:'boolean'}, + tagReportValue:{type:'string'} } } + +const ALREADY_MERGED_SCHEMA = { type:'object', additionalProperties:false, + required:['alreadyMerged'], properties:{ alreadyMerged:{type:'boolean'} } } + +const REPORT_PATH_SCHEMA = { type:'object', additionalProperties:false, + required:['found'], properties:{ + found:{type:'boolean'}, + path:{type:'string'}, + currentTagValue:{type:'string'} } } + +const CHANGED_FILES_SCHEMA = { type:'object', additionalProperties:false, + required:['files'], properties:{ + files:{type:'array', items:{type:'object', additionalProperties:false, + required:['status','path'], + properties:{ status:{type:'string'}, path:{type:'string'} } } } } } + +const CROSS_CLASSIFY_SCHEMA = { type:'object', additionalProperties:false, + required:['crossModule'], properties:{ + crossModule:{type:'array', items:{type:'object', additionalProperties:false, + required:['file','targetModule','reason','impact'], + properties:{ file:{type:'string'}, targetModule:{type:'string'}, + reason:{type:'string'}, impact:{type:'string'} } } } } } + +// 所有 action 步骤(写文件 / git 改写仓库状态)统一返回 success/error;JS 据此抛错 halt。 +const ACTION_RESULT_SCHEMA = { type:'object', additionalProperties:false, + required:['success'], properties:{ + success:{type:'boolean'}, + error:{type:'string'}, + detail:{type:'string'} } } + const ROOT = args?.projectRoot || '.' +// 子代理在 ${ROOT} 路径上跑 git -C / Read / Edit。相对路径 '.' 会绑定到子代理隐式 cwd,无保证。 +// 必须由 coding-start 显式传绝对路径;否则 fail-fast 让人工修复入口而不是在错路径上静默打 tag。 +if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) { + throw new Error(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`) +} // ============================================================================ // Stage prompt builders(纯字符串构造;只用 ROOT / id / phase / 入参,不触非确定性内建) // -// 每个 prompt 的共同契约(见 commonContract): +// 每个 prompt 的共同契约(见 featureStageContract): // - 子代理是非交互的,物理上无法弹窗;缺任何值都不要"问人"——把具体阻塞点写进产物并失败。 // - phase=backend 与 phase=frontend 的差异(路径作用域 / id 形态 / 测试命令来源)逐条写明。 // - 所有输出文档用中文。 @@ -53,8 +167,25 @@ const ROOT = args?.projectRoot || '.' function isFrontend(phase) { return phase === 'frontend' } +// 从 spec/plan 等 artifactPath 文件名提取 `YYYY-MM-DD` 前缀,下游所有日期相关产物(plan / verify / +// review report)一律复用同一日期,避免长跑或次日 resume 时各 sub-agent 各自解析"今天"导致路径分叉。 +// 纯字符串运算,不触发非确定性内建(Workflow runtime 仅禁用 time/random builtin)。 +function dateFromArtifactPath(artifactPath) { + const fname = (artifactPath || '').split('/').pop() || '' + const m = fname.match(/^(\d{4}-\d{2}-\d{2})-/) + if (!m) throw new Error(`HALT invalid-artifactPath: 文件名缺少 YYYY-MM-DD 前缀 (${JSON.stringify(artifactPath)})`) + // 进一步排查 pattern 合法但语义无效的日期(如 9999-99-99-foo.md): + // 正则只判位数;下面校验年/月/日落在真实日历范围内,防止下游 plan/verify 以无意义日期级联生成产物。 + const [, yStr, moStr, dStr] = m[0].match(/^(\d{4})-(\d{2})-(\d{2})-/) || [] + const y = Number(yStr), mo = Number(moStr), d = Number(dStr) + if (!(y >= 2024 && y <= 2099) || !(mo >= 1 && mo <= 12) || !(d >= 1 && d <= 31)) { + throw new Error(`HALT invalid-date-prefix: 文件名日期前缀语义无效 (${JSON.stringify(artifactPath)}),年须在 2024-2099、月 1-12、日 1-31`) + } + return m[1] +} + // 所有子代理共享的"非交互静默"硬约束。 -function commonContract(phase) { +function featureStageContract(phase) { const fe = isFrontend(phase) return [ '## 硬约束(非交互子代理)', @@ -106,7 +237,7 @@ function deriveSpecPrompt(id, phase) { return [ `# ${fe ? 'fe-feature-brainstorm' : 'feature-brainstorm'} — 派生规格 ${id}`, '', - commonContract(phase), + featureStageContract(phase), '', '## 目标', `静默派生 \`${id}\` 的实现规格(无 Q&A)。需求歧义本应在 Plan 期的结构化 per-REQ 表单 / 前端 scope-lock 锁定;这里**只消费已锁定的事实**,不再澄清。`, @@ -127,31 +258,40 @@ function deriveSpecPrompt(id, phase) { ].join('\n'), '', '## 写 spec', - `- 落盘 \`${ROOT}/docs/superpowers/specs/<当天日期 YYYY-MM-DD>-${id}.md\`(当天日期由你在自身上下文解析,脚本不传日期)。`, + `- 落盘路径:\`docs/superpowers/specs/<当天日期 YYYY-MM-DD>-${id}.md\`(项目根相对)。当天日期由你在自身上下文解析;**spec 是本功能链上唯一会解析"今天"的 stage**,下游 plan/verify/review 的产物日期一律复用本 spec 文件名前缀(脚本会从 artifactPath 读取)。`, + `- 若已经存在 \`docs/superpowers/specs/*-${id}.md\`(resume 场景),**复用最新一份的日期前缀**,不要起新日期前缀的文件;按需 Edit 已存在的 spec 而不是另起新文件。`, fe ? '- 规格至少含:关联 REQ + 关联原型;组件树(按页面 / 区域分块,推导自 prototype DOM);页面状态机(loading / empty / error / 正常 / 表单提交中 至少 5 态);消费的后端端点(对齐 docs/05);业务规则前端复刻清单(逐条:规则 / 触发时机 / 报错文案 / 来源 REQ);Design Tokens 引用清单(`var(--color-*)`)。' : '- 规格覆盖:goal / 输入输出 / 业务规则 / 约束 / schema / API 引用 / acceptance criteria。', '', + '## commit', + `- 写完 spec 后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`, + ` 1. \`git -C ${ROOT} add \``, + ` 2. \`git -C ${ROOT} commit -m "docs(spec:${id}): 派生规格"\``, + '- commit 失败 → halt,把 stderr 摘要写进 reason。', + '', '## 自审(inline 修,无须等待)', `- 占位符扫描:\`TBD\` / \`TODO\` / \`【人工填写:】\`${fe ? ' / `controller` / `service` / `SQL` / `migration`(前端 spec 不应出现后端字样)' : ''} → 命中即修;修不掉的缺值按硬约束失败。`, '- 内部一致性 / 范围检查(单 plan 能消化吗)/ 歧义检查(任一 requirement 两种解读 → 挑一个写明)。', '', - '## 结束', - `- 成功:输出一行 \`${fe ? 'fe-' : ''}feature-brainstorm: ${id} → \`,把该 spec 路径作为本步骤结果返回(供下游 plan stage 使用)。`, - '- 不要输出"交给下一步 / 等待检查"之类的桥接叙述。', + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', + '- 成功:`{ "status": "ok", "artifactPath": "docs/superpowers/specs/YYYY-MM-DD-' + id + '.md", "summary": "<1-2 句中文摘要>" }`', + '- 失败:`{ "status": "halt", "reason": "<缺值阻塞点:缺哪个值 / 应在哪个 Plan 闸门锁定 / 为何无法继续>" }`', + '- `artifactPath` 必须为项目根相对路径(无前导斜杠),文件名首段必须是 `YYYY-MM-DD`;schema 是 `additionalProperties:false`,不要返回额外字段。', ].join('\n') } // ---- stage 2:spec → 任务级 TDD 计划(原 feature-plan / fe-feature-plan)---- -function planPrompt(id, phase, spec) { +// specPath:调用方传入的 spec artifactPath(含 YYYY-MM-DD 前缀),plan 复用该日期。 +function planPrompt(id, phase, specPath) { const fe = isFrontend(phase) return [ `# ${fe ? 'fe-feature-plan' : 'feature-plan'} — 任务级计划 ${id}`, '', - commonContract(phase), + featureStageContract(phase), '', '## 输入', - `- 上游 spec:${spec ? `\`${spec}\`` : `\`${ROOT}/docs/superpowers/specs/<当天日期>-${id}.md\``}(不存在则失败)。`, + `- 上游 spec:\`${specPath}\`(已由 spec stage 落盘;不存在则 halt)。**plan 文件名日期前缀必须与 spec 一致**:取 spec 文件名首段 \`YYYY-MM-DD\`,写到 plan 路径,不要重新解析"今天"。`, fe ? `- \`${ROOT}/docs/04-技术规范.md § 一 前端架构\`(路由 / 状态库 / 组件目录约定 / 测试栈);\`${ROOT}/docs/09-项目目录结构.md § 前端目录结构\`(落盘位置)。用 Grep 在 \`${ROOT}/frontend/\` 定位现有文件。` : `- \`${ROOT}/docs/04-技术规范.md\` 与 \`${ROOT}/docs/09-项目目录结构.md\`(编码规范 + 目录规范)。用 Grep 在现有代码定位待修改文件。`, @@ -172,27 +312,36 @@ function planPrompt(id, phase, spec) { '- 首次出现的类 / 方法 / 组件 / hook / API client 函数必须给出签名;跨 task 的签名 / 错误码 / props 类型必须一致。', '', '## 写 plan + 自审', - `- 落盘 \`${ROOT}/docs/superpowers/plans/<当天日期 YYYY-MM-DD>-${id}.md\`,文件头含 Goal / Architecture / Tech Stack + checkbox 任务。`, + `- 落盘路径:\`docs/superpowers/plans/<同 spec 的 YYYY-MM-DD>-${id}.md\`,文件头含 Goal / Architecture / Tech Stack + checkbox 任务。`, '- 自审:占位符扫描(按硬约束清单);spec coverage(spec 每节至少指向一个 task,补 gap);类型一致性(签名 / 方法名 / 错误码 / props 一致)。', '', - '## 结束', - `- 成功:输出一行 \`${fe ? 'fe-' : ''}feature-plan: ${id} → \`,把该 plan 路径作为结果返回。`, + '## commit', + `- 写完 plan 后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`, + ` 1. \`git -C ${ROOT} add \``, + ` 2. \`git -C ${ROOT} commit -m "docs(plan:${id}): 任务级 TDD 计划"\``, + '- commit 失败 → halt,把 stderr 摘要写进 reason。', + '', + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', + '- 成功:`{ "status": "ok", "artifactPath": "docs/superpowers/plans/YYYY-MM-DD-' + id + '.md", "summary": "<1-2 句中文摘要:任务数 / 涉及文件作用域>" }`', + '- 失败:`{ "status": "halt", "reason": "<阻塞点描述>" }`', + '- 日期前缀必须与 spec 同;schema 是 `additionalProperties:false`。', ].filter(Boolean).join('\n') } // ---- stage 3:按 plan 逐任务 TDD(原 feature-tdd / fe-feature-tdd)---- -function tddPrompt(id, phase, plan) { +// planPath:上游 plan artifactPath;ledger 是 prompt 层的显式自约束(无 harness 强制)。 +function tddPrompt(id, phase, planPath) { const fe = isFrontend(phase) return [ `# ${fe ? 'fe-feature-tdd' : 'feature-tdd'} — 逐任务 TDD ${id}`, '', - commonContract(phase), + featureStageContract(phase), '', '## 输入', - `- 计划文件:${plan ? `\`${plan}\`` : `\`${ROOT}/docs/superpowers/plans/<当天日期>-${id}.md\``}(不存在则失败)。`, + `- 计划文件:\`${planPath}\`(不存在则 halt)。`, `- 测试命令来源:\`${ROOT}/docs/04-技术规范.md § 零\`${fe ? ' 的 `frontend.unit_test_runner` / `frontend.e2e_runner` / `frontend.test_command` / `frontend.e2e_command`(缺失则默认 `pnpm test:ci` / `pnpm e2e:ci`)。' - : ' 确认的后端测试命令(如 Maven profile / `./scripts/test.mjs`)。'}`, + : ' 确认的后端测试命令(如 Maven profile / `./scripts/test.mjs`);缺失则默认 `node scripts/test.mjs`(与 test-gate 一致)。'}`, '', '## 流程', fe ? '' : '- **Schema 改动前置**(仅当 plan 声明需要):第一个任务写 migration 文件 `V__.sql`(`` = 现有 `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) { fe ? '- **绝不**写非 `frontend/`(或 docs/09 前端根)路径的 `impl_file`;命中 `backend/` / `sql/` / `scripts/` → 硬停并打印 `不允许写非前端文件:`。' : '- **后端阶段路径硬护栏**:任意 `impl_file` 以 `frontend/` 开头 → 硬停并打印 `后端阶段不允许写前端代码:`,不再继续 TDD。', - '- 每次 commit 含 REQ/FE 标签,不混合无关改动。', - '- **同一测试修复超过 10 次仍失败 → 立即失败(halt)**,把"哪个测试、失败断言、已尝试的修复"写进诊断;**不要**问人、不要无限重试。', + '- 每次 commit 含 REQ/FE 标签,不混合无关改同。', + '', + '## 同测试重试账本(硬上限 10 次 / 测试)', + '- 你必须**显式**为每个出现过红色的测试维护一个内存账本 `attempts[::] = N`,每次该测试的"写失败实现 → 再跑"算 1 次。', + '- 每次失败跑后,**在自身输出中显式打印一行** JSON:`{ "attempts": { "::": N } }`(便于 review/审计追溯)。', + '- 任一测试的 `attempts >= 10` → **立刻 halt**:返回 `{status:"halt", reason:"tdd-test-stuck: :: 已尝试 10 次"}`,把"该测试名 / 最近一次 failing_assertion / 已尝试的修复摘要"写进 reason,**不要**无限重试。', '', - '## 结束', - `- 全部任务通过:输出一行 \`${fe ? 'fe-' : ''}feature-tdd: ${id} 完成\`,把"已实现 + 已 commit"摘要作为结果返回(供 verify stage)。`, + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', + '- 全部任务通过:`{ "status": "ok", "summary": "<完成的任务数 / 引入的文件清单摘要>" }`(artifactPath 可省)。', + '- 任意护栏 / 账本上限 / 缺值 → `{ "status": "halt", "reason": "<具体阻塞点>" }`。', ].filter(Boolean).join('\n') } // ---- stage 4:把功能测试派子会话跑,渲染证据(原 feature-verify / fe-feature-verify)---- -function verifyPrompt(id, phase, impl) { +// specPath:用于复用日期前缀;round:0 = TDD 后初次 verify,1..5 = fix 后 reverify(每轮独立证据文件, +// 避免 reverify 覆盖前轮证据)。 +function verifyPrompt(id, phase, implSummary, specPath, round = 0) { const fe = isFrontend(phase) + const suffix = round === 0 ? 'verify' : `verify-r${round}` return [ - `# ${fe ? 'fe-feature-verify' : 'feature-verify'} — 证据验证 ${id}`, + `# ${fe ? 'fe-feature-verify' : 'feature-verify'} — 证据验证 ${id}${round > 0 ? `(第 ${round} 轮 fix 后复验)` : ''}`, '', - commonContract(phase), + featureStageContract(phase), '', '## 目标', `把 \`${id}\` 的功能测试**派发到 Agent 子会话**执行,按结构化结果渲染证据。**主会话从不直接跑测试,也不自由编写证据。**`, - impl ? `(上游 TDD 摘要:${impl})` : '', + `- 上游 spec:\`${specPath}\`(日期前缀来源);本次产物文件名前缀必须 = spec 文件名首段 \`YYYY-MM-DD\`。`, + implSummary ? `- 上游 TDD 摘要:${implSummary}` : '', '', '## 流程', fe ? [ `- 测试目标:从 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\`)。`, '- 派子会话依次跑 unit + e2e,子会话只返回结构化 JSON:`{ unit:{command,exit_code,passed,failed,failed_list,stdout_excerpt}, e2e:{...同结构} }`(`stdout_excerpt` ≤ 30 行)。', - '- **任一目标 `exit_code != 0` 或 `failed > 0`** → 渲染证据后失败,不进入 review。', + '- **任一目标 `exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt,不进入 review。', ].join('\n') : [ `- 测试目标:从 plan 或项目标准命令确定(Maven profile / pnpm script / pytest path / \`${ROOT}/docs/04-技术规范.md § 零\` 的后端命令)。`, '- 派子会话执行,子会话只返回结构化 JSON:`{command, exit_code, passed, failed, failed_list, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行,不塞全文 stdout)。', - '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后失败,不进入 review。', + '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt,不进入 review。', ].join('\n'), - `- 证据渲染并打印到会话;如需落盘,写 \`${ROOT}/docs/superpowers/reviews/\` 旁的证据位(沿用项目既有约定)。`, + `- 证据落盘路径固定为 \`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}-${suffix}.md\`(与 review 报告同目录;round=0 → \`-verify.md\`;round>=1 → \`-verify-r.md\`,**每轮独立文件不覆盖前轮**)。同时把核心结构化结果摘要打印到会话便于上层 review stage 引用,**不要**自行另起目录或自由命名文件。`, + '', + '## commit', + `- 写完证据后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`, + ` 1. \`git -C ${ROOT} add <证据 artifactPath>\``, + ` 2. \`git -C ${ROOT} commit -m "docs(verify:${id}${round > 0 ? `:r${round}` : ''}): 证据验证"\``, + '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的证据路径)。', '', - '## 结束', - `- 全部通过:输出一行 \`${fe ? 'fe-' : ''}feature-verify: ${id} 通过\`,把验证摘要作为结果返回(供 review stage)。`, + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', + `- 全部通过:\`{ "status": "ok", "artifactPath": "docs/superpowers/reviews/YYYY-MM-DD-${id}-${suffix}.md", "summary": "" }\`。`, + '- 任一红色 / 越界 / 缺值 → `{ "status": "halt", "reason": "<具体阻塞点>", "artifactPath": "<已写入的证据路径(如有)>" }`。', ].filter(Boolean).join('\n') } // ---- stage 5a:AI 自审 diff(原 feature-review / fe-feature-review)——委托统一 reviewer agent ---- -function reviewPrompt(id, phase, round) { +// lastVerifySummary:round>1 时传入上轮 fix 后复验摘要,让 reviewer 看到"上轮 must-fix 真的修了什么"。 +// specPath:spec artifactPath(日期前缀来源 + reviewer 上下文输入)。 +function reviewPrompt(id, phase, round, lastVerifySummary, specPath) { const fe = isFrontend(phase) return [ `# ${fe ? 'fe-feature-review' : 'feature-review'} — AI 自审 ${id}(第 ${round} 轮)`, '', - commonContract(phase), + featureStageContract(phase), '', '## 目标', `对 \`${id}\` 本轮引入的代码 diff 做 AI 自审,给出 \`approve\` 或 \`request-changes\` 裁决。`, '', '## 输入给 reviewer', - `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${ROOT}/docs/superpowers/specs/<当天日期>-${id}.md\`。`, + `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}\`。`, fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '', - `- **phase = ${fe ? 'frontend → 附加前端 7 维 checklist(a11y / 对比度 / 响应式 等);主观维度仅标记明显问题,不因主观判断触发 request-changes(避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`, + `- **phase = ${fe ? 'frontend → 附加前端 7 维 checklist。其中仅"颜色对比度"(§3 子项)与"响应式"(§4)为主观/best-effort,绝不单独触发 request-changes;a11y 的 label/键盘可达/危险操作确认等客观项仍可作 must-fix(与 agents/code-reviewer.md §3-4 对齐,避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`, + round > 1 && lastVerifySummary + ? `\n## 上轮 fix 后复验摘要(round ${round - 1})\n${lastVerifySummary}\n\n你必须把"上轮 must-fix 在本轮 diff 中是否真的被修"作为本轮裁决的核心维度。已修的不要再次纳入 must-fix;未修 / 修得不对,单点列入 issues。` + : '', '', '## 输出(必须符合下发的 REVIEW JSON schema)', - `- \`verdict\`: \`approve\` | \`request-changes\`;\`round\`: 整数(本轮 = ${round});\`issues\`: must-fix 问题清单(approve 时可空数组)。`, - `- 渲染审阅报告写入 \`${ROOT}/docs/superpowers/reviews/<当天日期 YYYY-MM-DD>-${id}.md\`(\`verdict\` 字段与返回值一致——router / 进度判定靠它)。`, - `- approve 时,把 \`${ROOT}/docs/08-模块任务管理.md\` ${fe ? '§ 三' : '§ 二'} 中本 ${fe ? 'FE' : 'REQ'} 的 \`- [ ] ${id} ...\` 改为 \`- [x] ${id} ...\`(功能级可视化;模块完成仍以里程碑 tag 为准)。`, - '- 不要返回额外字段(schema 为 `additionalProperties:false`)。', + `- \`verdict\`: \`approve\` | \`request-changes\`;\`round\`: 整数(本轮 = ${round})。`, + `- \`issues\`: 结构化 must-fix 数组。\`approve\` 时必须为空数组 \`[]\`;\`request-changes\` 时**必须非空**,每项形如 \`{ "summary": "<一句问题>", "locator": "<文件路径或 file:line>", "severity": "blocker|high|medium|low" }\`。`, + `- \`locator\` **必须含可定位文件路径**(项目根相对,例如 \`backend/src/main/java/.../FooController.java\` 或 \`frontend/src/views/Bar.vue:42\`);没有具体文件无法定位 → 该项不是 must-fix(降级为口头建议,不要塞进 issues)。`, + `- 渲染审阅报告写入 \`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md\`(\`verdict\` 字段与返回值一致)。报告内可写更丰富的建议 / 风险 / 亮点;issues 数组只放硬性 must-fix。`, + `- **不要**在本步骤里编辑 docs/08 的 \`- [ ]\` checkbox——该 side effect 由上层 Workflow 的 micro step 在 approve 后另行落盘(你只负责裁决)。`, + '- 不要返回额外字段(schema 是 `additionalProperties:false`)。', + '', + '## commit', + `- 写完审阅报告后必须 commit(milestone 的 worktree-clean 前置依赖此 commit;该 commit 与 verdict 无关,approve 或 request-changes 都要 commit 报告本身):`, + ` 1. \`git -C ${ROOT} add docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md\``, + ` 2. \`git -C ${ROOT} commit -m "docs(review:${id}:r${round}): "\``, + '- commit 失败时仍按 schema 返回 verdict / issues;commit 错误信息打印到日志即可(不要在 schema 中夹带额外字段)。', ].filter(Boolean).join('\n') } // ---- stage 5b:按 review must-fix 修复并重新 commit(review 循环的 fix 步)---- +// issues:结构化对象数组 {summary, locator, severity}(见 REVIEW_SCHEMA)。 function fixPrompt(id, phase, issues) { const fe = isFrontend(phase) const list = Array.isArray(issues) && issues.length - ? issues.map((x, i) => ` ${i + 1}. ${x}`).join('\n') - : ' (上一轮 review 的 must-fix 清单——见 ' + (fe ? '§三' : '§二') + ' 对应 review 报告)' + ? issues.map((x, i) => ` ${i + 1}. [${x.severity}] ${x.summary} — locator: \`${x.locator}\``).join('\n') + : ' (上一轮 review 未提供 must-fix 清单——不应出现,调用方会先 halt)' return [ `# ${fe ? 'fe-feature' : 'feature'} fix — 修复 review must-fix ${id}`, '', - commonContract(phase), + featureStageContract(phase), '', - '## 待修复 must-fix', + '## 待修复 must-fix(已结构化)', list, '', '## 流程', - '- 逐项编辑 must-fix 指向的代码文件(遵守阶段路径作用域护栏)。', - `- 修复后 commit:\`fix(): 修复 review must-fix ${fe ? `REQ_ID: ${id}` : id}\`(不混合无关改动)。`, + '- 逐项编辑 locator 指向的代码文件(遵守阶段路径作用域护栏)。', + `- 编辑前必须先校验 locator 文件存在:跑 \`git -C ${ROOT} cat-file -e HEAD:\`(locator 形如 \`path:line\` 时取 \`path\`)。文件不存在 → halt,把 locator 写进 reason,不要"修一个不存在的文件"。`, + fe + ? '- **硬护栏(与 tdd 同款)**:命中 `backend/` / `sql/` / `scripts/` → halt 并把 file 路径写进 reason。' + : '- **硬护栏(与 tdd 同款)**:任一被编辑文件以 `frontend/` 开头 → halt 并把 file 路径写进 reason。', + `- 修复后 commit:\`fix(): 修复 review must-fix ${fe ? `FE: ${id}` : `REQ: ${id}`}\`(不混合无关改动)。`, '- 修复完成后本步骤即结束;上层 Workflow 会重新跑 verify + review(下一轮)。', - '- **缺值仍不要问人**:按硬约束把阻塞点写进诊断并失败。', '', - '## 结束', - `- 输出一行 \`${fe ? 'fe-' : ''}feature-fix: ${id} 已修复 ${Array.isArray(issues) ? issues.length : ''} 项\`。`, - ].join('\n') + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', + `- 全部修完:\`{ "status": "ok", "summary": "<已修复 ${Array.isArray(issues) ? issues.length : 0} 项的 1-2 句摘要>" }\`。`, + '- 任意阻塞(locator 文件不存在 / 越界 / 缺值)→ `{ "status": "halt", "reason": "<具体阻塞点 + locator>" }`。', + ].filter(Boolean).join('\n') } // ---- 测试闸(原 test-gate)---- -function gatePrompt(module, phase) { +// attempt:1 = 首次跑;2 = 上轮 red 后的 flake 重试。每次 attempt 写到独立证据文件,避免 retry +// 把首次 red 证据覆盖掉(report § ⑤ 失去 flake 信号)。 +function gatePrompt(module, phase, attempt = 1) { const fe = isFrontend(phase) const id = module?.id ?? '' + const phaseId = fe ? 'frontend-phase' : id return [ - `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase})`, + `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase}, attempt=${attempt})`, '', - commonContract(phase), + featureStageContract(phase), '', '## 目标', `打里程碑 tag 前的唯一硬测试门。**派发 Agent 子会话**跑测试,绿则通过,红则失败。**绝不**在主会话直接跑测试,红色时**绝不**跳过。`, + attempt > 1 ? `- 本次 = 第 ${attempt} 次(上一次 red,本轮用于辨识 flaky);证据**写到独立文件**不要覆盖前一次。` : '', '', '## 命令', fe @@ -313,33 +500,374 @@ function gatePrompt(module, phase) { '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。', '', '## 证据 + commit', - `- 渲染证据写入 \`${ROOT}/docs/superpowers/module-reports/${fe ? 'frontend-phase' : `${id}`}-test-gate.md\` 并 commit 到当前分支(保证证据随里程碑可审计)。`, + `- 渲染证据写入 \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r${attempt}.md\` 并 commit 到当前分支(每个 attempt 独立文件,retry 不覆盖前一次 red 证据)。`, + `- 文件头注明 \`attempt: ${attempt}\` + 命令 + 时间窗口(如可从子会话拿到),便于 report § ⑤ 识别 flake。`, '', '## 输出(必须符合下发的 GATE JSON schema)', '- `status`: `green`(`exit_code = 0` 且 `failed = 0`)| `red`;`failures`: 失败用例摘要(green 时可省略 / 空数组)。', '- 不要返回额外字段。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', + ].filter(Boolean).join('\n') +} + +// ---- 微步骤 prompt builders(runBranchSetup / runMilestone / runCrossModule 用)---- +// 每个 prompt 单职责、短文本;返回严格 schema;执行(action)步统一返回 ACTION_RESULT_SCHEMA。 +function microStepContract() { + return [ + '## 硬约束(非交互子代理)', + '- 你是 Workflow 派生的**非交互子代理**,绝不弹问。', + '- 全部输出**使用中文**。', + `- 项目根 = \`${ROOT}\`。所有 git 命令必须用 \`git -C ${ROOT} ...\`;Read/Edit/Write 的路径都以 \`${ROOT}\` 为根。`, + '- 严格按下方"输出"段返回 schema 字段;**不要**在 schema 外追加自由叙述。', ].join('\n') } -// ---- 跨模块改动记录(替代被删的 cross-module hook + cross-module-log skill)---- -function crossModulePrompt(module) { - const id = module?.id ?? '' +// ── 微步骤:可重用 read(多个 orchestrator 共用)── +function detectDefaultBranchPromptM() { return [ - `# cross-module-log — 记录模块 ${id} 的跨模块改动`, + '# 检测本地默认分支', + microStepContract(), '', - commonContract('backend'), + `用 \`git -C ${ROOT} rev-parse --verify \` 依次试 \`main\` / \`master\`,取第一个 exit=0 的为默认分支。`, + '## 输出(DEFAULT_BRANCH_SCHEMA)', + '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`', + '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。', + ].join('\n') +} + +function worktreeCleanPromptM() { + return [ + '# 检查工作树是否干净', + microStepContract(), '', - '## 目标', - `替代被删的 \`log-cross-module\` hook + \`cross-module-log\` skill:扫描本模块周期内对**非本模块**文件的改动,落跨模块日志(原因 + 影响评估),供 module-report § ⑦ 嵌入。`, + `跑 \`git -C ${ROOT} status --porcelain\`,按行解析 dirty 文件路径(第 4 字符起)。`, + '## 输出(WT_SCHEMA)', + '- 干净:`{ "clean": true }`', + '- 不干净:`{ "clean": false, "dirty": ["", ...] }`', + ].join('\n') +} + +function checkBranchExistsPromptM(branch) { + return [ + `# 本地分支 \`${branch}\` 是否存在`, + microStepContract(), + '', + `跑 \`git -C ${ROOT} rev-parse --verify ${branch}\`(用 2>/dev/null 抑制 stderr)。`, + '## 输出(EXISTS_SCHEMA)', + '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`', + ].join('\n') +} + +function currentBranchPromptM() { + return [ + '# 当前所在分支', + microStepContract(), + '', + `跑 \`git -C ${ROOT} rev-parse --abbrev-ref HEAD\`。`, + '## 输出(CURRENT_BRANCH_SCHEMA)', + '- `{ "branch": "" }`', + ].join('\n') +} + +// ── 微步骤:分支生命周期 action ── +function checkoutExistingBranchPromptM(branch) { + return [ + `# 切到已存在的本地分支 \`${branch}\``, + microStepContract(), + '', + `跑 \`git -C ${ROOT} checkout ${branch}\`。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 成功:`{ "success": true }`', + '- 失败:`{ "success": false, "error": "" }`', + ].join('\n') +} + +function createBranchFromPromptM(fromBranch, newBranch) { + return [ + `# 从 \`${fromBranch}\` 新建并切到 \`${newBranch}\``, + microStepContract(), + '', + `按序跑:\`git -C ${ROOT} checkout ${fromBranch}\`,然后 \`git -C ${ROOT} checkout -b ${newBranch}\`。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 全成功:`{ "success": true }`;任一失败:`{ "success": false, "error": "" }`', + ].join('\n') +} + +// ── 微步骤:REQ/FE 完成态 git tag(featureLoop dedup 的唯一 ground truth)── +// req-done/ 是功能级 git tag,approve 时打一次;featureLoop 入口先 check,存在就 skip, +// 避免 Router LLM 自审失误导致已 approve 的 REQ 被重新 spec→plan→tdd(撞 V、污染源码)。 +function checkReqDoneTagPromptM(id) { + return [ + `# tag \`req-done/${id}\` 是否存在(功能级 dedup 真值)`, + microStepContract(), + '', + `跑 \`git -C ${ROOT} tag -l req-done/${id}\`。`, + '## 输出(EXISTS_SCHEMA)', + '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`', + ].join('\n') +} + +function createReqDoneTagPromptM(id, phase) { + return [ + `# 打 annotated tag \`req-done/${id}\`(${phase==='frontend'?'前端 FE':'后端 REQ'} approve 后落地)`, + microStepContract(), + '', + `跑 \`git -C ${ROOT} tag -a req-done/${id} -m "feature(${id}): approved by code-reviewer (phase=${phase})"\`。`, + `先用 \`git -C ${ROOT} tag -l req-done/${id}\` 检查;已存在则视为成功(幂等)直接返回 success。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "" }`', + ].join('\n') +} + +// ── 微步骤:milestone 专用 ── +function checkAlreadyMergedPromptM(branch, defaultBranch) { + return [ + `# \`${branch}\` 是否已合入 \`${defaultBranch}\``, + microStepContract(), + '', + `先跑 \`git -C ${ROOT} checkout ${defaultBranch}\` 确保 HEAD 在 ${defaultBranch};然后跑 \`git -C ${ROOT} merge-base --is-ancestor ${branch} HEAD\`。`, + '## 输出(ALREADY_MERGED_SCHEMA)', + '- 第二条 exit=0 → `{ "alreadyMerged": true }`(功能分支已是 HEAD 祖先,无需再 merge)', + '- 非 0 → `{ "alreadyMerged": false }`', + '- checkout 自身失败 → 整步失败(schema 失败即可)。', + ].join('\n') +} + +function executeMergePromptM(defaultBranch, branch, phaseId) { + return [ + `# 把 \`${branch}\` 合并进 \`${defaultBranch}\`(已确认尚未合入,已在默认分支)`, + microStepContract(), + '', + `跑 \`git -C ${ROOT} merge --no-ff ${branch} -m "merge(${phaseId}): integrate ${branch}"\`。`, + '- 成功 → `{ "success": true }`', + '- 合并冲突 / 其它失败 → `{ "success": false, "error": "", "detail": "" }`', + '- **不要**自动 \`git merge --abort\` / 自动 stash / 自动改文件——把树留给人工处理。', + '## 输出(ACTION_RESULT_SCHEMA)', + ].join('\n') +} + +function readDocs08FieldPromptM(fe, id) { + if (fe) { + return [ + '# 读 docs/08 § 三 `整体里程碑:` 字段当前值', + microStepContract(), + '', + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 三(前端阶段)下的 \`- 整体里程碑: \` 行。`, + '## 输出(FIELD_VALUE_SCHEMA)', + '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <该行 1-based 行号> }`', + '- § 三 或该行不存在:`{ "found": false, "value": "" }`', + ].join('\n') + } + return [ + `# 读 docs/08 § 二 模块 \`${id}\` 的 \`里程碑:\` 字段当前值`, + microStepContract(), + '', + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 二 中 module id == \`${id}\` 的 bullet 段,取其 \` - 里程碑: \` 子项。`, + '## 输出(FIELD_VALUE_SCHEMA)', + '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <行号> }`', + `- 模块 \`${id}\` 或该字段不存在:\`{ "found": false, "value": "" }\``, + ].join('\n') +} + +function writeDocs08FieldPromptM(fe, id, targetValue, phaseId, lineNumber) { + const scope = fe ? `§ 三 整体里程碑` : `§ 二 模块 ${id} 里程碑` + const oldStr = fe ? '- 整体里程碑: —' : ' - 里程碑: —' + const newStr = fe ? `- 整体里程碑: ${targetValue}` : ` - 里程碑: ${targetValue}` + // 后端模块多个 bullet 同时含 ` - 里程碑: —`:必须按调用方传入的精确行号定位,否则在多模块 docs/08 + // 里 Edit 会替换到第一处(通常不是本模块),把别的模块误标 milestone-complete。 + const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber)) + ? `先 Read \`${ROOT}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行字面量等于 \`${oldStr}\`;不等则 halt(返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual="}\`)。然后仅替换第 ${lineNumber} 行;其余位置同名行**严禁**改动。` + : `严禁全局替换:通过定位上下文(${fe ? '§ 三' : `§ 二 中 module_id == \`${id}\` 的 bullet 段`})找到该 bullet 的 \`里程碑\` 子项行,仅替换这一行。` + return [ + `# 把 docs/08 ${scope} 从 \`—\` 改为 \`${targetValue}\` 并 commit`, + microStepContract(), + '', + `调用方已确认字段当前值 = \`—\`(你不必再读一遍)。`, + `1. ${lineGuard} Edit \`${ROOT}/docs/08-模块任务管理.md\`:把整行 \`${oldStr}\` 替换为 \`${newStr}\`(精确字符串替换;**只动一处**)。`, + `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`, + `3. 跑 \`git -C ${ROOT} commit -m "chore(${phaseId}): record ${targetValue} in docs/08"\`。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "" }`', + ].join('\n') +} + +// ── 微步骤:docs/08 功能行 checkbox 勾选态(reviewer approve 后的可观测 side effect)── +// 原 reviewPrompt 让 reviewer 顺手 flip checkbox:reviewer 失败时 router/进度判定看不到,且 +// reviewer agent 多了一项 file edit 副作用。拆为 read-then-write 两个 micro step: +// reviewWithFixLoop approve → read → 若 unchecked → write → assert success; +// 已 checked → 静默跳过(resume 幂等)。 +function readDocs08CheckboxPromptM(fe, id) { + if (fe) { + return [ + `# 读 docs/08 § 三 功能 \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, + microStepContract(), + '', + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 三(前端阶段)下的 \`功能:\` 项,从中找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`, + '## 输出(CHECKBOX_STATE_SCHEMA)', + `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``, + `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``, + `- 找不到:\`{ "found": false }\``, + ].join('\n') + } + return [ + `# 读 docs/08 § 二 REQ \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, + microStepContract(), + '', + `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 二,找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`, + '## 输出(CHECKBOX_STATE_SCHEMA)', + `- \`- [x] ${id} ...\` → \`{ "found": true, "state": "checked", "lineNumber": <行号> }\``, + `- \`- [ ] ${id} ...\` → \`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``, + `- 找不到:\`{ "found": false }\``, + ].join('\n') +} + +function writeDocs08CheckboxPromptM(fe, id, phase) { + const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}` + return [ + `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`, + microStepContract(), + '', + `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`, + `1. Edit \`${ROOT}/docs/08-模块任务管理.md\`:把以 \`- [ ] ${id} \` 开头的整行替换为对应的 \`- [x] ${id} ...\`(保留原行 id 之后的全部文本,仅 \`[ ]\` → \`[x]\`,精确字符串替换;只动一处)。`, + `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`, + `3. 跑 \`git -C ${ROOT} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "" }`', + ].join('\n') +} + +function checkTagExistsPromptM(tagName) { + return [ + `# tag \`${tagName}\` 是否存在`, + microStepContract(), + '', + `跑 \`git -C ${ROOT} tag -l ${tagName}\`。`, + '## 输出(EXISTS_SCHEMA)', + '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`', + ].join('\n') +} + +function createTagPromptM(phaseId, fe) { + return [ + `# 打 annotated tag \`milestone/${phaseId}\``, + microStepContract(), + '', + `跑 \`git -C ${ROOT} tag -a milestone/${phaseId} -m "milestone(${phaseId}): ${fe ? '前端' : '后端'}阶段完成"\`。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 成功:`{ "success": true }`;失败:`{ "success": false, "error": "" }`', + ].join('\n') +} + +// 校验 milestone tag 指向的 commit 中报告 § ⑫ 是否已是 targetTag(而非 placeholder)。 +// 用于识别旧 bug 残留:报告 § ⑫ commit 顺序在 tag 之后时,tag 指向占位符版本。 +function checkTagReportFreshPromptM(targetTag, reportPath) { + return [ + `# 校验 tag \`${targetTag}\` 指向的 commit 中 \`${reportPath}\` § ⑫ 是否新鲜`, + microStepContract(), + '', + `跑 \`git -C ${ROOT} show ${targetTag}:${reportPath}\`。在输出中定位 § ⑫("里程碑"小节)的 tag 字段值。`, + '## 输出(TAG_REPORT_FRESHNESS_SCHEMA)', + `- § ⑫ 字段值 == \`${targetTag}\`:\`{ "fresh": true, "tagReportValue": "${targetTag}" }\``, + `- § ⑫ 字段值 == \`{{milestone_tag}}\` 或其它陈旧值:\`{ "fresh": false, "tagReportValue": "<实际值>" }\``, + '- `git show` 失败(tag 不存在 / 报告路径不在 tag commit 中)→ 本步骤失败(schema 失败即可)。', + ].join('\n') +} + +function findReportPromptM(phaseId) { + return [ + `# 找最新的 \`${phaseId}\` 完成报告并读取 § ⑫ 的 milestone tag 字段当前值`, + microStepContract(), + '', + `用 Glob 在 \`${ROOT}/docs/superpowers/module-reports/\` 查找 \`*-${phaseId}.md\`(按文件名 YYYY-MM-DD 日期前缀降序取最新一份)。`, + 'Read 该文件,定位 § ⑫("里程碑"小节)。', + '## 输出(REPORT_PATH_SCHEMA)', + `- 找到:\`{ "found": true, "path": "docs/superpowers/module-reports/", "currentTagValue": "<§ ⑫ 当前的字面值(应为 \\\`{{milestone_tag}}\\\` 或 \\\`milestone/${phaseId}\\\` 之一)>" }\``, + '- 完全没有匹配文件:`{ "found": false }`', + ].join('\n') +} + +function updateReportPromptM(reportPath, targetTag, phaseId) { + return [ + `# 把 \`${reportPath}\` § ⑫ 的 \`{{milestone_tag}}\` 替换为 \`${targetTag}\` 并 commit`, + microStepContract(), + '', + `1. Edit \`${ROOT}/${reportPath}\`:把字面量 \`{{milestone_tag}}\` 替换为 \`${targetTag}\`(精确替换;如多处出现就全部替换)。`, + `2. \`git -C ${ROOT} add ${reportPath}\`;3. \`git -C ${ROOT} commit -m "docs(${phaseId}): record ${targetTag} in completion report"\`。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 全 OK:`{ "success": true }`;失败:`{ "success": false, "error": "" }`', + ].join('\n') +} + +// ── 微步骤:cross-module 专用 ── +function collectCrossModuleChangedPromptM(defaultBranch) { + return [ + `# 收集功能分支自 \`${defaultBranch}\` 分叉以来的全部改动文件`, + microStepContract(), + '', + `跑 \`git -C ${ROOT} diff --name-status ${defaultBranch}...HEAD\`(三点 diff)。按行解析每行 \`\\t\`(status 通常为 M/A/D/R/C 等)。`, + '## 输出(CHANGED_FILES_SCHEMA)', + '- `{ "files": [ { "status": "M", "path": "backend/.../X.java" }, ... ] }`', + '- diff 为空 → `{ "files": [] }`', + ].join('\n') +} + +function classifyCrossModulePromptM(moduleId, files) { + const filesText = files.map(f => `- ${f.status} ${f.path}`).join('\n') + return [ + `# 把改动文件分类:哪些落在**非本模块 \`${moduleId}\`** 的目录下`, + microStepContract(), + '', + `本模块目录归属请以 \`${ROOT}/docs/09-项目目录结构.md\` 与 \`${ROOT}/docs/08-模块任务管理.md § 二\` 中本模块 bullet 的 \`路径:\` 字段为准。Read 这两份文档以建立"路径 → 模块"映射。`, + '', + '## 改动文件清单', + filesText, + '', + '## 判定规则', + `- 落在本模块路径(\`${moduleId}\`)下 → **不算**跨模块。`, + '- 落在其它模块路径下 → 算跨模块,给出该文件归属的目标模块 id。', + '- 落在共享根(如 `docs/`、`scripts/`、`sql/migrations/`、`README.md` 等)→ **不算**跨模块。', + '', + '## 输出(CROSS_CLASSIFY_SCHEMA)', + '- `{ "crossModule": [ { "file": "...", "targetModule": "module_x", "reason": "<本模块哪个 REQ-XXX-NNN 迫使改它,1 句>", "impact": "<目标模块哪些 API/行为/调用方/测试受影响,1-3 句>" }, ... ] }`', + '- 无跨模块改动:`{ "crossModule": [] }`', + '- **不要留 `TBD(CC 补)`**:本步骤就是补齐的唯一时机;推不出原因 / 影响 → 整步失败(schema 失败即可,调用方会 halt)。', + ].join('\n') +} + +// dedup-and-rewrite 不再 append:resume / 多次跑同一模块时,append 会产生重复行污染 § ⑦。 +// 改为整体重写:读现有行 → 与本次 items 合并 → 按 (file, targetModule) dedup(本次 items 覆盖旧值) +// → 按 (targetModule, file) 排序 → 整表重写。commit 前用 `git diff --quiet` 判定,无变更则跳过 commit。 +function writeCrossModuleLogPromptM(moduleId, items) { + const newRowsJson = JSON.stringify(items, null, 2) + return [ + `# 把跨模块改动以 dedup-and-rewrite 方式写入 \`docs/superpowers/module-reports/${moduleId}-cross-module.md\``, + microStepContract(), + '', + `目标文件(项目根相对):\`docs/superpowers/module-reports/${moduleId}-cross-module.md\`。`, '', '## 流程', - `- 用 \`git -C ${ROOT} diff --name-status <默认分支 main/master>...HEAD\`(三点 diff,区间 = 本功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)找出改动文件,判定哪些落在**其它模块**的目录下(按 docs/09 目录归属)。`, - `- 写 / 更新 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`,每行列:时间戳(你自身上下文解析当天,脚本不传)/ 目标模块 / 文件 / 改动摘要 / **原因**(本模块哪个 REQ 迫使改它)/ **影响评估**(目标模块哪些 API / 行为 / 调用方受影响、现有测试是否仍有效、是否需新测试,1-3 句)。`, - '- 无跨模块改动 → 输出 `cross-module-log: 无跨模块改动,跳过`,不创建文件。', - '- **不要留 `TBD(CC 补)`**:本步骤就是补齐的唯一时机;推不出原因/影响 → 按硬约束写阻塞点并失败。', + `1. **读现有行**:如果文件存在,用 Read 取出表格内已有的数据行(跳过表头与分隔行)。把每行解析为 \`{ file, targetModule, reason, impact }\`,得到 \`existingRows\`。文件不存在 → \`existingRows = []\`。`, + '2. **合并 + dedup**:把"本次新增行 JSON"中的项加入 `existingRows`,按 `(file + "\\u0001" + targetModule)` 作为 dedup key——**本次新增项覆盖旧项**(同一 file × targetModule 的最新原因 / 影响为准)。', + '3. **排序**:按 `(targetModule, file)` 字典序升序。', + '4. **整体重写**:用 Write 把整个文件重写为:', + ' ```', + ' # 跨模块改动日志', + ' ', + ' | 文件 | 目标模块 | 原因 | 影响 |', + ' |---|---|---|---|', + ' <已排序的全部行>', + ' ```', + `5. **空变更跳过 commit**:跑 \`git -C ${ROOT} diff --quiet -- docs/superpowers/module-reports/${moduleId}-cross-module.md\`。`, + ' - exit_code = 0(无变更)→ 不要 commit,直接返回 `{ "success": true, "detail": "no-diff-skip-commit" }`。', + ` - exit_code != 0(有变更)→ \`git add\` + \`git commit -m "chore(${moduleId}): record cross-module log"\`。`, + '', + '## 本次新增行(JSON,作为合并输入)', + '```json', + newRowsJson, + '```', '', - '## 结束', - `- 输出一行 \`cross-module-log: 模块 ${id} 更新 N 行 / 跳过\`。`, + '## 输出(ACTION_RESULT_SCHEMA)', + '- 写成功且有/无 commit:`{ "success": true, "detail": "" }`', + '- 任一步失败:`{ "success": false, "error": "" }`', ].join('\n') } @@ -347,16 +875,17 @@ function crossModulePrompt(module) { function reportPrompt(module) { const id = module?.id ?? '' const fe = id === 'frontend-phase' + const phaseId = fe ? 'frontend-phase' : id return [ `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`, '', - commonContract(fe ? 'frontend' : 'backend'), + featureStageContract(fe ? 'frontend' : 'backend'), '', '## 目标', `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`, '', '## 前置', - `- 验证上游 test-gate 已绿:读 \`${ROOT}/docs/superpowers/module-reports/${fe ? 'frontend-phase' : `${id}`}-test-gate.md\`;红则停。`, + `- 验证上游 test-gate 绿:Glob \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`, '', '## 收集输入(取摘要而非正文)', fe @@ -365,14 +894,14 @@ function reportPrompt(module) { `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`, `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`, '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。', - `- § ⑤:读 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate.md\`。`, + `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`, '- § ⑧ 偏离清单:额外审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出。', '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。', ].join('\n') : [ `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`, `- § ② / § ⑨:读 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`, - `- § ⑤:读 \`${ROOT}/docs/superpowers/module-reports/${id}-test-gate.md\`。`, + `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/${id}-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`, `- § ⑥ Migration:\`git -C ${ROOT} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`, `- § ⑦ 跨模块改动:读 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`, '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。', @@ -380,66 +909,132 @@ function reportPrompt(module) { '', '## 渲染 + 验证 + commit', '- 渲染 12 节。硬验证:§ ⑧ 必须列举所有偏离(无则写"无偏离")。', - `- 写入 \`${ROOT}/docs/superpowers/module-reports/<当天日期 YYYY-MM-DD>-${fe ? 'frontend-phase' : `${id}`}.md\`,连同跨模块日志(如存在)一起 commit 到当前分支(milestone 的 worktree-clean 前置依赖此 commit)。`, + `- 写入 \`docs/superpowers/module-reports/<当天日期 YYYY-MM-DD>-${phaseId}.md\`(项目根相对;resume 时复用已存在的 \`*-${phaseId}.md\` 最新日期前缀,不要起新文件),连同跨模块日志(如存在)一起 commit 到当前分支(milestone 的 worktree-clean 前置依赖此 commit)。`, '', - '## 结束', - `- 输出一行 \`module-report: ${fe ? 'frontend-phase' : id} → \`。`, + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', + `- 成功:\`{ "status": "ok", "artifactPath": "docs/superpowers/module-reports/YYYY-MM-DD-${phaseId}.md", "summary": "<1-2 句中文摘要:测试是否 flake / 主要变更 / 是否有偏离>" }\`。`, + '- 失败:`{ "status": "halt", "reason": "<阻塞点(如最后一次 test-gate red / 跨模块日志缺失)>" }`。', ].join('\n') } -// ---- 里程碑:本地 merge --no-ff + tag + 回写 docs/08(原 milestone-tag,单 stage 内幂等)---- -function milestonePrompt(module) { +// ---- runBranchSetup:原 branchSetupPrompt 的散文流程 → JS 编排 + 微步骤 agent ---- +// 幂等:分支已存在则 checkout,否则从默认分支新建。条件分支由 JS 判定,子代理只负责执行单一动作。 +async function runBranchSetup(module) { const id = module?.id ?? '' const fe = id === 'frontend-phase' - const phaseId = fe ? 'frontend-phase' : id - return [ - `# milestone-tag — ${fe ? '前端阶段' : `模块 ${id}`} 本地集成 + 打里程碑(幂等)`, - '', - commonContract(fe ? 'frontend' : 'backend'), - '', - '## 目标', - `把当前分支(${fe ? '`frontend-phase`' : `\`module-${id}\``})本地合并进默认分支并打 \`milestone/${phaseId}\` tag,把 tag 名回写 docs/08 + 报告 § ⑫。**全程无人工介入**;本 stage 内**重入幂等**(先写 docs/08,再打 tag,已存在则跳过)。`, - '', - '## 流程(顺序执行,任一硬错误 → 停下打印诊断,不自动 stash / 覆盖 / --abort)', - '1. **验证 worktree 干净**:`git -C ' + ROOT + ' status --porcelain` 非空 → 失败并打印 dirty 文件清单(检查 test-gate / module-report 是否都已 commit)。', - `2. **探测默认分支**:用 \`git -C ${ROOT} rev-parse --verify\` 依次试本地 \`main\` / \`master\`,取第一个存在的为 \`default_branch\`;都不存在 → 失败。`, - `3. **本地集成**:\`git -C ${ROOT} checkout \` 后 \`git -C ${ROOT} merge --no-ff ${fe ? 'frontend-phase' : `module-${id}`} -m "merge(${phaseId}): integrate ${fe ? 'frontend-phase' : `module-${id}`}"\`。合并冲突 → 失败并打印冲突文件清单(引导人工解决后重跑 coding-start)。`, - `4. **回写 docs/08 + commit**:在 default_branch 上 Edit \`${ROOT}/docs/08-模块任务管理.md\`:${fe - ? '§ 三 `- 整体里程碑: —` 改为 `- 整体里程碑: milestone/frontend-phase`' - : `§ 二 该模块 \` - 里程碑: —\` 改为 \` - 里程碑: milestone/${id}\``};commit \`chore(${phaseId}): record milestone/${phaseId} in docs/08\`。`, - `5. **打 annotated tag**(幂等):\`git -C ${ROOT} tag -a milestone/${phaseId} -m "milestone(${phaseId}): ${fe ? '前端' : '后端'}阶段完成"\`;tag 已存在则跳过。`, - `6. **追加 tag 到报告 § ⑫**:Edit 当天报告 \`${ROOT}/docs/superpowers/module-reports/<日期>-${phaseId}.md\` 的 § ⑫,把 \`{{milestone_tag}}\` 替换为 \`milestone/${phaseId}\`(已替换则跳过);commit \`docs(${phaseId}): record milestone/${phaseId} in completion report\`。`, - '', - '## 结束', - `- 输出一行 \`milestone-tag: ${phaseId} → milestone/${phaseId}\`。不要在本 stage 内回调 coding-start——推进下一模块由上层 Workflow 的循环负责。`, - ].join('\n') + const branch = fe ? 'frontend-phase' : `module-${id}` + const lbl = (k) => `branch:${k}:${id}` + + const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) + + const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) + if (!wt.clean) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${(wt.dirty || []).join(', ')}`) + + const exists = await agent(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) + + if (exists.exists) { + const r = await agent(checkoutExistingBranchPromptM(branch), {label: lbl('checkout'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) + if (!r.success) throw new Error(`HALT branchSetup-checkout ${branch}: ${r.error || ''}`) + } else { + const r = await agent(createBranchFromPromptM(def.branch, branch), {label: lbl('create'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) + if (!r.success) throw new Error(`HALT branchSetup-create ${branch}: ${r.error || ''}`) + } + + const head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: CURRENT_BRANCH_SCHEMA}) + if (head.branch !== branch) throw new Error(`HALT branchSetup-branch-mismatch ${branch}: HEAD on ${head.branch}`) + + log(`branch-setup: ${id} → ${branch}`) } -// ---- 功能分支生命周期:进入模块前建/切功能分支(milestone 的 merge 源)---- -// 幂等支持续跑:分支已存在则 checkout 续跑,否则从默认分支开新支。 -function branchSetupPrompt(module) { +// ---- runMilestone:原 milestonePrompt 的 6 步散文流程 → JS 编排 ---- +// 所有"已是目标态则跳过"的条件由 JS 在 read 结果上判定,子代理只执行确定性动作。 +async function runMilestone(module) { const id = module?.id ?? '' const fe = id === 'frontend-phase' + const phaseId = fe ? 'frontend-phase' : id const branch = fe ? 'frontend-phase' : `module-${id}` - return [ - `# branch-setup — ${fe ? '前端阶段' : `模块 ${id}`} 功能分支准备(幂等)`, - '', - commonContract(fe ? 'frontend' : 'backend'), - '', - '## 目标', - `为本${fe ? '前端阶段' : '模块'}准备功能分支 \`${branch}\`,使后续 featureLoop / testGate / report 的 commit 都落在该分支上;milestone stage 再把它 \`merge --no-ff\` 回默认分支。**本 stage 内重入幂等**。`, - '', - '## 流程(顺序执行,任一硬错误 → 停下打印诊断,不自动 stash / 覆盖)', - `1. **探测默认分支**:用 \`git -C ${ROOT} rev-parse --verify\` 依次试本地 \`main\` / \`master\`,取第一个存在的为 \`default_branch\`;都不存在 → 失败。`, - `2. **校验工作树干净**:\`git -C ${ROOT} status --porcelain\` 非空 → 失败并打印 dirty 文件清单(进入模块前必须是干净状态)。`, - `3. **建 / 切功能分支**(幂等):`, - ` - 若 \`git -C ${ROOT} rev-parse --verify ${branch}\` 成功(分支已存在,续跑场景)→ \`git -C ${ROOT} checkout ${branch}\`。`, - ` - 否则 → \`git -C ${ROOT} checkout \` 后 \`git -C ${ROOT} checkout -b ${branch}\`(从含上一里程碑成果的默认分支开新支)。`, - `4. 确认当前已在 \`${branch}\`:\`git -C ${ROOT} rev-parse --abbrev-ref HEAD\` == \`${branch}\`,否则失败。`, - '', - '## 结束', - `- 输出一行 \`branch-setup: ${id} → ${branch}\`。`, - ].join('\n') + const targetTag = `milestone/${phaseId}` + const lbl = (k) => `milestone:${k}:${phaseId}` + + // step 1: worktree clean precondition + const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) + if (!wt.clean) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${(wt.dirty || []).join(', ')}`) + + // step 2: detect default branch + const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) + + // step 3: merge (idempotent — skip if already an ancestor) + const merged = await agent(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA}) + if (!merged.alreadyMerged) { + const r = await agent(executeMergePromptM(def.branch, branch, phaseId), {label: lbl('merge'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) + if (!r.success) throw new Error(`HALT milestone-merge ${phaseId}: ${r.error || ''}${r.detail ? '\n' + r.detail : ''}`) + } + + // step 4: docs/08 field (idempotent — read first, only write if at initial '—') + const field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA}) + if (!field.found) throw new Error(`HALT milestone-docs08-missing ${phaseId}: 字段不存在(docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`})`) + if (field.value === '—') { + const r = await agent(writeDocs08FieldPromptM(fe, id, targetTag, phaseId, field.lineNumber), {label: lbl('field-write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) + if (!r.success) throw new Error(`HALT milestone-docs08-write ${phaseId}: ${r.error || ''}`) + } else if (field.value !== targetTag) { + throw new Error(`HALT milestone-docs08-unexpected ${phaseId}: 字段当前 = ${JSON.stringify(field.value)}(行 ${field.lineNumber || '?'}),期望 '—' 或 '${targetTag}'`) + } + // else: 已是 targetTag → 静默跳过(续跑场景) + + // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则 + // `git checkout milestone/` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug, + // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。) + const rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA}) + if (!rpt.found) throw new Error(`HALT milestone-report-missing ${phaseId}: 没有找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件`) + if (rpt.currentTagValue === '{{milestone_tag}}') { + const r = await agent(updateReportPromptM(rpt.path, targetTag, phaseId), {label: lbl('report'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) + if (!r.success) throw new Error(`HALT milestone-report-update ${phaseId}: ${r.error || ''}`) + } else if (rpt.currentTagValue !== targetTag) { + throw new Error(`HALT milestone-report-unexpected ${phaseId}: ${rpt.path} § ⑫ 当前 = ${JSON.stringify(rpt.currentTagValue)}`) + } + // else: 已是 targetTag → 静默跳过(resume 幂等) + + // step 6: annotated tag (idempotent + stale-tag 自检) + // 注:HEAD 此刻已包含 § ⑫ 更新 commit(或本来就在 targetTag 上),tag 指向新鲜 commit。 + const tag = await agent(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) + if (tag.exists) { + // 旧版 bug 残留:tag 可能指向 § ⑫ 仍为占位符的 commit。检查并要求人工 `git tag -f`。 + const fresh = await agent(checkTagReportFreshPromptM(targetTag, rpt.path), {label: lbl('tag-fresh?'), phase: 'Milestone', schema: TAG_REPORT_FRESHNESS_SCHEMA}) + if (!fresh.fresh) { + 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。`) + } + } else { + const r = await agent(createTagPromptM(phaseId, fe), {label: lbl('tag'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) + if (!r.success) throw new Error(`HALT milestone-tag ${phaseId}: ${r.error || ''}`) + } + + log(`milestone: ${phaseId} → ${targetTag}`) +} + +// ---- runCrossModule:原 crossModulePrompt 的"diff → 分类 → 写日志" → JS 编排 ---- +// diff 和写文件是机械动作;"按 docs/09 路径归属判定哪些是跨模块"需要 LLM 判断,独立成一步。 +async function runCrossModule(module) { + const id = module?.id ?? '' + const lbl = (k) => `xmod:${k}:${id}` + + const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) + + const changed = await agent(collectCrossModuleChangedPromptM(def.branch), {label: lbl('diff'), phase: 'Milestone', schema: CHANGED_FILES_SCHEMA}) + if (!changed.files.length) { + log(`cross-module-log: 模块 ${id} 无文件改动,跳过`) + return + } + + const classified = await agent(classifyCrossModulePromptM(id, changed.files), {label: lbl('classify'), phase: 'Milestone', schema: CROSS_CLASSIFY_SCHEMA}) + if (!classified.crossModule.length) { + log(`cross-module-log: 模块 ${id} 无跨模块改动,跳过`) + return + } + + const r = await agent(writeCrossModuleLogPromptM(id, classified.crossModule), {label: lbl('write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) + if (!r.success) throw new Error(`HALT crossModule-write ${id}: ${r.error || ''}`) + + log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`) } // ============================================================================ @@ -447,34 +1042,120 @@ function branchSetupPrompt(module) { // ============================================================================ // ---- 单功能链(后端 / 前端同构)---- +// **顺序 for-await**(不是 pipeline)。理由: +// - tdd / fix stage 会编辑共享工作树并 git commit;并发会争 .git/index.lock、撞 migration V。 +// - pipeline 的"stage throw → item 掉 null、pipeline 永不 reject"语义会吞掉 reviewWithFixLoop / +// verify / tdd 的 HALT throw,让模块主循环 try/catch 捕获不到,残缺模块照样被推进到 milestone。 +// 顺序 for-await 让 throw 自然冒泡到主循环 try → catch → break,使 fail-fast 真正生效。 +// +// 派生 stage 全部 schema 化:spec/plan/tdd/verify/fix 共用 STAGE_RESULT_SCHEMA, +// sub-agent 写 `{status:'halt', reason}` 时 JS 立即抛 HALT,让"无法继续"不再混入"成功返回"。 +// 功能级 dedup 真值 = `req-done/` git tag:featureLoop 入口先 check,存在则 skip(Router 文档/ +// LLM 自审失误不再导致已 approve 的 REQ 被重新 spec→plan→tdd 污染源码 / 撞 V)。 +// +// 语义边界(重要):`req-done/` 表示"该功能在写 tag 时已通过 reviewer approve",**不**表示 +// "实现自此再未变化"。若 testGate / 后续模块工作中人工或子代理改动了已 approve 功能的代码,重跑 +// coding-start 时此 dedup 会跳过 spec/plan/tdd/verify/review,**不会**再次审阅这些后期改动。 +// 这是有意的设计:避免在共享工作树里因为别的模块的 cross-cut 改动反复重跑前面所有 REQ。 +// 若需要"approve 后改动必须再次走 review"的语义,请在改动前手动删除对应 `req-done/` tag。 async function featureLoop(items, phase) { - return pipeline(items, - (id) => agent(deriveSpecPrompt(id, phase), {label:`spec:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), - (spec, id) => agent(planPrompt(id, phase, spec), {label:`plan:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), - (plan, id) => agent(tddPrompt(id, phase, plan), {label:`tdd:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), - (impl, id) => agent(verifyPrompt(id, phase, impl), {label:`verify:${phase}:${id}`, phase: phase==='backend'?'Backend':'Frontend'}), - (v, id) => reviewWithFixLoop(id, phase, v), - ) -} - -// 有界 5 轮修复;超出 → throw(终止态,非对话框) -// fix 后重新跑 verify(功能复验,verify 内部失败即 throw → halt),再进入下一轮 review, -// 使 fixPrompt 对子代理"上层会重新跑 verify + review"的承诺成真。 -async function reviewWithFixLoop(id, phase, verifyResult) { const grp = phase === 'backend' ? 'Backend' : 'Frontend' + for (const id of items) { + // 入口 dedup:req-done/ 已存在 → 已 approve,整段 skip。 + const done = await agent(checkReqDoneTagPromptM(id), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA}) + if (done.exists) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue } + + const spec = await agent(deriveSpecPrompt(id, phase), {label:`spec:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) + if (spec.status === 'halt') throw new Error(`HALT spec ${phase}:${id}: ${spec.reason || ''}`) + if (!spec.artifactPath) throw new Error(`HALT spec-no-artifactPath ${phase}:${id}: spec returned ok but no artifactPath`) + // 日期一致性自校验:spec 文件名首段必须可被解析为 YYYY-MM-DD(dateFromArtifactPath 会抛)。 + dateFromArtifactPath(spec.artifactPath) + + const plan = await agent(planPrompt(id, phase, spec.artifactPath), {label:`plan:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) + if (plan.status === 'halt') throw new Error(`HALT plan ${phase}:${id}: ${plan.reason || ''}`) + if (!plan.artifactPath) throw new Error(`HALT plan-no-artifactPath ${phase}:${id}`) + if (dateFromArtifactPath(plan.artifactPath) !== dateFromArtifactPath(spec.artifactPath)) { + throw new Error(`HALT plan-date-mismatch ${phase}:${id}: plan ${plan.artifactPath} 与 spec ${spec.artifactPath} 日期前缀不一致`) + } + + const impl = await agent(tddPrompt(id, phase, plan.artifactPath), {label:`tdd:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) + if (impl.status === 'halt') throw new Error(`HALT tdd ${phase}:${id}: ${impl.reason || ''}`) + + const v0 = await agent(verifyPrompt(id, phase, impl.summary || '', spec.artifactPath, 0), {label:`verify:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) + if (v0.status === 'halt') throw new Error(`HALT verify ${phase}:${id}: ${v0.reason || ''}`) + + const reviewResult = await reviewWithFixLoop(id, phase, v0, spec.artifactPath) + log(`review approved ${phase}:${id} after ${reviewResult.rounds} round(s)`) + + // approve 后落地 dedup 真值:req-done/ tag。 + const tagR = await agent(createReqDoneTagPromptM(id, phase), {label:`reqdone:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) + if (!tagR.success) throw new Error(`HALT req-done-tag ${phase}:${id}: ${tagR.error || ''}`) + } +} + +// 有界 5 轮修复;超出 → throw(终止态,非对话框)。 +// fix 后再跑 reverify 让 fix-stage 的 commit 有机会被新一轮 verify 看到; +// verify 内部失败 throw 在顺序 for-await 下会冒泡到模块主循环 try。 +// approve 后通过独立 micro step 把 docs/08 对应 checkbox flip 为 `[x]`(拆出 reviewer side-effect, +// 写失败可观测;幂等:已 checked 静默跳过)。 +async function reviewWithFixLoop(id, phase, verifyResult, specPath) { + const grp = phase === 'backend' ? 'Backend' : 'Frontend' + const fe = isFrontend(phase) + let lastVerify = verifyResult + let lastIssuesCount = 0 for (let round = 1; round <= 5; round++) { - const r = await agent(reviewPrompt(id, phase, round), {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'}) - if (r.verdict === 'approve') return { id, phase, approved:true, rounds:round } - await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: grp}) - await agent(verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验)`), {label:`reverify:${phase}:${id}:r${round}`, phase: grp}) + const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || '' + // 命名碰撞警告:opts.phase = grp('Backend'/'Frontend'/'Milestone')是 harness 的 UI 分组, + // 与 code-reviewer.md 文档里的"domain phase = backend|frontend"是**同名不同物**。 + // reviewer agent 从 prompt 正文 `**phase = ...**` 那一行读 domain phase,**不要**读 opts.phase。 + // 见 agents/code-reviewer.md "Domain phase resolution" 段。 + const r = await agent( + reviewPrompt(id, phase, round, lastVerifySummary, specPath), + {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'} + ) + if (r.verdict === 'approve') { + // docs/08 checkbox flip(observable side effect,原 reviewer 隐式 Edit → micro step) + const cb = await agent(readDocs08CheckboxPromptM(fe, id), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA}) + if (!cb.found) throw new Error(`HALT docs08-checkbox-missing ${phase}:${id}: docs/08 ${fe?'§ 三':'§ 二'} 中找不到 \`- [ ] ${id} ...\` / \`- [x] ${id} ...\` 行`) + // 防御:即使 schema 已 require state,再做一次 JS 校验,杜绝"found:true 但 state 缺失/枚举外"静默走 checked 分支。 + if (cb.state !== 'checked' && cb.state !== 'unchecked') { + throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`) + } + if (cb.state === 'unchecked') { + const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) + if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`) + } + // cb.state === 'checked' → 静默跳过(resume 幂等) + return { id, phase, approved:true, rounds:round } + } + // request-changes 必须带 must-fix 清单(结构化对象数组);否则 fix 步无法定位 → 直接 halt 暴露 reviewer 契约违例。 + if (!Array.isArray(r.issues) || r.issues.length === 0) { + throw new Error(`HALT review-empty-issues ${phase}:${id} r${round}: reviewer 返回 request-changes 但 issues 为空,无法驱动 fix 步`) + } + lastIssuesCount = r.issues.length + // 每个 issue 必须含 locator(locator 校验由 fix sub-agent 在 git cat-file 阶段再做一次硬把关)。 + const missingLocator = r.issues.filter(x => !x || typeof x.locator !== 'string' || !x.locator.trim()) + if (missingLocator.length) { + 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(' | ')})`) + } + + const fixR = await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA}) + if (fixR.status === 'halt') throw new Error(`HALT fix ${phase}:${id} r${round}: ${fixR.reason || ''}`) + + lastVerify = await agent( + verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验,上轮 must-fix: ${r.issues.length} 项)`, specPath, round), + {label:`reverify:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA} + ) + if (lastVerify.status === 'halt') throw new Error(`HALT reverify ${phase}:${id} r${round}: ${lastVerify.reason || ''}`) } - throw new Error(`HALT review-unresolved ${phase}:${id} after 5 rounds`) + throw new Error(`HALT review-unresolved ${phase}:${id}: 5 轮 review 仍未 approve(最后一次 reverify ${lastVerify?.status || '?'},最后一轮 must-fix ${lastIssuesCount} 项)`) } +// flake 重试 1 次:attempt=2 写到独立证据文件 `-test-gate-r2.md`,不覆盖 r1 的 red 证据(report § ⑤ 用得到)。 async function testGate(module, phase) { - let g = await agent(gatePrompt(module, phase), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) + let g = await agent(gatePrompt(module, phase, 1), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) if (g.status === 'red') { // 自动重试 1 次(防 flaky) - g = await agent(gatePrompt(module, phase) + '\n(retry once for flakiness)', {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) + g = await agent(gatePrompt(module, phase, 2), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) } if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${(g.failures||[]).join('; ')}`) return g @@ -482,34 +1163,80 @@ async function testGate(module, phase) { phase('Router') const routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA}) + +// Router 语义运行时断言:后端模块 feItems 必空、frontend-phase 聚合模块 reqs 必空。 +// schema 用 additionalProperties:false 但不强制互斥;这里把契约违例在最早处暴露而不是让错配 phase 静默跑下去。 +// +// 同时硬约束所有 id 形状为 /^[A-Za-z0-9_-]+$/:下游 micro step prompt 大量把 id / branch / phaseId +// 模板进 `git ... ${id}` shell 命令字符串,未单引号包裹也未做字符校验。LLM 返回畸形 id(含 ;、`、 +// $()、空格等)会改变子代理执行的命令;这里在 Router 出口一次性把关,让 fail-fast 比 shell 注入早。 +const ID_PATTERN = /^[A-Za-z0-9_-]+$/ +function assertSafeId(kind, value) { + if (typeof value !== 'string' || !ID_PATTERN.test(value)) { + throw new Error(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`) + } +} +for (const m of routed.modules) { + assertSafeId('module-id', m.id) + for (const r of m.reqs || []) assertSafeId('req-id', r) + for (const f of m.feItems || []) assertSafeId('fe-id', f) + const isFE = m.id === 'frontend-phase' + if (isFE && Array.isArray(m.reqs) && m.reqs.length) { + throw new Error(`HALT router-violation: frontend-phase 聚合模块的 reqs 必须为空,实测含 ${m.reqs.length} 项 (${m.reqs.join(',')})`) + } + if (!isFE && Array.isArray(m.feItems) && m.feItems.length) { + throw new Error(`HALT router-violation: 后端模块 ${m.id} 的 feItems 必须为空(前端只在 frontend-phase 聚合),实测含 ${m.feItems.length} 项 (${m.feItems.join(',')})`) + } +} + const todo = routed.modules.filter(m => !m.done) log(`coding: ${todo.length}/${routed.modules.length} modules to run`) const results = [] -for (const module of todo) { +let haltedAtIdx = -1 +for (const [idx, module] of todo.entries()) { try { - // C1:进入模块前建/切功能分支(milestone 的 merge 源)。 - await agent(branchSetupPrompt(module), {label:`branch:${module.id}`, phase:'Milestone'}) + // C1:进入模块前建/切功能分支(milestone 的 merge 源)。runBranchSetup 把"探测默认分支 / + // 校验工作树 / 切或新建分支 / 校验 HEAD"分解为 4-5 个微 agent,分支判定全在 JS 里。 + phase('Milestone') + await runBranchSetup(module) if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过) + phase('Backend') await featureLoop(module.reqs, 'backend') + phase('Gate') await testGate(module, 'backend') - await agent(crossModulePrompt(module), {label:`xmod:${module.id}`, phase:'Milestone'}) // 替代被删 hook + phase('Milestone') + await runCrossModule(module) // 替代被删 hook,JS 编排:diff → 分类 → 写日志 } if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) + phase('Frontend') await featureLoop(module.feItems, 'frontend') + phase('Gate') await testGate(module, 'frontend') } - await agent(reportPrompt(module), {label:`report:${module.id}`, phase:'Milestone'}) - await agent(milestonePrompt(module), {label:`milestone:${module.id}`, phase:'Milestone'}) // git merge --no-ff + tag + 更新 docs/08(单 stage 内幂等) + phase('Milestone') + const rep = await agent(reportPrompt(module), {label:`report:${module.id}`, phase:'Milestone', schema: STAGE_RESULT_SCHEMA}) + if (rep.status === 'halt') throw new Error(`HALT report ${module.id}: ${rep.reason || ''}`) + // runMilestone:原 6 步散文(worktree / 默认分支 / merge / docs/08 / tag / report)由 JS 编排, + // 每个"已是目标态则跳过"的条件由 JS 在 read 微 agent 的结构化返回上判定,跨重入幂等。 + await runMilestone(module) results.push({ module: module.id, status:'done' }) } catch (e) { results.push({ module: module.id, status:'halted', reason: String(e.message||e) }) + haltedAtIdx = idx break // 整阶段 fail-fast:halt 后停,等人工修复后重跑 coding-start } } -// Workflow 结果:跑完 / halt 的逐模块摘要。 -// 注:Workflow 运行时在异步包装上下文中执行脚本体,顶层 `return` 即为结果(与 `export const meta` -// 并存)。这是 Workflow 脚本的契约,**不是**独立 ESM 模块——因此 `node --check` 会报 Illegal -// return statement,但运行时正确(不要据 node --check 改成 export default,那会让结果丢失)。 -return { results } +// pending:halt 后被跳过的剩余模块(M5)。caller / coding-start 可据此告知用户"修好后还有哪些待跑", +// 而不是仅看到一个 halted 模块就误以为只剩一个。 +const pending = haltedAtIdx >= 0 + ? todo.slice(haltedAtIdx + 1).map(m => ({ module: m.id, status: 'pending' })) + : [] + +// Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表。 +// 注:顶层 `return` 在 CommonJS 中合法,但在 ESM 中非法。本脚本被 Workflow 运行时以 ESM 方式 +// (dynamic import)加载时,运行时会把脚本体包进 async function 再执行,于是顶层 `return` 实际成为 +// Workflow 的结果通道(与 `export const meta` 并存)。**不要**改成 `export default {...}` —— 那 +// 会破坏返回值契约,Workflow 拿不到 results / pending。 +return { results, pending } -- libgit2 0.22.2