// workflows/coding.mjs // // 整个 ERP Coding(B 阶段)= 一个静默、全自动的 Workflow 脚本。 // 设计原则见仓库根 README.md「阶段 B」与「设计原则」节;featureLoop 顺序 for-await 的取舍 // 详见 featureLoop 函数处的注释。运行时禁用日期 / 随机数 builtin,所有"今天"由子代理解析。 export const meta = { name: 'erp-coding', description: 'Run the entire ERP coding phase autonomously and silently: per-module backend+frontend feature loops, test gate, milestone tag.', phases: [ { title: 'Router' }, { title: 'Schedule' }, { title: 'Backend' }, { title: 'Preview' }, { title: 'Frontend' }, { title: 'Behavior' }, { title: 'Gate' }, { title: 'Seed' }, { title: 'Milestone' }, ], // 注:'Behavior' = 阶段级行为验收门(v3)——整个前端阶段在 featureLoop 全部 FE 完成后只跑**一次** // 行为验收(含 fix 循环),不再在每个 FE 的 review 循环内做 approve 子门(v2 per-FE 形态已撤销)。 // 时序:featureLoop(frontend) → Behavior(行为门+fix)→ Gate(testGate 全量回归,兜底行为 fix 引入的回归)。 // 注:'Preview' = 静态原型渲染门——前端段开头一次性渲染 prototype/**/*.html 本身(Playwright headless // 截图归档 + 原型自带交互点击冒烟)。与 Behavior 正交:Preview 测**静态原型**、Behavior 测**实现**。 // 硬门:原型渲染失败 / 脚本未捕获异常(jsErrors) → halt(原型属上游 plan 权威,coding 不改原型源码, // 应回 plan/add-req 修后重跑);console.error advisory;环境未就绪 retry→仲裁。时序:Preview → Frontend(骨架+featureLoop)。 } const ROUTER_SCHEMA = { type:'object', additionalProperties:false, required:['modules'], properties:{ modules:{ type:'array', items:{ type:'object', additionalProperties:false, required:['id','done','reqs','feItems'], properties:{ id:{type:'string'}, done:{type:'boolean'}, reqs:{type:'array',items:{type:'string'}}, feItems:{type:'array',items:{type:'string'}} } } } } } // SCHEDULE_SCHEMA:调度子代理只输出"依赖边 + 证据",绝不直接输出"并行组"—— // 并行性是全局属性由 JS 拓扑推导;LLM 只做局部、可引证的判断。kind=uncertain 也算边(宁串勿并)。 const SCHEDULE_SCHEMA = { type:'object', additionalProperties:false, required:['deps'], properties:{ deps:{ type:'array', items:{ type:'object', additionalProperties:false, required:['id','dependsOn'], properties:{ id:{type:'string'}, dependsOn:{ type:'array', items:{ type:'object', additionalProperties:false, required:['id','kind','evidence'], properties:{ id:{type:'string'}, kind:{type:'string', enum:['fk','api','seed','order','doc','uncertain']}, evidence:{type:'string'} } } } } } } } } // REVIEW_SCHEMA:reviewer 裁决;issues 结构化对象(summary/locator/severity)驱动 fix。 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:'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 统一返回。 // status=halt 不再立即 fail-fast——先经 adjudicate() 仲裁(retry/continue/halt)才可能终止。 // decisions[]:stage 自主决策日志(缺值时不再停下,而是挑最有依据的默认/解读并登记于此),上层汇总进结果供人工事后审阅。 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'}, decisions:{ type:'array', items:{ type:'object', additionalProperties:false, required:['question','choice','rationale'], properties:{ question:{type:'string'}, choice:{type:'string'}, rationale:{type:'string'}, confidence:{type:'string', enum:['high','medium','low']} } } } } } // ADJUDICATE_SCHEMA:仲裁子代理在确定性 halt 之前的裁决—— // retry = 失败疑似一次性/可纠正,携 guidance 重跑上游; // continue = 缺陷不阻断正确性、可安全前进(降级为口头建议); // halt = 确属不可恢复(结构性缺失无旁证 / git 树需人工 / 会污染源码或伪造业务语义)。 const ADJUDICATE_SCHEMA = { type:'object', additionalProperties:false, required:['action','rationale'], properties:{ action:{type:'string', enum:['retry','continue','halt']}, guidance:{type:'string'}, rationale:{type:'string'} } } const GATE_SCHEMA = { type:'object', additionalProperties:false, required:['status'], properties:{ status:{type:'string',enum:['green','red']}, failures:{type:'array',items:{type:'string'}} } } // BEHAVIOR_GATE_SCHEMA:前端行为门(阶段级,frontend-phase 末尾一次)返回。 // 不杂交 GATE×STAGE_RESULT——复用既有词汇但独立成型:交互层 / 文字层 / 样式层 / 覆盖率 / 环境错误分别结构化, // JS 据 source/kind 分流(交互/样式硬边界转 must-fix,文字按 source 二分 allowContinue,envError 走 retry)。 // 设计:见 docs/design/2026-06-05-frontend-behavior-stage-gate.md(v3,取代 per-FE approve 子门形态)。 const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false, required:['status','routesPlanned','routesReached','controlsEnumerated'], properties:{ status:{type:'string', enum:['green','red']}, routesPlanned:{type:'integer'}, // 覆盖率分母 = 全部 FE spec「行为验收作用域」小节关联路由的并集(去重) routesReached:{type:'integer'}, // 实际带鉴权加载成功的路由数 controlsEnumerated:{type:'integer'}, // live 枚举到的白名单控件数(全 FE 并集;空覆盖必须可见) authState:{type:'string'}, // 以何角色登录 / 覆盖角色 / 未覆盖角色集 // interactionFailures.locator:行为硬问题的源码定位(组件文件 [+ DOM 描述])。行为门必须反查到 // 组件文件路径才能转 must-fix 喂 fix;反查不出(B 类)→ 不入 interactionFailures,归 coverageGap(不放行)。 // 交互层硬边界:no-observable-effect / js-error / console-error / missing-docs05-call / binding-garbage interactionFailures:{ type:'array', items:{ type:'object', additionalProperties:false, required:['page','control','kind','detail'], properties:{ page:{type:'string'}, control:{type:'string'}, kind:{type:'string', enum:['no-observable-effect','js-error','console-error','missing-docs05-call','binding-garbage']}, detail:{type:'string'}, locator:{type:'string'} } } }, // 组件文件路径 [+ DOM 选择器/绑定片段描述];有则可转 must-fix 喂 fix // 文字层软边界:source 决定 allowContinue(sentinel 客观 bug 不可 continue;i18n/literal/semantic 可 adjudicate continue) textIssues:{ type:'array', items:{ type:'object', additionalProperties:false, required:['page','region','expected','actual','source'], properties:{ page:{type:'string'}, region:{type:'string'}, expected:{type:'string'}, actual:{type:'string'}, source:{type:'string', enum:['sentinel','i18n','literal','semantic']}, locator:{type:'string'} } } }, // styleIssues:样式/布局客观断言(颜色 token 比对 + layout sanity)。全部客观、可 fix—— // 有 locator → JS 并入 behaviorHard 转 must-fix;无 locator → 与交互硬问题同口径走 noLoc 仲裁。 // 不确定项(半透明混合 / 无法归一化)按 prompt 约定不入此数组,记 decisions(宁漏勿误)。 styleIssues:{ type:'array', items:{ type:'object', additionalProperties:false, required:['page','element','kind','expected','actual'], properties:{ page:{type:'string'}, element:{type:'string'}, kind:{type:'string', enum:[ 'non-token-color', // 渲染色 ∉ tokens.css 色值集合(限项目自有样式作用域) 'token-mismatch', // 应取某 token 但渲染值 ≠ 该 token 解析值(被硬编码/级联覆盖) 'horizontal-overflow', // 路由页面出现横向滚动条(容差 1px) 'overlap', // 白名单控件 bounding box 相互重叠(双方可见可点) 'zero-size', // 预期可见的白名单控件渲染为 0 尺寸 'offscreen']}, // scrollIntoView 后仍不在视口内 expected:{type:'string'}, actual:{type:'string'}, locator:{type:'string'} } } }, // 覆盖率缺口:写证据 + recordDecisions,不单独 halt(空覆盖由 controlsEnumerated==0 兜底) // locator-not-resolvable:行为硬问题连组件文件都反查不出(B 类),计入未覆盖阻断 green,不静默放行 // scope-missing:某 FE spec 缺「行为验收作用域」小节(该 FE 路由不在分母)——与 B 类同级阻断 green coverageGaps:{ type:'array', items:{ type:'object', additionalProperties:false, required:['page','reason','detail'], properties:{ page:{type:'string'}, reason:{type:'string', enum:['unreachable-auth','unreachable-no-route','deep-control-not-driven','dynamic-route-no-seed','locator-not-resolvable','scope-missing']}, detail:{type:'string'} } } }, // 环境错误(与业务断言失败严格区分):none 表示无环境问题。 // build-failed:阶段末尾全部 FE 已实现,不再有「兄弟未实现」短路——根因落在 frontend/ 源码且可定位 → // 应归 interactionFailures[kind="js-error"](带 locator,可 fix);仅根因不可归到 frontend/ 源码 // (依赖/环境/无法定位)才用本 kind(确定性失败,跳过自动 attempt 重试直送仲裁)。rootCausePath 写报错根因文件路径。 envError:{ type:'object', additionalProperties:false, required:['kind'], properties:{ kind:{type:'string', enum:['port-conflict','stack-not-ready','seed-error','auth-failed','timeout','build-failed','none']}, detail:{type:'string'}, ports:{type:'string'}, pids:{type:'string'}, rootCausePath:{type:'string'} } }, // decisions[]:复用 STAGE_RESULT 形状,缺值自主决策日志 decisions:{ type:'array', items:{ type:'object', additionalProperties:false, required:['question','choice','rationale'], properties:{ question:{type:'string'}, choice:{type:'string'}, rationale:{type:'string'}, confidence:{type:'string', enum:['high','medium','low']} } } }, artifactPath:{type:'string'} } } // PROTOTYPE_PREVIEW_SCHEMA:静态原型渲染门(phase('Preview'))返回。与 BEHAVIOR_GATE 正交—— // 后者测「实现」(冷起全栈 + Playwright 驱动真前端),本门只渲染 *静态* prototype/**/*.html 本身: // 逐页 headless 渲染→截图归档(可视化基线)→跑原型自带交互点击冒烟→捕获 JS/console 错误。 // status:ok=渲染门正常跑完(含 advisory 问题);blocked=仅限环境未就绪(Playwright/浏览器缺失/超时)需仲裁。 // 原型属上游 plan 权威、coding 绝不改原型源码——故渲染/JS 问题是 advisory(记 decisions+证据,绝不硬 halt)。 const PROTOTYPE_PREVIEW_SCHEMA = { type:'object', additionalProperties:false, required:['status','pagesFound','pagesRendered'], properties:{ status:{type:'string', enum:['ok','blocked']}, pagesFound:{type:'integer'}, // Glob 到的 prototype/**/*.html 总数 pagesRendered:{type:'integer'}, // 成功渲染并截图的页数 pages:{ type:'array', items:{ type:'object', additionalProperties:false, required:['file','rendered'], properties:{ file:{type:'string'}, // prototype 相对路径 rendered:{type:'boolean'}, screenshot:{type:'string'}, // assets 相对路径(已归档,纳入版本管理) controlsExercised:{type:'integer'}, // 实际驱动(点击/切换)过的控件数 jsErrors:{type:'array', items:{type:'string'}}, // pageerror 文本(原型自身脚本质量信号) consoleErrors:{type:'array', items:{type:'string'}}, // console.error 文本 notes:{type:'string'} } } }, // envError:仅环境未就绪(与「原型自身有问题」严格区分——后者是 advisory,不进 envError)。 envError:{ type:'object', additionalProperties:false, required:['kind'], properties:{ kind:{type:'string', enum:['playwright-missing','browser-missing','timeout','none']}, detail:{type:'string'} } }, decisions:{ type:'array', items:{ type:'object', additionalProperties:false, required:['question','choice','rationale'], properties:{ question:{type:'string'}, choice:{type:'string'}, rationale:{type:'string'}, confidence:{type:'string', enum:['high','medium','low']} } } }, artifactPath:{type:'string'} } } // ── 微步骤 schemas(runBranchSetup / runMilestone / runCrossModule 用)───────── 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 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 功能行勾选态;state 必填——只 require found 时 cb.state 缺失会静默走 checked 分支。 const CHECKBOX_STATE_SCHEMA = { type:'object', additionalProperties:false, required:['found','state'], properties:{ found:{type:'boolean'}, state:{type:'string', enum:['checked','unchecked']}, lineNumber:{type:'integer'} } } 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'} } } // lane DB 支持探测结果(附录补 6b:两点联查)。两布尔各自独立据实返回,"支持与否"的合取判定与 // 降级决策(任一 false → 本波次降级 maxWidth=1,不 halt)留给编排层(Phase D 波次执行器)—— // 子代理只做局部、可验证的事实陈述,与调度器"LLM 出边、JS 推全局"同一职责切分。 const LANE_DB_PROBE_SCHEMA = { type:'object', additionalProperties:false, required:['scriptsEnv','ymlPlaceholder'], properties:{ scriptsEnv:{type:'boolean'}, ymlPlaceholder:{type:'boolean'}, detail:{type:'string'} } } // 残留 lane 枚举(附录补 3a:波次前置占用感知)。只列 `-lanes/` 下已注册的 worktree—— // 它们持有的 module-* 分支会让主根 checkout 与新 worktree add 双向 fatal(git 实测), // 必须在**每个波次**(含宽 1)开跑前感知并收口,否则"降级回串行"这条兜底路径本身会被残留 lane 毒化。 const LANE_LIST_SCHEMA = { type:'object', additionalProperties:false, required:['lanes'], properties:{ lanes:{ type:'array', items:{ type:'object', additionalProperties:false, required:['path','branch'], properties:{ path:{type:'string'}, branch:{type:'string'} } } } } } // PLAN_FACTS_SCHEMA(Phase F Task 12:feature 级并行准入的事实提取)。LLM 只陈述"plan 锁定了 // 哪些文件、是否声明 schema 改动"——局部、可对照 plan 原文复核的事实;"能否并行"由 JS 确定性 // 判定(批内两两 files 不相交 ∧ 全部 schemaChange=false ∧ 批宽限额),不信 LLM 单方判断。 const PLAN_FACTS_SCHEMA = { type:'object', additionalProperties:false, required:['schemaChange','files'], properties:{ schemaChange:{type:'boolean'}, files:{type:'array', items:{type:'string'}}, detail:{type:'string'} } } // 兼容 harness 把 args 以 JSON 字符串(而非对象)传入的情况。 const ARGS = typeof args === 'string' ? (() => { try { return JSON.parse(args) } catch { return undefined } })() : args const ROOT = ARGS?.projectRoot || '.' // ROOT 必须是绝对路径——相对 '.' 会绑定到子代理隐式 cwd,无保证。 if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) { throw haltError(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`) } // 插件根(coding-start 透传):用于调用插件 lib/*.mjs(如 req-ledger 建需求台账基线)。 // 缺省(旧 coding-start 未传)则相关增强静默跳过——由 /add-req 首跑兜底建基线。 const PLUGIN = ARGS?.pluginRoot || '' // 模块执行上下文:串行/主根模式 root=ROOT、lane=null;并行 lane 模式由 Phase C/D 填充。 // 所有"模块作用域"的 prompt builder 与 runner 一律经 C 取根,不再直接闭包 ROOT。 // ROOT 仍保留:仅供"主根专属"操作(milestone merge / docs/08 字段 / RESUME / ledger / preflight)使用。 // 字段:root=本模块工作树根(lane 模式 = lane worktree 路径);lane=lane 序号(null=串行/主根); // dbSchema=lane 测试库 schema 名(串行为空串);vBase=migration 版本段基址(0=不分段,沿用 max+1); // decisions=per-module 自主决策收集器(替代全局水位线,见 recordDecisions 的 sink 参数)。 function makeCtx(overrides = {}) { return { root: ROOT, lane: null, dbSchema: '', vBase: 0, decisions: [], ...overrides } } // ── Feature-loop stage prompts(共享非交互契约见 featureStageContract)── 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] } // 所有子代理共享的"非交互静默"硬约束。 // c:模块执行上下文(末位参数,调用方必传)。lane 模式(c.dbSchema 非空)追加 lane 测试库硬约束—— // env 前缀(ERP_TEST_DB_SCHEMA)指定本 lane 的一次性副本库名,是 JS 拼好的**成品字符串**(附录补 6c): // tdd 等主会话还会把命令转述给嵌套子会话(提示词链两跳),任何一跳自行拼接都可能丢/错前缀,导致 lane 间 // 用错副本库互相踩——成品照抄是唯一可靠形态(源库有 COPY≠SOURCE 硬守卫兜底、绝不会被误 drop,但 lane 隔离仍靠前缀正确)。 function featureStageContract(phase, c) { const fe = isFrontend(phase) return [ '## 硬约束(非交互子代理)', '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', '- **测试执行子会话的物理实现(授权)**:下文「派发 Agent 子会话跑测试 / 绝不在主会话直接跑测试」的护栏,其**真实意图**是「测试执行与你的主推理流隔离、只回收结构化结果(不把全文 stdout 灌进上下文)」。本运行环境若**没有** Agent/Task 子会话派发工具(用 ToolSearch `select:Task` 无匹配即属此情形),则以 **`Bash` 的 `run_in_background` 隔离后台子进程**作为该子会话的物理实现:派一个 detached 子进程跑测试命令、输出重定向到 `.tmp/` 日志文件,你只读回 `{command, exit_code, failing_assertion / passed / failed}` 等结构化摘要。这**已满足**护栏意图,故**绝不**因「缺 Agent 派发工具」而 halt——这不是硬事实缺失、不是缺值阻塞,retry 也不会改变工具面,halt 只会空耗一次重跑。**仍然禁止**:把测试命令在主推理流里内联同步执行并把全文 stdout 读进上下文。下文出现「Agent 子会话」一律按本条解释为「隔离后台子进程子会话」。', '- **后台等待纪律(硬约束,违反即前功尽弃)**:把测试/闸门派到后台子进程后,必须在**同一回合内**等到终态——用重复的 `Bash` 调用轮询(`sleep N` 后读日志尾/exit 标记文件,长跑闸门每 60~120s 一轮,多少轮都行)。**绝不**使用 Monitor / ScheduleWakeup / 任何「装个监听器然后结束回合等通知」的模式:你是 Workflow 派生子代理,**结束回合=宣告完成**,会被判「未调 StructuredOutput」使整个模块 halt,任何通知都不会把你唤醒(AGT test-gate 2026-07-11 实锤教训)。拿到终态先写证据再调 StructuredOutput,一个回合内闭环。', '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md` → `docs/05-API接口契约.md` → `prototype/`(前端布局/交互权威)→ `src/styles/tokens.css`(前端色值)→ `CLAUDE.md` → 现有代码。', '- 仍查不到时——**优先自主决策继续,不要停下**:基于现有代码约定 / 技术规范 / 同类实现,挑选**最有依据的解读或合理默认值**,把该决策写进产物显著位置,并在返回的 `decisions[]` 中逐条登记 `{question, choice, rationale, confidence}`(这是默认动作,项目目标是全自动静默、尽可能少 halt)。', '- 红线:**绝不**留 `【人工填写:】` / `TBD` / `TODO` 占位;**绝不**编造与现有约定/技术规范冲突的"事实";自主默认必须可被现有证据支撑且记入 `decisions[]`。', '- 仅当缺失的是**无法自洽决策的硬事实**(如某表结构 / 业务主键语义完全缺失且无任何旁证,任何默认都可能污染源码或伪造业务语义)时,才以 `status:halt` 结束并把阻塞点写清;上层会再经仲裁评估能否继续,halt 是最后手段而非首选。', '- 输出纪律:本次若做过任何自主默认 / 解读,成功返回(status:ok)**必须**带 `decisions[]`(逐条 `{question,choice,rationale,confidence}`,与上面登记要求一致);完全没有自主决策时才可省略——别照抄"输出"段里不含 decisions 的最简示例而漏登记。', c.dbSchema ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,测试跑在**一次性副本库**(从源库复制而来、跑完删除,源库绝不被 drop)。**所有**测试 / 建副本 / 种子 / 晋升命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data.mjs\`、\`node scripts/drop-test-db.mjs\`、\`node scripts/promote-to-source.mjs\`、playwright 等,**含派发给子会话 / 嵌套子会话的命令**)必须前置环境变量 \`ERP_TEST_DB_SCHEMA=${c.dbSchema}\`——成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略前缀。` : '', c.dbSchema ? '- **lane 内禁全量测试入口(硬约束)**:lane 内 tdd/verify/fix 阶段**只允许目标化测试命令**(如 gradle 按类/按模块过滤、vitest 过滤模式);**禁止**运行含 e2e / 冷起全栈的全量入口(`node scripts/test.mjs`、`pnpm e2e:ci` 等)——它们按固定端口起栈,会击杀兄弟 lane 正在验证的进程;全量回归只发生在 test-gate(已由编排层起栈互斥保护)。' : '', '- 全部输出文档**使用中文**。', `- **阶段 = ${fe ? '前端(frontend)' : '后端(backend)'}**。路径作用域:${fe ? '实现文件必须落在 `frontend/` 下;命中 `backend/` / `sql/` / `scripts/` 即越界,硬停。' : '产出范围限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约;**禁止**写 `frontend/` 路径下的实现(UI 推迟到前端阶段)。'}`, `- id 形态:${fe ? '前端为 `FE-NN`(业务功能粒度,可关联多个 prototype 区域与多个 REQ)。' : '后端为 `<模块代码>-<子模块代码>-<功能名>`(3 段 req_id,模块代码大写短码 + 子模块代码/功能名大驼峰,如 `USR-UserInfo-Login`)。'}`, ].filter(Boolean).join('\n') } // commitBlock:spec/plan/verify/review 共用的"写完 → add → commit → 失败 halt"四行块。 // c 为末位参数(调用方必传);tail 用缺省时调用方传 undefined 占位(null 不触发参数默认值)。 function commitBlock(addPath, msg, tail = '- commit 失败 → halt,把 stderr 摘要写进 reason。', c) { return [ '## commit', `- 写完后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`, ` 1. \`git -C ${c.root} add ${addPath}\``, ` 2. \`git -C ${c.root} commit -m "${msg}"\``, tail, ].join('\n') } // Router:读 docs/08 §二/§三 + git tag,重算进度,返回 ROUTER_SCHEMA。 function routerPrompt(root) { return [ '# Coding Router — 从账本重算进度', '', `项目根:\`${root}\``, '', '你是 Coding 阶段的路由子代理。**只读不写**(不改任何代码 / 文档),仅从状态账本重算"哪些模块还要跑",返回结构化结果。', '', '## 读取来源(账本 = docs/08 + git tag,里程碑和功能级完成都以 tag 为真值)', '1. `docs/08-模块任务管理.md § 二`(后端模块元数据):逐个模块取 `id`(英文蛇形 module id)、本模块的 REQ 列表(按 `docs/02-开发计划.md § 二 开发顺序清单` 的顺序,A5 约束保证同模块 REQ 连续),以及该模块的 `里程碑:` 字段。', '2. `docs/08-模块任务管理.md § 三`(前端阶段元数据):取 `整体里程碑:` 字段,以及 `功能:` 项下所有 `- [ ] FE-NN ...` / `- [x] FE-NN ...` 行(FE 清单)。前端 item 形如 `FE-NN`。', '3. `git -C tag -l "milestone/*"`:列出已打的里程碑 tag。', '4. `git -C tag -l "req-done/*"`:列出已通过 review 并落地的功能级完成 tag。`docs/08` checkbox 只作可视化,不作为跳过功能的真值。', '', '## 完成判定(每个模块独立)', '- 后端模块 `done = true` 当且仅当:§二 该模块 `里程碑:` 字段 == `milestone/` **且** `git tag -l "milestone/"` 能查到该 tag。任一缺失 → `done = false`。', '- 前端 item(FE-NN)归属一个"逻辑前端模块"。前端阶段整体 `done` 当且仅当 §三 `整体里程碑:` == `milestone/frontend-phase` 且 `git tag -l "milestone/frontend-phase"` 存在。', '- 后端 REQ / 前端 FE 的功能级完成判定:仅当 `git tag -l "req-done/"` 能查到该 tag 才视为已 approve。不要因为存在 review markdown 或 docs/08 checkbox 已勾就跳过;若 tag 缺失,必须把该 id 放回待跑列表。', '', '## 输出(必须符合下发的 JSON schema)', '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:', ' - `id`: 模块标识(后端为英文蛇形 module id;前端聚合模块固定用 `frontend-phase`)。', ' - `done`: 该模块/前端阶段是否已完成(按上面的 milestone 判定)。', ' - `reqs`: **仅后端模块**填本模块**缺少 `req-done/` tag** 的后端 REQ 有序列表;模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。', ' - `feItems`: **仅前端聚合模块**填——把**全部模块**缺少 `req-done/` tag 的前端 FE-NN 汇总为一个有序列表放进 `frontend-phase` 这一项。**后端模块 `feItems` 恒为空数组**(前端不分摊到后端模块)。', '- 即:后端模块只承载 `reqs`、`feItems=[]`;末尾的 `frontend-phase` 模块只承载 `feItems`、`reqs=[]`。整个项目至多一个前端聚合模块,对应至多一个 `milestone/frontend-phase` tag。', '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。', '', '## 缺值处理', '- docs/08 §二/§三 缺失 / 格式不符 / 无法解析 → **不要猜**:把具体的解析失败点写入返回前的诊断并使本步骤失败(让 Workflow halt),由人工修复 Plan 产物后重跑 `coding-start`。', ].join('\n') } // Scheduler(Phase B):模块依赖边取证。只读、主根专属(调度发生在任何 lane 建立之前, // root 传 ROOT,与 routerPrompt 同口径)。LLM 只输出局部、可引证的"依赖边 + 证据"; // 并行组/波次是全局属性,由 JS 拓扑(nextWave)确定性推导——职责切分是本调度器的设计核心。 function schedulerPrompt(todoBackendIds, allBackendIds, root) { return [ '# Coding Scheduler — 模块依赖边取证', '', `项目根:\`${root}\``, '', '你是 Coding 阶段的调度子代理。**只读不写**(不改任何代码 / 文档)。逐对审视下列待跑后端模块,输出"谁必须先于谁"的**依赖边 + 证据**,供脚本做确定性拓扑波次调度。', '**绝不输出"并行组 / 波次"**——并行性是全局属性,由脚本侧拓扑推导;你只做局部、有证据可查的判断。', '', '## 保守偏置(最高优先级)', '对**真实依赖**(fk/api/seed/共享表/docs 显式约束)拿不准时就加边(kind=uncertain)——错误串行只损失时间,错误并行损失正确性。但"不确定"**仅指**怀疑存在真实依赖却证据不全;**绝不**把"在 docs/02 里靠后 / 排版顺序靠后"当作不确定理由——列序不是依赖(见 order 规则)。', '', '## 待跑后端模块(只对它们逐项输出 deps;`frontend-phase` 不在你的职责内——脚本硬规则强制它依赖全部后端模块)', todoBackendIds.map(id => `- \`${id}\``).join('\n'), '', '## dependsOn 合法取值域(全部后端模块 id;不在待跑清单内的为已完成模块,其依赖恒满足,但查到证据时仍应如实列出,保留可审计性)', allBackendIds.map(id => `- \`${id}\``).join('\n'), '', '## 边规则(按证据来源定 kind;方向:`dependsOn` 列出的是"必须先完成"的模块)', `1. **order**(仅 docs/06 显式顺序约束才加;docs/02 列序**不是**依赖):\`${root}/docs/02-开发计划.md § 二 开发顺序清单\` 的列出顺序**只是开发计划的排版次序,绝不构成依赖**——**绝不**因某模块"在 docs/02 里靠后 / 是另一模块的紧邻后继"就对其前驱输出 order 边。order 边**只在** \`${root}/docs/06-实现策略.md\`(或 \`${root}/docs/01-需求清单/\` REQ 卡)**显式声明**"模块 X 必须先于模块 Y 完成"这类顺序约束时才输出,evidence 写"文件 + 小节 + 原文约束"。**关键**:对**已完成模块**(不在待跑清单内)的 fk/api 证据**绝不构成**两个待跑模块之间的 order 理由——order 只看**待跑模块两两之间**显式声明的顺序约束。两个待跑模块间查不到 fk/api/seed/共享表/docs/06 显式顺序约束 → 对该对**不加边**(让它们落进同一波并行,这是默认而非例外)。`, `2. **fk**:\`${root}/docs/03-数据库设计文档.md\` 中本模块归属表的外键引用了另一模块归属的表 → 本模块依赖对方。evidence 写"docs/03 + 表名.外键列 → 被引表"。`, `3. **api**:\`${root}/docs/05-API接口契约.md\` 中本模块消费另一模块提供的端点,或 \`${root}/docs/01-需求清单/\` REQ 卡「依赖接口」字段指向对方模块端点 → api 边。evidence 写"文件 + 端点路径"。`, '4. **seed**:演示种子跨模块主键引用(本模块种子数据需引用对方模块表已存在的主键)→ seed 边。evidence 写引用的表与主键区间出处。', `5. **共享表**(必须串行):两个模块的演示种子会触达**同一张表**(按 docs/03 的表归属 / \`${root}/docs/08-模块任务管理.md\` 各模块 \`路径:\` 字段判定共同表)→ 必须加 fk 或 seed 边(宁串勿并),evidence 写共同表名。**方向固定单向**:按 \`docs/02 § 二\` 顺序**后者依赖前者**(只在后序模块的 dependsOn 列前序模块);**绝不**双向各输出一条——那会构造环,白白损失并行度(环虽有降级兜底)。`, '6. **doc**:docs/06 实现策略、REQ 卡等文档**显式声明**的顺序/依赖约束,而证据无法归入 fk/api/seed → doc 边。evidence 写"文件 + 小节"。', '7. **uncertain**:怀疑存在依赖但证据不完整 → 照样输出边(uncertain 也是边,同样阻断并行)。evidence 写怀疑依据。', '', '## evidence 纪律', '- 每条边 `evidence` 必须**可定位**:文件 + 小节 / 表名 / 端点,让人工能直接翻到出处复核。', '- 空泛的"业务上相关 / 感觉有关"不构成 fk/api/seed/doc 证据——拿不准时按保守偏置改出 uncertain 边,把怀疑点写进 evidence。', '', '## 输出(必须符合下发的 JSON schema)', '- `deps`: 数组,**待跑后端模块每个恰好一项**:`{ "id": "", "dependsOn": [{ "id", "kind", "evidence" }] }`;无依赖则 `dependsOn: []`。', '- `dependsOn[].id` 只能取上面列出的后端模块 id;**绝不**含 `frontend-phase`,**绝不**自依赖,**绝不**构造互相依赖的环。', '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。', ].join('\n') } // ---- 功能内循环 stage 1:派生 spec(原 feature-brainstorm / fe-feature-brainstorm)---- // batch(Phase E Task 11):spec 批量预生成模式——spec 只读 docs/prototype/tokens、不读兄弟 // feature 代码,故可在同一工作树并行预生成;但并行 commit 会争 .git/index.lock,batch 下把 // commitBlock 替换为「只写盘不 commit」,统一提交由 featureLoop 的单一微步骤收口。 // 注:batch 接在 c 之后是计划 Task 11 的字面签名(`deriveSpecPrompt(id, phase, c, { batch: true })`), // 是"c 为末位参数"约定的唯一特例——现有 3 参调用点无须改动。 function deriveSpecPrompt(id, phase, c, { batch = false } = {}) { const fe = isFrontend(phase) return [ `# ${fe ? 'fe-feature-brainstorm' : 'feature-brainstorm'} — 派生规格 ${id}`, '', featureStageContract(phase, c), '', '## 目标', `静默派生 \`${id}\` 的实现规格(无 Q&A)。需求歧义本应在 Plan 期的结构化 per-REQ 表单锁定,前端布局/交互以 \`${c.root}/prototype/\` 为权威;这里**只消费已锁定的事实**,不再澄清。`, '', '## 收集上下文', fe ? [ `- 关联 REQ 卡片:\`${c.root}/docs/01-需求清单//.md\`(提取业务校验规则、acceptance、UI 描述)。`, `- 关联 prototype:Read \`${c.root}/prototype/**/*.html\`(含 anchor 时聚焦相应区域),作为页面布局权威。`, `- API 契约:\`${c.root}/docs/05-API接口契约.md\`,按本 FE 关联的 REQ 过滤出消费的端点。`, `- Design Tokens:\`${c.root}/src/styles/tokens.css\`(色值 / 状态色单一来源;只用 var(--color-*),禁硬编码 hex)。**与 prototype 的色值冲突时以 tokens.css 为准**(prototype 管结构/布局/交互)。`, `- 前端组件库:\`${c.root}/docs/04-技术规范.md § 零\` 的 \`frontend.ui_lib\`,决定组件选型。`, `- 实现策略:\`${c.root}/docs/06-实现策略.md\`(A2 人工填写;关键技术决策 / 取舍、实现顺序与依赖、难点·风险、对默认流程的偏离——遵循其中与本 FE 相关的指引)。`, ].join('\n') : [ `- REQ 卡片:\`${c.root}/docs/01-需求清单//${id}.md\`。**忽略 UI 描述**(控件类型 / 按钮位置 / 列表布局),但校验规则、业务规则仍要落到后端 DTO + service。`, `- 涉及的数据表定义:\`${c.root}/docs/03-数据库设计文档.md\`(必要时实时查 mysql 只读)。`, `- API 契约:\`${c.root}/docs/05-API接口契约.md\` 中本 REQ 相关端点。`, `- 实现策略:\`${c.root}/docs/06-实现策略.md\`(A2 人工填写;关键技术决策 / 取舍、实现顺序与依赖、难点·风险、对默认流程的偏离——遵循其中与本 REQ 相关的指引)。`, ].join('\n'), '', '## 写 spec', `- 落盘路径:\`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。', fe ? [ '', '## 行为验收作用域结构化小节(阶段末尾行为门按全部 FE 聚合断言作用域的唯一来源,**强制写到 spec 头部**)', '- 在 spec 文件头部(紧随标题/关联 REQ 之后)写一个**结构化小节**,标题逐字为 `## 行为验收作用域`,内含两条机器可读清单:', ' ```', ' ## 行为验收作用域', ' - 关联路由: [/orders, /orders/:id]', ' - 负责控件白名单: [data-testid=order-submit, /orders 页 "提交" 按钮, ...]', ' ```', `- **关联路由**:从 \`${c.root}/frontend/\` router 配置(用 Grep 定位)取本 FE 真正负责渲染的路由 path(与 router 一致;带参动态路由保留 \`:id\` 占位)。**只列本 FE 路由**,不要列兄弟 FE / 共享路由。`, '- **负责控件白名单**:本 FE 页面上"点了必须有可观测效果 / 显示必须正确"的控件清单(优先 `data-testid` 约定;无 testid 时用 `<页面> + DOM 选择器/可见文案` 描述)。行为门只对白名单内控件判 must-fix;白名单外控件记证据不算缺陷。', '- 该小节是**确定性映射**(fe-feature-review 会校验其存在且与 router 一致,缺失/不一致 → request-changes;阶段末尾的行为门会聚合**全部 FE** 的该小节作为整体断言作用域,缺失的 FE 会被记 `scope-missing` 阻断 green);推不出路由(router 尚未声明本 FE 路由)→ 按硬约束登记 decisions 取最有依据的占位 path 或 halt(不要留空)。', ].join('\n') : '', '', batch ? [ '## 落盘纪律(批量预生成模式:**只写盘,不 commit**)', '- 本 stage 正与兄弟 feature 的 spec 在**同一工作树并行**预生成:写完 spec 文件即止,**绝不** `git add` / `git commit` / 任何改写 .git 状态的命令(并行 commit 会争 .git/index.lock)。', '- 统一提交由编排层在全部 spec 落盘后以单个微步骤完成(`git add docs/superpowers/specs` + commit),无须你善后。', ].join('\n') : commitBlock('', `docs(spec:${id}): 派生规格`, undefined, c), '', '## 自审(inline 修,无须等待)', `- 占位符扫描:\`TBD\` / \`TODO\` / \`【人工填写:】\`${fe ? ' / `controller` / `service` / `SQL` / `migration`(前端 spec 不应出现后端字样)' : ''} → 命中即修;修不掉的缺值按硬约束失败。`, '- 内部一致性 / 范围检查(单 plan 能消化吗)/ 歧义检查(任一 requirement 两种解读 → 挑一个写明)。', '', '## 输出(必须符合下发的 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)---- // specPath:调用方传入的 spec artifactPath(含 YYYY-MM-DD 前缀),plan 复用该日期。 function planPrompt(id, phase, specPath, c) { const fe = isFrontend(phase) return [ `# ${fe ? 'fe-feature-plan' : 'feature-plan'} — 任务级计划 ${id}`, '', featureStageContract(phase, c), '', '## 输入', `- 上游 spec:\`${specPath}\`(已由 spec stage 落盘;不存在则 halt)。**plan 文件名日期前缀必须与 spec 一致**:取 spec 文件名首段 \`YYYY-MM-DD\`,写到 plan 路径,不要重新解析"今天"。`, fe ? `- \`${c.root}/docs/04-技术规范.md § 二 前端规范\`(§ 2.1 目录约定 = 落盘位置;状态管理 / 请求封装 / 错误处理);色值 / 样式见 \`${c.root}/src/styles/tokens.css\`;测试栈见 § 零。用 Grep 在 \`${c.root}/frontend/\` 定位现有文件。` : `- \`${c.root}/docs/04-技术规范.md\`(编码规范 + § 1.2 分层结构 = 后端落盘)。用 Grep 在现有代码定位待修改文件。`, '', '## 计划写作原则', '- Plan 告诉 TDD 执行者**做什么**,不是**怎么写代码**(执行者是同模型、全上下文的 tdd stage)。', `- Plan 锁定**文件边界 + 测试意图 + ${fe ? 'props 契约' : 'API 形状'} + 完成判据**;代码由 TDD 红绿循环产出。`, '- **禁止 dump 整个文件内容**(build.gradle / entity / config / 组件源码)到 plan——避免双 source of truth 漂移。', fe ? '- 每个任务标注"测试先行类型" = **jsdom 组件测试** OR **Playwright E2E**。' : '', '- DRY、YAGNI、TDD、frequent commits。', '', '## 任务结构(每个 task = 一个 red-green-commit 单元,4 step)', '1. 写失败测试(给 `test_file::test_name` + 测试意图);2. 实现最小代码(给 `impl_file`);3. 子会话验证 PASS;4. commit。任务粒度 2-5 分钟。', fe ? `- **硬护栏**:每个任务 \`impl_file\` 必须以 \`frontend/\` 开头且**不得**是测试文件(\`*.test.*\` / \`*.spec.*\`,也不得落在 \`__tests__/\` / \`__mocks__/\` / \`__smoke__/\` 目录);\`test_file\` 必须以 \`frontend/tests/\`(jsdom 单测,目录镜像 \`frontend/src/\` 相对路径)或 \`frontend/e2e/\`(Playwright)开头,**绝不**把测试文件计划进 \`frontend/src/\`(交付源码与测试物理分离,见 docs/04 § 2.1);命中 \`backend/\` / \`sql/\` / \`scripts/\` → 修正后重渲染。` : `- **硬护栏**:任务粒度限定后端文件(controller / service / repository / DTO / 校验 / SQL migration);**禁止**生成 \`frontend/\` 路径任务。`, '- 允许写死的少数场景:DDL / migration 语句、合同级常量(错误码 / JWT claim / Redis key / 路由 path / API client 签名 / Design Tokens 名)、可选的测试断言 sketch。其余一律散文 + 签名描述。', '- 首次出现的类 / 方法 / 组件 / hook / API client 函数必须给出签名;跨 task 的签名 / 错误码 / props 类型必须一致。', '', '## 写 plan + 自审', `- 落盘路径:\`docs/superpowers/plans/<同 spec 的 YYYY-MM-DD>-${id}.md\`,文件头含 Goal / Architecture / Tech Stack + checkbox 任务。`, '- 自审:占位符扫描(按硬约束清单);spec coverage(spec 每节至少指向一个 task,补 gap);类型一致性(签名 / 方法名 / 错误码 / props 一致)。', '', commitBlock('', `docs(plan:${id}): 任务级 TDD 计划`, undefined, c), '', '## 输出(必须符合下发的 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)---- // planPath:上游 plan artifactPath;ledger 是 prompt 层的显式自约束(无 harness 强制)。 function tddPrompt(id, phase, planPath, c, feStage = 'all') { const fe = isFrontend(phase) return [ `# ${fe ? 'fe-feature-tdd' : 'feature-tdd'} — 逐任务 TDD ${id}`, '', featureStageContract(phase, c), '', '## 输入', `- 计划文件:\`${planPath}\`(不存在则 halt)。`, // 后端默认命令按 lane 分支:顶部硬约束已禁 lane 内全量入口,输入段若仍把 `node scripts/test.mjs` // 钉成默认,同一 prompt 就顶部禁止、底部指定——命令还要两跳转述给嵌套子会话,必须在源头给出 // lane 内合法默认(目标化 gradle 过滤)。串行(c.dbSchema 空)与前端文案一字不动。 `- 测试命令来源:\`${c.root}/docs/04-技术规范.md § 零\`${fe ? ' 的 `frontend.unit_test_runner` / `frontend.e2e_runner` / `frontend.test_command` / `frontend.e2e_command`(缺失则默认 `pnpm test:ci` / `pnpm e2e:ci`)。' : c.dbSchema ? ' 确认的后端测试命令,并**目标化**到本功能涉及的测试类/模块(如 Gradle `test --tests` 按类过滤)——lane 模式见上方硬约束,**禁全量入口**;docs/04 缺后端命令时默认 Gradle 按类过滤跑本功能测试,**绝不**回退 `node scripts/test.mjs` 等冷起全栈的全量命令。' : ' 确认的后端测试命令(如 Gradle task / `./scripts/test.mjs`);缺失则默认 `node scripts/test.mjs`(与 test-gate 一致)。'}`, '', '## 流程', // V 段规则(Task 7 Step 3):c.vBase > 0(并行 lane)时取号改走专属百段,杜绝同波次模块从同一 // 基点各算 max+1 撞号。依据:同波次以"无依赖边"为准入——独立模块的 migration 互不引用,版本相对 // 顺序任意;lane 测试库每次从零重建、合并后全量按版本序 apply,段间空洞无影响。并行版本段下版本号 // **只保唯一、不保连续、不保合并序**(附录补 5:高版本段可能先合入默认分支,骨架约定 Flyway // out-of-order: true,乱序 apply 安全)。串行(vBase=0)维持现行 max+1 文案,一字不差。 fe ? '' : `- **Schema 改动前置**(仅当 plan 声明需要):第一个任务写 migration 文件 \`V__.sql\`(${c.vBase > 0 ? `\`\` 取**你的专属版本段 [${c.vBase}, ${c.vBase + 99}]** 内现有最大版本号 +1(段内尚无文件则取 ${c.vBase});**绝不**使用段外版本号——并行版本段下版本号只保唯一、不保连续,段间空洞与乱序合并安全(骨架已约定 Flyway out-of-order),**不要**"补洞"或改用段外小号` : '\`\` = 现有 \`sql/migrations/V*.sql\` 最大版本号 + 1'},只含 DDL),**同步**把新 CREATE / ALTER 反向更新到 \`docs/03-数据库设计文档.md\` 对应表小节(docs/03 是 schema 的 SSoT),migration + docs/03 改动同一 commit。`, // 数据源占位约定(附录补 6a):lane 测试库 env 能打穿 Spring 进程的前提——application*.yml 写死 // schema 时 ERP_TEST_DB_SCHEMA 传进去无人消费(脚本支持 ≠ 测试进程支持)。**关键安全点**:占位缺省值 // 必须是副本库 `__test`,绝不能是源库 `` 本身——否则 env 万一缺省,测试就直接跑在源库上。 fe ? '' : '- **数据源 schema 占位约定**:生成 / 修改后端 `application*.yml`(含测试 profile)时,JDBC URL 的 schema 段必须写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:__test}`(**缺省值是副本库 `__test`,绝不是源库**——测试永远连一次性副本、绝不连源库);**绝不**把 schema 名写死成纯字面量、**绝不**把缺省值写成源库名。', // feStage 任务子集(前端 jsdom-only 重叠;设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)。 // 'all'(缺省,含全部后端)与现行一字不差;'jsdom'/'e2e' 仅前端 phase1/phase2 传入。 fe && feStage === 'jsdom' ? '- **本阶段 = 仅 jsdom 任务(重叠 phase1,不依赖后端)**:只处理 plan 中 `测试先行类型 = jsdom` 的代码任务;`= e2e` 的任务**全部跳过**,并在 `summary` 里以 `deferredE2e: []` 列出(留待 phase2)。测试命令**只用** vitest(`frontend.test_command` / `frontend.unit_test_runner`)。**硬护栏**:本阶段**绝不**运行 Playwright / `pnpm e2e:ci` / `frontend.e2e_command` / 任何冷起全栈或固定端口的 e2e 命令——命中即 `{status:"halt", reason:"jsdom 模式禁跑 e2e(重叠 phase1 后端尚未就绪)"}`。FeStub→真组件替换(见下)**在本阶段完成**。' : '', fe && feStage === 'e2e' ? '- **本阶段 = 仅 e2e 任务(phase2,后端已就绪;jsdom 已在 phase1 绿)**:只处理 plan 中 `测试先行类型 = e2e` 的任务;jsdom 任务**跳过**(phase1 已完成实现与单测)。e2e 基线约束见下;FeStub→真组件替换 phase1 已做,本阶段确认 router 仍指向真组件即可。' : '', '- 按顺序处理每个代码类任务:(a) 在 `test_file::test_name` 写**失败**测试;(b) **派发 Agent 子会话**跑测试确认失败,子会话只返回 `{command, exit_code, failing_assertion}` JSON;(c) 写**最小**实现使测试通过;(d) 再派子会话确认通过;(e) commit(含 `REQ_ID` / REQ 标签)。', fe ? '- **测试落位(硬约定,对齐 docs/04 § 2.1)**:jsdom 单测用 vitest/jest 一律写到 `frontend/tests/`,目录**镜像** `frontend/src/` 相对路径(如 `src/pages/home/HomePage.tsx` → `tests/pages/home/HomePage.test.tsx`);e2e 类型在 `frontend/e2e/` 写 Playwright(headless)。**绝不**把 `*.test.*` / `*.spec.*` / `__tests__/` / `__mocks__/` / `__smoke__/` 落在 `frontend/src/` 内(交付源码与测试物理分离,同后端 src/main↔src/test)。实现时:色值用 `var(--color-*)`(不硬编码 hex),业务校验按 spec 在 form-level 复刻。' : '', fe ? '- **e2e 基线约束**:e2e 跑在「源库副本 + 本轮新迁移 + 演示种子」基线上(骨架 globalSetup 已复制源库为副本并注入 `sql/seed`,无需测试自行建库/起栈)。e2e 断言**优先**定位**演示种子已知主键行**(1000–9999)或**测试自建数据**;**禁止**「全表恰好 N 行」式依赖全局计数的脆弱断言(副本含源库真实数据 + 演示种子行数会随后续模块种子增长,全局计数断言必然 flaky)。' : '', fe ? `- **占位替换(保证中途可构建 + 阶段末尾行为门可达本 FE 路由)**:前端骨架阶段已在 router 里为本 FE 路由声明 lazy import 但指向占位组件 \`FeStub\`。本 FE 实现完成后,**必须**把 router 中本 FE 路由的 import 从 \`FeStub\` 改为本 FE 真组件(用 Grep 在 \`${c.root}/frontend/\` router 定位本 FE 路由 path 的 import 行;仍在 \`frontend/\` 路径内,不破坏护栏)。改完确保 router 该路由 lazy import 指向真组件、可构建可达。` : '', '', '## 护栏', '- **绝不**在主会话直接跑测试(gradle / pnpm / playwright / scripts/test.mjs)——必须通过 Agent 子会话。', fe ? '- **绝不**写非 `frontend/` 路径的 `impl_file`;命中 `backend/` / `sql/` / `scripts/` → 硬停并打印 `不允许写非前端文件:`。' : '- **后端阶段路径硬护栏**:任意 `impl_file` 以 `frontend/` 开头 → 硬停并打印 `后端阶段不允许写前端代码:`,不再继续 TDD。', '- 每次 commit 含 REQ/FE 标签,不混合无关改同。', '', '## 同测试重试账本(硬上限 10 次 / 测试)', '- 你必须**显式**为每个出现过红色的测试维护一个内存账本 `attempts[::] = N`,每次该测试的"写失败实现 → 再跑"算 1 次。', '- 每次失败跑后,**在自身输出中显式打印一行** JSON:`{ "attempts": { "::": N } }`(便于 review/审计追溯)。', '- 任一测试的 `attempts >= 10` → **立刻 halt**:返回 `{status:"halt", reason:"tdd-test-stuck: :: 已尝试 10 次"}`,把"该测试名 / 最近一次 failing_assertion / 已尝试的修复摘要"写进 reason,**不要**无限重试。', '', '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', '- 全部任务通过:`{ "status": "ok", "summary": "<完成的任务数 / 引入的文件清单摘要>" }`(artifactPath 可省)。', '- 任意护栏 / 账本上限 / 缺值 → `{ "status": "halt", "reason": "<具体阻塞点>" }`。', ].filter(Boolean).join('\n') } // ---- stage 4:把功能测试派子会话跑,渲染证据(原 feature-verify / fe-feature-verify)---- // specPath:用于复用日期前缀;round:0 = TDD 后初次 verify,1..5 = fix 后 reverify(每轮独立证据文件, // 避免 reverify 覆盖前轮证据)。 function verifyPrompt(id, phase, implSummary, specPath, round = 0, c, feStage = 'all') { const fe = isFrontend(phase) const suffix = round === 0 ? 'verify' : `verify-r${round}` return [ `# ${fe ? 'fe-feature-verify' : 'feature-verify'} — 证据验证 ${id}${round > 0 ? `(第 ${round} 轮 fix 后复验)` : ''}`, '', featureStageContract(phase, c), '', '## 目标', `把 \`${id}\` 的功能测试**派发到 Agent 子会话**执行,按结构化结果渲染证据。**主会话从不直接跑测试,也不自由编写证据。**`, `- 上游 spec:\`${specPath}\`(日期前缀来源);本次产物文件名前缀必须 = spec 文件名首段 \`YYYY-MM-DD\`。`, implSummary ? `- 上游 TDD 摘要:${implSummary}` : '', '', '## 流程', fe && feStage === 'jsdom' ? [ // 重叠 phase1:只验 jsdom 单测(不依赖后端),e2e 留 phase2。 `- 测试目标:从 plan 取 \`测试先行类型 = jsdom\` 的 test_file → 拼 vitest/jest 过滤模式。命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.unit_test_runner\` 取(缺失默认 \`pnpm test:ci\`)。`, '- 派子会话**只跑 unit(vitest)**,返回 `{ unit:{command,exit_code,passed,failed,failed_list,stdout_excerpt} }`(`stdout_excerpt` ≤ 30 行)。**硬护栏**:本阶段绝不跑 e2e / Playwright / `pnpm e2e:ci`(后端尚未就绪)。', '- **unit `exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt。', ].join('\n') : fe ? [ `- 测试目标:从 plan 取 \`测试先行类型 = jsdom\` 的 test_file → 拼 vitest/jest 过滤模式;\`= e2e\` 的 → 拼 Playwright spec 过滤模式。命令从 \`${c.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`** → 渲染证据后 halt,不进入 review。', ].join('\n') : [ // lane 模式追加过滤提示:泛指入口里混着全量命令形态,不点破会与顶部"禁全量入口"硬约束打架。 `- 测试目标:从 plan 或项目标准命令确定(Gradle task / pnpm script / pytest path / \`${c.root}/docs/04-技术规范.md § 零\` 的后端命令)。${c.dbSchema ? 'lane 模式见上方硬约束:只取**目标化**命令(gradle 按类/按模块过滤),**禁全量入口**。' : ''}`, '- 派子会话执行,子会话只返回结构化 JSON:`{command, exit_code, passed, failed, failed_list, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行,不塞全文 stdout)。', '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt,不进入 review。', ].join('\n'), `- 证据落盘路径固定为 \`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}-${suffix}.md\`(与 review 报告同目录;round=0 → \`-verify.md\`;round>=1 → \`-verify-r.md\`,**每轮独立文件不覆盖前轮**)。同时把核心结构化结果摘要打印到会话便于上层 review stage 引用,**不要**自行另起目录或自由命名文件。`, '', commitBlock('<证据 artifactPath>', `docs(verify:${id}${round > 0 ? `:r${round}` : ''}): 证据验证`, '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的证据路径)。', c), '', '## 输出(必须符合下发的 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 ---- // lastVerifySummary:round>1 时传入上轮 fix 后复验摘要,让 reviewer 看到"上轮 must-fix 真的修了什么"。 // specPath:spec artifactPath(日期前缀来源 + reviewer 上下文输入)。 function reviewPrompt(id, phase, round, lastVerifySummary, specPath, c) { const fe = isFrontend(phase) return [ `# ${fe ? 'fe-feature-review' : 'feature-review'} — AI 自审 ${id}(第 ${round} 轮)`, '', featureStageContract(phase, c), '', '## 目标', `对 \`${id}\` 本轮引入的代码 diff 做 AI 自审,给出 \`approve\` 或 \`request-changes\` 裁决。`, '', '## 输入给 reviewer', `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}\`。`, fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '', `- **phase = ${fe ? 'frontend → 附加前端 8 维 checklist(含 §8 测试文件隔离:本轮 diff 在 frontend/src/ 内引入任何 *.test.* / *.spec.* / __tests__ / __mocks__ / __smoke__ → must-fix,应移至 frontend/tests/ 镜像路径)。其中仅"颜色对比度"(§3 子项)与"响应式"(§4)为主观/best-effort,绝不单独触发 request-changes;a11y 的 label/键盘可达/危险操作确认等客观项仍可作 must-fix(与 agents/code-reviewer.md §3-4 对齐,避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`, fe ? `- **行为验收作用域小节校验(阶段级行为门的作用域真值来源,必查)**:spec \`${specPath}\` 头部**必须**含逐字标题为 \`## 行为验收作用域\` 的结构化小节,且其 \`关联路由:\` 清单与 \`${c.root}/frontend/\` router 配置一致(本 FE 路由都在 router 声明、无悬空/错配)。该小节缺失 或 与 router 不一致 → **必须 request-changes**,把"补齐/对齐 行为验收作用域小节"列入 issues(locator 指向 spec 文件路径)。这是 approve 前置——阶段末尾的行为门按全部 FE spec 的该小节聚合断言作用域,缺失/错配会让该 FE 漏验或归因失真。` : '', 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\` 时必须为空数组 \`[]\`;\`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`)。', '', commitBlock(`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md`, `docs(review:${id}:r${round}): `, '- commit 失败时仍按 schema 返回 verdict / issues;commit 错误信息打印到日志即可(不要在 schema 中夹带额外字段)。', c), ].filter(Boolean).join('\n') } // ---- stage 5b:按 review must-fix 修复并重新 commit(review 循环的 fix 步)---- // issues:结构化对象数组 {summary, locator, severity}(见 REVIEW_SCHEMA)。 function fixPrompt(id, phase, issues, c) { const fe = isFrontend(phase) const list = Array.isArray(issues) && issues.length ? 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}`, '', featureStageContract(phase, c), '', '## 待修复 must-fix(已结构化)', list, '', '## 流程', '- 逐项编辑 locator 指向的代码文件(遵守阶段路径作用域护栏)。', `- 编辑前必须先校验 locator 文件存在:跑 \`git -C ${c.root} cat-file -e HEAD:\`(locator 形如 \`path:line\` 时取 \`path\`)。文件不存在 → halt,把 locator 写进 reason,不要"修一个不存在的文件"。`, `- 修复后 commit:\`fix(): 修复 review must-fix ${fe ? `FE: ${id}` : `REQ: ${id}`}\`(不混合无关改动)。`, '- 修复完成后本步骤即结束;上层 Workflow 会重新跑 verify + review(下一轮)。', '', '## 输出(必须符合下发的 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)---- // attempt:1 = 首次跑;2 = 上轮 red 后的 flake 重试。每次 attempt 写到独立证据文件,避免 retry // 把首次 red 证据覆盖掉(report § ⑤ 失去 flake 信号)。 function gatePrompt(module, phase, attempt = 1, c) { const fe = isFrontend(phase) const id = module?.id ?? '' const phaseId = fe ? 'frontend-phase' : id return [ `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase}, attempt=${attempt})`, '', featureStageContract(phase, c), '', '## 目标', `打里程碑 tag 前的唯一硬测试门。**派发 Agent 子会话**跑测试,绿则通过,红则失败。**绝不**在主会话直接跑测试,红色时**绝不**跳过。`, attempt > 1 ? `- 本次 = 第 ${attempt} 次(上一次 red,本轮用于辨识 flaky);证据**写到独立文件**不要覆盖前一次。` : '', '', '## 命令', fe ? `- 前端:命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 拼接(缺失则 \`pnpm test:ci && pnpm e2e:ci\`),跑 vitest + playwright(含全 FE 回归)。**收尾(无论红绿)**:测试跑完后执行 \`node ${c.root}/scripts/drop-test-db.mjs\` 删测试副本(前端阶段不走 test.mjs,副本清理落在本门;源库不动)。` : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归;其内部自带 复制副本→测试→绿后晋升→删副本 全时序)。`, '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。', // 补 9:testGate 的 e2e(Playwright globalSetup 按固定端口冷起后端)与 Seed 同属起栈类 stage—— // 整段由编排层包进全局起栈互斥(stackLock,Phase D 定义);lane 测试库硬约束已由 featureStageContract 注入。 c.dbSchema ? '- **起栈互斥纪律(并行 lane)**:本 test-gate 整段在全局起栈互斥内执行——端口探测 / 残留 pid 回收**只允许**在持有该互斥的本 stage 内做;**绝不**触碰非本 stage 启动的进程(兄弟 lane 的栈不归你管)。' : '', '', '## 证据 + commit', `- 渲染证据写入 \`${c.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') } // ---- 演示种子生成 stage(Seed)---- // 设计:每个后端模块 testGate green 之后生成本模块演示假数据(demo seed)并冷起栈真跑验证。 // 与 behaviorGateContract 同属「跨栈 stage 不套 featureStageContract」的**第三类 stage**:本门要**运行** // scripts/setup-test-db.mjs / 起后端 / scripts/seed-demo-data.mjs(featureStageContract('backend') 的路径护栏 // 会把 scripts/ 命中越界硬停,与本门必须运行这些脚本自相矛盾),故另起 seedStageContract 自带契约。 // 锁定契约(与 A2/A4/test.mjs/行为门一致):种子文件 `sql/seed/__.sql`(随 git 提交);头部 // 机器可读行 `-- demo-seed: ` + 每表一行 `-- expect: =`;主键区间 1000–9999; // 演示数据值绝不含 `_S<数字>` 样式(预留行为门 sentinel);注入脚本 scripts/seed-demo-data.mjs(A2 已生成)。 // seedStageContract:种子 stage 的硬约束。非交互;证据报告用中文但 SQL/标识符可英文(受控格式); // 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端 / scripts/seed-demo-data.mjs / mysql 只读查询, // 唯一**可写** = sql/seed/ + .tmp/seed-gen//(跑完即弃)+ docs/superpowers/module-reports/-seed-verify.md; // 改 backend//frontend//scripts/ 源码即越界硬停。 function seedStageContract(c) { return [ '## 硬约束(非交互演示种子子代理)', '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', '- 你的职责 = **为本模块生成演示种子(demo seed)并冷起栈真跑验证**——**不是**实现功能、**不是**改源码、**不是**改 schema。', '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块语义引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等,Flyway 在副本上 apply 本轮新迁移)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子到副本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(收尾删副本)、mysql **只读** COUNT/查询;唯一允许**写入**的路径是 \`${c.root}/sql/seed/\`(种子文件,随 git 提交)+ \`${c.root}/.tmp/seed-gen//\`(一次性 runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/-seed-verify.md\`。`, `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`, '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。', '- **区间隔离红线**:演示数据值**绝不含 `_S<数字>` 样式编码串**(如 `CUST_NAME_S001`)——该样式预留给行为门 sentinel;数值主键固定落 1000–9999(1–999=初始数据 / ≥100000=sentinel)。', // lane 注入(附录补 10):Seed 是不套 featureStageContract 的"第三类 stage",必须单独注入同一条 // 硬约束——Seed 全链恰是 复制副本 / 起栈 / 灌种子 的大户,漏注入会让 lane 用错副本库互相踩(源库有 // COPY≠SOURCE 守卫兜底、绝不被误 drop)。与 marker 兜底(补 7,lane 根 .tmp/erp-lane-db)自洽:lane 内一切脚本确定性指向本 lane 副本。 ...(c.dbSchema ? [ `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,跑在一次性副本库上。Seed 全链——\`node scripts/setup-test-db.mjs\` / 起后端(gradle bootRun 等)/ \`node scripts/seed-demo-data.mjs\` / \`node scripts/drop-test-db.mjs\` / mysql COUNT 对账——的**全部**命令(含派发给子会话的)必须前置环境变量 \`ERP_TEST_DB_SCHEMA=${c.dbSchema}\`:成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略(bootRun 经 application*.yml 的 \`\${ERP_TEST_DB_SCHEMA:...}\` 占位吃到同一 lane 副本;COUNT 对账必须连 lane 副本查询)。`, ] : []), '- 红线:**绝不**伪造验证通过;**绝不**留 `TBD` / `TODO` / `【人工填写:】`;自主默认必须可被现有证据支撑且记入 `decisions[]`。', '- 证据报告 / 注释 / 提示**使用中文**;SQL / 标识符 / 表名可用英文(受控 `[A-Za-z0-9_]` 格式)。', ].join('\n') } // seedGenPrompt:单模块演示种子生成 + 冷起栈真跑验证的完整流水线提示。 // module:本后端模块(含 id);本 stage 在该模块 testGate green 之后跑(schema 含 tdd 新增 V 已终态全绿)。 function seedGenPrompt(module, c) { const id = module?.id ?? '' const tmpDir = `${c.root}/.tmp/seed-gen/${id}` const evidence = `docs/superpowers/module-reports/${id}-seed-verify.md` return [ `# seed — 演示种子生成 + 冷起栈真跑验证(模块 ${id})`, '', seedStageContract(c), '', '## 目标', `为本模块 \`${id}\` 生成**演示假数据(demo seed)**并冷起栈真跑验证:生成 → \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 起后端(Flyway apply 本轮新迁移到副本)→ \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌进副本)→ mysql 只读 COUNT 对账 → \`node ${c.root}/scripts/drop-test-db.mjs\`(收尾删副本)。`, '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——每次都从源库副本重注入,副本跑完即删;源库绝不被 drop)。', '', '## 输入', `- \`${c.root}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / 语义引用关系 / NOT NULL / UNIQUE 约束)。`, `- \`${c.root}/docs/01-需求清单//\` 本模块 REQ 卡:业务语义(让假数据有真实感、符合业务取值)。`, `- 既有 \`${c.root}/sql/seed/*.sql\`:跨模块语义引用前序模块种子的**已知确定性主键**(你的语义引用列必须指向这些已存在的主键,不可悬空)。`, `- \`${c.root}/config-vars.yaml\`:database 段凭据(seed-demo-data.mjs / setup-test-db.mjs 自行读取,你只需确保起栈参数一致)。`, '', '## 幂等(resume 安全)', `- 用 Glob 查 \`${c.root}/sql/seed/*__${id}.sql\`。**已存在** → **Edit 复用该文件**(保留原 \`NN\` 序号,不另起新文件);按需补齐/修正内容。`, `- **不存在** → 新建 \`sql/seed/__${id}.sql\`,其中 \`NN\` = 既有 \`sql/seed/*.sql\` 文件名最大序号 + 1(两位补零,如既有最大为 \`03\` → 本文件用 \`04\`;无任何既有文件 → \`01\`)。`, // 并行 lane 附注(仅 lane 模式渲染,串行输出一字不变)。 // 补 9:冷起栈资源(config-vars 固定端口 + 进程树)全局唯一——本 stage 整段由编排层包在全局起栈 // 互斥(stackLock,Phase D 定义)内串行执行;"按既知端口回收残留 pid"若不在互斥内做,会把兄弟 lane // 正在验证的后端当残留 kill 掉(≥2 宽波次必然踩中的确定性互杀,非小概率 race)。 ...(c.dbSchema ? [ '', '## 并行 lane 附注', '- **起栈互斥纪律**:本 Seed stage 整段在全局起栈互斥内执行——端口探测 / 按既知端口回收残留 pid **只允许**在持有该互斥的本 stage 内做;**绝不**在 stage 外、或对非本 stage 启动的进程做回收。', ] : []), // 补 17:两个并行模块从同一基点各算 NN=max+1 必产同号文件——豁免必须显式落进 prompt,否则与上文 // "NN = 最大序号 + 1"字面约定静默冲突,实现者会各自发挥。(NN, module_id) 才是唯一键:文件名含 // module id(git 无冲突),_demo_seed_history 按完整文件名记账、注入按文件名字典序应用(现状已满足)。 ...(c.vBase > 0 ? [ '- **并行 lane 间允许同 NN**:NN 仅表观排序,`(NN, module_id)` 才是唯一键;同波次模块无 seed 依赖边、互不引用,注入按完整文件名字典序应用。发现兄弟分支存在同 NN 文件**不算冲突,不要改号**。', ] : []), '', '## 生成规则', '- **按语义引用有序(先被引用方后引用方)**:同一文件内 INSERT 先被引用方后引用方;跨模块语义引用列指向既有 `sql/seed/*` 中前序模块种子的已知主键。', '- **显式主键**:本模块种子行主键固定落 **1000–9999** 区间(避开 1–999 初始数据 / ≥100000 sentinel);同表内主键唯一、确定性。', '- **真实感中文业务数据**:依 REQ 卡业务语义取值(人名 / 机构 / 金额 / 状态等),不要 `测试1`/`aaa` 占位;但**绝不含 `_S<数字>` 样式编码**(预留 sentinel)。', '- **enum 取值域**:enum 列只从 `docs/03` 声明的值域取值(越界即数据类失败)。', '- **固定历史日期**:日期/时间列写死固定历史字面量(如 `2024-03-15 10:00:00`),绝不 `NOW()` / 时间戳。', '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够语义引用 + 下拉非空)。', `- **头部注释(机器可读,验证对账依赖)**:文件头第一行 \`-- demo-seed: ${id}\`;随后**每张被本文件 INSERT 的表各一行** \`-- expect:
=\`(rows = 本文件向该表插入的行数)。`, `- **本模块无可种表**(纯计算/无表模块)→ **不建文件**,直接 \`status:ok\` + summary 说明「模块 ${id} 无可种表,跳过」(跳过下面的验证与 commit)。`, '', '## 运行验证(写一次性 runner,仿行为门冷起栈纪律的简化版)', `- **入口清目录**:先用确定性、跨平台方式重建 \`${tmpDir}/\`(\`fs.rmSync(tmpDir,{recursive:true,force:true})\` 后 \`fs.mkdirSync(tmpDir,{recursive:true})\`),仅限该受控路径,绝不删其它路径。`, `- 在 \`${tmpDir}/\` 写一次性 runner \`run.mjs\`,依序:`, ` 1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。`, ` 2) **起后端**:从 \`${c.root}/config-vars.yaml\` 取端口;起栈前先探测端口占用并按 \`${tmpDir}/*.pid\` / 既知端口回收上一次残留 pid;spawn 到后台进程树 + 轮询健康端点(\`/actuator/health\` 或登录端点 200)就绪(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过)。`, ` 3) \`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子到副本;幂等账本 \`_demo_seed_history\` 自动跳过已应用文件)。`, ' 4) **mysql 只读 COUNT 对账**:对本模块种子涉及的**每张表**,跑 `SELECT COUNT(*) ... WHERE <主键列> BETWEEN 1000 AND 9999`(**只数演示种子区间**——后端启动可能把 admin_init 等初始数据 bootstrap 进共表,其键落 1–999,不计入 expect),与「全部 `sql/seed/*.sql` 文件头 `-- expect:
=` 之和」逐表比对(同一张表可能被多个种子文件插入,必须求和后再比)。', ` - \`finally\` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动)。`, '- **失败归类(reason 里必须分清)**:', ' - **环境类**(端口占用 / 起栈超时 / setup-test-db 失败 / 健康端点不就绪)→ reason 标 `env-error` + 端口/pid。', ' - **数据类**(撞主键/唯一键 / 引用错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。', '', '## 证据落盘', `- 写 \`${evidence}\`(中文):逐表「期望行数 / 实际行数 / 结论(match/mismatch)」表格 + 本模块种子文件路径 + 起栈端口 + 关键决策。`, '- 若验证失败(环境类或数据类)→ 证据**头部用红字标注根因**(区分环境类 vs 数据类)。', '', commitBlock(`sql/seed ${evidence}`, `chore(seed:${id}): 演示种子数据`, '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的种子/证据路径)。', c), '', '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', `- 成功(含验证 COUNT 全部对账通过):\`{ "status": "ok", "artifactPath": "sql/seed/__${id}.sql", "summary": "<种子表数 / 总行数 / 验证结论 ≤ 200 字>" }\`。`, `- 本模块无可种表:\`{ "status": "ok", "summary": "模块 ${id} 无可种表,跳过" }\`(artifactPath 可省)。`, '- 验证失败 / 越界 / 缺值(无法自洽决策)→ `{ "status": "halt", "reason": "", "artifactPath": "<已写入的种子路径(如有)>" }`。', '- `artifactPath`(如有)必须为项目根相对路径;做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。', ].filter(Boolean).join('\n') } // ---- 静态原型渲染门(Preview)prompt ---- // 与下方「前端行为验收」正交:行为门测**实现**(冷起全栈 + Playwright 驱动真前端),本门只渲染 // **静态** prototype/**/*.html 本身——逐页 headless 渲染→截图归档(可视化基线)→跑原型自带交互点击 // 冒烟→捕获 JS/console 错误。原型是 plan/add-req 的上游权威产物:本门**只渲染不修改**,原型自身问题 // 走 advisory(记证据,不硬 halt)。不起后端/前端 app、不跑 setup-test-db/seed。 // prototypePreviewContract:渲染门硬约束(只读、不起全栈、唯一可写区 = .tmp + assets + 证据报告)。 function prototypePreviewContract(c) { return [ '## 硬约束(非交互原型渲染子代理)', '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹 AskUserQuestion / 等人输入。**绝不要尝试问人**。', '- 你是**静态原型只读渲染门**:用 Playwright headless 渲染 `prototype/**/*.html` 本身、截图、跑原型自带交互——**不是**实现功能、**不是**起后端/前端 app、**不是**改任何源码。', `- **作用域(唯一可写区)**:\`${c.root}/.tmp/prototype-preview/\`(一次性 runner,跑完即弃)+ 截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/prototype/\` + 证据报告 \`${c.root}/docs/superpowers/module-reports/prototype-preview.md\`。`, '- **越界硬停**:**绝不**编辑 `prototype/` / `frontend/` / `backend/` / `sql/` / `scripts/` 下任何文件——原型是上游 plan 的权威产物,本门只渲染不修改。命中越界即以 `status:blocked` 或写清阻塞点结束。', '- **不起全栈**:本门只渲染静态 HTML(`file://` 或仅服务 `prototype/` 的临时静态服务器),**绝不**冷起后端 / 前端 dev server、**绝不**跑 setup-test-db / seed。', '- 证据报告**使用中文**;文件名 / 选择器标识符可用英文。', '- **运行时确定性**:视口尺寸 / 临时目录名 / 截图文件名 / 临时端口一律确定性派生(按页面相对路径 / attempt 序号),**绝不**依赖时间戳 / 随机数。', ].join('\n') } // prototypePreviewPrompt:静态原型渲染门子代理完整流水线(step0-3 + schema)。 // feItems:本前端阶段全部 FE-NN(仅作分组参考,不强制与原型页一一对应);attempt:环境 race 重试序号(1..)。 function prototypePreviewPrompt(feItems, attempt, c) { const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无 FE 清单)' const tmpDir = `${c.root}/.tmp/prototype-preview` const assets = 'docs/superpowers/module-reports/assets/prototype' const evidence = 'docs/superpowers/module-reports/prototype-preview.md' return [ `# preview — 静态原型渲染门(headless,attempt=${attempt})`, '', prototypePreviewContract(c), '', '## 目标', '渲染**全部静态原型** `prototype/**/*.html` 本身,产出两类产物:① 每页**截图**(可视化基线,供人工 / 行为门比对);② 原型**自带交互**的点击冒烟(按钮 / 链接 / 表单切换是否触发 JS 错误)。这是对静态设计原型的「查看界面 + 模拟操作」,与实现无关。', `- 关联前端功能(仅作分组参考,不强制一一对应):${feList}。`, attempt > 1 ? `- 本次 = attempt ${attempt}(上次 \`status:blocked\` 环境未就绪后重试)。` : '', '', '## step0 发现原型 + 探测环境', `- Glob \`${c.root}/prototype/**/*.html\`,得页面清单 = \`pagesFound\`。**空**(项目无原型)→ 直接返回 \`{ "status":"ok", "pagesFound":0, "pagesRendered":0, "pages":[] }\`,不报错、不写任何文件。`, '- 探测 Playwright 是否可用(`frontend/node_modules` 内或全局)+ 浏览器内核是否已装。不可用 → `status:"blocked"` + `envError.kind="playwright-missing"|"browser-missing"`(**不要**尝试 `npm install` / `playwright install`——环境就绪由上层处理)。', '', '## step1 渲染 + 截图(确定性)', `- 入口清目录:删除并重建 \`${tmpDir}/\`(确定性、跨平台:\`fs.rmSync(p,{recursive:true,force:true})\` 后 \`fs.mkdirSync(p,{recursive:true})\`;**仅限**此路径)。`, `- 在 \`${tmpDir}/\` 写一次性 runner(如 \`run.mjs\`):用 Playwright headless 逐页加载。优先 \`file://\` 直开;若原型用了相对 fetch / 根绝对路径资源导致 file:// 加载不全 → 起一个**仅服务 \`${c.root}/prototype/\` 静态目录**的临时本地服务器(端口确定性派生,\`finally\` 中 teardown)。`, '- 视口确定性(默认 1440×900;某原型在 `` / 注释里声明了特定尺寸则用之并记 `decisions[]`)。每页 `waitForLoadState` 稳定后整页截图。', `- 截图归档到**已纳入版本管理**的 \`${assets}/<安全文件名>.png\`(文件名按原型相对路径确定性派生,如 \`prototype/order/list.html\` → \`order__list.png\`;**不要**引用 \`.tmp\` 防断链)。\`pagesRendered\` = 成功截图页数。`, '', '## step2 原型自带交互冒烟(模拟操作)', '- 每页注册 `page.on("pageerror")`(收 JS 异常)与 `page.on("console")`(收 `error` 级);注册 `page.on("dialog")` 自动 dismiss,防原生 `confirm/alert` 阻塞。', '- 枚举页面可见可点控件(`button` / `a[href]` / `[role=button]` / `input` / `select` / 带 `onclick` 的元素),逐个**点击 / 切换一次**。`target=_blank` / 会 `window.open` 的只记不点(防新窗暴增)。`controlsExercised` 计实际驱动的控件数。', '- 收集每页 `jsErrors`(pageerror 文本)/ `consoleErrors`(console.error 文本)——**原型自身**的脚本质量信号,仅作记录(advisory)。', '- `finally` 中 kill 临时静态服务器(若起过)。', '', '## step3 证据落盘 + commit', `- 写 \`${evidence}\`:按页一小节(原型相对路径 / 截图链接 / controlsExercised / jsErrors / consoleErrors / 视口决策),头部写 pagesFound-Rendered 汇总。渲染失败 / JS 错用**红字**标注为**阻断项**(上层据此 halt——回 /erp-workflow:plan-start 或 /add-req 修原型后重跑,**不在 coding 改**);console.error 标注为 advisory。`, commitBlock(`${evidence} ${assets}`, 'docs(preview:prototype): 静态原型渲染基线 + 交互冒烟证据', undefined, c), '', '## 输出(必须符合下发的 PROTOTYPE_PREVIEW JSON schema)', '- `status`: `ok`(渲染门正常跑完——即使某页渲染失败/有 JS 错也填 ok,硬/软由上层 JS 据 `pages[]` 分流)| `blocked`(**仅限**环境未就绪:Playwright / 浏览器缺失 / 超时)。', '- `pages[]` **据实填**(上层据此判硬门):`rendered=false` 或 `jsErrors` 非空 = 硬缺陷(上层会 halt 等人工修原型);`consoleErrors` = 软信号(advisory)。`pagesFound` / `pagesRendered` 整数据实填;每页 file / rendered / screenshot / controlsExercised / jsErrors / consoleErrors / notes。', '- `envError`: 无环境问题填 `{ "kind":"none" }`;环境未就绪填对应 kind + detail。', '- 做过自主默认(视口 / 服务方式等)→ `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。', '- schema 是 `additionalProperties:false`,不要返回额外字段。**不要自动重试**——重试由上层 Workflow 控制。', ].filter(Boolean).join('\n') } // ---- 前端行为验收(阶段级行为门,v3)---- // 设计权威:docs/design/2026-06-05-frontend-behavior-stage-gate.md。 // 时机:featureLoop(frontend) 全部 FE 通过静态 review(req-done tag 已打)之后、testGate 之前, // 整个前端阶段只跑**一次**行为验收:起全栈 + 演示/sentinel 种子,按全部 FE spec 聚合的作用域并集 // 枚举路由控件/文字,硬问题转可 fix must-fix→fix→复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮),green 才进 testGate。 // 门是**跨栈只读验证 + 临时产物**的第三类 stage:不套 featureStageContract('frontend') // (其路径护栏命中 backend/sql/scripts 即越界硬停,与门必须运行 setup-test-db / 起后端 / 生成 SQL 种子自相矛盾)。 // behaviorGateContract:门的硬约束。非交互;证据报告用中文但 spec/sentinel/SQL 可英文标识符; // 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端前端 / 跑 playwright, // 唯一**可写** = .tmp/behavior-gate/frontend-phase/r/ + 证据报告及 assets;改 frontend//backend//sql/ 源码即越界硬停。 // lane 注入豁免(附录补 10 审计口径):行为门只在 frontend-phase 跑——其 deps 恒为全部后端模块, // 终波恒宽 1、主根执行(不建 lane、c.dbSchema 恒空),prompt 含 setup-test-db/seed-demo-data 字样 // 但天然不需要 ERP_TEST_DB_SCHEMA 注入分支。 function behaviorGateContract(c) { return [ '## 硬约束(非交互行为验收子代理)', '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', '- 你是**跨栈只读验证门**:用真实运行(起后端 + 起前端 headless + Playwright 枚举)证明「每个 FE 的每个按钮/点击真的生效、每段文字显示正确内容」,**不是**实现功能、**不是**改源码。', '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。', `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(只运行注入演示种子,不修改脚本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(teardown 删副本)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${c.root}/.tmp/behavior-gate/frontend-phase/r/\`(种子 SQL/runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r-a.md\` + 其 assets(截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/...\`)。`, `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${c.root}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`, '- **全量终态前提(关键)**:本门跑在**全部 FE 已实现并通过静态 review 之后**——`frontend/` 不应再有未实现路由 / FeStub 占位。某路由仍渲染 `data-fe-stub` 占位 → 这是硬缺陷(tdd 漏做占位替换),归 `interactionFailures[kind="no-observable-effect"]`,locator 指向 router 文件该路由 import 行,detail 写明「路由仍指向 FeStub 占位」。**断言作用域 = 全部 FE spec 的 `## 行为验收作用域` 小节并集**;白名单外控件记证据不入断言集。', '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。', '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。', '- **运行时确定性**:sentinel 值 / 端口 / 临时目录名一律由你确定性派生(按列类型 / config-vars 端口 / behaviorRound / attempt 序号),**绝不**依赖时间戳 / 随机数。', ].join('\n') } // behaviorGatePrompt:阶段级行为验收子代理的完整流水线提示(step0-6 + schema)。 // feItems:本前端阶段全部 FE-NN(作用域聚合的清单真值,来自 Router frontend-phase 模块); // behaviorRound:阶段门内的行为 fix 轮(1..BEHAVIOR_STAGE_MAX);attempt:本轮内环境 race 重试序号(1..)。 // 每 (behaviorRound × attempt) 独立 .tmp 子目录 + 独立证据文件,绝不互相覆盖(不丢 flake 信号)。 function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(调用方未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)' const tmpDir = `${c.root}/.tmp/behavior-gate/frontend-phase/r${behaviorRound}` const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-r${behaviorRound}-a${attempt}.md` return [ `# behavior — 前端阶段级行为验收(headless,frontend-phase, behaviorRound=${behaviorRound}, attempt=${attempt})`, '', behaviorGateContract(c), '', '## 目标', '用真实全栈运行证明**全部 FE** 的「每个按钮/点击都真的生效、每段文字都显示正确内容(right context)」。整个前端阶段只跑这一道行为门(featureLoop 全部 FE 已过静态 review)。', '单个子会话内**收敛完成**:冷起栈 → 逐路由枚举(全 FE 作用域并集)+ 两层断言 → teardown。期望即时推导(prototype/ + REQ + docs/05),**不**持久化为契约,但推导期望写进已提交证据报告。', `- 本阶段 FE 清单:${feList}。`, `- 断言作用域真值 = **每个 FE** 的 spec(\`${c.root}/docs/superpowers/specs/-.md\`,同一 FE 多份取最新日期)头部的 \`## 行为验收作用域\` 小节(\`关联路由:\` + \`负责控件白名单:\`)。先逐 FE Read 取出并**聚合为并集**(路由去重、逐路由标注归属 FE);某 FE 缺 spec 或缺该小节 → 记 \`coverageGaps[reason="scope-missing", page=""]\`(该 FE 路由不计入分母,**绝不**静默跳过)。`, behaviorRound > 1 || attempt > 1 ? `- 本次 = behaviorRound ${behaviorRound} / attempt ${attempt}(上一次 red / envError / fix 后重验);证据**写到独立文件 r${behaviorRound}-a${attempt}** 不要覆盖前一次。` : '', '', '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)', '- **冷起栈(运行时硬约束)**:本项目**无既有 e2e webServer / playwright.config 复用入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。', `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1 ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子),再新建本轮子目录 \`${tmpDir}/\`。` : `本次 behaviorRound=${behaviorRound} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`, `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 本门起的全部子进程**并透传结构化结果。**绝不**让前台 gradle bootRun / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`, `- **确定性端口/pid 回收前置**:起栈前先按既知端口 + \`${tmpDir}/*.pid\` 强制回收上一 attempt 残留(编排层 + runner 双保险);端口先探测占用,占用则回收或退到动态空闲端口 + 把 baseURL 注入下游。`, `- \`${c.root}/.tmp/behavior-gate/\`(含子目录)已被仓库 \`.gitignore\` 忽略,是唯一临时写区;跑完即弃,只提交证据报告 + assets。`, '', '## step0 探测 + build 归因', `- 读 \`${c.root}/docs/04-技术规范.md § 零\` + \`${c.root}/frontend/package.json\` + \`${c.root}/config-vars.yaml\`。`, '- runner 自负冷起后端 + 前端 headless(无既有 webServer 可复用)。**起 dev / source-map 模式**(注入定位辅助:`data-testid` 约定 / Vue `__file`),便于把 page+selector 映射回组件文件。', '- **build / 起 dev server 失败时先归因**:用 `git` / `Grep` 判断报错根因文件路径——全部 FE 已实现,**没有**「兄弟未实现」豁免:', ' - 根因落在 `frontend/` 源码且可定位到文件 → 真构建 bug → 归 `interactionFailures[kind="js-error"]`(locator=根因文件路径,可转 must-fix 喂 fix)。', ' - 根因不可归到 `frontend/` 源码(依赖 / 工具链 / 无法定位)→ `envError.kind="build-failed"`(如能定位仍填 `rootCausePath`)。', ' - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。', '', '## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)', '- 分母来源 = 全部 FE spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。', `- 与 \`${c.root}/frontend/\` router 配置对账:FE 作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属任何 FE 作用域的路由记证据(不入分母、不断言)。`, '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。', '- 带参动态路由用**种子已知主键**实例化(可用**演示种子已知主键**(1000–9999)或 **sentinel 主键**(≥100000));无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。', '', '## step2 起栈五段严格时序(副本从源库复制而来,Flyway 在后端启动时把本轮新迁移 apply 到副本)', `1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。建副本前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', `3) **注入演示种子**:\`node ${c.root}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过源库已灌过的文件,把新 \`sql/seed/*.sql\` 演示数据灌进副本)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`, '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。', ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——数值主键**一律 ≥100000**(固定区间,不再动态扫描既有键:初始数据 1–999 / 演示种子 1000–9999 已由区间约定隔离,sentinel 落 ≥100000 天然不冲突);字符串列**仍逐字段唯一编码**(`_S` 样式,如 `CUST_NAME_S001`,抓绑错字段——演示数据已被禁用该样式,故 sentinel 独占)+ 行序号保 UNIQUE;enum 列从 docs/03 值域取并标注。断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', `- \`finally\` **硬要求 kill 本门起的全部子进程**,**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动);端口 + pid 写入 \`envError.ports\` / \`envError.pids\`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。`, '', '## step2.5 鉴权 bootstrap(确定性前置)', '- 用 config-vars `admin_init` 或种子已知凭据,经 `docs/05` 登录端点**真实登录**拿 JWT,注入 Playwright `storageState`;`authState` 记角色覆盖(覆盖 / 未覆盖角色集)。', '- 登录失败 = `envError.kind="auth-failed"`(环境 race,走 retry),**绝不**当成死控件。', '', '## step3 枚举(可达性驱动 + 分母对账,非首帧快照;驱动全部 FE 作用域并集)', '- **枚举/驱动 step1 聚合的全部路由 + 各 FE 控件白名单并集**。每路由带 `storageState` 加载,收集 DOM 真实控件与文字区域。分母 = step1 聚合清单,分子 = live 枚举。', '- **FeStub 残留检测**:每路由加载后检查 `data-fe-stub` 元素;仍渲染占位 → 该 FE 的 tdd 漏做占位替换(硬缺陷),归 `interactionFailures[kind="no-observable-effect"]`(locator=router 文件该路由 import 行,detail 写「路由仍指向 FeStub 占位」)。', '- 分母有但首帧无的控件:runner 尝试**驱动到出现态**(种子保列表非空触发行级操作 / 进多步流程下屏 / 展开 dropdown / 切 tab 后二次枚举);仍不可达 → `coverageGaps[reason="deep-control-not-driven"]`,不静默判 green。到不了的路由 → `coverageGaps[reason="unreachable-auth"|"unreachable-no-route"]`,与「到达了但控件死」严格区分。', '- **白名单外控件**(任何 FE 白名单都未列,如共享导航/布局区):不入「必须有效果」断言集,记证据即可;确属可疑死控件可记 `coverageGaps[reason="deep-control-not-driven"]`。', '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。', '- `routesReached` / `controlsEnumerated` 据实填(空覆盖必须可见)。', '', '## step4 推导期望', '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。', '', '## step5 断言(三层:交互/文字/样式 + 可观测效果白名单 + 硬问题带源码 locator)', '- **交互层可观测效果白名单**:URL 变化 / docs05 网络调用(`page.on("request")` 比对端点)/ DOM 变更 / 校验信息 / 弹层 / toast / 原生对话框(枚举前注册 `page.on("dialog")`,confirm/alert/beforeunload 计合法效果,防 confirm 阻塞误判 missing-docs05-call)/ 下载(`page.on("download")`)/ 新标签(`page.on("popup")` / `target=_blank`)。', ' - 无任何效果 → `interactionFailures[kind="no-observable-effect"]`;JS 异常 → `js-error`;`console.error` → `console-error`;应发未发网络调用 → `missing-docs05-call`。断言用 auto-waiting / `expect.poll`,**不用**固定 sleep。', '- **文字层**:动态文字格对比该 region 字段的唯一 sentinel(抓绑错字段)。', '- **绑定垃圾分级**:`null` / `undefined` / `[object Object]` / `NaN` / `lorem` 出现在绑定位 → `interactionFailures[kind="binding-garbage"]`;双花括号未渲染 / 空占位 `—` / 疑似 i18n key → `textIssues`(走 adjudicate;i18n 类额外加载真实 locale 比对)。', '- **文字不符按来源分流到 source**:绑定 sentinel 不符 → `source="sentinel"`(客观 bug,转 must-fix,必须带 `locator`;反查不到组件文件则归 `coverageGaps[reason="locator-not-resolvable"]`);i18n key / 字面 / 语义类 → `source="i18n"|"literal"|"semantic"`(软文字,走仲裁,永不阻断 green)。', '- **样式层(客观断言:颜色 token 比对 + layout sanity)**。断言作用域 = **白名单控件及其直接容器 + spec/prototype 点名区域**;组件库深层内部元素**不查**,只查可见元素:', ` - **色值基准确定性派生**:读 \`${c.root}/src/styles/tokens.css\` 解析全部 \`--color-*\`,用探针元素 getComputedStyle 把任意色值格式归一化为 canonical rgb 集合;被检元素的渲染值(\`color\` / \`background-color\` / \`border-color\`)同法归一化后比对。`, ' - **颜色断言**:渲染色 ∉ token 集合 → `styleIssues[kind="non-token-color"]`;spec「Design Tokens 引用清单」点名了具体 token 的元素,渲染值 ≠ 该 token 解析值 → `styleIssues[kind="token-mismatch"]`。半透明混合 / 无法归一化的值 → **不入** styleIssues,记 `decisions[]`(宁漏勿误)。', ' - **几何断言(layout sanity)**:每路由 `scrollWidth > clientWidth + 1` → `styleIssues[kind="horizontal-overflow"]`(locator=该路由 view 组件文件);白名单控件两两 boundingBox 交叠 >4px² 且双方可见非 inert → `overlap`;预期可见控件 box 为零 → `zero-size`;`scrollIntoViewIfNeeded` 后仍不在视口 → `offscreen`。', ' - **视口**:默认用 Playwright 默认视口;prototype 明确声明 viewport 时用之并记 `decisions[]`。', ' - styleIssues 全部是客观硬问题:locator 要求与交互层逐字同口径(A 类反查组件文件;反查不出归 `coverageGaps[reason="locator-not-resolvable"]`),`expected`/`actual` 写比对双方的具体值(如 `expected="var(--color-primary)→rgb(22,119,255)" actual="rgb(255,0,0)"`)。', '- **行为硬问题必须带源码 locator(转 must-fix 喂 fix 的前置)**:', ' - **A 类(可反查到组件文件)**:经 route → router 配置 → view 组件文件反查到**组件级文件路径**。`interactionFailures[].locator` = `<组件文件路径>`(可附 DOM 选择器 / 绑定文本片段,写进 `detail`);`detail` 写「失败 kind + 归属 FE + 期望端点/期望 sentinel 值 + 实际渲染值 + DOM 路径 + 绑定片段」,供 fix 子代理在该组件内 Grep 定位 handler/绑定。binding-garbage / sentinel-mismatch 同样附 DOM 路径 + 绑定片段 + 期望 sentinel + 实际渲染值。', ' - **B 类(连组件文件都反查不出)**:**不静默降级放行**——归 `coverageGaps[reason="locator-not-resolvable"]`(计入未覆盖,使本轮不能判 green),或归 `envError.kind="stack-not-ready"` 走 retry。绝不把无 locator 的硬问题塞进 `interactionFailures` 不带 locator(上层会因无 locator 走 adjudicate(allowContinue:false),绝不放行)。', '', `## step6 证据落盘 + commit(运行时行为,沿用证据 commit 习惯)`, `- 写 \`${evidence}\`:**按 FE 分小节**(每 FE:作用域 / 推导期望 / 逐控件判定 / 该 FE 的 styleIssues 与 coverageGaps),全局段写 routesPlanned-Reached-controlsEnumerated / authState(含未覆盖角色集)/ token 色值集合摘要 / 截图索引。`, `- 截图归档到**已纳入版本管理**的 \`docs/superpowers/module-reports/assets/...\`(**不要**引用 \`.tmp\` 防断链)。`, `- 若本次 \`status:red\` 或存在 envError,证据**头部用红字标注原因**。`, commitBlock(`${evidence} docs/superpowers/module-reports/assets`, `docs(behavior:frontend-phase:r${behaviorRound}-a${attempt}): 阶段级行为验收证据`, undefined, c), '', '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)', '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + **样式层无失败** + 无阻断性 envError + 覆盖非空)| `red`。', '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数全部 FE 作用域并集**;空覆盖必须可见)。', '- `interactionFailures` / `textIssues` / `styleIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`(含 `source="sentinel"` 的 textIssue 与全部 styleIssues)。', '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。', '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。', '- 不要返回额外字段(schema 是 `additionalProperties:false`)。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', ].filter(Boolean).join('\n') } // behaviorReverifyPrompt:阶段级行为 fix 后的功能复验。fix 改的是 frontend/ UI 源码,可能引入功能回归—— // 在下一 behaviorRound 重起全栈之前,先派子会话跑**全量前端单测**(vitest,不跑 e2e——e2e/行为维度由下一轮 // 行为门重跑 + 阶段 testGate 全量回归兜底),红则当功能回归硬边界(调用方 allowContinue:false)。 function behaviorReverifyPrompt(behaviorRound, fixedCount, c) { const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-reverify-r${behaviorRound}.md` return [ `# behavior-reverify — 行为 fix 后前端单测复验(behaviorRound=${behaviorRound})`, '', featureStageContract('frontend', c), '', '## 目标', `阶段级行为门第 ${behaviorRound} 轮 fix(${fixedCount} 项 must-fix)改动了 \`frontend/\` 源码——**派发 Agent 子会话**跑全量前端单测确认无功能回归。**主会话从不直接跑测试,也不自由编写证据。**`, '', '## 流程', `- 命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` 取(缺失默认 \`pnpm test:ci\`);**只跑单测(vitest),不跑 e2e**。`, '- 派子会话执行,子会话只返回结构化 JSON:`{command, exit_code, passed, failed, failed_list, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行)。', '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt(fix 引入功能回归,绝不带红进入下一轮行为门)。', `- 证据写入 \`${evidence}\`(每轮独立文件不覆盖前轮)。`, '', commitBlock(evidence, `docs(behavior:frontend-phase:r${behaviorRound}): 行为 fix 后单测复验`, '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的证据路径)。', c), '', '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', `- 全部通过:\`{ "status": "ok", "artifactPath": "${evidence}", "summary": "" }\`。`, '- 任一红色 / 缺值 → `{ "status": "halt", "reason": "<失败用例摘要>" }`。', ].join('\n') } // ---- 前端骨架占位 stage(runFrontendSkeleton 用)---- // 设计:docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 2(前置依赖 A;v3 阶段级行为门下仍保留)。 // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(未实现 FE 路由指向 FeStub 占位) // + 不指悬空 path 的共享导航——保证「前端只建了一部分」的任意时刻 app 仍可构建可起、每个 FE 路由可达。 // 由此逐 FE 的 verify(e2e) 与阶段末尾行为门的「可构建前提」成立、tddPrompt 的占位替换有真值起点。 // feItems:本前端阶段的全部 FE-NN(来自 Router 的 frontend-phase 聚合模块),即 router 全量路由表的清单。 // lane 注入豁免(附录补 10 审计口径):本 builder 仅 frontend-phase 调用(终波恒宽 1、主根执行), // globalSetup 文案里的 seed-demo-data.mjs 字样不需要 ERP_TEST_DB_SCHEMA 注入分支。 function frontendSkeletonPrompt(feItems, c) { const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(Router 未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)' return [ '# fe-skeleton — 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位)', '', featureStageContract('frontend', c), '', '## 目标', '在逐 FE 实现开始**之前**,一次性建出前端「可构建可起」的骨架:App 外壳 + router **全量** lazy 路由表(每个 FE 路由都声明,未实现的指向占位组件 `FeStub`)+ 不指悬空 path 的共享导航。', '保证后续「只建了一部分 FE」的任意时刻 `vite build` / dev server 都能起、每个 FE 路由都可达(加载到占位);逐 FE 实现时再把对应路由的 import 从 `FeStub` 换成真组件。', '', `## 本前端阶段 FE 清单(router 全量路由表必须覆盖的全部 FE)`, `- ${list}`, '', '## 收集上下文(确定技术栈 + 目录约定 + 路由)', `- \`${c.root}/docs/04-技术规范.md § 零\`(\`frontend.ui_lib\` / framework / 构建工具)+ \`§ 二 前端规范\`(§ 2.1 目录约定 = 落盘位置 / 路由库 / 入口文件名)。`, `- \`${c.root}/docs/08-模块任务管理.md § 三\`(前端阶段元数据 + \`功能:\` 下全部 \`FE-NN\` 行;与上面清单核对,以本提示给出的清单为准)。`, `- \`${c.root}/docs/01-需求清单/\` 各 FE 关联 REQ + \`${c.root}/prototype/\`(页面/路由结构权威)+ \`${c.root}/docs/05-API接口契约.md\`,据此推导每个 FE-NN 对应的**路由 path**(带参动态路由保留 \`:id\` 占位)。`, `- 用 Grep 在 \`${c.root}/frontend/\` 探测现有 App 外壳 / 入口 / router 是否已存在(幂等:已存在则按需补齐,不重复创建/不覆盖已实现的真组件)。`, '', '## 产出(全部落在 `frontend/` 路径内——遵守前端阶段路径作用域护栏)', '1. **App 外壳 + 入口**:`frontend/src/App.*` 与入口 `frontend/src/main.*`(按 framework / docs/04 约定的扩展名;不存在才创建)。挂载共享布局 + ``(或等价 outlet)。', '2. **router 全量路由表**(按 docs/04 § 2.1 约定的路由文件位置,如 `frontend/src/router/index.*`):', ' - **每个** FE-NN 对应路由都声明,**全部用 lazy import**(`component: () => import(...)` 或 framework 等价的动态 import;**绝不** eager `import X from ...` 顶部静态引入,否则未建组件会让整表编译失败)。', ' - **未实现的 FE 路由全部指向占位组件 `FeStub`**:`component: () => import("../views/_stub/FeStub.vue")`(或 framework 等价)。逐 FE 实现后由 tdd stage 把对应路由 import 换成真组件。', ' - 路由 path 取自上面推导的 FE→path 映射;带参路由用 `:id` 等占位。', '3. **占位组件 `FeStub`**:`frontend/src/views/_stub/FeStub.vue`(framework 非 Vue 时落对应等价文件,如 `FeStub.tsx`),最小渲染一个带 `data-fe-stub` 属性的元素(如 `
占位
`;行为门据 `data-fe-stub` 识别占位态)。**不实现任何业务逻辑**。', '4. **共享布局/导航**:导航链接**全部指向已在 router 声明的路由 path**(不指向任何不存在的 path),保证任意时刻无悬空链接。', '5. **e2e 基线脚手架(全部落 `frontend/` 内)**:', ' - **Playwright 配置**(按 docs/04 § 零 `frontend.e2e_runner` 约定,如 `frontend/playwright.config.*`):声明 `globalSetup` / `globalTeardown` 入口 + 共享 `storageState`。', ` - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 冷起后端 + 轮询健康端点就绪(Flyway 在此把本轮新迁移 apply 到副本)→ 执行 \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌演示种子进副本)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`, // globalTeardown 只 kill 进程、**不删副本**:test.mjs 的晋升(promote-to-source)在 e2e 之后跑、 // 靠读副本的 flyway_schema_history 算 delta——teardown 若删副本,promote 会静默判"无迁移可晋升", // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做; // tdd 内循环单独跑 e2e 时副本留存到下次 globalSetup 重新复制,无害。 ' - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树(**不**删副本——副本清理由外层闸门在晋升后负责)。', ' - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐。', '6. **单测基线(测试隔离接线点)**:vitest 配置(按 docs/04 § 零 `frontend.unit_test_runner` 约定,如 `frontend/vitest.config.*` 或 package.json 内配置段)`include` **限定** `tests/**/*.test.*`——单测一律落 `frontend/tests/**`(镜像 `src/` 结构,smoke 类归 `tests/__smoke__/` 且文件名同样以 `.test.*` 结尾,见 docs/04 § 2.1),`frontend/src/` 内的测试残留不被执行(约定漂移立即可见)。不存在才创建,已存在则只补齐 include 限定。**legacy 守卫**:若 `frontend/src/` 内已存在测试文件(旧约定 colocation 残留),**绝不**收窄 include(否则旧单测静默停跑、回归覆盖丢失)——保持现有 include 不动,登记 `decisions[]` 提示人工迁移(src/ 内测试迁至 tests/ 镜像路径后方可收窄)。', '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。', '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。', '', '## 自检(可构建)', '- 推断本项目前端 build / typecheck 命令(docs/04 § 零 / `frontend/package.json` scripts)。若可在子会话内安全跑(不挂死),**派 Agent 子会话**跑一次 build / dev-server 就绪探测确认骨架可构建可起;不可行则至少静态核对「全部 FE 路由已声明 + 全 lazy + 导航无悬空 path + FeStub 存在」。', '- 占位符扫描:`TBD` / `TODO` / `【人工填写:】` → 命中即修。', '', commitBlock('frontend/', 'feat(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位', '- commit 失败 → halt,把 stderr 摘要写进 reason。', c), '', '## 输出(必须符合下发的 STAGE_RESULT JSON schema)', '- 成功:`{ "status": "ok", "summary": "<已声明的 FE 路由数 / 入口与 router 文件路径摘要>" }`(artifactPath 可省)。', '- 任一护栏 / 缺值(如无法推导某 FE 的路由 path 且无任何旁证)→ `{ "status": "halt", "reason": "<具体阻塞点>" }`。', '- 做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。', ].filter(Boolean).join('\n') } // fe-skeleton 幂等判定:检测 router 是否已声明本阶段全部 FE 路由(全量 + 全 lazy)。 // router/state 是骨架真实完成态;fe-skeleton-done tag 只作补记,避免陈旧 tag 跳过缺失骨架。 function frontendSkeletonStatePromptM(feItems, c) { const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)' return [ '# 检测前端骨架是否已建(router 已声明全部 FE 路由 + 全 lazy)', microStepContract(c.root), '', `用 Grep / Read 检查 \`${c.root}/frontend/\`:是否已存在 router 配置文件,且其中**本阶段全部 FE 路由**(对应 FE:${list})都已声明、全部为 lazy import(\`() => import(...)\`),占位组件 \`FeStub\`(\`frontend/src/views/_stub/FeStub.*\`)存在,**且 e2e 基线脚手架存在**——Playwright 配置文件(\`frontend/playwright.config.*\`)+ globalSetup 文件(如 \`frontend/e2e/global-setup.*\`),**且单测基线存在**——vitest 配置 \`include\` 限定 \`tests/**/*.test.*\`。`, `- **legacy 豁免**:若 \`${c.root}/frontend/src/\` 内已存在测试文件(\`*.test.*\` / \`*.spec.*\`,旧约定 colocation 残留),vitest include 项**不作为缺失判据**(视为满足)——该状态须人工迁移决策,绝不由骨架重跑静默收窄 include 停跑旧测试。`, '- 全部满足(骨架已建齐,含 e2e 基线脚手架 + 单测基线)→ `{ "exists": true }`', '- 任一缺失(无 router / 缺某 FE 路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`', '## 输出(EXISTS_SCHEMA)', ].join('\n') } // ---- 微步骤 prompt builders(runBranchSetup / runMilestone / runCrossModule 用)---- // 每个 prompt 单职责、短文本;返回严格 schema;执行(action)步统一返回 ACTION_RESULT_SCHEMA。 // root 显式入参(附录补 11):模块作用域微步骤传 c.root(lane 模式在 lane 树上取证/操作), // 主根专属微步骤(milestone / RESUME / ledger / preflight / 默认分支探测)传 ROOT——两组名单见各调用方。 function microStepContract(root) { return [ '## 硬约束(非交互子代理)', '- 你是 Workflow 派生的**非交互子代理**,绝不弹问。', '- 全部输出**使用中文**。', `- 项目根 = \`${root}\`。所有 git 命令必须用 \`git -C ${root} ...\`(除非步骤明示了其它 \`-C\` 目标——lane / feature worktree 路径,以步骤原文为准);Read/Edit/Write 的路径都以 \`${root}\` 为根。`, '- 严格按下方"输出"段返回 schema 字段;**不要**在 schema 外追加自由叙述。', ].join('\n') } // ============================================================================ // 仲裁 / 自主决策基础设施(halt 收敛) // 设计:原先每个"缺值 / 结构违约 / 重试耗尽"点都直接 throw HALT 让整阶段 fail-fast。 // 现在改为先经 adjudicate() 仲裁——retry(带 guidance 重跑)/ continue(降级前进)/ halt(确属不可恢复)。 // stage 自身也被要求优先自主决策继续(见 featureStageContract),其默认/解读记入 decisions[] 汇总。 // 仅 git 树冲突 / 配置错 / id 形状错(assertSafeId)保持硬 halt——这些不可由 LLM 代决。 // ============================================================================ const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限;超出则确定性 halt(防无限循环) // 阶段级行为门预算(二维,钉死防证据覆盖): // - BEHAVIOR_STAGE_MAX = 阶段门内的行为 fix 轮硬上限(整个前端阶段共用,每轮 fix 可批量修当轮全部 must-fix); // 超限 throw HALT。典型一次过(1 轮),最坏 3 轮。 // - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。 const BEHAVIOR_STAGE_MAX = 3 const BEHAVIOR_ATTEMPT_MAX = 2 // 静态原型渲染门(Preview)的环境 race 重起上限(Playwright/浏览器缺失/超时时自动重起,沿用 behavior attempt 思路)。 const PROTOTYPE_PREVIEW_ATTEMPT_MAX = 2 const NULL_AGENT_PROBLEM = 'agent() 返回 null(子代理终端 API 错误 / 被用户跳过)——典型为断网或机器休眠;可重试' const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记后随结果回传供人工事后审阅。 // sink 参数:模块作用域调用传 c.decisions(per-module 收集器,并行交错下水位线语义失效的替代); // 缺省落全局 autonomousDecisions(仅剩"漏传 dec 的兜底"语义,顶层 return 时作为残量并入)。 const autonomousDecisions = [] const decisionDigestMd = (list, emptyText) => list.length ? list.map(d => ` - [\`${d.site}\`] ${d.question || '?'} → ${d.choice || '?'}(${d.confidence || '?'})`).join('\n') : ` - ${emptyText}` function recordDecisions(site, decisions, sink = autonomousDecisions) { if (!Array.isArray(decisions)) return for (const d of decisions) { if (!d) continue sink.push({ site, question:d.question, choice:d.choice, rationale:d.rationale, confidence:d.confidence }) log(`decision ${site}: ${d.question || '?'} → ${d.choice || '?'} (${d.confidence || '?'})`) } } // halt 可见性:HALT 在抛出前先 log(出现在 /workflows 进度叙述行与 transcript), // 保证终端能直接看到 halt 原因,而不是只埋在 Workflow 返回值 / RESUME.md 里。 // 顶层 function 声明会提升,:201 等早于本定义的调用点也可用。 function haltError(reason) { log(`⛔ ${reason}`) return new Error(reason) } // 直调 agent() 的非 runner 点位统一防御:null(断网/休眠/被跳过)→ 带诊断的 halt,而非下游 TypeError。 async function agentR(prompt, opts) { const r = await agent(prompt, opts) if (!r) throw haltError(`HALT agent-null ${opts?.label || '?'}: ${NULL_AGENT_PROBLEM}`) return r } // root(附录补 11):仲裁取证树根——lane stage 失败时传 c.root,让仲裁子代理看到 lane 分支上 // 刚 commit 的 spec/plan/源码(主根工作树可能停在默认分支,树面错误会让裁决与 guidance 失真)。 function adjudicatePromptM(site, context, root) { const ctx = typeof context === 'string' ? context : JSON.stringify(context, null, 2) return [ `# 仲裁:\`${site}\` 触发潜在 halt,请裁决 retry / continue / halt`, microStepContract(root), '', '## 你的角色', '你是 ERP 编码 Workflow 的**仲裁子代理**。某上游步骤触发了一个原本会让整阶段 fail-fast 停下的护栏。', '项目目标是全自动静默、尽可能少停。请在**不损坏 git 工作树、不伪造业务事实、不污染源码**的前提下,尽量让流程继续。', '', '## 触发上下文', '```', ctx, '```', '', '## 裁决口径', '- `retry`:失败疑似一次性 / 可纠正(子代理输出不符 schema 约定、git 命令瞬时失败、上游漏给某字段)。**必须**在 `guidance` 写清"重跑时要修正什么",下游会把它原样注入重跑提示。', '- `continue`:缺陷不阻断正确性、可安全前进(reviewer 的非必须建议 / 可降级为口头建议的 issue / 纯可视化副作用缺失 / 已可由后续 verify / test-gate 兜底的疑虑)。在 `rationale` 说明为何安全。', '- `halt`:确属不可恢复——结构性缺失且无任何旁证、git 树冲突需人工、继续会污染源码 / 伪造业务语义。在 `rationale` 写清人工需要做什么。', '- 若上下文含 `"allowContinue": false`,**不得**选 continue(如红色测试不可跳过),只在 retry / halt 间选。', '## 输出(ADJUDICATE_SCHEMA)', '- `{ "action": "retry|continue|halt", "guidance": "", "rationale": "<裁决理由>" }`', ].join('\n') } async function adjudicate(site, context, grp, round, root = ROOT) { const verdict = await agent(adjudicatePromptM(site, context, root), {label:`adjudicate:${site}:r${round}`, phase: grp, schema: ADJUDICATE_SCHEMA}) if (!verdict) { // 仲裁子代理自身遭遇终端 API 错误 → 默认 retry(确定性,防 null 级联) log(`adjudicate ${site} r${round}: 仲裁子代理无返回(终端 API 错误/被跳过),默认 retry`) return { action: 'retry', guidance: '', rationale: 'adjudicate agent 无返回,默认 retry' } } log(`adjudicate ${site} r${round}: ${verdict.action}${verdict.rationale ? ' — ' + verdict.rationale : ''}`) return verdict } // runStage:跑一个 STAGE_RESULT 派生 stage(spec/plan/tdd/verify/fix/report)。 // ① 登记 decisions[];② status:halt 或 validate() 报结构问题 → 经 adjudicate 决定 retry/continue/halt。 // makePrompt(guidanceTail) 接收仲裁追加指令串(adjGuidance 已格式化);validate(res) 返回 null=通过 / 问题串。 // allowContinue=true 只用于后续 reviewer / behavior 会再次兜底的软 stage;流程前提默认不可 continue。 // allowContinue=false:本 stage 的 halt 代表**硬正确性边界**(功能测试红色 verify/reverify、路径越界/卡死 tdd、 // test-gate 红 report),仲裁只许 retry/halt,**绝不 continue 放行**残缺/越界状态去 approve / milestone。 // dec:decisions 落点(模块作用域调用点传 c.decisions);root:仲裁取证树根(模块作用域传 c.root,补 11)。 async function runStage(makePrompt, { site, grp, label, validate, allowContinue = false, dec, root = ROOT }) { let guidance = '' for (let round = 1; round <= ADJUDICATE_MAX; round++) { const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA}) if (!res) { const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root) if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`) guidance = verdict.guidance || '' continue } recordDecisions(site, res.decisions, dec) let problem = null if (res.status === 'halt') problem = `stage 返回 status:halt;reason: ${res.reason || '(空)'}` else if (validate) { try { problem = validate(res) } catch (e) { problem = String(e?.message || e) } } if (!problem) return res const verdict = await adjudicate(site, { problem, stageResult: res, allowContinue }, grp, round, root) if (verdict.action === 'continue' && allowContinue) return res if (verdict.action !== 'retry') throw new Error(`HALT ${site}: ${verdict.rationale || problem}`) guidance = verdict.guidance || '' // retry:带 guidance 重跑 } throw new Error(`HALT ${site}-adjudication-exhausted: ${ADJUDICATE_MAX} 轮仲裁仍未解决`) } // runAction:跑一个 ACTION_RESULT 微步骤(git / 文件写),失败时经 adjudicate 决定 retry/continue/halt。 // allowContinue=true 时 continue 视为"接受失败并前进"(仅用于纯可视化等可安全跳过的副作用)。 // dec:与 runStage 调用点签名统一而收(ACTION_RESULT 无 decisions 字段,当前不消费);root:仲裁取证树根(补 11)。 async function runAction(makePrompt, { site, grp, label, allowContinue = false, dec, root = ROOT }) { let guidance = '' for (let round = 1; round <= ADJUDICATE_MAX; round++) { const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: ACTION_RESULT_SCHEMA}) if (!r) { const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root) if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`) guidance = verdict.guidance || '' continue } if (r.success) return r const verdict = await adjudicate(site, { problem:`action 失败:${r.error || ''}${r.detail ? '\n' + r.detail : ''}`, allowContinue }, grp, round, root) if (verdict.action === 'continue' && allowContinue) return r if (verdict.action === 'halt' || verdict.action === 'continue') throw new Error(`HALT ${site}: ${verdict.rationale || r.error || ''}`) guidance = verdict.guidance || '' // retry:带 guidance 重跑 } throw new Error(`HALT ${site}-adjudication-exhausted: ${ADJUDICATE_MAX} 轮仲裁仍未解决`) } // best-effort 微步骤统一包裹:失败/异常只 log 绝不阻断主流程。返回是否成功(供 RESUME flush 记账判断)。// ≠ runAction:本 helper 不经 adjudicate、绝不 halt。 async function bestEffortAction(prompt, { label, phase: ph, okMsg, failTag, errTag = failTag }) { try { const r = await agent(prompt, { label, phase: ph, schema: ACTION_RESULT_SCHEMA }) if (r && r.success) { log(okMsg); return true } log(`${failTag}失败(不阻断):${(r && r.error) || ''}`) } catch (e) { log(`${errTag}异常(不阻断):${String(e?.message || e)}`) } return false } // ── 续跑 handoff(RESUME.md):halt / 全完成时追加一条跨运行记录 ───────────── // 进度真值仍是 git tag(milestone/req-done)+ 已 commit 的 module-reports / specs 工件—— // 这些已让硬中断后 Router 正确续跑、per-feature 决策也落在工件里。RESUME.md 补的是 // 「上次为何 halt + 本次做过哪些自主默认假设 + 还剩哪些模块」这类**跨会话会丢失**的定向信息 // (GSD continue-here.md 类比),供下次 coding-start 重跑时向人工复盘。 // best-effort:它自身写失败**绝不**阻断 / 掩盖主流程(尤其在 halt 善后路径上)。 const RESUME_PATH = 'docs/superpowers/RESUME.md' function resumeJournalPromptM(sectionMd) { return [ '# 追加续跑日志(RESUME handoff)— 非交互静默', microStepContract(ROOT), '', `## 任务:把给定条目**追加到 \`${RESUME_PATH}\` 末尾**后 commit(不改其它任何文件)`, `1. 确保目录 \`${ROOT}/docs/superpowers/\` 存在(不存在则创建)。`, `2. 取时间戳:\`git -C ${ROOT} log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M'\`;取不到(无 commit)则用空串。`, `3. 若 \`${RESUME_PATH}\` 不存在 → 先写文件头:一行 \`# 续跑日志(RESUME handoff)\`,空行,再一行引用块说明「Coding 每次 halt/完成时追加,**最新条目在末尾**;中断后重跑 /erp-workflow:coding-start 前先读末尾条目」。`, '4. 把下面这段条目**追加到文件末尾**(前置一空行;把其中的 `` 替换为步骤 2 的时间戳):', '```markdown', sectionMd, '```', `5. commit:\`git -C ${ROOT} add ${RESUME_PATH}\` → \`git -C ${ROOT} commit -m "chore(resume): 续跑 handoff 追加"\`。`, '', '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 → `{ "success": true }`;任何步骤失败 → `{ "success": false, "error": "<原因>", "detail": "" }`(**不要**抛错,照实返回即可)。', ].join('\n') } async function recordResume(sectionMd) { // best-effort:续跑日志写失败绝不阻断主流程(它为 resume 而存在,不该反过来制造 halt)。 return bestEffortAction(resumeJournalPromptM(sectionMd), { label: 'resume-journal', phase: 'Milestone', okMsg: 'resume-journal 已追加 RESUME.md', failTag: 'resume-journal 写入', errTag: 'resume-journal ' }) } // ── 需求台账基线(P1#3):coding 首跑时若 .req-ledger.json 缺失,自动建立并提交 ────── // 语义 = 把「coding 开始时的需求快照」定为基线,使日后 /add-req 能正确 diff 出新增/变更, // 免去现有项目首次 add-req 只为建基线的空跑。幂等(已存在则跳过)、best-effort(失败不阻断)。 function ledgerBaselinePromptM() { return [ '# 需求台账基线(首跑)— 非交互静默', microStepContract(ROOT), '', `## 任务:若 \`${ROOT}/.req-ledger.json\` 不存在,则建立并 commit;已存在则跳过`, `1. 检查 \`${ROOT}/.req-ledger.json\` 是否存在。存在 → 直接返回 \`{ "success": true, "detail": "ledger 已存在,跳过" }\`。`, `2. 不存在 → 建立基线:\`node ${PLUGIN}/lib/req-ledger.mjs commit ${ROOT}\`(扫 docs/01 REQ 卡 + docs/08 §三 FE 行写哈希)。`, `3. commit 到当前分支:\`git -C ${ROOT} add .req-ledger.json\` → \`git -C ${ROOT} commit -m "chore(req-ledger): 建立需求台账基线(coding 首跑)"\`。`, ' (只 add .req-ledger.json 这一个文件;其余未提交的 Plan 产物由后续 runBranchSetup 的脏树恢复处理,勿在此一并提交。)', '', '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 已存在 → `{ "success": true }`;失败 → `{ "success": false, "error": "...", "detail": "..." }`(不要抛错)。', ].join('\n') } async function ensureLedgerBaseline() { if (!PLUGIN) { log('req-ledger 基线跳过:未透传 pluginRoot(由 /add-req 首跑兜底)'); return } return bestEffortAction(ledgerBaselinePromptM(), { label: 'req-ledger-baseline', phase: 'Router', okMsg: 'req-ledger 基线就绪', failTag: 'req-ledger 基线' }) } function preflightPromptM() { return [ '# Preflight:编码阶段环境探测(只读探测 + 少量无副作用命令,不改任何文件)', microStepContract(ROOT), '', `读 \`${ROOT}/docs/04-技术规范.md § 零\`(锁定技术栈与命令清单),逐项探测本机环境是否满足:`, '1. **node**:`node --version` 主版本满足 docs/04 要求。', '2. **JDK**(若后端为 Java):`java -version` 主版本与 docs/04 锁定版本一致;不一致时探测 `/usr/libexec/java_home -v <版本>`(macOS)或 `JAVA_HOME` 是否可解析到正确版本,并在 detail 给出导出 JAVA_HOME 的具体命令。', '3. **数据库客户端**:docs/04 指明的 DB(如 mysql)客户端命令存在;若 `.env.local` 存在则尝试一次只读连接探测(失败不算硬错,记入 detail 提示)。', '4. **测试入口**:docs/04 § 零命令清单里 test-gate 用到的脚本文件(如 `scripts/test.mjs`)存在。', `5. **测试库脚本必须是复制副本模型(fail-closed 硬门)**:若 \`${ROOT}/scripts/setup-test-db.mjs\` 存在,Grep 其内容是否含字符串 \`COPY_SCHEMA\`(新模型:复制源库→一次性副本,带 COPY≠SOURCE 守卫)。**不含即硬错**——旧模型脚本对 env 缺省时直接 DROP config-vars.yaml 的源库(真实数据全灭,已有事故)。error 写「scripts/setup-test-db.mjs 是旧 DROP-源库模型」,detail 写明:用插件 \`skills/plan/skeleton-gen/templates/\` 下的 scripts-setup-test-db / scripts-drop-test-db / scripts-promote-to-source / scripts-seed-demo-data / scripts-test 模板升级目标项目 \`scripts/\` 后重跑。`, '', '全部满足 → `{ "success": true }`;任一硬缺失 → `{ "success": false, "error": "<一句话>", "detail": "<逐项列出缺什么、装/配的具体命令>" }`。', '## 输出(ACTION_RESULT_SCHEMA)', ].join('\n') } // recoverDirtyWorktreePromptM:branchSetup / milestone 前置的"工作树干净"被打破时的自主恢复(class D 部分)。 // 子代理检查脏文件——全是本阶段合法产物 → 自动 commit 后继续;含越界/不明改动 → 不提交、返回失败让上层 halt。 // **分支护栏(branch)**:自动 commit 只允许发生在目标功能分支上。若当前 HEAD 不在 branch(如里程碑后 HEAD // 停在默认分支、resume 时残留落在默认分支),绝不 add -A/commit——否则会把绕过 review/test-gate 的改动 // 直接提交进默认分支,且该改动对模块 `...HEAD` 三点 diff 不可见(污染 cross-module / 完成报告)。 function recoverDirtyWorktreePromptM(dirty, branch, scopeHint, c) { const list = (dirty || []).map(p => `- ${p}`).join('\n') || '(调用方未给清单,请自行 `git status --porcelain` 复核)' return [ '# 工作树不干净——判定能否自主提交后继续', microStepContract(c.root), '', '## 背景', `分支切换 / 里程碑前要求工作树干净,当前存在未提交改动。${scopeHint || ''}`, '在**不丢失工作、不混入越界改动、不提交到错误分支**的前提下尽量让流程继续。', '', '## 脏文件清单', list, '', '## 流程', `0. **分支护栏(必须先做)**:跑 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\`。若当前分支 **!= \`${branch}\`**(目标功能分支),**绝不提交**——直接返回 \`{ "success": false, "error": "dirty-on-wrong-branch", "detail": "HEAD=<当前分支>, expected ${branch};拒绝把残留提交到非功能分支,留给人工" }\`。只有当前已在 \`${branch}\` 才继续 step 1。`, `1. 逐一检查改动(\`git -C ${c.root} status --porcelain\`,必要时 \`git -C ${c.root} diff\`)。`, `2. **全部都是本阶段合法产物**(spec/plan/verify/review/report/源码/migration,且落在当前阶段路径作用域内)→ \`git -C ${c.root} add -A\` 后 \`git -C ${c.root} commit -m "chore: 自动提交上一步残留改动"\`,返回 \`{ "success": true, "detail": "committed-in-scope" }\`。`, '3. 含**越界 / 不明 / 与本阶段无关**的改动(手工临时文件、其它模块代码、构建产物等)→ **不要提交**,返回 `{ "success": false, "error": "dirty-out-of-scope", "detail": "<可疑文件 + 原因>" }`。', '## 输出(ACTION_RESULT_SCHEMA)', ].join('\n') } // ── 微步骤:可重用 read(多个 orchestrator 共用)── function detectDefaultBranchPromptM() { return [ '# 检测本地默认分支', microStepContract(ROOT), '', `用 \`git -C ${ROOT} rev-parse --verify refs/heads/\` 依次试 \`main\` / \`master\`,取第一个 exit=0 的为默认分支。`, '## 输出(DEFAULT_BRANCH_SCHEMA)', '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`', '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。', ].join('\n') } function worktreeCleanPromptM(c) { return [ '# 检查工作树是否干净', microStepContract(c.root), '', `跑 \`git -C ${c.root} status --porcelain\`,按行解析 dirty 文件路径(第 4 字符起)。`, '## 输出(WT_SCHEMA)', '- 干净:`{ "clean": true }`', '- 不干净:`{ "clean": false, "dirty": ["", ...] }`', ].join('\n') } function checkBranchExistsPromptM(branch, c) { return [ `# 本地分支 \`${branch}\` 是否存在`, microStepContract(c.root), '', `跑 \`git -C ${c.root} rev-parse --verify refs/heads/${branch}\`(用 2>/dev/null 抑制 stderr)。`, '## 输出(EXISTS_SCHEMA)', '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`', ].join('\n') } function currentBranchPromptM(c) { return [ '# 当前所在分支', microStepContract(c.root), '', `跑 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\`。`, '## 输出(DEFAULT_BRANCH_SCHEMA)', '- `{ "branch": "" }`', ].join('\n') } // ── 微步骤:分支生命周期 action ── function checkoutExistingBranchPromptM(branch, c) { return [ `# 切到已存在的本地分支 \`${branch}\``, microStepContract(c.root), '', `跑 \`git -C ${c.root} checkout ${branch}\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功:`{ "success": true }`', '- 失败:`{ "success": false, "error": "" }`', ].join('\n') } function createBranchFromPromptM(fromBranch, newBranch, c) { return [ `# 从 \`${fromBranch}\` 新建并切到 \`${newBranch}\``, microStepContract(c.root), '', `按序跑:\`git -C ${c.root} checkout ${fromBranch}\`,然后 \`git -C ${c.root} checkout -b ${newBranch}\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 全成功:`{ "success": true }`;任一失败:`{ "success": false, "error": "" }`', ].join('\n') } // syncBranchWithDefaultPromptM:把默认分支合进当前功能分支(幂等)。 // // 为什么必须有这一步:runBranchSetup 对**新建**分支以默认分支为起点(createBranchFromPromptM), // 但对**已存在**分支只做 checkout(主根)/ worktree add(lane 分支 2),二者都不带任何同步动作。 // 于是「上一轮跑完留下的功能分支」会停在它当时的位置,其后提交到默认分支的一切——新 REQ 卡片、 // 新 migration、docs 增量——对本轮**全部不可见**。子代理据残缺事实作业不会报错,只会自行发明 // 需求(实测:spec 阶段报「全仓 grep 零命中」后照自己的理解另实现一套),到 milestone // merge 才以内容冲突暴露,此时整轮返工。故此步须在功能分支上作业**之前**执行。 // // 冲突处置:不 abort、不 stash——保留冲突现场返回失败,由 runAction 仲裁后 halt 交人工。 // 陈旧分支与默认分支真冲突意味着两边都动了同一处,只有人能判断取舍;自动 abort 会把这个 // 信号抹掉,让下一轮再次撞上同一个坑。 function syncBranchWithDefaultPromptM(branch, defaultBranch, c) { return [ `# 把默认分支 \`${defaultBranch}\` 合进功能分支 \`${branch}\`(幂等)`, microStepContract(c.root), '', `前置:确认 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\` 等于 \`${branch}\`;不等则直接返回失败(error 写明实际分支),**绝不**自行切分支。`, `跑 \`git -C ${c.root} merge --no-edit ${defaultBranch}\`。`, '按结果分三种:', `1. 输出含 \`Already up to date\` → 分支本就包含默认分支全部提交,返回 \`{ "success": true, "detail": "up-to-date" }\`。`, `2. 合并成功(fast-forward 或产生合并提交)→ 返回 \`{ "success": true, "detail": "merged: <被合入的提交数,取 git -C ${c.root} rev-list --count ${branch}@{1}..${branch}>" }\`。`, `3. 冲突或其它失败 → **保留冲突现场**(绝不跑 \`merge --abort\` / \`reset\` / \`stash\`),返回 \`{ "success": false, "error": "merge conflict", "detail": "" }\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 见上三种分支;`detail` 必填,供上层日志与人工诊断。', ].join('\n') } // ── lane 物理隔离基础设施(Phase C:worktree 生命周期 + migration 版本段 + lane 测试库)── // 启用时机:Phase D 波次执行器在 ≥2 宽波次的前置串行段调用(读 maxV / 读 schema 基底 / 探测支持 / // 建 lane),本阶段只提供确定性原语;串行 / 主根路径(c.lane == null)不触达其中任何一个,行为不变。 // lane worktree 路径约定(Task 6 Step 1):主根同级的 `-lanes/`——由 module id // 确定性导出(运行时禁时间/随机源),落在 ROOT 之外(不脏主树、不被主根 status / 脏树恢复误扫)。 function laneRoot(moduleId) { return `${ROOT}-lanes/${moduleId}` } // migration 版本段基址(Task 7 Step 2):lane 0 得下一个整百段、lane 1 再下一段——确定性、零随机。 // maxV 必须取"主根文件 ∪ 全部 module-* 分支"的全局并集口径(maxMigrationVersionPromptM,附录补 4), // 由此 halt-resume 后 laneIdx 漂移**无害**:每个新波次的段基址恒高于一切已知版本(含未合并分支已占 // 用的段),同段绝不二次发放,只产生无害的段空洞(独立模块 migration 互不引用 + lane 库从零重建 + // 合并后全量按版本序 apply,空洞与乱序均安全——段规则文案见 tddPrompt)。 function vBase(maxV, laneIdx) { return (Math.floor(maxV / 100) + 1 + laneIdx) * 100 } // lane 测试库命名(附录补 8,序号制统一口径):`_lane`。固定短后缀避开 moduleId 进 // schema 名的三个坑:64 字符上限的截断规则、截断后长前缀撞名重新互相清库、`-` 字符的 MySQL 标识符 // 转义。N = 波次内 lane 序号(波次严格串行执行,不存在并发撞名)。波次执行器读 config-vars.yaml 的 // database.schema(readDbSchemaPromptM)后经本函数拼成**成品**放 c.dbSchema——prompt 只渲染成品 // 字符串,绝不让子代理自己拼(附录补 6c)。 function laneDbSchema(baseSchema, laneIdx) { return `${baseSchema}_lane${laneIdx}` } // feature lane 路径约定(Phase F Task 13):`<模块工作树根>-feats/`——由 feature id // 确定性导出(id 已经 assertSafeId 校验,路径安全)。串行主根模式 = `-feats/`;模块 // lane 模式 = `-lanes/-feats/`,后者带 `-lanes/` 前缀会被 listLaneWorktrees // 枚举到,但其分支为 feat/*——占用感知只按 module-*/frontend-phase 分支匹配,互不干扰。 function featureLaneRoot(moduleRoot, id) { return `${moduleRoot}-feats/${id}` } // createLaneWorktreePromptM(Task 6 Step 2):建/复用 lane worktree,幂等(resume 安全)。 // worktree add 在主仓库(ROOT)上执行;每个 worktree 独立 index,跨 lane 并行 commit 不争 // .git/index.lock。分支语义与主根路径完全一致(module- / frontend-phase),defaultBranch 由 // 调用方传 detectDefaultBranchPromptM 的结果——不让子代理自行探测,保持单一真值来源。 function createLaneWorktreePromptM(branch, path, defaultBranch) { return [ `# 建立 lane worktree \`${path}\`(分支 \`${branch}\`,幂等)`, microStepContract(ROOT), '', '按下列三分支判定后执行(全部幂等,resume 安全):', `1. **path 已注册**:跑 \`git -C ${ROOT} worktree list --porcelain\`,按行解析各条目的 \`worktree \` 行——路径字段**全等于** \`${path}\` 才算已注册(**绝不**用子串包含判断:lane id 互为前缀时会误匹配兄弟树)→ 校验该树的 HEAD 分支:跑 \`git -C ${path} rev-parse --abbrev-ref HEAD\`,等于 \`${branch}\` → 直接返回成功(resume 复用);**不等** → 返回失败(error 写明实际分支;**绝不**自行强切 / 删树,留给上层处置)。`, `2. path 未注册、分支已存在(\`git -C ${ROOT} rev-parse --verify refs/heads/${branch}\` exit=0)→ 跑 \`git -C ${ROOT} worktree add ${path} ${branch}\`。`, `3. path 未注册、分支不存在 → 跑 \`git -C ${ROOT} worktree add -b ${branch} ${path} ${defaultBranch}\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功(含 resume 复用):`{ "success": true, "detail": "" }`', '- 失败:`{ "success": false, "error": "<分支不符 / git stderr 摘要>" }`', ].join('\n') } // removeLaneWorktreePromptM(Task 6 Step 2/4):移除 lane worktree,幂等。脏树**禁用 --force**—— // 脏 lane 说明有未收口的工作(理应已被 runModule 的 lane 收口段提交干净),照实返回失败留给人工, // 绝不静默丢工作。调用方一律 bestEffortAction:删失败只 log(分支/对象库全仓共享,留树不损正确性)。 function removeLaneWorktreePromptM(path) { return [ `# 移除 lane worktree \`${path}\`(幂等;脏树禁 --force)`, microStepContract(ROOT), '', `1. 注册判定:跑 \`git -C ${ROOT} worktree list --porcelain\`,按行解析各条目的 \`worktree \` 行——路径字段**全等于** \`${path}\` 才算已注册(**绝不**用子串包含判断:lane 路径是其 feature 树路径的严格前缀,feature 树残留会让已删的 lane 误判"仍注册",把幂等成功变成 spurious 失败)。未注册 → 本就不存在,直接返回成功(幂等)。`, `2. 否则跑 \`git -C ${ROOT} worktree remove ${path}\`。**绝不**加 \`--force\`:树脏 / 含未提交改动时该命令会失败——照实返回失败并把 stderr 摘要写进 error(lane 留给人工取证)。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 本就不存在:`{ "success": true }`', '- 失败(脏树等):`{ "success": false, "error": "" }`', ].join('\n') } // maxMigrationVersionPromptM(Task 7 Step 1):读全仓已知最大 migration 版本号——**全局并集口径** // (附录补 4)。只看主根文件不够:halt 波次收敛 + 保留 lane 意味着 halted 模块的 V 文件已 commit 在 // 未合并的 module- 分支上、对主根 ls 不可见,漏看会把同一版本段二次发放给别的模块(两分支各写 // V,文件名不同 git 合并静默通过,Flyway 在"同版本号多文件"上硬失败)。纯 git 只读、确定性。 // 主根专属(波次前置串行段每波读一次,microStepContract(ROOT));FIELD_VALUE_SCHEMA 复用。 function maxMigrationVersionPromptM() { return [ '# 读全仓最大 migration 版本号(主根文件 ∪ 全部 module-* 分支,全局并集)', microStepContract(ROOT), '', `1. 主根文件:列出 \`${ROOT}/sql/migrations/V*.sql\`(Glob 即可)。`, `2. 分支并集:\`git -C ${ROOT} for-each-ref --format='%(refname:short)' refs/heads/module-*\` 列出全部模块分支;对**每个**分支跑 \`git -C ${ROOT} ls-tree -r --name-only -- sql/migrations\`。`, '3. 对以上全部文件名解析 `V__*.sql` 的 ``(十进制整数),取**全体最大值**。', '## 输出(FIELD_VALUE_SCHEMA)', '- 有任何 migration 文件:`{ "found": true, "value": "<最大版本号十进制字符串,如 "12">" }`', '- 全仓(含全部 module-* 分支)无任何 V*.sql:`{ "found": true, "value": "0" }`', '- 命令失败无法判定:`{ "found": false, "value": "" }`(调用方按失败降级处理,**不要**猜值)。', ].join('\n') } // readDbSchemaPromptM(Task 8 Step 2):读 config-vars.yaml 的 database.schema 字面量——lane 库名 // 基底。主根专属(波次前置串行段读一次);拼接由 JS 的 laneDbSchema 完成,子代理只取字面值。 function readDbSchemaPromptM() { return [ '# 读 config-vars.yaml 的 database.schema(lane 测试库名基底)', microStepContract(ROOT), '', `Read \`${ROOT}/config-vars.yaml\`,定位 \`database\` 段的 \`schema\` 字段,取其字面值(去引号去空白)。`, '## 输出(FIELD_VALUE_SCHEMA)', '- 命中:`{ "found": true, "value": "", "lineNumber": <行号> }`', '- 文件或字段不存在:`{ "found": false, "value": "" }`(调用方据此降级单宽,**不要**猜值)。', ].join('\n') } // laneDbSupportPromptM(Task 8 Step 3 / 附录补 6b:两点联查替换单 grep):存量项目 lane 测试库 // 支持探测。只 grep scripts/ 不够——schema 引用若只活在 Node 脚本里,gradle test / bootRun 的 // datasource 仍写死库名,单点探测会假阳性放行(脚本建了副本,但 gradle test / bootRun 照样连那个写死的库—— // 两条 lane 互相踩数据、甚至直接跑在源库上污染它)。两点都满足才算支持;任一不满足 → Phase D 波次执行器把本波次 // 降级 maxWidth=1(log 写明原因 + 建议重跑 skeleton 升级脚本),**不 halt**——与其并行却让测试跑在同一个写死的库上互相踩,不如明示串行。主根专属、只读。 function laneDbSupportPromptM() { return [ '# 探测目标项目是否支持 lane 测试库(ERP_TEST_DB_SCHEMA 两点联查,只读)', microStepContract(ROOT), '', `1. \`scriptsEnv\`:Grep \`${ROOT}/scripts/\` 是否含字符串 \`ERP_TEST_DB_SCHEMA\`(setup-test-db / test / seed-demo-data 脚本支持 env 覆盖 schema)。`, `2. \`ymlPlaceholder\`:Grep \`${ROOT}/backend/src/\` 下的 \`application*.yml\` / \`application*.properties\` 是否含 \`\${ERP_TEST_DB_SCHEMA\` 占位(Spring datasource 的 JDBC URL schema 段支持 env 打穿)。`, '## 输出(LANE_DB_PROBE_SCHEMA)', '- `{ "scriptsEnv": , "ymlPlaceholder": , "detail": "<命中文件路径摘要 / 未命中说明>" }`——两项独立据实填,**不要**互相推断或合并判断。', ].join('\n') } // listLaneWorktreesPromptM(附录补 3a:占用感知枚举,主根专属、只读)。 // 只看 `-lanes/` 前缀——主根自身与用户自建的其它 worktree 一概不碰、不上报。 function listLaneWorktreesPromptM() { return [ '# 枚举残留 lane worktree(只读)', microStepContract(ROOT), '', `1. 跑 \`git -C ${ROOT} worktree list --porcelain\`,按空行分组解析每个条目的 \`worktree \` 与 \`branch refs/heads/\` 行。`, `2. 只保留 path 以 \`${ROOT}-lanes/\` 开头的条目;branch 取短名(去 \`refs/heads/\` 前缀);detached 条目 branch 填空串。`, '## 输出(LANE_LIST_SCHEMA)', '- `{ "lanes": [ { "path": "<绝对路径>", "branch": "<分支短名>" }, ... ] }`;无残留 lane → `{ "lanes": [] }`。', ].join('\n') } // pruneWorktreesPromptM(计划 D2):清理"目录已删"的 worktree 管理残留。注意(附录补 3 末注): // 它对目录完好的 lane(恰是 halt 刻意保留的取证态)毫无作用——占用感知(listLaneWorktrees + 恢复/ // 移除)才是收口主力,绝不要依赖 prune 释放被残留 lane 持有的分支。 function pruneWorktreesPromptM() { return [ '# git worktree prune(清理目录已删的管理残留)', microStepContract(ROOT), '', `跑 \`git -C ${ROOT} worktree prune\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- exit=0 → `{ "success": true }`;失败 → `{ "success": false, "error": "" }`(不要抛错)。', ].join('\n') } // writeLaneDbMarkerPromptM(附录补 7:lane 副本库名的 fail-closed 兜底 marker)。 // lane 注入是纯 prompt-only,且 tdd 主会话会把命令转述给二级子会话(提示词链两跳)——任何一跳丢掉 // ERP_TEST_DB_SCHEMA 前缀,若无兜底则各脚本回退到串行缺省副本名 `__test`(多条 lane 撞同一副本名、 // 互相 DROP 重建对方副本,随机红测/烧 testGate/仲裁预算甚至假 halt;源库有 COPY≠SOURCE 守卫、绝不被误 drop)。 // lane 隔离需要确定性兜底:setup / drop / promote / seed / test 脚本模板均按「env → 本仓库根 .tmp/erp-lane-db → // `__test`」顺序取副本库名(缺省是副本 `__test`,**绝非源库本身**),lane 副本天然 lane 作用域; // 主根无此文件,串行缺省副本 `__test`。`.tmp/` 已被 gitignore 忽略,绝不会被 recoverDirtyWorktree 判 // in-scope 自动 commit。明令**禁止**改 lane 副本 config-vars.yaml 的 database.schema 兜底(该文件随 git // 提交,lane 树一脏会被自动 commit 并随 milestone 合回默认分支,把真 schema 名搞坏)。幂等(覆盖写)。 function writeLaneDbMarkerPromptM(lanePath, schema) { return [ `# 写 lane 测试库 marker \`${lanePath}/.tmp/erp-lane-db\`(幂等覆盖写)`, microStepContract(ROOT), '', `1. 确保目录 \`${lanePath}/.tmp/\` 存在(不存在则创建)。`, `2. 把单行内容 \`${schema}\`(行尾换行,无其它任何内容)**覆盖写**入 \`${lanePath}/.tmp/erp-lane-db\`。`, '3. **不 commit**(该文件被 .gitignore 忽略,仅供 lane 内脚本兜底读取);**绝不**改 config-vars.yaml。', '## 输出(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, c) { return [ `# tag \`req-done/${id}\` 是否存在(功能级 dedup 真值)`, microStepContract(c.root), '', `跑 \`git -C ${c.root} tag -l req-done/${id}\`。`, '## 输出(EXISTS_SCHEMA)', '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`', ].join('\n') } function createReqDoneTagPromptM(id, phase, c) { return [ `# 打 annotated tag \`req-done/${id}\`(${phase==='frontend'?'前端 FE':'后端 REQ'} approve 后落地)`, microStepContract(c.root), '', `跑 \`git -C ${c.root} tag -a req-done/${id} -m "feature(${id}): approved by code-reviewer (phase=${phase})"\`。`, `先用 \`git -C ${c.root} tag -l req-done/${id}\` 检查;已存在则视为成功(幂等)直接返回 success。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "" }`', ].join('\n') } // ── 微步骤:前端 jsdom-only 重叠的中间完成态 tag `fe-code-done/`(设计见 // docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)── // 语义:phase1 完成(组件代码 + jsdom 绿,e2e/review/req-done 全部延后)。与 req-done 正交、双 tag 续跑链: // fe-code-done 存在 → phase1 跳过该 FE;req-done 存在 → phase2 跳过;两者皆缺 → phase1 重跑该 FE。 // 仅在 frontendOverlap 开启时产生;关闭时全程只用 req-done,与现行一字不差。 function checkFeCodeDoneTagPromptM(id, c) { return [ `# tag \`fe-code-done/${id}\` 是否存在(前端 phase1 完成态 dedup 真值)`, microStepContract(c.root), '', `跑 \`git -C ${c.root} tag -l fe-code-done/${id}\`。`, '## 输出(EXISTS_SCHEMA)', '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`', ].join('\n') } function createFeCodeDoneTagPromptM(id, c) { return [ `# 打 annotated tag \`fe-code-done/${id}\`(前端 phase1:组件代码 + jsdom 绿,e2e/review 延后)`, microStepContract(c.root), '', `跑 \`git -C ${c.root} tag -a fe-code-done/${id} -m "fe-code(${id}): jsdom green, e2e/review deferred (overlap phase1)"\`。`, `先用 \`git -C ${c.root} tag -l fe-code-done/${id}\` 检查;已存在则视为成功(幂等)直接返回 success。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "" }`', ].join('\n') } // ── 微步骤:批量预生成 spec 的统一提交(Phase E Task 11)── // batch 模式 spec 只写盘不 commit(避免同一工作树并行 commit 争 .git/index.lock);全部预生成 // 落盘后由本微步骤一次性 add+commit 收口。幂等:resume 场景 spec 可能是"复用已提交文件、就地 // Edit 后无实际变更"——无 staged 变更直接视为成功,绝不空 commit 报错。 function commitPrefetchedSpecsPromptM(ids, phase, c) { return [ `# 统一提交预生成 spec(${phase},共 ${ids.length} 份)`, microStepContract(c.root), '', `1. 跑 \`git -C ${c.root} add docs/superpowers/specs\`。`, `2. 跑 \`git -C ${c.root} diff --cached --quiet\`:exit=0(无 staged 变更)→ 直接返回成功,跳过第 3 步。`, `3. 跑 \`git -C ${c.root} commit -m "docs(spec:${phase}): 批量预生成派生规格" -m "ids: ${ids.join('、')}"\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 无变更:`{ "success": true }`;失败:`{ "success": false, "error": "" }`(不要抛错)。', ].join('\n') } // ── 微步骤:feature 级受限并行(Phase F Task 12/13;仅后端 REQ,附录补 2)── // planFactsPromptM(Task 12):从 plan 提取并行准入事实。保守偏置同调度器:拿不准 schemaChange // → true、文件边界不清 → files 空数组——错误串行只损失时间,错误并行损失正确性。模块作用域 // (plan 在模块分支上,主根树面可能看不到,microStepContract(c.root))。 function planFactsPromptM(planPath, c) { return [ `# 提取 plan 并行准入事实(只读):\`${planPath}\``, microStepContract(c.root), '', `Read \`${c.root}/${planPath}\`(任务级 TDD 计划),只提取两项事实:`, '1. `schemaChange`:plan 是否声明**任何** schema 改动——migration 文件(`sql/migrations/V*.sql`)/ DDL / docs/03 反向更新任务,或任何会写 `sql/` 路径的任务。**拿不准 → `true`**(宁串勿并)。', '2. `files`:全部任务的 `impl_file` 与 `test_file` 路径**全集**(项目根相对、去重、按 plan 原文字面取,不要归一化 / 猜补)。某任务文件边界模糊 / 给不出明确路径 → **`files` 返回空数组**(调用方会让该 feature 留在串行链)。', '## 输出(PLAN_FACTS_SCHEMA)', '- `{ "schemaChange": , "files": ["backend/src/.../Foo.java", ...], "detail": "<一句提取依据>" }`;不要返回额外字段。', ].join('\n') } // createFeatureWorktreePromptM(Task 13):建/复用 feature worktree——复用 createLaneWorktreePromptM // 的幂等三分支形态,差异仅两点:基分支是**模块分支**(feature 从模块分支 HEAD 分叉,不是默认分支)、 // git 操作经 c.root(worktree 命令对任意同仓工作树等效,模块作用域保持穿线纪律)。 function createFeatureWorktreePromptM(featBranch, path, baseBranch, c) { return [ `# 建立 feature worktree \`${path}\`(分支 \`${featBranch}\` 自 \`${baseBranch}\`,幂等)`, microStepContract(c.root), '', '按下列三分支判定后执行(全部幂等,resume 安全):', `1. **path 已注册**:跑 \`git -C ${c.root} worktree list --porcelain\`,按行解析各条目的 \`worktree \` 行——路径字段**全等于** \`${path}\` 才算已注册(**绝不**用子串包含判断:feature id 互为前缀时会误匹配兄弟树)→ 校验该树 HEAD:跑 \`git -C ${path} rev-parse --abbrev-ref HEAD\`,等于 \`${featBranch}\` → 直接返回成功(resume 复用);**不等** → 返回失败(error 写实际分支;**绝不**自行强切 / 删树)。`, `2. path 未注册、分支已存在(\`git -C ${c.root} rev-parse --verify refs/heads/${featBranch}\` exit=0)→ 跑 \`git -C ${c.root} worktree add ${path} ${featBranch}\`。`, `3. path 未注册、分支不存在 → 跑 \`git -C ${c.root} worktree add -b ${featBranch} ${path} ${baseBranch}\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功(含 resume 复用):`{ "success": true, "detail": "" }`', '- 失败:`{ "success": false, "error": "<分支不符 / git stderr 摘要>" }`', ].join('\n') } // mergeFeatureBranchPromptM(Task 13):批后把 feature 分支串行合并回模块分支。冲突处置与 // milestone 合并的"留树给人工"**刻意相反**:准入已保证文件集不相交,冲突本身即 facts 提取失真 // 的信号,计划钉死了自动恢复路径(丢弃 feature 分支 → 回模块分支串行重跑全链,不 adjudicate)—— // abort 恢复干净模块分支正是该路径的前置,树面没有人工要救的东西。 function mergeFeatureBranchPromptM(featBranch, moduleBranch, id, c) { return [ `# 把 feature 分支 \`${featBranch}\` 合并回模块分支 \`${moduleBranch}\``, microStepContract(c.root), '', `0. **前置校验**:跑 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\`,必须等于 \`${moduleBranch}\`;不等 → 直接返回失败(error 写实际分支,**不要**自行 checkout)。`, `1. 跑 \`git -C ${c.root} merge --no-ff ${featBranch} -m "merge(feat:${id}): integrate ${featBranch}"\`。`, `2. **合并冲突** → 先跑 \`git -C ${c.root} merge --abort\` 恢复干净模块分支(调用方要在它上面串行重跑),再返回 \`{ "success": false, "error": "merge-conflict", "detail": "<冲突文件清单,换行分隔>" }\`。`, '3. 其它失败照实返回 failure(不要重试、不要改文件)。', '## 输出(ACTION_RESULT_SCHEMA)', '- 成功:`{ "success": true }`;失败:`{ "success": false, "error": "", "detail": "<冲突文件 / stderr 前 30 行>" }`', ].join('\n') } // deleteFeatureBranchPromptM(Task 13):删除 feature 分支,幂等。-D 强删的依据:合并成功路径上 // 该分支已并入模块分支(无独有 commit);丢弃路径则是有意弃置(冲突 = facts 失真,串行重跑会在 // 模块分支重做)——两条调用路径都不需要 -d 的"未合并"安全网。 function deleteFeatureBranchPromptM(featBranch, c) { return [ `# 删除 feature 分支 \`${featBranch}\`(幂等)`, microStepContract(c.root), '', `1. \`git -C ${c.root} rev-parse --verify refs/heads/${featBranch}\` 非 0(分支不存在)→ 直接返回成功(幂等)。`, `2. 否则跑 \`git -C ${c.root} branch -D ${featBranch}\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 本就不存在:`{ "success": true }`;失败:`{ "success": false, "error": "" }`', ].join('\n') } // ── 微步骤:milestone 专用 ── function checkAlreadyMergedPromptM(branch, defaultBranch) { return [ `# \`${branch}\` 是否已合入 \`${defaultBranch}\``, microStepContract(ROOT), '', `先跑 \`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(ROOT), '', `跑 \`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') } // reconcile(前端 jsdom-only 重叠 phase1→phase2 边界,设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md): // phase1 的 frontend-phase 分支自**重叠开始时**的 default 分叉,不含后端代码;phase2 的 e2e 要打真后端, // 故先把已完成的 default(含全部后端 milestone)合并进 frontend-phase。路径互斥(frontend/ vs backend/) // → 冲突概率极低;冲突按 milestone merge 同口径「留树 halt 给人工」,绝不 abort。幂等:default 已是 // frontend-phase 祖先(无重叠 / resume 已 merge)→ git "Already up to date" → success。主根专属。 function mergeDefaultIntoFrontendPromptM(branch, defaultBranch) { return [ `# reconcile:把 \`${defaultBranch}\`(已完成后端)合并进 \`${branch}\``, microStepContract(ROOT), '', `1. 跑 \`git -C ${ROOT} checkout ${branch}\` 确保 HEAD 在 ${branch}(phase1 worktree 已移除,分支空闲;若仍被占用则整步失败留人工)。`, `2. 跑 \`git -C ${ROOT} merge --no-ff ${defaultBranch} -m "merge(reconcile): integrate backend into ${branch} for e2e"\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 已是最新(Already up to date)→ `{ "success": true }`', '- 合并冲突 / 其它失败 → `{ "success": false, "error": "", "detail": "<冲突文件换行分隔 或 stderr 前 30 行>" }`;**不要**自动 abort / stash / 改文件。', ].join('\n') } function readDocs08FieldPromptM(fe, id) { const section = fe ? '§ 三' : '§ 二' const title = fe ? '# 读 docs/08 § 三 `整体里程碑:` 字段当前值' : `# 读 docs/08 § 二 模块 \`${id}\` 的 \`里程碑:\` 字段当前值` const locator = fe ? `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`- 整体里程碑: \` 行。` : `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section} 中 module id == \`${id}\` 的 bullet 段,取其 \` - 里程碑: \` 子项。` const missing = fe ? '- § 三 或该行不存在:`{ "found": false, "value": "" }`' : `- 模块 \`${id}\` 或该字段不存在:\`{ "found": false, "value": "" }\`` return [ title, microStepContract(ROOT), '', locator, '## 输出(FIELD_VALUE_SCHEMA)', '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <行号> }`', missing, ].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(ROOT), '', `调用方已确认字段当前值 = \`—\`(你不必再读一遍)。`, `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;read-then-write)── function readDocs08CheckboxPromptM(fe, id, c) { const section = fe ? '§ 三' : '§ 二' const kind = fe ? '功能' : 'REQ' const locator = fe ? `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。` : `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section},找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。` return [ `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, microStepContract(c.root), '', locator, '## 输出(CHECKBOX_STATE_SCHEMA)', `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``, `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``, '- 找不到:`{ "found": false, "state": "unchecked" }`(state 仍必填,避免 schema 失败掩盖真实缺口)。', ].join('\n') } function writeDocs08CheckboxPromptM(fe, id, phase, lineNumber, c) { const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}` const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber)) ? `先 Read \`${c.root}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行去掉行首空白后以 \`- [ ] ${id} \` 开头;不满足则返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual="}\`。然后只替换第 ${lineNumber} 行的第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。` : `定位 docs/08 ${scope} 中去掉行首空白后以 \`- [ ] ${id} \` 开头的唯一一行,只替换该行第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。` return [ `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`, microStepContract(c.root), '', `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`, `1. ${lineGuard}`, `2. 跑 \`git -C ${c.root} add docs/08-模块任务管理.md\`。`, `3. 跑 \`git -C ${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(ROOT), '', `跑 \`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(ROOT), '', `跑 \`git -C ${ROOT} tag -a milestone/${phaseId} -m "milestone(${phaseId}): ${fe ? '前端' : '后端'}阶段完成"\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功:`{ "success": true }`;失败:`{ "success": false, "error": "" }`', ].join('\n') } // fe-skeleton-done:前端骨架占位 stage 的补记 tag;真实完成态以 router/state 检测为准。 function createFeSkeletonTagPromptM(c) { return [ '# 打 annotated tag `fe-skeleton-done`(前端骨架占位已建)', microStepContract(c.root), '', `先用 \`git -C ${c.root} tag -l fe-skeleton-done\` 检查;已存在则视为成功(幂等)直接返回 success。`, `否则跑 \`git -C ${c.root} tag -a fe-skeleton-done -m "chore(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位已建"\`。`, '## 输出(ACTION_RESULT_SCHEMA)', '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "" }`', ].join('\n') } function findReportPromptM(phaseId) { return [ `# 找最新的 \`${phaseId}\` 完成报告并读取 § ⑫ 的 milestone tag 字段当前值`, microStepContract(ROOT), '', `用 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(ROOT), '', `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, c) { return [ `# 收集功能分支自 \`${defaultBranch}\` 分叉以来的全部改动文件`, microStepContract(c.root), '', `跑 \`git -C ${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, c) { const filesText = files.map(f => `- ${f.status} ${f.path}`).join('\n') return [ `# 把改动文件分类:哪些落在**非本模块 \`${moduleId}\`** 的目录下`, microStepContract(c.root), '', `本模块目录归属以 \`${c.root}/docs/08-模块任务管理.md § 二\` 中本模块 bullet 的 \`路径:\` 字段为准。Read 它以建立"路径 → 模块"映射(粒度/分层约定见 docs/04 § 1.2/2.1)。`, '', '## 改动文件清单', filesText, '', '## 判定规则', `- 落在本模块路径(\`${moduleId}\`)下 → **不算**跨模块。`, '- 落在其它模块路径下 → 算跨模块,给出该文件归属的目标模块 id。', '- 落在共享根(如 `docs/`、`scripts/`、`sql/migrations/`、`README.md` 等)→ **不算**跨模块。', '', '## 输出(CROSS_CLASSIFY_SCHEMA)', '- `{ "crossModule": [ { "file": "...", "targetModule": "module_x", "reason": "<本模块哪个 REQ(req_id,如 USR-UserInfo-Login)迫使改它,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, c) { const newRowsJson = JSON.stringify(items, null, 2) return [ `# 把跨模块改动以 dedup-and-rewrite 方式写入 \`docs/superpowers/module-reports/${moduleId}-cross-module.md\``, microStepContract(c.root), '', `目标文件(项目根相对):\`docs/superpowers/module-reports/${moduleId}-cross-module.md\`。`, '', '## 流程', `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 ${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, '```', '', '## 输出(ACTION_RESULT_SCHEMA)', '- 写成功且有/无 commit:`{ "success": true, "detail": "" }`', '- 任一步失败:`{ "success": false, "error": "" }`', ].join('\n') } // ---- 模块完成报告(原 module-report)---- function reportPrompt(module, c) { const id = module?.id ?? '' const fe = id === 'frontend-phase' const phaseId = fe ? 'frontend-phase' : id return [ `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`, '', featureStageContract(fe ? 'frontend' : 'backend', c), '', '## 目标', `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`, '', '## 前置', `- 验证上游 test-gate 绿:Glob \`${c.root}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`, fe ? `- **验证阶段级行为门绿**:Glob \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`,按 behaviorRound → attempt 数字升序读取。**最后一份必须非 RED(绿)**;最后一份 red 或一份证据都没有 → 立即 halt(绝不在行为红 / 无行为证据上打 milestone)。注意 per-FE 行为证据(\`reviews/--behavior-*\`)已不再产生,不要校验。` : '', '', '## 收集输入(取摘要而非正文)', fe ? [ '- § ① `module_id = frontend-phase`,`module_name = 前端阶段(整体)`。', `- § ② "FE 完成清单":扫 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`, `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`, '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。', `- § ⑤:把 \`${c.root}/docs/superpowers/module-reports/frontend-phase-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。**另把阶段级行为证据 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`(按 behaviorRound → attempt 排序)与行为复验 \`frontend-phase-behavior-reverify-r*.md\` 的 flake / 环境 race(envError)/ 行为 fix 轮数 / 样式 styleIssues 修复轮次 / 文字 continue 记录一并纳入 § ⑤ 汇总**。`, `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外按阶段级行为证据 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`(取最后一份的逐 FE 小节)汇总各 FE 的 \`coverageGaps\` + 样式 \`styleIssues\` + 文字 \`textIssues\` 的 continue 记录 + 逐控件判定摘要 + authState 未覆盖角色集**。`, '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。', ].join('\n') : [ `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`, `- § ② / § ⑨:读 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`, `- § ⑤:把 \`${c.root}/docs/superpowers/module-reports/${id}-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`, `- § ⑥ Migration:\`git -C ${c.root} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`, `- § ⑦ 跨模块改动:读 \`${c.root}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`, '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。', ].join('\n'), '', '## 渲染 + 验证 + commit', '- 渲染 12 节。硬验证:§ ⑧ 必须列举所有偏离(无则写"无偏离")。', `- 写入 \`docs/superpowers/module-reports/<当天日期 YYYY-MM-DD>-${phaseId}.md\`(项目根相对;resume 时复用已存在的 \`*-${phaseId}.md\` 最新日期前缀,不要起新文件),连同跨模块日志(如存在)一起 commit 到当前分支(milestone 的 worktree-clean 前置依赖此 commit)。`, '', '## 输出(必须符合下发的 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') } // ---- runBranchSetup:原 branchSetupPrompt 的散文流程 → JS 编排 + 微步骤 agent ---- // 幂等:分支已存在则 checkout,否则从默认分支新建。条件分支由 JS 判定,子代理只负责执行单一动作。 // c:模块上下文——checkout/HEAD/脏树恢复全部作用于 c.root(串行 = 主根;lane 分叉见下)。 // 默认分支探测保持 ROOT(读 refs,全仓共享,主根专属)。 // lane 分叉(Task 6 Step 3):c.lane != null 时不做主根 checkout——`git worktree add` 原子完成 // "建树 + 上分支"(分支语义不变:module- / frontend-phase)。波次前置串行段通常已建好 lane, // 这里的 createLaneWorktree 是幂等兜底(resume / 单独重派模块时仍能自洽建树)。 async function runBranchSetup(module, c) { const id = module?.id ?? '' const fe = id === 'frontend-phase' const branch = fe ? 'frontend-phase' : `module-${id}` const lbl = (k) => `branch:${k}:${id}` const def = await agentR(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) if (c.lane != null) { // lane 模式:先确保 lane worktree 存在且在目标分支(幂等三分支见 prompt),树才存在、脏树检查才有意义。 await runAction(g => createLaneWorktreePromptM(branch, c.root, def.branch) + g, {site:`branchSetup-lane-worktree:${branch}`, grp:'Milestone', label: lbl('lane-add'), dec: c.decisions, root: c.root}) } // 工作树脏:先自主恢复(in-scope 残留 → 自动 commit);含越界改动则恢复失败 → halt(留给人工)。 // 作用树 = c.root:lane 模式查 lane 树(halt 保留的 lane 在 resume 复用时可能带残留),主根模式与现行一致。 const wt = await agentR(worktreeCleanPromptM(c), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) if (!wt.clean) { const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `分支 setup 前置(目标分支 ${branch})。`, c), {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) if (!rec.success) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`) log(`branch-setup: ${id} 自动提交脏工作树残留(${rec.detail || ''})`) } if (c.lane == null) { // 主根模式:现行 checkout 路径,一行不差(lane 模式跳过——worktree add 已把 lane 放上目标分支, // 且 checkout 语义在"分支被别的 worktree 持有"时会 fatal,绝不能在主根重复执行)。 const exists = await agentR(checkBranchExistsPromptM(branch, c), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) if (exists.exists) { await runAction(g => checkoutExistingBranchPromptM(branch, c) + g, {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('checkout'), dec: c.decisions, root: c.root}) } else { await runAction(g => createBranchFromPromptM(def.branch, branch, c) + g, {site:`branchSetup-create:${branch}`, grp:'Milestone', label: lbl('create'), dec: c.decisions, root: c.root}) } } // HEAD 确认:不符则经仲裁重切(retry)或留人工(halt)。 let head = await agentR(currentBranchPromptM(c), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) for (let adj = 1; head.branch !== branch && adj <= ADJUDICATE_MAX; adj++) { const verdict = await adjudicate(`branchSetup-branch-mismatch:${branch}`, { problem:`分支 setup 后 HEAD 在 ${head.branch},期望 ${branch}` }, 'Milestone', adj, c.root) if (verdict.action !== 'retry') throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${verdict.rationale || `HEAD on ${head.branch}`}`) await runAction(g => checkoutExistingBranchPromptM(branch, c) + g, {site:`branchSetup-recheckout:${branch}`, grp:'Milestone', label: lbl('recheckout'), dec: c.decisions, root: c.root}) head = await agentR(currentBranchPromptM(c), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) } if (head.branch !== branch) throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${ADJUDICATE_MAX} 轮后 HEAD 仍在 ${head.branch}`) // 默认分支 → 功能分支同步(幂等)。新建分支走到这里恒为 up-to-date(起点就是默认分支); // 真正吃这一步的是**复用已存在分支**的两条路径——主根 checkout 与 lane worktree add—— // 它们此前不带任何同步,会让上一轮遗留的功能分支看不到其后进入默认分支的 REQ 卡片 / // migration / docs 增量,子代理据残缺事实自行发明实现,直到 milestone merge 才以冲突暴露。 // 冲突时保留现场 halt(见 prompt 说明),由人工判断取舍。 const sync = await runAction(g => syncBranchWithDefaultPromptM(branch, def.branch, c) + g, {site:`branchSetup-sync-default:${branch}`, grp:'Milestone', label: lbl('sync'), dec: c.decisions, root: c.root}) log(`branch-setup: ${id} → ${branch}(同步默认分支 ${def.branch}:${sync.detail || 'ok'})`) } // ---- runMilestone:原 milestonePrompt 的 6 步散文流程 → JS 编排 ---- // 所有"已是目标态则跳过"的条件由 JS 在 read 结果上判定,子代理只执行确定性动作。 // 主根专属(不穿线 c):merge / docs/08 字段 / tag 全部作用于主根 + 默认分支。step 1 的 wt-clean // 只管 merge 的机械安全(恒查主根树,用 mc);lane 树的 milestone 前收口由 runModule 另行处理(补 15)。 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}` const targetTag = `milestone/${phaseId}` const lbl = (k) => `milestone:${k}:${phaseId}` const mc = makeCtx() // 主根上下文:worktreeClean / recoverDirtyWorktree 已穿线,本函数恒以主根调用 // step 1: worktree clean precondition(脏树先自主恢复 in-scope 残留;含越界改动则 halt 留人工) const wt = await agentR(worktreeCleanPromptM(mc), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) if (!wt.clean) { const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `里程碑前置(阶段 ${phaseId},应在功能分支 ${branch})。`, mc), {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) if (!rec.success) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`) log(`milestone: ${phaseId} 自动提交脏工作树残留(${rec.detail || ''})`) } // step 2: detect default branch const def = await agentR(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) // step 3: merge (idempotent — skip if already an ancestor) // merge 冲突保持**硬 halt**:自动 abort/stash/改文件均不安全,把树留给人工(设计原则不变)。 const merged = await agentR(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA}) if (!merged.alreadyMerged) { const r = await agentR(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 '—') let field = await agentR(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA}) for (let adj = 1; !field.found && adj <= ADJUDICATE_MAX; adj++) { const verdict = await adjudicate(`milestone-docs08-missing:${phaseId}`, { problem:`docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`} 里程碑字段未找到` }, 'Milestone', adj) if (verdict.action !== 'retry') throw new Error(`HALT milestone-docs08-missing ${phaseId}: ${verdict.rationale || '字段不存在'}`) field = await agentR(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA}) } if (!field.found) throw new Error(`HALT milestone-docs08-missing ${phaseId}: ${ADJUDICATE_MAX} 轮仲裁后仍未找到字段`) if (field.value === '—') { await runAction(g => writeDocs08FieldPromptM(fe, id, targetTag, phaseId, field.lineNumber) + g, {site:`milestone-docs08-write:${phaseId}`, grp:'Milestone', label: lbl('field-write')}) } else if (field.value !== targetTag) { const verdict = await adjudicate(`milestone-docs08-unexpected:${phaseId}`, { problem:`docs/08 里程碑字段当前 = ${JSON.stringify(field.value)}(行 ${field.lineNumber || '?'}),期望 '—' 或 '${targetTag}'`, allowContinue:true }, 'Milestone', 1) if (verdict.action === 'halt') throw new Error(`HALT milestone-docs08-unexpected ${phaseId}: ${verdict.rationale || JSON.stringify(field.value)}`) log(`milestone ${phaseId}: docs/08 字段非预期值(${JSON.stringify(field.value)}),仲裁判放行`) } // else: 已是 targetTag → 静默跳过(续跑场景) // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则 // `git checkout milestone/` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug, // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。) let rpt = await agentR(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA}) for (let adj = 1; !rpt.found && adj <= ADJUDICATE_MAX; adj++) { const verdict = await adjudicate(`milestone-report-missing:${phaseId}`, { problem:`未找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件` }, 'Milestone', adj) if (verdict.action !== 'retry') throw new Error(`HALT milestone-report-missing ${phaseId}: ${verdict.rationale || '报告文件缺失'}`) rpt = await agentR(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA}) } if (!rpt.found) throw new Error(`HALT milestone-report-missing ${phaseId}: ${ADJUDICATE_MAX} 轮仲裁后仍无报告文件`) if (rpt.currentTagValue === '{{milestone_tag}}') { await runAction(g => updateReportPromptM(rpt.path, targetTag, phaseId) + g, {site:`milestone-report-update:${phaseId}`, grp:'Milestone', label: lbl('report')}) } else if (rpt.currentTagValue !== targetTag) { const verdict = await adjudicate(`milestone-report-unexpected:${phaseId}`, { problem:`${rpt.path} § ⑫ 当前 = ${JSON.stringify(rpt.currentTagValue)},期望占位符 {{milestone_tag}} 或 ${targetTag}`, allowContinue:true }, 'Milestone', 1) if (verdict.action === 'halt') throw new Error(`HALT milestone-report-unexpected ${phaseId}: ${verdict.rationale || JSON.stringify(rpt.currentTagValue)}`) log(`milestone ${phaseId}: 报告 § ⑫ 非预期值(${JSON.stringify(rpt.currentTagValue)}),仲裁判放行`) } // else: 已是 targetTag → 静默跳过(resume 幂等) // step 6: annotated tag (idempotent — tag exists 时静默跳过) const tag = await agentR(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) if (!tag.exists) { await runAction(g => createTagPromptM(phaseId, fe) + g, {site:`milestone-tag:${phaseId}`, grp:'Milestone', label: lbl('tag')}) } log(`milestone: ${phaseId} → ${targetTag}`) } // ---- runCrossModule:原 crossModulePrompt 的"diff → 分类 → 写日志" → JS 编排 ---- // diff 和写文件是机械动作;"按 docs/08 § 二 路径归属判定哪些是跨模块"需要 LLM 判断,独立成一步。 // c:diff/分类/写日志全部在 c.root 上做(lane 模式读 lane 树;diff 基准默认分支为只读,不需要锁)。 async function runCrossModule(module, c) { const id = module?.id ?? '' const lbl = (k) => `xmod:${k}:${id}` const def = await agentR(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) const changed = await agentR(collectCrossModuleChangedPromptM(def.branch, c), {label: lbl('diff'), phase: 'Milestone', schema: CHANGED_FILES_SCHEMA}) if (!changed.files.length) { log(`cross-module-log: 模块 ${id} 无文件改动,跳过`) return } const classified = await agentR(classifyCrossModulePromptM(id, changed.files, c), {label: lbl('classify'), phase: 'Milestone', schema: CROSS_CLASSIFY_SCHEMA}) if (!classified.crossModule.length) { log(`cross-module-log: 模块 ${id} 无跨模块改动,跳过`) return } await runAction(g => writeCrossModuleLogPromptM(id, classified.crossModule, c) + g, {site:`crossModule-write:${id}`, grp:'Milestone', label: lbl('write'), dec: c.decisions, root: c.root}) log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`) } // ---- runFrontendSkeleton:前端骨架占位 stage 的 JS 编排(设计 § 2,前置依赖 A)---- // 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(FeStub 占位)+ 无悬空导航。 // 幂等(resume 安全):router/state 是唯一真实完成态;fe-skeleton-done 只作补记,避免陈旧 tag 跳过缺失骨架。 async function runFrontendSkeleton(feItems, c) { const lbl = (k) => `fe-skeleton:${k}` // step 1: 检查 router 是否已声明全 FE 路由;已建则只确保补记 tag 存在。 const state = await agentR(frontendSkeletonStatePromptM(feItems, c), {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA}) if (state.exists) { log('fe-skeleton: router 已声明全部 FE 路由,确保 fe-skeleton-done tag 存在') await runAction(g => createFeSkeletonTagPromptM(c) + g, {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag'), dec: c.decisions, root: c.root}) return } // step 2: 派子代理生成骨架(成功后子代理自行 commit;此处仅经 runStage 仲裁 halt 收敛)。 await runStage(g => frontendSkeletonPrompt(feItems, c) + g, {site:'fe-skeleton', grp:'Frontend', label: lbl('gen'), dec: c.decisions, root: c.root}) // step 3: 打 fe-skeleton-done 补记 tag。 await runAction(g => createFeSkeletonTagPromptM(c) + g, {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag'), dec: c.decisions, root: c.root}) log(`fe-skeleton: 已生成前端骨架(覆盖 ${(feItems || []).length} 个 FE 路由),打 fe-skeleton-done tag`) } // ============================================================================ // 编排逻辑(结构按 plan 骨架;featureLoop / reviewWithFixLoop / testGate / 顶层循环) // ============================================================================ // ---- 单功能链(后端 / 前端同构)---- // **顺序 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,让 runModule 的 try/catch 捕获不到,残缺模块照样被推进到 milestone。 // 顺序 for-await 让 throw 自然冒泡到 runModule try → catch → 结构化 halted 返回,使 fail-fast 真正生效。 // // 唯一并行豁免 = 入口 spec 预生成(Phase E Task 11):spec 只读 docs/prototype/tokens、不读兄弟 // feature 代码(plan 才要 Grep 现有代码定位文件,依赖前序 feature 产出,**不**预生成),且 batch 模式 // 只写盘不 commit(文件名含 id 不相撞、不碰 .git),上面两条顺序理由对它都不成立;失败项 thunk 内 // catch 落 null 回退主链串行重跑(现行路径含 commit),halt 语义不受 parallel() 吞 throw 影响。 // // 派生 stage 全部 schema 化:spec/plan/tdd/verify/fix 共用 STAGE_RESULT_SCHEMA,统一经 runStage 跑: // stage 优先自主决策继续(缺值挑默认/解读并记入 decisions[]);返回 status:halt 或结构校验失败时不再立即 // fail-fast,而是经 adjudicate 仲裁 retry/continue/halt(最多 ADJUDICATE_MAX 轮),把"无法继续"收敛为最后手段。 // 功能级 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。 // spec 预生成闸(Phase E Task 11):spec 预生成无物理冲突风险(batch 只写盘不 commit、文件名含 id // 不相撞),与模块并行开关解耦——parallel.features 或 parallel.modules 任一开启即启用,前后端 // featureLoop 同享;缺省(parallel 缺省 / modules:false 逃生口且未开 features)全关,维持与 master // 串行等价。注意它不是 Phase F 的 feature 级并行(tdd/fix 链仍严格串行,那才归 features 闸全权管辖)。 const useSpecPrefetch = ARGS?.parallel?.features === true || ARGS?.parallel?.modules === true // feature 级受限并行闸(Phase F Task 12-14 全权归 features 闸管辖;附录补 1:coding-start 不传 // features——开启是 Task 14 收益评估通过后的显式一行改动)。范围仅后端 REQ(附录补 2); // featureMaxWidth 缺省 2(计划 Task 12 字面默认)。与 useSpecPrefetch 解耦:spec 预生成无物理 // 冲突风险、两开关任一开启即用;feature 级 tdd/fix 链并行只看本闸。 const useFeatureParallel = ARGS?.parallel?.features === true const featureMaxWidth = Math.max(1, ARGS?.parallel?.featureMaxWidth ?? 2) // feStage(前端 jsdom-only 重叠;设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md): // 'all'(缺省)= 现行完整链(spec→plan→tdd→verify→review→req-done),后端恒走此;前端 overlap 关时亦走此。 // 'jsdom'(overlap phase1)= spec→plan→tdd(仅 jsdom 任务)→verify(仅 vitest)→tag fe-code-done;**不** review、**不** req-done。 // 'e2e'(phase2,后端已就绪)= dedup 读 fe-code-done → spec/plan(复用)→tdd(仅 e2e 任务)→verify(unit+e2e)→review→req-done。 async function featureLoop(items, phase, c, feStage = 'all') { const grp = phase === 'backend' ? 'Backend' : 'Frontend' const isPhase1 = feStage === 'jsdom' // 只推进到 fe-code-done(中间态) // spec 的 artifactPath 校验(主链与批量预生成共用同一份,确保两条路径产物口径一致)。 const specValidate = r => { if (!r.artifactPath) return 'spec 返回 ok 但缺 artifactPath(流程靠它定位 spec 并派生下游日期前缀)' dateFromArtifactPath(r.artifactPath) // 文件名日期前缀非法 → 抛,被 runStage 捕获转为仲裁 return null } // ── 入口 spec 预生成(Phase E Task 11;准入与豁免理由见函数头注释)──────────── // tag check 并行(只读)→ 未完成项 parallel() 跑 batch spec(thunk 内 catch,失败项落 null)→ // 单一微步骤统一 add+commit → 成功项存 specById 供主链直接消费;null 项回退主链重跑(含 commit)。 const specById = new Map() // id → 预生成 spec 的 STAGE_RESULT(主链命中即跳过 spec stage) // id → 预生成期间的临时决策 sink:仅当该项成果被主链**实际消费**(ensureSpec 命中)时才并入 // c.decisions——回退路径(thunk null / 统一提交失败清空 specById)连 sink 一起丢弃,主链重跑 // spec 会重新登记,同一 stage 的决策绝不记两遍。 const specDecsById = new Map() const doneById = new Map() // id → tag check 布尔缓存(批量查失败的槽位缺席,主链回退逐项重查) if (useSpecPrefetch && items.length >= 2) { // 直用 agent() 而非 agentR:thunk 内 throw 会被 parallel() 折成 null 丢失诊断;null 槽位留给 // 主链 agentR 重查,那里的 halt 语义与现行串行路径逐字一致。 const checks = await parallel(items.map(id => () => agent(checkReqDoneTagPromptM(id, c), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA}))) checks.forEach((r, i) => { if (r) doneById.set(items[i], r.exists) }) // 只对"确认未完成"的项预生成;tag check 失败(缺席)的项不预生成——其完成态未知,预生成可能 // 白烧一次 spec 子代理,且主链重查后若已完成会整段 skip。 const todo = items.filter(id => doneById.get(id) === false) if (todo.length >= 2) { // 仅 1 项时预生成无并行收益,纯多一次统一 commit 微步骤,走主链即可 const specs = await parallel(todo.map(id => () => { const sink = [] // 临时决策 sink(F11):与成果同生共死,回退时一起丢弃 return runStage(g => deriveSpecPrompt(id, phase, c, { batch: true }) + g, { site:`spec:${phase}:${id}`, grp, label:`spec:${phase}:${id}`, dec: sink, root: c.root, validate: specValidate, }).then(r => ({ r, sink })) .catch(e => { // thunk 内 catch:runStage 的 HALT throw 折成 null(先 log 保诊断), log(`spec 预生成失败 ${phase}:${id}(落 null,回退主链串行重跑):${String(e?.message || e)}`) return null // 绝不让 throw 进 parallel()——那会静默吞掉 halt 原因 }) })) specs.forEach((x, i) => { if (x) { specById.set(todo[i], x.r); specDecsById.set(todo[i], x.sink) } }) if (specById.size) { // 统一提交(bestEffort 不 halt):失败 → 丢弃全部预生成结果,主链按现行路径逐项重跑 spec // (含 commit;写盘内容已在树上,spec prompt 的"已存在同 id spec 复用/就地 Edit"规则会复用它, // commit 若仍失败由现行 commitBlock 的 halt 语义兜底——不为预生成机制新增 halt 点)。 const ok = await bestEffortAction(commitPrefetchedSpecsPromptM([...specById.keys()], phase, c), { label:`spec-batch-commit:${phase}`, phase: grp, okMsg:`spec 预生成: 统一提交 ${specById.size} 份(${phase})`, failTag:`spec 预生成统一提交(${phase})` }) if (!ok) { specById.clear(); specDecsById.clear() } // 全量回退:决策随成果一起丢,主链重跑重新登记 } } } // ── 链段共用闭包:现行串行链与 Phase F 并行批共用同一组调用点(site/label/allowContinue/ // dec/root 口径一字不差,仅执行 ctx 可换成 feature lane 的 fc)────────────────── // 入口 dedup:req-done/ 已存在 → 已 approve,整段 skip。批量 tag check 命中缓存直接消费 // (同一次运行刚查过;req-done/ 按 id 归属本模块,兄弟 lane 不会代打);缺席槽位回退逐项重查。 const isDone = async (id) => { const cached = doneById.get(id) if (cached !== undefined) return cached const done = await agentR(checkReqDoneTagPromptM(id, c), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA}) return done.exists } // 预生成命中 → 直接消费 artifactPath、跳过 spec stage(统一提交已落 commit),并把预生成期间的 // 临时决策 sink 并入 c.decisions(F11:仅消费时并入,delete 防同 id 二次并入;条目在预生成时已 // 经 recordDecisions 落过 log,此处只并收集器不重复打日志);未命中/落 null → 现行串行路径重跑 // (含 commitBlock,决策直落 c.decisions)。spec/plan 恒在模块树(ctx = c)上串行产出——Phase F // 只把 tdd→verify→review→fix 段挪进 feature lane。 const ensureSpec = async (id) => { const pre = specById.get(id) if (pre) { const sink = specDecsById.get(id) if (sink) { c.decisions.push(...sink); specDecsById.delete(id) } return pre } return runStage(g => deriveSpecPrompt(id, phase, c) + g, { site:`spec:${phase}:${id}`, grp, label:`spec:${phase}:${id}`, dec: c.decisions, root: c.root, validate: specValidate, }) } const runPlanStage = async (id, spec) => { // spec 经仲裁 continue 时 artifactPath 仍可能不带合法日期前缀——防御取值,避免重算抛出把 continue 变成隐式 halt。 let specDate = null try { specDate = dateFromArtifactPath(spec.artifactPath) } catch { specDate = null } return runStage(g => planPrompt(id, phase, spec.artifactPath, c) + g, { site:`plan:${phase}:${id}`, grp, label:`plan:${phase}:${id}`, dec: c.decisions, root: c.root, validate: r => { if (!r.artifactPath) return 'plan 返回 ok 但缺 artifactPath' if (specDate && dateFromArtifactPath(r.artifactPath) !== specDate) return `plan 日期前缀与 spec 不一致:plan=${r.artifactPath} / spec=${spec.artifactPath}` return null }, }) } // fc:执行上下文(串行 = 模块 ctx c;Phase F 并行批 = feature lane ctx)。rwOpts 透传 // reviewWithFixLoop(feature lane 内跳过 docs/08 勾选,理由见该函数头注释)。 // stage(末位,缺省 'all'=现行):透传 tddPrompt/verifyPrompt 的 feStage;'jsdom' 额外跳过 review(phase1 // 只到 fe-code-done,review→req-done 延到 phase2)。'e2e'/'all' 含 review,一字不差。 const runImplChain = async (id, planPath, specPath, fc, rwOpts, stage = 'all') => { // tdd allowContinue:false:tddPrompt 的 halt = 路径作用域越界护栏 / 同测试卡死 10 次——硬边界, // 仲裁不得 continue 放行(越界把前端实现混进后端分支 / 卡死等于测试没真过)。 const impl = await runStage(g => tddPrompt(id, phase, planPath, fc, stage) + g, { site:`tdd:${phase}:${id}`, grp, label:`tdd:${phase}:${id}`, allowContinue: false, dec: fc.decisions, root: fc.root, }) // verify allowContinue:false:verifyPrompt 的 halt = 功能测试红色(exit!=0 / failed>0)——与 test-gate 红同级硬边界, // 绝不 continue 放行红色实现进 review→approve→打 req-done tag(否则红色功能被永久标记完成、resume 跳过)。 const v0 = await runStage(g => verifyPrompt(id, phase, impl.summary || '', specPath, 0, fc, stage) + g, { site:`verify:${phase}:${id}`, grp, label:`verify:${phase}:${id}`, allowContinue: false, dec: fc.decisions, root: fc.root, }) if (stage === 'jsdom') return // overlap phase1:jsdom 绿即止,不 review(review→req-done 留 phase2) const reviewResult = await reviewWithFixLoop(id, phase, v0, specPath, fc, rwOpts) log(`review approved ${phase}:${id} after ${reviewResult.rounds} round(s)`) } // approve 后落地 dedup 真值:req-done/ tag(失败经仲裁重试,确不可恢复才 halt)。 // tag 全仓共享,恒以模块 ctx c 打(Phase F 在合并回模块分支**之后**才打,见 featureBatchRun 批后收口)。 const tagDone = async (id) => runAction(g => createReqDoneTagPromptM(id, phase, c) + g, { site:`req-done-tag:${phase}:${id}`, grp, label:`reqdone:${phase}:${id}`, dec: c.decisions, root: c.root, }) // overlap phase1 中间完成态 tag:fe-code-done/(组件代码 + jsdom 绿)。 const tagFeCodeDone = async (id) => runAction(g => createFeCodeDoneTagPromptM(id, c) + g, { site:`fe-code-done-tag:${id}`, grp, label:`fecodedone:${id}`, dec: c.decisions, root: c.root, }) // phase1 入口 dedup:fe-code-done/ 已存在 → 本 FE phase1 已完成,跳过(phase2 用 req-done dedup,走 isDone)。 const isPhase1Done = async (id) => { const r = await agentR(checkFeCodeDoneTagPromptM(id, c), {label:`fecodecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA}) return r.exists } // ── Phase F(Task 12/13):后端 REQ 的 feature 级受限并行(设计见 featureBatchRun 头注释)。 // frontend phase 不进准入(附录补 2);闸关 / 单 feature 时走下方现行串行链,一字不差。 if (useFeatureParallel && phase === 'backend' && items.length >= 2) { await featureBatchRun(items, phase, c, { grp, isDone, ensureSpec, runPlanStage, runImplChain, tagDone }) return } for (const id of items) { // dedup:phase1 用 fe-code-done;'all'/'e2e'(phase2)用 req-done。phase2 的 fe-code-done 必已存在 // (phase1 落过),spec/plan 经 ensureSpec/runPlanStage 复用既有产物,tdd(e2e) 只处理延后的 e2e 任务。 if (isPhase1 ? await isPhase1Done(id) : await isDone(id)) { log(`featureLoop skip ${phase}:${id} — tag ${isPhase1 ? 'fe-code-done' : 'req-done'}/${id} 已存在`); continue } const spec = await ensureSpec(id) const plan = await runPlanStage(id, spec) await runImplChain(id, plan.artifactPath, spec.artifactPath, c, undefined, feStage) await (isPhase1 ? tagFeCodeDone(id) : tagDone(id)) } } // laneEnvCache:lane 库 schema 基底 + lane DB 支持度是**运行内不变量**(同一运行环境不变),首个 // 探测点(featureBatchRun 的 lane 前置 / ≥2 宽波次的 prepareParallelWave,谁先到谁填)探测后缓存 // 复用,成功失败都缓存(不支持也是稳定事实,后续免重复子代理调用直接降级)。agent 无返回 / 抛错 // 是瞬时态不是环境事实,**不缓存**,留给下个探测点重探。形态:null=未探测;{ fail } / { baseSchema }。 let laneEnvCache = null // ---- Phase F(Task 12/13):后端 REQ 的 feature 级受限并行批 ---- // 准入闸的灵魂:**LLM 提议、JS 验证**——plan 本就锁文件边界,文件集相交与否是确定性可算的, // 不信 LLM 单方判断。准入 = 批内两两 files 不相交 ∧ 全部 schemaChange=false ∧ 批宽 ≤ // featureMaxWidth;改 schema 的 feature **永远串行**;frontend phase 不进准入(附录补 2:FE 的 // e2e 要打穿 vite/playwright/后端三层端口配置,复杂度/收益比不成立,FE 收益由 spec 预生成承担)。 // 失败路径一律 fail-open:准入不过 / facts 提取失败 / feature lane 建失败 → 该 feature 留串行链, // 绝不因并行机制新增 halt 点(feature 链自身的 stage halt 是真实正确性边界,不在豁免之列)。 // 隔离三前提逐一替换(与模块级 lane 同构):feature worktree(独立 index 不争 .git/index.lock)、 // 准入排除 schema 改动(无 V 取号即无撞号)、feature 专属测试库(`_lane_f`, // 附录补 8 序号制;tdd/verify 不起栈不占固定端口,无需 stackLock——这正是收窄到后端 REQ 的原因)。 // h:featureLoop 的链段共用闭包(isDone/ensureSpec/runPlanStage/runImplChain/tagDone)——并行批 // 与串行链共用同一组调用点。 async function featureBatchRun(items, phase, c, h) { const { grp, isDone, ensureSpec, runPlanStage, runImplChain, tagDone } = h const serialChain = async (f) => { await runImplChain(f.id, f.planPath, f.specPath, c); await tagDone(f.id) } // 执行序第 1 段(Task 13 字面):模块内先**串行**跑完全部 spec+plan(轻;plan 要 Grep 现有代码 // 定位文件,必须在模块树上串行产出)——与现行链的逐 feature 交错不同:plan 全集先行,才有 // facts 可提取、可整体分批。 const feats = [] // { id, specPath, planPath, facts: Set|null } for (const id of items) { if (await isDone(id)) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue } const spec = await ensureSpec(id) const plan = await runPlanStage(id, spec) feats.push({ id, specPath: spec.artifactPath, planPath: plan.artifactPath, facts: null }) } // 执行序第 2 段:提取 PLAN_FACTS(只读微步骤,agent 直调——失败不仲裁不 halt)。null / 形状 // 不符 / 文件集为空 / 改 schema → facts 留 null(该 feature 走串行链);待跑不足 2 个则无并行 // 可言,全员 facts=null 自然全串行(省 facts 子代理)。 if (feats.length >= 2) { for (const f of feats) { const r = await agent(planFactsPromptM(f.planPath, c), { label:`plan-facts:${f.id}`, phase: grp, schema: PLAN_FACTS_SCHEMA }) if (!r) { log(`feature-parallel: ${f.id} facts 提取无返回(fail-open 留串行链)`); continue } if (r.schemaChange !== false) { log(`feature-parallel: ${f.id} plan 声明 schema 改动 → 永远串行`); continue } const files = Array.isArray(r.files) ? r.files.map(x => typeof x === 'string' ? x.trim() : '').filter(Boolean) : [] if (!files.length) { log(`feature-parallel: ${f.id} plan 文件集为空/不可辨(fail-open 留串行链)`); continue } f.facts = new Set(files) } } // JS 准入分批(Task 12):保 docs/02 原序的贪心**连续**分批。批内并发放弃成员间顺序——两两 // 不相交 + 无 schema 改动正是顺序无关的准入前提;但**跨批**顺序保持原序:无 facts 的 feature // 自成串行项并截断当前批,绝不跨越它重排(后续 feature 可能依赖它的 schema/代码产出)。 const runs = [] // 每项 = feats 子数组;length 1 即串行项 { let cur = [] const flush = () => { if (cur.length) { runs.push(cur); cur = [] } } const disjoint = (f, batch) => batch.every(b => { for (const x of f.facts) if (b.facts.has(x)) return false; return true }) for (const f of feats) { if (!f.facts) { flush(); runs.push([f]); continue } if (cur.length >= featureMaxWidth || (cur.length && !disjoint(f, cur))) flush() cur.push(f) } flush() } // feature lane 前置(首个 ≥2 批前惰性准备一次;任何一步失败 → 本模块全部批降级串行,fail-open)。 // lane 库命名(附录补 8 序号制):`_lane_f`——模块 lane 模式 c.dbSchema 已是 // `_lane`,直接缀 `_f`;串行主根模式 N 取 0。后缀固定短(≤10 字符),不引入 // moduleId 进库名的截断/撞名/转义问题。 let featPrep // undefined=未试;null=失败(降级);{ base, moduleBranch } const prepareFeatureLanes = async () => { if (featPrep !== undefined) return featPrep featPrep = null try { // 模块分支名 = c.root 当前 HEAD(runBranchSetup 已确保在 module- 上)——featureLoop // 不接收 module 形参,取 HEAD 是唯一不另起约定的真值来源。 const head = await agent(currentBranchPromptM(c), { label:'feat:module-branch', phase: grp, schema: DEFAULT_BRANCH_SCHEMA }) if (!head || !head.branch) { log('feature-parallel: 模块分支名读取失败 → 本模块 feature 批全部降级串行'); return featPrep } let base = c.dbSchema if (!base) { // 串行主根模式:lane 测试库支持与模块级同判据(附录补 6b 两点联查)——并行 feature 链的 // tdd 内循环测试若同连共享库会互相清库,任一不满足 → 老老实实串行,不 halt。 // (模块 lane 模式 c.dbSchema 非空 = 波次前置已探测通过,免重查;探测/读基底两个微步骤 // 均主根专属,串行主根下 c.root === ROOT,口径一致。) // 经 laneEnvCache 缓存:串行主根 + features 闸模式下波次前置永不执行、缓存恒空,不在此 // 读/填会逐模块白烧两个探测子代理。agent 无返回不缓存(瞬时态,仅本模块降级,留待重探)。 if (!laneEnvCache) { const probe = await agent(laneDbSupportPromptM(), { label:'feat:lane-db?', phase: grp, schema: LANE_DB_PROBE_SCHEMA }) if (!probe) { log('feature-parallel: lane 测试库支持度探测无返回 → 本模块 feature 批全部降级串行'); return featPrep } if (!probe.scriptsEnv || !probe.ymlPlaceholder) { laneEnvCache = { fail: `目标项目不支持 lane 测试库(scriptsEnv=${probe.scriptsEnv}, ymlPlaceholder=${probe.ymlPlaceholder})` } } else { const sb = await agent(readDbSchemaPromptM(), { label:'feat:db-base', phase: grp, schema: FIELD_VALUE_SCHEMA }) if (!sb) { log('feature-parallel: database.schema 读取无返回 → 本模块 feature 批全部降级串行'); return featPrep } laneEnvCache = (!sb.found || !sb.value) ? { fail: 'config-vars.yaml 的 database.schema 读不到(lane 库名无基底)' } : { baseSchema: sb.value } } } if (laneEnvCache.fail) { log(`feature-parallel: ${laneEnvCache.fail} → 本模块 feature 批全部降级串行`); return featPrep } base = laneDbSchema(laneEnvCache.baseSchema, 0) // serial 主根跑 feature 并行:N 取 0(附录补 8) } featPrep = { base, moduleBranch: head.branch } } catch (e) { log(`feature-parallel: lane 前置准备异常(${String(e?.message || e)})→ 本模块 feature 批全部降级串行`) } return featPrep } // lane 清理/丢弃共用:移除 worktree + 删分支(均 bestEffort——分支与对象库全仓共享,残留不损 // 正确性,删失败只 log;残留 feature lane 持 feat/* 分支,不挡 module-* 占用感知)。 const dropFeatureLane = async (l) => { await bestEffortAction(removeLaneWorktreePromptM(l.path), { label:`feat:lane-remove:${l.f.id}`, phase: grp, okMsg:`feature-parallel: 已移除 lane ${l.path}`, failTag:`feature-parallel: 移除 lane(${l.path})` }) await bestEffortAction(deleteFeatureBranchPromptM(l.branch, c), { label:`feat:branch-del:${l.f.id}`, phase: grp, okMsg:`feature-parallel: 已删除分支 ${l.branch}`, failTag:`feature-parallel: 删除分支(${l.branch})` }) } for (const run of runs) { const prep = run.length >= 2 ? await prepareFeatureLanes() : null if (run.length < 2 || !prep) { // 串行项 / 前置失败降级 → 现行串行链(与非并行路径同源闭包) for (const f of run) await serialChain(f) continue } // 建 feature lanes(复用 createLaneWorktreePromptM 的幂等三分支形态;从模块分支 HEAD 建 // feat/)。fail-open:建 lane / 写 marker(附录补 7 fail-closed 兜底——setup-test-db 是 // DROP+CREATE,不能只靠提示词注入)失败 → 该 feature 退串行;可并行的不足 2 条 → 整批退串行。 const lanes = [] // { f, branch, path, fc } const fallback = [] for (const [i, f] of run.entries()) { const l = { f, branch: `feat/${f.id}`, path: featureLaneRoot(c.root, f.id) } const schema = `${prep.base}_f${i}` const add = await agent(createFeatureWorktreePromptM(l.branch, l.path, prep.moduleBranch, c), { label:`feat:lane-add:${f.id}`, phase: grp, schema: ACTION_RESULT_SCHEMA }) if (!add || !add.success) { log(`feature-parallel: ${f.id} 建 feature lane 失败(${(add && add.error) || 'agent 无返回'})→ 留串行链`) fallback.push(f); continue } const mk = await agent(writeLaneDbMarkerPromptM(l.path, schema), { label:`feat:lane-marker:${f.id}`, phase: grp, schema: ACTION_RESULT_SCHEMA }) if (!mk || !mk.success) { log(`feature-parallel: ${f.id} 写 lane DB marker 失败(${(mk && mk.error) || 'agent 无返回'})→ 留串行链`) await dropFeatureLane(l); fallback.push(f); continue } // feature 级 ctx:root=feature lane、dbSchema=feature 专属库;decisions **共享**模块收集器 // (单线程事件循环下并发 push 原子,逐模块 RESUME flush 口径不变);vBase 透传仅保字段语义 // 一致(准入已排除 schema 改动,tdd 不会取号)。 l.fc = makeCtx({ lane: c.lane, root: l.path, dbSchema: schema, vBase: c.vBase, decisions: c.decisions }) lanes.push(l) } if (lanes.length < 2) { // 并行不成立:孤 lane 弃用回收,全员按原序走串行链(在模块树上跑,与 lane 无关)。 for (const l of lanes) await dropFeatureLane(l) for (const f of run) await serialChain(f) continue } // 并行跑 tdd→verify→review→fix(thunk 内 catch:HALT throw 先 log 保诊断再折成结构化失败, // 绝不让 throw 进 parallel()——那会静默折成 null 丢失原因)。lane 内跳过 docs/08 勾选 // (flipDocs08:false):相邻 REQ 行的并行编辑会在批后合并时制造无谓 docs/08 冲突,批后统一勾。 const rs = await parallel(lanes.map(l => () => runImplChain(l.f.id, l.f.planPath, l.f.specPath, l.fc, { flipDocs08: false }) .then(() => ({ ok: true })) .catch(e => { const msg = String(e?.message || e) log(`feature-parallel: ${l.f.id} 链失败(feature lane 保留取证 ${l.path}):${msg}`) return { ok: false, error: msg } }))) // 批后串行收口(Task 13):成功链按批内序合并回模块分支 → 勾 docs/08 → 打 req-done → 清理 // lane。模块内本就串行(同一时刻只有本批在收口),无需全局锁。 const haltMsgs = [] const rerun = [] for (const [i, l] of lanes.entries()) { const r = rs[i] if (!r || r.ok !== true) { // 链失败 / runtime-null:feature lane 整树保留取证(reason 带路径),批边界收敛后统一 // halt——这是真实 stage halt 的批级聚合(串行链同样会在此 halt),不属 fail-open 范畴。 haltMsgs.push(`${l.f.id} [feature-lane 保留取证: ${l.path}]: ${r ? r.error : 'runtime-null(parallel() 槽位为 null)'}`) continue } // 合并经 agentR(null 走 NULL_AGENT halt——计划只豁免"冲突不 adjudicate",不豁免 runtime 故障)。 const mg = await agentR(mergeFeatureBranchPromptM(l.branch, prep.moduleBranch, l.f.id, c), { label:`feat:merge:${l.f.id}`, phase: grp, schema: ACTION_RESULT_SCHEMA }) if (mg.success) { await flipDocs08Checkbox(false, l.f.id, phase, grp, c) // lane 内跳过的勾选在此补落(纯可视化,失败只 log) await tagDone(l.f.id) await dropFeatureLane(l) } else { // 合并冲突(文件集不相交下理论不可能,命中即 facts 提取失真)→ 不 adjudicate:丢弃 // feature 分支,回模块分支串行重跑该 feature 全链(merge 微步骤已 abort 恢复干净模块分支)。 log(`feature-parallel: ${l.f.id} 合并回 ${prep.moduleBranch} 失败(${mg.error || ''}${mg.detail ? ':' + mg.detail : ''})——文件集不相交下不应发生,按 facts 提取失真处置:丢弃 feat/${l.f.id},回模块分支串行重跑全链`) await dropFeatureLane(l) rerun.push(l.f) } } if (haltMsgs.length) { // 兄弟链已收口(成功者的工作已合并 + 打 tag 落地);冲突重跑搁置——模块即将 halt,不再起 // 新链(与模块级"波次边界收敛后不再起新波"同口径),resume 时这些 feature 因缺 req-done tag 自动重跑。 for (const f of rerun) log(`feature-parallel: ${f.id} 的串行重跑因同批链失败而搁置(resume 由 req-done tag 缺失兜底)`) throw new Error(`HALT feature-batch ${phase}: ${haltMsgs.join(';')}`) } for (const f of [...rerun, ...fallback]) await serialChain(f) } } // review→fix 循环。halt 收敛点: // - 软上限 REVIEW_SOFT_ROUNDS 轮起每轮经仲裁决定**再延一轮(retry) 或 收尾(halt)**——禁止 continue(approve 只能来自 // reviewer,仲裁不得在仍有未修 must-fix 时凌驾它放行);绝对硬上限 REVIEW_HARD_ROUNDS 防无限循环。 // - reviewer 契约小瑕疵不再直接 halt:缺 locator 的 issue 降级为口头建议丢弃;若一条可定位 issue 都不剩(无可执行 // must-fix),经仲裁决定 continue(视为无 must-fix → approve)/ retry(带 guidance 重判)/ halt。 // - fix 经 runStage(显式 allowContinue,可 continue 跳过——未修的 must-fix 由后续 reviewer 重新 flag 兜底); // reverify 经 runStage 但 allowContinue:false(复验红色 = 修复没生效,绝不放行)。 // - approve 后的 docs/08 checkbox 是纯可视化副作用(req-done tag 才是完成真值),缺失/写失败一律 log 跳过不 halt。 const REVIEW_SOFT_ROUNDS = 5 const REVIEW_HARD_ROUNDS = 10 // flipDocs08Checkbox:approve 后把功能行 [ ]→[x]。纯可视化;任何缺失/异常/写失败都降级为日志,绝不 halt。 // 注意:docs/08 **checkbox** 发生在模块分支上(approve 后逐功能勾选)→ 穿线 c; // docs/08 **里程碑字段**发生在 merge 后的默认分支上 → 主根专属(见 runMilestone),两组不要搞混。 async function flipDocs08Checkbox(fe, id, phase, grp, c) { const cb = await agent(readDocs08CheckboxPromptM(fe, id, c), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA}) if (!cb) { log(`docs08-checkbox ${id}: 读取子代理无返回(不阻断)`); return } if (!cb.found) { log(`docs08-checkbox ${phase}:${id}: 未找到功能行,跳过可视化勾选(req-done tag 仍是完成真值)`); return } if (cb.state === 'checked') return if (cb.state !== 'unchecked') { log(`docs08-checkbox ${phase}:${id}: state 异常 (${JSON.stringify(cb.state)}),跳过勾选`); return } const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber, c), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) if (!wr || !wr.success) log(`docs08-checkbox ${phase}:${id}: 勾选写入失败(${(wr && wr.error) || ''}),跳过——cosmetic,不阻断`) } // rwOpts.flipDocs08(Phase F Task 13):false 时 approve 路径跳过 docs/08 checkbox 勾选——feature // lane 内相邻 REQ 行的并行编辑会在批后合并时制造无谓 docs/08 冲突(冲突即整链串行重跑,cosmetic // 副作用不配这个代价),勾选由批后收口在模块分支统一补落。opts 接在 c 之后是继 deriveSpecPrompt // ({batch}) 之后"c 为末位参数"约定的第二个特例(同因:带默认值的 opts 不动既有调用点),后续勿仿。 async function reviewWithFixLoop(id, phase, verifyResult, specPath, c, { flipDocs08 = true } = {}) { const grp = phase === 'backend' ? 'Backend' : 'Frontend' const fe = isFrontend(phase) let lastVerify = verifyResult let lastIssuesCount = 0 let reviewGuidance = '' // 仲裁 retry 时注入下一轮 review 的纠正指令 for (let round = 1; round <= REVIEW_HARD_ROUNDS; round++) { const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || '' // opts.phase = grp('Backend'/'Frontend')是 harness UI 分组;domain phase 见 agents/code-reviewer.md。 const r = await agentR( reviewPrompt(id, phase, round, lastVerifySummary, specPath, c) + adjGuidance(reviewGuidance), {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'erp-workflow:code-reviewer'} ) reviewGuidance = '' // 已消费 if (r.verdict === 'approve') { // approve = 静态 review 通过即放行(前后端同构)。行为验收已挪到阶段末尾的 phase('Behavior') // 一次性阶段级行为门(runBehaviorGate)——不再是 per-FE approve 子门。req-done/ 语义 =「静态过」; // 行为维度由阶段门统一验收,行为 green 是 milestone 的前置(reportPrompt 校验行为证据非 RED)。 if (flipDocs08) await flipDocs08Checkbox(fe, id, phase, grp, c) return { id, phase, approved:true, rounds:round } } // request-changes:保留带 locator 的 must-fix;缺 locator 的降级丢弃(fix 步无从定位)。 const issues = Array.isArray(r.issues) ? r.issues.filter(x => x && typeof x.locator === 'string' && x.locator.trim()) : [] const dropped = (Array.isArray(r.issues) ? r.issues.length : 0) - issues.length if (dropped > 0) log(`review ${phase}:${id} r${round}: 丢弃 ${dropped} 个缺 locator 的 issue(降级为口头建议)`) if (issues.length === 0) { // 无任何可执行 must-fix(空 issues 或全缺 locator)→ 仲裁,而非直接 halt。 const verdict = await adjudicate(`review-no-actionable:${phase}:${id}:r${round}`, { problem:'reviewer 判 request-changes 但无任何带 locator 的可执行 must-fix(无法驱动 fix 步)', reviewerIssues: r.issues || [] }, grp, round, c.root) // continue 视为「无 must-fix → 静态 approve」(行为维度由阶段末尾的行为门统一验收,不在此处)。 if (verdict.action === 'continue') { if (flipDocs08) await flipDocs08Checkbox(fe, id, phase, grp, c) return { id, phase, approved:true, rounds:round } } if (verdict.action === 'halt') throw new Error(`HALT review-no-actionable ${phase}:${id} r${round}: ${verdict.rationale || ''}`) reviewGuidance = verdict.guidance || '' // retry:带 guidance 重判(进入下一轮) continue } lastIssuesCount = issues.length await runStage(g => fixPrompt(id, phase, issues, c) + g, { site:`fix:${phase}:${id}:r${round}`, grp, label:`fix:${phase}:${id}:r${round}`, allowContinue: true, dec: c.decisions, root: c.root, }) // reverify allowContinue:false:fix 后复验红色 = 修复没真正生效,绝不 continue 放行去 approve。 lastVerify = await runStage( g => verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验,上轮 must-fix: ${issues.length} 项)`, specPath, round, c) + g, { site:`reverify:${phase}:${id}:r${round}`, grp, label:`reverify:${phase}:${id}:r${round}`, allowContinue: false, dec: c.decisions, root: c.root }, ) if (round >= REVIEW_SOFT_ROUNDS) { // 软上限到顶:仲裁只在"再延一轮(retry) / 收尾(halt)"间选。**禁止 continue 放行 approve**—— // 此处仍有 reviewer 认定的可定位 must-fix 未清,仲裁不得凌驾专用 code-reviewer 直接判 approve(approve 只能来自 reviewer 本身)。 const verdict = await adjudicate(`review-extend:${phase}:${id}`, { problem:`已 ${round} 轮 review 仍未 approve(上轮 ${lastIssuesCount} 项可定位 must-fix 未清)`, lastVerify: lastVerify.summary || lastVerify.reason || '', allowContinue:false }, grp, round, c.root) if (verdict.action !== 'retry') throw new Error(`HALT review-unresolved ${phase}:${id}: ${verdict.rationale || `${round} 轮仍有未修 must-fix`}`) // retry → 继续跑到硬上限(approve 仍由后续轮的 reviewer 决定) } } throw new Error(`HALT review-unresolved ${phase}:${id}: ${REVIEW_HARD_ROUNDS} 轮 review 仍未 approve(最后一次 reverify ${lastVerify?.status || '?'},最后一轮 must-fix ${lastIssuesCount} 项)`) } // flake 重试:每个 attempt 写独立证据文件 `-test-gate-r.md`,不覆盖前一次 red 证据(report § ⑤ 用得到)。 // red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。 async function testGate(module, phase, c) { let attempt = 1 let g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) if (g.status === 'red') { // 自动重试 1 次(防 flaky) attempt = 2 g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) } // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。 for (let adj = 1; g.status === 'red' && adj <= ADJUDICATE_MAX; adj++) { const verdict = await adjudicate(`test-gate-red:${phase}:${module.id}`, { problem:`test-gate 第 ${attempt} 次仍 red`, failures: g.failures || [], allowContinue:false }, 'Gate', adj, c.root) if (verdict.action !== 'retry') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`) attempt += 1 // retry:再跑一个独立 attempt 证据文件 g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA}) } if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`) return g } // ---- 前端阶段级行为验收控制流(runBehaviorGateOnce + runBehaviorGate)---- // 设计:docs/design/2026-06-05-frontend-behavior-stage-gate.md(v3)。 // 时机:顶层 frontend 段 featureLoop 全部 FE 完成(req-done 已打)之后、testGate 之前,phase('Behavior') 下 // 整个前端阶段只跑**一次**行为验收(含 fix 循环)。失败分层: // - envError / 空覆盖 = 环境 race:runBehaviorGateOnce 内部 attempt 1→2 重试;仍异常 → adjudicate(allowContinue:false)。 // build-failed(阶段末尾无「兄弟未实现」豁免)属确定性失败:跳过自动 attempt 重起(重跑不自愈),直送仲裁。 // - 软文字(i18n/literal/semantic) → adjudicate(continue 记 decisions + 跨 behaviorRound softPassed;sentinel 并入 behaviorHard);永不阻断 green。 // - behaviorHard = interactionFailures + sentinel textIssues + styleIssues(颜色 token / layout sanity): // 有 locator → 降维喂 fixPrompt 跑 fix(fix 后全量前端单测复验 + 下一 behaviorRound 重跑门); // 无 locator → adjudicate(allowContinue:false) retry/halt,绝不静默丢弃、绝不放行。 // - BEHAVIOR_STAGE_MAX 轮仍未 green → throw HALT behavior-unresolved(冒泡到顶层 try/catch → fail-fast)。 // envBlocked / ifails:环境/空覆盖与交互失败判定。v3:build-failed 计入 envBlocked(不再有短路放行分支)。 function behaviorEnvBlocked(r) { const k = r.envError && r.envError.kind const ev = (k && k !== 'none') ? r.envError : null const emptyCov = (Number(r.controlsEnumerated) === 0) || (Number(r.routesReached) === 0) return { ev, emptyCov, blocked: !!ev || emptyCov } } function behaviorIfails(r) { return Array.isArray(r.interactionFailures) ? r.interactionFailures : [] } const isBuildFailed = (r) => !!(r.envError && r.envError.kind === 'build-failed') // runBehaviorGateOnce:跑一次阶段级行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。 // 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 runBehaviorGate 推进)。 // behaviorRound:阶段门内的行为 fix 轮;内部 attempt 1..BEHAVIOR_ATTEMPT_MAX(环境 race 重起)+ 仲裁兜底。 // runPrototypePreview:静态原型渲染门主驱动(被前端段开头调用,phase('Preview') 下)。 // 与 runBehaviorGate 正交——本门只渲染静态 prototype/。两类边界严格分流: // - **硬门(halt)**:原型**渲染失败**(rendered=false)或**原型脚本未捕获异常**(jsErrors=pageerror)—— // 经仲裁 allowContinue:false(只许 retry/halt)。原型属上游 plan 权威、coding 不改原型源码——故 halt = // 「停下等人工回 /erp-workflow:plan-start 或 /add-req 修原型后重跑」,不是在 coding 里改原型。 // - **advisory**:console.error(弱信号,缺图标/CDN 警告等噪声多)——只记 decisions 不阻断。 // - **环境未就绪**(Playwright/浏览器缺失/超时):retry→仲裁 allowContinue:true(不让「机器没装浏览器」卡死 coding)。 // 终波恒宽 1、主根执行(c.lane==null);静态渲染无 DB、无需 ERP_TEST_DB_SCHEMA 注入。 async function runPrototypePreview(feItems, c) { const lbl = (a) => `preview:proto:a${a}` let attempt = 1 let pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) recordDecisions('preview:prototype', pv.decisions, c.decisions) // 环境未就绪 → 自动重起 attempt 1→PROTOTYPE_PREVIEW_ATTEMPT_MAX;仍 blocked → 进仲裁循环。 while (pv.status === 'blocked' && attempt < PROTOTYPE_PREVIEW_ATTEMPT_MAX) { attempt += 1 pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) recordDecisions('preview:prototype', pv.decisions, c.decisions) } for (let adj = 1; pv.status === 'blocked' && adj <= ADJUDICATE_MAX; adj++) { const ev = pv.envError || { kind: '?', detail: '' } // allowContinue:true——渲染门只产可视化基线(非硬正确性边界);环境长期缺 Playwright 时仲裁可降级跳过, // 不让「机器没装浏览器」卡死整个 coding(与行为门的 allowContinue:false 严格区分)。 const verdict = await adjudicate('preview-env:prototype', { problem: `原型渲染门环境未就绪 envError=${ev.kind}: ${ev.detail || ''}`, envError: pv.envError || null, allowContinue: true }, 'Preview', adj, c.root) if (verdict.action === 'continue') { log(`原型渲染门:环境未就绪经仲裁降级为口头建议继续(${ev.kind})——本次跳过原型可视化基线`) recordDecisions('preview:prototype', [{ question: '原型渲染门环境未就绪', choice: '降级继续(跳过可视化基线)', rationale: ev.detail || ev.kind, confidence: 'low' }], c.decisions) return pv } if (verdict.action !== 'retry') throw new Error(`HALT preview-env prototype: ${verdict.rationale || ev.kind}`) attempt += 1 pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) recordDecisions('preview:prototype', pv.decisions, c.decisions) } if (pv.status === 'blocked') throw new Error(`HALT preview-env prototype: ${ADJUDICATE_MAX} 轮仲裁后原型渲染门仍环境未就绪`) // 至此 env 就绪(status:ok)。硬门评估(含仲裁 retry 重跑——疑似一次性渲染抖动/超时被误判时重跑一次门): // 硬缺陷 = 渲染失败(rendered=false) 或 原型脚本未捕获异常(jsErrors);console.error 仅 advisory(弱信号)。 for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) { if (pv.pagesFound === 0) { log('原型渲染门:项目无 prototype/**/*.html,跳过(无可视化基线可产)') return pv } const pages = Array.isArray(pv.pages) ? pv.pages : [] const hardBroken = pages.filter(p => p && (!p.rendered || (p.jsErrors || []).length)) const consoleOnly = pages.filter(p => p && p.rendered && !(p.jsErrors || []).length && (p.consoleErrors || []).length) // advisory:渲染成功但有 console.error 的页只记 decisions,不阻断。 if (consoleOnly.length) { recordDecisions('preview:prototype', consoleOnly.map(p => ({ question: `原型 ${p.file} 有 console.error`, choice: '记录不阻断(弱信号,回 plan/add-req 酌情修)', rationale: `consoleErrors=${(p.consoleErrors || []).length}`, confidence: 'low' })), c.decisions) } if (!hardBroken.length) { log(`原型渲染门:${pv.pagesRendered}/${pv.pagesFound} 页全部渲染通过(无渲染失败/JS 异常)——可视化基线已归档${consoleOnly.length ? `;${consoleOnly.length} 页有 console.error(advisory)` : ''}`) return pv } const digest = hardBroken.map(p => `${p.file}(rendered=${p.rendered}, jsErrors=${(p.jsErrors || []).length})`).join('; ') // allowContinue:false——原型渲染失败/脚本异常是硬缺陷,仲裁只许 retry/halt,绝不放行带病原型进前端实现。 const verdict = await adjudicate('preview-broken:prototype', { problem: `静态原型渲染门硬缺陷(渲染失败 / 原型脚本未捕获异常):${digest}。原型属上游 plan 权威,coding 不改原型源码——应回 /erp-workflow:plan-start 或 /add-req 修原型后重跑。`, allowContinue: false }, 'Preview', adj, c.root) if (verdict.action !== 'retry') throw new Error(`HALT preview-broken prototype: ${verdict.rationale || digest}`) // retry:重跑渲染门一次(仲裁判定疑似一次性,如渲染超时被误标 jsError)。 attempt += 1 pv = await agentR(prototypePreviewPrompt(feItems, attempt, c), {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA}) recordDecisions('preview:prototype', pv.decisions, c.decisions) if (pv.status === 'blocked') throw new Error(`HALT preview-env prototype: 硬门 retry 后又遇环境未就绪 envError=${(pv.envError || {}).kind || '?'}`) } throw new Error(`HALT preview-broken prototype: ${ADJUDICATE_MAX} 轮仲裁后原型仍有硬缺陷(渲染失败/JS 异常)`) } async function runBehaviorGateOnce(feItems, behaviorRound, c) { const lbl = (a) => `behavior:frontend-phase:r${behaviorRound}:a${a}` let attempt = 1 let bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c), {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA}) recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions) // 内部 envError / 空覆盖重试:attempt 1→BEHAVIOR_ATTEMPT_MAX(沿用 testGate 思路);仍异常 → adjudicate(allowContinue:false)。 // build-failed 是确定性失败(重起不自愈)→ 跳过自动重起,直接进下方仲裁循环。 while (behaviorEnvBlocked(bg).blocked && !isBuildFailed(bg) && attempt < BEHAVIOR_ATTEMPT_MAX) { attempt += 1 bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c), {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA}) recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions) } let envState = behaviorEnvBlocked(bg) for (let adj = 1; envState.blocked && adj <= ADJUDICATE_MAX; adj++) { const reason = envState.ev ? `behavior envError=${envState.ev.kind}: ${envState.ev.detail || ''}${envState.ev.rootCausePath ? `(rootCausePath=${envState.ev.rootCausePath})` : ''}` : `behavior 空覆盖:routesReached=${bg.routesReached} controlsEnumerated=${bg.controlsEnumerated}(绝不带空覆盖判 green)` // riders:环境失败同轮搭车的硬问题(如 build-failed 时已归因到的 interactionFailures/styleIssues)—— // 本轮不进 fix(环境未就绪,fix 无意义),但透传给仲裁者辅助 retry/halt 判断;若环境修复后仍真实,下一轮门会重新发现。 const verdict = await adjudicate('behavior-env:frontend-phase', { problem: reason, envError: bg.envError || null, ports:(bg.envError||{}).ports, pids:(bg.envError||{}).pids, riders: { interactionFailures: behaviorIfails(bg).length, styleIssues: (bg.styleIssues || []).length, sentinelTextIssues: (Array.isArray(bg.textIssues) ? bg.textIssues : []).filter(t => t && t.source === 'sentinel').length }, allowContinue:false }, 'Behavior', adj, c.root) if (verdict.action !== 'retry') throw new Error(`HALT behavior-env frontend-phase: ${verdict.rationale || reason}`) attempt += 1 bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c), {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA}) recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions) envState = behaviorEnvBlocked(bg) } if (envState.blocked) throw new Error(`HALT behavior-env frontend-phase: ${ADJUDICATE_MAX} 轮仲裁后仍环境异常 / 空覆盖`) return bg } // runBehaviorGate:阶段级行为门主循环(被顶层 frontend 段调用,phase('Behavior') 下)。green 才正常返回(放行进 testGate)。 // softPassed:本函数内声明,跨 behaviorRound 持久(软文字一旦放行不再追问,避免反复消耗仲裁预算)。 // green ≡ behaviorHard.length===0 ∧ envError===none ∧ 无 B 类/scope-missing 未覆盖 ∧ 覆盖非空 ∧ 无未解释漏达路由。 async function runBehaviorGate(feItems, c) { const regionKey = (x) => `${x.page || '?'}::${x.region || '?'}` const softPassed = new Set() for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_STAGE_MAX; behaviorRound++) { const bg = await runBehaviorGateOnce(feItems, behaviorRound, c) // 1) coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 runBehaviorGateOnce 兜底)。 // locator-not-resolvable / scope-missing 在 §3.5 单独阻断 green。 for (const cg of (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : [])) { if (!cg) continue recordDecisions('behavior-coverage:frontend-phase', [{ question:`覆盖缺口 ${cg.page}(${cg.reason})`, choice:'记录不阻断', rationale: cg.detail || '', confidence:'low' }], c.decisions) } // 2) 软文字(i18n/literal/semantic)→ 仲裁 continue 记 decisions + softPassed;sentinel 客观 bug 不在此处放行(下面并入 behaviorHard)。 // 永不阻断 green;retry/halt 同现。一旦有软文字 retry → 重跑本 behaviorRound(continue 进下一轮迭代)。 let softRetry = false for (const ti of (Array.isArray(bg.textIssues) ? bg.textIssues : [])) { if (!ti || ti.source === 'sentinel') continue // sentinel 归 behaviorHard,不在软文字处理 if (softPassed.has(regionKey(ti))) continue const site = `behavior-text:${ti.page || '?'}:${ti.region || '?'}` const verdict = await adjudicate(site, { problem:`文字不符(source=${ti.source},可 continue 降级;永不阻断 green):${ti.page}:${ti.region} 期望=${JSON.stringify(ti.expected)} 实际=${JSON.stringify(ti.actual)}`, textIssue: ti, allowContinue: true }, 'Behavior', behaviorRound, c.root) if (verdict.action === 'continue') { recordDecisions(site, [{ question:`文字不符 ${ti.page}:${ti.region}(source=${ti.source})`, choice:'continue(仲裁判可安全前进)', rationale: verdict.rationale || '', confidence:'low' }], c.decisions) softPassed.add(regionKey(ti)); continue } if (verdict.action !== 'retry') throw new Error(`HALT ${site}: ${verdict.rationale || `文字不符 source=${ti.source}`}`) softRetry = true; break // retry → 重跑本 behaviorRound(跳到下一轮迭代重起整门) } if (softRetry) continue // 3.5) B 类硬问题(locator-not-resolvable:连组件文件都反查不出)+ scope-missing(某 FE 作用域小节缺失, // 其路由整体漏验):不静默放行——计入未覆盖阻断 green,走 adjudicate(allowContinue:false) retry/halt。 const bClass = (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : []) .filter(cg => cg && (cg.reason === 'locator-not-resolvable' || cg.reason === 'scope-missing')) if (bClass.length) { const summary = bClass.map(cg => `[${cg.reason}] ${cg.page} — ${cg.detail}`).join('; ') const verdict = await adjudicate('behavior-bclass:frontend-phase', { problem:`behavior 不可降级的未覆盖(B 类反查不出 / FE 作用域缺失,阻断 green):${summary}`, coverageGaps: bClass, allowContinue:false }, 'Behavior', behaviorRound, c.root) if (verdict.action !== 'retry') throw new Error(`HALT behavior-bclass frontend-phase: ${verdict.rationale || summary}`) continue // retry → 重跑行为验收(下一 behaviorRound) } // 3.6) 覆盖率对账(确定性兜底):空覆盖只兜 ==0;这里兜 0 cg && ROUTE_GAP.has(cg.reason) && typeof cg.page === 'string' && cg.page.trim()) .map(cg => cg.page.trim())) const routeGapCount = routeGapPages.size const missedRoutes = Math.max(0, planned - reached) const unaccounted = Math.max(0, missedRoutes - routeGapCount) if (planned > 0 && unaccounted > 0) { const verdict = await adjudicate('behavior-undercoverage:frontend-phase', { problem:`路由覆盖不足:routesPlanned=${planned} routesReached=${reached},仅 ${routeGapCount} 条不同路由有路由级 coverageGap 解释,尚有 ${unaccounted} 条漏达路由无证据(绝不带静默漏达判 green)`, coverageGaps: bg.coverageGaps || [], allowContinue: false }, 'Behavior', behaviorRound, c.root) if (verdict.action !== 'retry') throw new Error(`HALT behavior-undercoverage frontend-phase: ${verdict.rationale || `${unaccounted} 条漏达路由无证据`}`) continue // retry → 下一 behaviorRound 重跑整门 } // 4) behaviorHard = interactionFailures(含 binding-garbage)+ source=='sentinel' textIssues // + styleIssues(颜色 token / layout sanity,全部客观可 fix)。 const sentinelHard = (Array.isArray(bg.textIssues) ? bg.textIssues : []) .filter(t => t && t.source === 'sentinel') .map(t => ({ page:t.page, control:t.region, kind:'binding-garbage', detail:`sentinel 不符 期望=${t.expected} 实际=${t.actual}`, locator:t.locator })) const styleHard = (Array.isArray(bg.styleIssues) ? bg.styleIssues : []) .filter(Boolean) .map(s => ({ page:s.page, control:s.element, kind:`style-${s.kind}`, detail:`期望=${s.expected} 实际=${s.actual}`, locator:s.locator })) const behaviorHard = [...behaviorIfails(bg), ...sentinelHard, ...styleHard] const hasEnvSignal = !!(bg.envError && bg.envError.kind && bg.envError.kind !== 'none') const hasAnyClassifiedSignal = hasEnvSignal || behaviorHard.length > 0 || (Array.isArray(bg.textIssues) && bg.textIssues.length > 0) || (Array.isArray(bg.styleIssues) && bg.styleIssues.length > 0) || (Array.isArray(bg.coverageGaps) && bg.coverageGaps.length > 0) if (bg.status === 'red' && !hasAnyClassifiedSignal) { const verdict = await adjudicate('behavior-red-unclassified:frontend-phase', { problem:'behavior 返回 status:red,但没有 envError / interactionFailures / textIssues / styleIssues / coverageGaps 可解释该 red;拒绝把未分类红灯判 green', behaviorResult: bg, allowContinue:false }, 'Behavior', behaviorRound, c.root) if (verdict.action !== 'retry') throw new Error(`HALT behavior-red-unclassified frontend-phase: ${verdict.rationale || 'status:red 无分类原因'}`) continue } // 5) green 判定:behaviorHard 为空 ∧ 无 B 类/scope-missing 未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 门 green 放行。 if (behaviorHard.length === 0) { log(`behavior frontend-phase green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`) return } // 6) 分流:无 locator 的硬问题 → adjudicate(allowContinue:false) retry/halt(绝不静默丢弃、绝不放行)。 const withLoc = behaviorHard.filter(x => typeof x.locator === 'string' && x.locator.trim()) const noLoc = behaviorHard.filter(x => !(typeof x.locator === 'string' && x.locator.trim())) if (noLoc.length) { const summary = noLoc.map(f => `[${f.kind}] ${f.page}:${f.control} — ${f.detail}`).join('; ') const verdict = await adjudicate('behavior-noloc-hard:frontend-phase', { problem:`behavior 硬问题无源码 locator(无法转 must-fix 喂 fix,绝不 continue 放行):${summary}`, interactionFailures: noLoc, allowContinue:false }, 'Behavior', behaviorRound, c.root) if (verdict.action !== 'retry') throw new Error(`HALT behavior-noloc-hard frontend-phase: ${verdict.rationale || summary}`) continue // retry → 重跑行为验收(下一 behaviorRound) } // 7) 有 locator 的硬问题 → 降维成 {summary,locator,severity} 喂现有 fixPrompt 跑 fix(schema 不合并、fix 入参合并)。 // 一轮 fix 批量修当轮全部 must-fix(跨 FE 也在同一 fix 子会话内逐项修,locator 已含组件文件路径)。 const fixIssues = withLoc.map(f => ({ summary: `[behavior:${f.kind}] ${f.page}:${f.control} — ${f.detail}`, locator: f.locator, severity: 'high', })) await runStage(g => fixPrompt('frontend-phase', 'frontend', fixIssues, c) + g, { site:`behavior-fix:frontend-phase:r${behaviorRound}`, grp:'Behavior', label:`behavior-fix:r${behaviorRound}`, allowContinue: true, dec: c.decisions, root: c.root, }) // 8) fix 后功能复验(allowContinue:false):行为 fix 改的是 frontend/ UI 源码,可能引入功能回归—— // 先跑全量前端单测(不起全栈、不跑 e2e,成本低),红则当功能回归硬边界;绿后下一 behaviorRound 重跑行为验收。 // (e2e 维度由下一轮行为门 + 后续阶段 testGate 全量回归兜底。) await runStage( g => behaviorReverifyPrompt(behaviorRound, fixIssues.length, c) + g, { site:`behavior-reverify:frontend-phase:r${behaviorRound}`, grp:'Behavior', label:`behavior-reverify:r${behaviorRound}`, allowContinue: false, dec: c.decisions, root: c.root }, ) // 进入下一 behaviorRound → 重跑行为验收 } throw new Error(`HALT behavior-unresolved frontend-phase: ${BEHAVIOR_STAGE_MAX} 轮阶段级行为门仍未 green(硬问题未清)`) } phase('Router') // Router 语义断言(feItems/reqs 互斥)+ id 形状硬约束(防 shell 注入:id 直接拼入 `git ... ${id}`)。 // id 形状(assertSafeId)是**安全护栏**——失败立即硬 halt,绝不重试绕过。 // reqs/feItems 互斥违例可由仲裁带 guidance 重跑 router 纠正(绝对上限 ADJUDICATE_MAX)。 const ID_PATTERN = /^[A-Za-z0-9_-]+$/ function assertSafeId(kind, value) { if (typeof value !== 'string' || !ID_PATTERN.test(value)) { throw haltError(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`) } } function routerViolation(modules) { for (const m of modules) { const isFE = m.id === 'frontend-phase' if (isFE && Array.isArray(m.reqs) && m.reqs.length) return `frontend-phase 聚合模块的 reqs 必须为空,实测含 ${m.reqs.length} 项 (${m.reqs.join(',')})` if (!isFE && Array.isArray(m.feItems) && m.feItems.length) return `后端模块 ${m.id} 的 feItems 必须为空(前端只在 frontend-phase 聚合),实测含 ${m.feItems.length} 项 (${m.feItems.join(',')})` } return null } let routed = await agentR(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA}) for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) { const violation = routerViolation(routed.modules) if (!violation) break const verdict = await adjudicate('router-violation', { problem: violation }, 'Router', adj) if (verdict.action !== 'retry') throw haltError(`HALT router-violation: ${verdict.rationale || violation}`) routed = await agentR(routerPrompt(ROOT) + adjGuidance(verdict.guidance || ''), {label:`router:r${adj + 1}`, phase:'Router', schema: ROUTER_SCHEMA}) } const finalViolation = routerViolation(routed.modules) if (finalViolation) throw haltError(`HALT router-violation: ${ADJUDICATE_MAX} 轮仲裁后仍违例:${finalViolation}`) // id 安全护栏:最终选定的 routed 必须全部通过(assertSafeId 硬 halt)。 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 todo = routed.modules.filter(m => !m.done) log(`coding: ${todo.length}/${routed.modules.length} modules to run`) // ============================================================================ // 调度器(Phase B):LLM 出依赖边 + JS 拓扑波次。Phase D 的波次主循环消费——useParallel 开启时 // 起跑前调一次 runScheduler 标注 deps,逐波 nextWave 取 ready 集;关闭时直接 serialChainDeps。 // 降级原则(fail-open to serial):任何调度环节失败(scheduler agent null / schema 违例 / // 仲裁耗尽 / 图卡死)一律 log 后降级 docs/02 全序(todo 顺序链式依赖),**绝不 halt**—— // 错误并行损失正确性,错误串行只损失时间,调度永远不该成为新的停机点。 // ============================================================================ // 波次宽度上限(确定性,仅由 args 导出)。Task 9 主循环直接复用本 const,勿重复定义; // 是否启用并行由 Task 9 的 useParallel 闸决定,这里只约束"同波次最多取几个"。 const maxWidth = Math.max(1, ARGS?.parallel?.maxWidth ?? 3) // 降级形态:docs/02 全序 = todo 顺序链式依赖(每项依赖紧邻前项)。 // nextWave 在此图上每轮恰出 1 个,与现行串行主循环逐模块推进完全一致。 function serialChainDeps(todo) { return todo.map((m, i) => ({ ...m, deps: i > 0 ? [todo[i - 1].id] : [] })) } // 仿 routerViolation 形态:返回 null=通过 / 违例描述串(喂 adjudicate guidance 重跑 scheduler)。 // 检查:非 todo 后端 id / 重复项 / 缺项(完整性)/ 依赖指向未知模块 / 自依赖 / 环(Kahn 消解)。 // 与 router 不同:违例最终不 halt,由 runScheduler 降级全序兜底(调度失败只损失并行收益)。 function scheduleViolation(deps, todoBackendIds, allBackendIds) { const todoSet = new Set(todoBackendIds) const knownSet = new Set(allBackendIds) const seen = new Set() for (const d of deps) { if (!todoSet.has(d.id)) return `deps 含非 todo 后端模块 id:${d.id}(只允许对待跑后端模块输出;frontend-phase 由 JS 硬规则处理)` if (seen.has(d.id)) return `deps 含重复模块项:${d.id}(重复项会静默丢边,必须合并为一项)` seen.add(d.id) for (const e of d.dependsOn) { if (e.id === d.id) return `模块 ${d.id} 自依赖` if (!knownSet.has(e.id)) return `模块 ${d.id} 的依赖指向未知模块 id:${e.id}` } } // 完整性:deps 必须**恰好**逐一覆盖全部 todo 后端模块。LLM 漏报的模块若静默放过, // annotateDeps 会把它当"无依赖"放进首波最大并行——与保守偏置(宁串勿并)正相反,缺项必须打回。 const missing = todoBackendIds.filter(id => !seen.has(id)) if (missing.length) return `deps 缺待跑后端模块项:${missing.join('、')}(每个待跑模块必须恰好一项,无依赖也要输出 dependsOn: [])` // 环检测(Kahn 消解):只看 todo 内部边——指向已完成模块的边恒满足,不参与成环。 const blocking = new Map(deps.map(d => [d.id, d.dependsOn.map(e => e.id).filter(x => todoSet.has(x))])) let rest = [...todoBackendIds] const settled = new Set() while (rest.length) { const ready = rest.filter(id => (blocking.get(id) || []).every(x => settled.has(x))) if (!ready.length) return `依赖图成环(Kahn 无法消解):${rest.join('、')}` for (const id of ready) settled.add(id) rest = rest.filter(id => !settled.has(id)) } return null } // 成功路径标注:把校验通过的依赖边落到 todo 模块项上(deps = 依赖模块 id 数组,nextWave 消费; // deps 可含已完成模块 id——Task 9 的 doneSet 初值含 routed 里 done:true 的模块,恒满足)。 // frontend-phase 不经 LLM:JS 硬规则强制依赖**全部**后端模块(前端聚合消费所有后端契约/种子)。 function annotateDeps(todo, deps, allBackendIds) { const byId = new Map(deps.map(d => [d.id, d.dependsOn.map(e => e.id)])) return todo.map(m => m.id === 'frontend-phase' ? { ...m, deps: [...allBackendIds] } // `|| []` 纯防御:scheduleViolation 的完整性校验保证每个 todo 后端模块在 deps 恰有一项, // 校验通过后此 fallback 理论不可达——仅 0/1 模块免调度的空边集路径会走到(语义恰为"无依赖")。 : { ...m, deps: byId.get(m.id) || [] }) } // nextWave:Kahn 拓扑取下一波——remaining 中 deps ⊆ doneSet 的模块,按 docs/02 原序(remaining 保序) // 取前 maxWidth 个。纯函数、不依赖 agent;coding.mjs 无法 node --test,以下内联 case 自证正确性: // ① 菱形(b、c 依赖 a;d 依赖 b+c):done=∅ → [a];done={a} → [b,c];done={a,b,c} → [d]。 // ② 链式(serialChainDeps 降级形态即此,b 依赖 a、c 依赖 b):每轮恰出 1 个,与现行串行全序一致。 // ③ 全独立 a,b,c,d(maxWidth=3):第一轮 [a,b,c](原序取前 3),第二轮 [d]。 // ④ 环 a⇄b(理论上 scheduleViolation 已排除):ready 恒空 → log 后按原序硬出 remaining[0]; // a 跑完进 doneSet 后 b 的边随之消解——行为收敛于 docs/02 全序,绝不 halt、绝不空转。 function nextWave(remaining, doneSet) { const ready = remaining.filter(m => (m.deps || []).every(d => doneSet.has(d))) if (!ready.length && remaining.length) { log(`schedule: 图卡死(ready 空而 remaining=${remaining.map(m => m.id).join('、')}),降级 docs/02 全序逐个推进`) return remaining.slice(0, 1) } return ready.slice(0, maxWidth) } // runScheduler:调度执行点(波次主循环起跑前、useParallel 开启时调用一次)。返回 todo 模块数组 // (保 docs/02 原序)每项追加 deps: string[],供 nextWave 消费。永不 throw: // 一切失败路径走 serialChainDeps 降级(见上方设计注释),调度只能让流程变快、不能让它停下。 async function runScheduler(todo, routedModules) { const allBackendIds = routedModules.filter(m => m.id !== 'frontend-phase').map(m => m.id) const todoBackendIds = todo.filter(m => m.id !== 'frontend-phase').map(m => m.id) // 0/1 个 todo 后端模块:模块间不存在任何可判边(dependsOn 只能指向 done 模块,恒满足)—— // 免去一次取证子代理,免校验直接按"无依赖"标注(不经 scheduleViolation:其完整性校验会把 // 空边集判成缺项,但此处"无可判边"本身就是结论,无需取证也无需打回)。 // phase('Schedule') 在早退**之后**才切:resume 全 done / 仅剩单模块的项目什么调度都没发生, // 不该让 UI 终态从 Router 漂移到 Schedule。 if (todoBackendIds.length <= 1) return annotateDeps(todo, [], allBackendIds) phase('Schedule') const degrade = (why) => { log(`schedule: ${why} → 降级 docs/02 全序(todo 顺序链式依赖),不 halt`) return serialChainDeps(todo) } // 整体 try/catch:fail-open 不能只依赖 agent() 永不 reject 的契约——adjudicate / 校验等任何 // runtime 抛错同样必须降级全序,调度绝不把整个 Workflow 顶层带崩(与"永不 throw"承诺名副其实)。 try { let sched = await agent(schedulerPrompt(todoBackendIds, allBackendIds, ROOT), { label: 'scheduler', phase: 'Schedule', schema: SCHEDULE_SCHEMA }) if (!sched) return degrade(`scheduler agent 无返回(${NULL_AGENT_PROBLEM})`) for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) { const violation = scheduleViolation(sched.deps, todoBackendIds, allBackendIds) if (!violation) break const verdict = await adjudicate('schedule-violation', { problem: violation }, 'Schedule', adj) if (verdict.action !== 'retry') return degrade(`调度违例且仲裁未予 retry(${verdict.rationale || violation})`) sched = await agent(schedulerPrompt(todoBackendIds, allBackendIds, ROOT) + adjGuidance(verdict.guidance || ''), { label: `scheduler:r${adj + 1}`, phase: 'Schedule', schema: SCHEDULE_SCHEMA }) if (!sched) return degrade(`scheduler agent 重跑无返回(${NULL_AGENT_PROBLEM})`) } const finalViolation = scheduleViolation(sched.deps, todoBackendIds, allBackendIds) if (finalViolation) return degrade(`${ADJUDICATE_MAX} 轮仲裁后仍违例:${finalViolation}`) // 审计可见性:依赖边落 log,人工可复盘"为什么这两个模块没并行"(evidence 详情在子代理 transcript)。 for (const d of sched.deps) { if (d.dependsOn.length) log(`schedule: ${d.id} ← ${d.dependsOn.map(e => `${e.id}[${e.kind}]`).join(' ')}`) } return annotateDeps(todo, sched.deps, allBackendIds) } catch (e) { return degrade(`调度 runtime 异常(${String(e?.message || e)})`) } } // P1#3:首跑建需求台账基线(幂等、best-effort),使日后 /add-req 能正确识别增量、免空跑。 await ensureLedgerBaseline() // Preflight:一次性环境探测。新机器上工具链缺失若拖到 test-gate / 行为门才爆, // 要先白跑几十分钟编码;这里在起跑线带诊断 halt。 if (todo.length) { phase('Router') const pre = await agentR(preflightPromptM(), { label: 'preflight', phase: 'Router', schema: ACTION_RESULT_SCHEMA }) if (!pre.success) throw haltError(`HALT preflight-env: ${pre.error || ''}${pre.detail ? '\n' + pre.detail : ''}`) log('preflight: 环境探测通过') } // ── 全局互斥锁(Task 9 Step 1;附录补 9 / 补 13)──────────────────────────── // promise 链实现:无原生锁可用,且运行时禁时间/随机源——链式 then 即"排队",确定性、零轮询。 // fn 抛错不破坏链:尾指针始终指向 catch(()=>{}) 后的节点,下一个获取者照常排队;调用方拿到的 // run 仍保留原始 rejection(halt 语义不被锁吞掉)。两把锁同构,统一由本工厂生成。 // // withMainRootLock:milestone(merge / docs/08 字段 / tag)与 RESUME 追加都作用于主根 + 默认分支, // 并行下必须全局串行化(merge 冲突保持硬 halt 的设计原则不变)。runCrossModule 的 diff 基准读 // 默认分支为**只读**,不需要锁。 // withStackLock(补 9):冷起栈资源(config-vars 固定端口 + 进程树)全局唯一——相关 stage 串行化 // 而非 lane 化:Seed 与 testGate(test.mjs 第 5 步 e2e 的 Playwright globalSetup 同按固定端口 // 冷起后端,e2e 段不可单拆则整个 testGate 包锁)整段互斥。"按既知端口回收残留 pid"只允许在持锁 // 的 stage 内做,否则后到者会把兄弟 lane 正在验证的后端当残留 kill 掉(≥2 宽波次必然踩中的确定 // 性互杀)。Seed/testGate 占模块总时长比例小,串行损失远低于打穿三层端口配置;并行收益主体 // (tdd/review/fix,不起栈、Spring 测试随机端口)完整保留、不进锁。 // // **非重入纪律(补 13,两把锁同适用)**:只允许 runModule 顶层 / loop-end 逐段获取 // (`await withMainRootLock(() => runMilestone(m))`、`await withMainRootLock(() => recordResume(...))` // 这种形态);被包函数及其调用链内**绝不**再调同一把锁,两把锁之间也**绝不**嵌套获取—— // promise 链锁嵌套获取即互等死锁,且静默无诊断(Workflow 直接挂死)。 function makeChainLock() { let tail = Promise.resolve() return (fn) => { const run = tail.then(fn) tail = run.catch(() => {}) return run } } const withMainRootLock = makeChainLock() const withStackLock = makeChainLock() // ---- runModule:单模块全链(branchSetup → 后端段 → 前端段 → report → milestone → RESUME flush)---- // **永不 throw**:内部 catch 全部 HALT 并结构化返回 { module, status, reason?, decisions }—— // 这是它能放进 parallel() 的前提(parallel() 把 thunk 异常静默折成 null、永不 reject,throw 会丢失 halt 原因)。 // c:模块执行上下文(makeCtx();串行 = 主根,lane 模式由 Phase C/D 填充)。 // phase(...) 全局 UI 分组只在主根模式调用(c.lane == null):lane 并行下兄弟模块会竞态改全局 phase 状态, // 而 agent 级 opts.phase(grp)已全覆盖分组——注意不能写 `!c.lane`(lane 序号 0 是合法 lane,falsy 会误判主根)。 const resumeFlushed = new Set() // per-module RESUME flush 成功记账(替代 flushedCount 水位线;loop-end 据此补记失败模块) // ---- runFrontendPhase1:前端 jsdom-only 重叠的 phase1(设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)---- // 与后端波次循环**并发**跑,**只做不依赖后端**的事:branchSetup(建前端 worktree)→ skeleton → featureLoop(jsdom)。 // 跑在**独立前端 worktree**(c.lane 非 null、root=前端 lane 路径、dbSchema=''——jsdom 无 DB,featureStageContract // 的 lane-DB 注入按 c.dbSchema 非空判定,此处不触发),与后端主根/lane 的 git index 互不干扰;**不**调全局 // phase()(并发下会与后端波次竞态改全局 UI 状态——agent 级 grp='Frontend' 已覆盖分组),**不** merge/milestone/ // RESUME(全部留 phase2)。**永不 throw**:catch 结构化返回,phase1 halt 不停后端(reason 带前端 worktree 取证前缀)。 // 幂等(resume):skeleton 凭 fe-skeleton-done、各 FE 凭 fe-code-done 去重,已完成项整段 skip。 async function runFrontendPhase1(feModule, c) { try { await runBranchSetup(feModule, c) await runFrontendSkeleton(feModule.feItems, c) await featureLoop(feModule.feItems, 'frontend', c, 'jsdom') log(`fe-overlap phase1 完成:${feModule.feItems.length} 个 FE 的组件代码 + jsdom 已绿(e2e/review 留 phase2)`) return { module: 'frontend-phase', status: 'done', decisions: c.decisions } } catch (e) { const reason = `[fe-overlap phase1 保留取证: ${c.root}] ${String(e?.message || e)}` log(`⛔ HALT — fe-overlap phase1:${reason}`) return { module: 'frontend-phase', status: 'halted', reason, decisions: c.decisions } } } async function runModule(module, c) { try { if (c.lane == null) phase('Milestone') await runBranchSetup(module, c) if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过) if (c.lane == null) phase('Backend') await featureLoop(module.reqs, 'backend', c) if (c.lane == null) phase('Gate') // 起栈互斥(补 9):testGate 整段包锁——其 e2e 段 Playwright globalSetup 按固定端口冷起后端, // 不可单拆。串行模式锁恒空闲,行为不变(统一纪律,免按模式分叉)。 await withStackLock(() => testGate(module, 'backend', c)) // 演示种子生成 stage(Seed):在 testGate 后跑——此时本模块 schema(含 tdd 新增的 V migration) // 已终态且全绿,按它生成的种子才不会撞结构。allowContinue:false——e2e 基线(globalSetup 注入)与行为门 // step2 子项③(演示种子注入)都依赖该种子,坏种子放行会让整个前端阶段(行为验收/e2e)在脏数据上全线误判。 // 起栈互斥(补 9):Seed 冷起 gradle bootRun 占固定端口且会按既知端口回收"残留"pid——整段取锁。 if (c.lane == null) phase('Seed') await withStackLock(() => runStage(g => seedGenPrompt(module, c) + g, { site:`seed:${module.id}`, grp:'Seed', label:`seed:${module.id}`, allowContinue: false, dec: c.decisions, root: c.root })) if (c.lane == null) phase('Milestone') await runCrossModule(module, c) // 替代被删 hook,JS 编排:diff → 分类 → 写日志 } if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) // 静态原型渲染门(Preview):前端段开头先渲染 prototype/**/*.html 本身——Playwright headless // 截图归档为可视化基线 + 跑原型自带交互点击冒烟。与行为门(测实现)正交;advisory(原型属上游 // plan 权威,coding 不改原型源码,渲染/JS 问题只记证据不阻断)。静态渲染、不起全栈。终波主根执行。 if (c.lane == null) phase('Preview') await runPrototypePreview(module.feItems, c) if (c.lane == null) phase('Frontend') // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy // 路由表(FeStub 占位)+ 无悬空导航——保证逐 FE 实现中途任意时刻 app 可构建可起、每 FE 路由可达, // 使逐 FE verify(e2e) 与阶段末尾行为门的可构建前提成立、tddPrompt 的 FeStub→真组件占位替换有真值起点。幂等(fe-skeleton-done tag)。 await runFrontendSkeleton(module.feItems, c) // featureLoop 的 review 循环只做静态验收(reviewer approve 即打 req-done)——行为验收不在内循环。 // module.feStage(前端 jsdom-only 重叠):缺省 'all'=现行完整链;overlap phase2 传 'e2e'(phase1 已在 // 前端 worktree 跑完 jsdom,本处只补 e2e 任务 + review + req-done)。skeleton 上方已幂等(fe-skeleton-done)。 await featureLoop(module.feItems, 'frontend', c, module.feStage || 'all') // 阶段级行为门(v3):整个前端阶段只跑一次行为验收——起全栈 + 演示/sentinel 种子,按全部 FE spec 聚合 // 作用域并集验「按钮真生效/文字对」;硬问题转 must-fix→fix→单测复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮)。 // 放在 testGate 之前:行为 fix 改动 frontend/ 源码,绿后由 testGate 全量回归兜底,不让回归证据过期。 if (c.lane == null) phase('Behavior') // 行为门虽也冷起全栈,但只在 frontend-phase 终波(宽 1、主根)跑——无兄弟 lane 可互杀, // 不进起栈互斥(补 9 范围仅 Seed / testGate)。 await runBehaviorGate(module.feItems, c) if (c.lane == null) phase('Gate') // 阶段级 testGate(全量回归 vitest+playwright),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。 await withStackLock(() => testGate(module, 'frontend', c)) } if (c.lane == null) phase('Milestone') // report allowContinue:false:reportPrompt 的前置硬验证含"最后一次 test-gate 必须 green,红则 halt"—— // 绝不 continue 放行去打 milestone(否则可能在红色测试上 milestone)。 await runStage(g => reportPrompt(module, c) + g, { site:`report:${module.id}`, grp:'Milestone', label:`report:${module.id}`, allowContinue: false, dec: c.decisions, root: c.root, }) // lane 收口(附录补 15):lane 模式 milestone 前先在 lane 树上 wt-clean + 自主恢复——lane 里 tdd/fix // 的未提交残留参与了 testGate(test.mjs 跑在工作树上)却不进 merge,默认分支会拿到"没测过的子集"。 // in-scope 残留 commit 进模块分支;越界 → halt 留人工。串行/主根模式不加这一步:runMilestone 自身的 // step 1 wt-clean 已覆盖同一棵树(主根),重复检查徒增一次子代理调用。 if (c.lane != null) { const branch = module.id === 'frontend-phase' ? 'frontend-phase' : `module-${module.id}` const wt = await agentR(worktreeCleanPromptM(c), {label:`lane-wt:${module.id}`, phase:'Milestone', schema: WT_SCHEMA}) if (!wt.clean) { const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `lane 收口(模块 ${module.id},milestone 前;lane 树 ${c.root})。`, c), {label:`lane-wt-recover:${module.id}`, phase:'Milestone', schema: ACTION_RESULT_SCHEMA}) if (!rec.success) throw new Error(`HALT lane-dirty-worktree ${module.id}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`) log(`lane 收口: ${module.id} 自动提交 lane 残留(${rec.detail || ''})`) } } // 主根串行段互斥(Task 9 Step 1):milestone(merge/docs08/tag)与紧随的 RESUME 追加都作用于 // 主根 + 默认分支——并行下逐段取锁串行化(补 13 非重入纪律:被包调用链内绝不再取锁,两段分别获取)。 await withMainRootLock(() => runMilestone(module)) // P1#1 增量 flush:每个模块里程碑落定即追加 RESUME.md,使**硬中断**(进程被杀,到不了主循环末尾) // 也能复盘已完成到哪、各模块做过哪些自主假设——不必等 loop-end 才写。best-effort,绝不阻断。 // 决策改读 c.decisions(per-module 收集器,替代 decStart 水位线——并行交错下水位线语义失效)。 { const ok = await withMainRootLock(() => recordResume([ '## ✅ 模块完成 `' + module.id + '` → milestone/' + module.id + '()', '', '- **本模块自主默认决策**(缺值时自动取的解读,可能含错误假设):', decisionDigestMd(c.decisions, '(本模块无自主默认记录)'), ].join('\n'))) if (ok) resumeFlushed.add(module.id) // 仅成功才记账:flush 失败时 loop-end 兜底补记该模块全部决策 } // lane 清理(Task 6 Step 4):milestone 成功后即移除 lane worktree——bestEffort,删失败只 log // 绝不阻断(分支 / 对象库 / tag 全仓共享,留树不损正确性;残树由下个波次的前置占用感知收口,补 3)。 if (c.lane != null) { await bestEffortAction(removeLaneWorktreePromptM(c.root), { label:`lane-remove:${module.id}`, phase:'Milestone', okMsg:`lane 清理: 已移除 ${c.root}`, failTag:`lane 清理(${c.root})` }) } return { module: module.id, status:'done', decisions: c.decisions } } catch (e) { // halt:lane 整树**保留**供取证(绝不在此清理;resume 时由波次前置的占用感知统一恢复/移除,附录 // 补 3)。reason 前缀 lane 路径——RESUME halt 条目与终端日志据此直接定位现场(Task 6 Step 4)。 const reason = (c.lane != null ? `[lane 保留取证: ${c.root}] ` : '') + String(e.message || e) log(`⛔ HALT — 模块 ${module.id}:${reason}`) return { module: module.id, status:'halted', reason, decisions: c.decisions } } } // ── 波次前置·占用感知(附录补 3a/3b)───────────────────────────────────────── // 残留 lane(上次 halt 保留取证 / removeLaneWorktree bestEffort 删失败)持有 module-* 分支时, // 主根 checkout 与新 worktree add 双向 fatal(git 实测)——本波要跑的模块若其分支被占用,必须先 // 收口:树脏 → recoverDirtyWorktree(in-scope 残留 commit 进模块分支,工作不丢)→ 移除;恢复/移除 // 失败(越界残留等)→ 该模块按 branchSetup-dirty-worktree 同类语义**结构化 halt**(等价前移,非 // 新增 halt 类——串行路径走到 runBranchSetup 也会在同一种脏树上 halt),波次其余模块照常调度。 // 每个波次(**含宽 1**)开跑前都要跑:宽 1 回退路径正是被残留 lane 卡死的高发场景。 // 枚举自身失败 → fail-open 跳过感知(看不见就当没有,由 branchSetup 现有 fatal→仲裁→halt 兜底)。 async function laneOccupancySweep(wave) { const halted = [] let lanes try { // 枚举直调 agent() 判空(不走 agentR):agentR 的 null 路径会先打 ⛔ HALT 再被下面 catch 吞掉 // 继续跑,稀释「⛔=真 halt」约定——这里 null 与抛错同义,都安静 log 后 fail-open 跳过本波感知。 const r = await agent(listLaneWorktreesPromptM(), { label: 'wave:lanes?', phase: 'Milestone', schema: LANE_LIST_SCHEMA }) if (!r) { log(`wave: 残留 lane 枚举无返回(${NULL_AGENT_PROBLEM}),本波跳过占用感知(由 branchSetup 现有护栏兜底)`) return { wave, halted } } lanes = r.lanes || [] } catch (e) { log(`wave: 残留 lane 枚举失败(${String(e?.message || e)}),本波跳过占用感知(由 branchSetup 现有护栏兜底)`) return { wave, halted } } if (!lanes.length) return { wave, halted } const byBranch = new Map(lanes.filter(l => l && l.branch).map(l => [l.branch, l.path])) const survivors = [] for (const m of wave) { const branch = m.id === 'frontend-phase' ? 'frontend-phase' : `module-${m.id}` const lanePath = byBranch.get(branch) if (!lanePath) { survivors.push(m); continue } try { const lc = makeCtx({ root: lanePath }) // 只为复用穿线 prompt 的取根;不是 lane 执行上下文 const wt = await agentR(worktreeCleanPromptM(lc), { label: `wave:lane-wt:${m.id}`, phase: 'Milestone', schema: WT_SCHEMA }) if (!wt.clean) { const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `残留 lane 占用感知(波次前置;lane 树 ${lanePath} 持有分支 ${branch})。`, lc), { label: `wave:lane-recover:${m.id}`, phase: 'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!rec.success) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`) log(`wave: 残留 lane ${lanePath} in-scope 残留已 commit 进 ${branch}(${rec.detail || ''})`) } const rm = await agentR(removeLaneWorktreePromptM(lanePath), { label: `wave:lane-remove:${m.id}`, phase: 'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!rm.success) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: 残留 lane 移除失败——${rm.error || ''}`) log(`wave: 残留 lane ${lanePath} 已收口移除(释放分支 ${branch})`) survivors.push(m) } catch (e) { const reason = `[lane 保留取证: ${lanePath}] ${String(e?.message || e)}` log(`⛔ HALT — 模块 ${m.id}:${reason}`) halted.push({ module: m.id, status: 'halted', reason, decisions: [] }) } } return { wave: survivors, halted } } // ── 波次前置·并行准备(Task 9 Step 3;计划 D2 + 附录补 3c / 补 4 / 补 6b / 补 7)── // 仅 ≥2 宽波次调用(主根,串行)。成功返回 { defBranch, maxV, baseSchema };任何一步失败 → 先清 // 掉本次已建的 lane(其持有的分支会毒化降级后的宽 1 legacy checkout)再返回 null,调用方把本波次 // 降级 maxWidth=1(fail-open to serial,**绝不 halt**——隔离失败只损失并行收益,不该成为新停机点)。 // laneEnvCache(声明在 featureBatchRun 旁,与其 lane 前置共享,语义见声明处注释):首个探测点 // 填充后跨波次/跨模块复用。maxV 绝不入缓存——它随波次合并增长,必须每波重读。 async function prepareParallelWave(wave) { const created = [] const degrade = async (why) => { log(`wave: ${why} → 本波次降级 maxWidth=1(串行主根路径),不 halt`) for (const path of created) { await bestEffortAction(removeLaneWorktreePromptM(path), { label: 'wave:lane-rollback', phase: 'Milestone', okMsg: `wave: 降级回收 lane ${path}`, failTag: `wave: 降级回收 lane(${path})` }) } return null } try { const def = await agentR(detectDefaultBranchPromptM(), { label: 'wave:default', phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA }) const mc = makeCtx() // (1) 主根复位(D2 + 补 3c):module-* 分支必须未被主根持有(worktree add 才不 fatal),且兄弟 // milestone 的 merge 要落在默认分支主根上。脏树恢复的目标分支 = **当前 HEAD 分支**(传默认分支 // 会被 recoverDirtyWorktree 的分支护栏按 dirty-on-wrong-branch 拒绝,造成本可避免的降级); // HEAD 已在默认分支且脏 → 不在此处理(绝不为并行机制新增"对默认分支自动 commit"),降级交给 // legacy 路径现有恢复/halt 语义。 const head = await agentR(currentBranchPromptM(mc), { label: 'wave:head', phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA }) const wt = await agentR(worktreeCleanPromptM(mc), { label: 'wave:wt', phase: 'Milestone', schema: WT_SCHEMA }) if (!wt.clean) { if (head.branch === def.branch) return degrade(`主根在默认分支 ${def.branch} 上有未提交改动(绝不自动 commit 默认分支)`) const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, head.branch, `并行波次前置主根复位(HEAD=${head.branch},就地收口后回默认分支)。`, mc), { label: 'wave:wt-recover', phase: 'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!rec.success) return degrade(`主根脏树恢复失败(${rec.error || ''})`) log(`wave: 主根 in-scope 残留已 commit 进 ${head.branch}(${rec.detail || ''})`) } if (head.branch !== def.branch) { const co = await agentR(checkoutExistingBranchPromptM(def.branch, mc), { label: 'wave:checkout-default', phase: 'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!co.success) return degrade(`主根切回默认分支失败(${co.error || ''})`) } // (2) prune:只清"目录已删"的管理残留(完好取证 lane 由占用感知收口,勿依赖 prune)。 const pr = await agentR(pruneWorktreesPromptM(), { label: 'wave:prune', phase: 'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!pr.success) return degrade(`git worktree prune 失败(${pr.error || ''})`) // (3) 全仓最大 migration 版本(全局并集口径,补 4)→ 执行段据此给每条 lane 发 vBase 版本段。 const mv = await agentR(maxMigrationVersionPromptM(), { label: 'wave:maxV', phase: 'Milestone', schema: FIELD_VALUE_SCHEMA }) const maxV = mv.found ? parseInt(mv.value, 10) : NaN if (!Number.isInteger(maxV) || maxV < 0) return degrade(`migration 版本并集读取失败(found=${mv.found}, value=${JSON.stringify(mv.value)})`) // (4)(5) lane 测试库 schema 基底(config-vars.yaml database.schema)+ 支持度两点联查(补 6b) // ——经 laneEnvCache 缓存(运行内不变量,见函数头注释)。agentR 抛错(runtime 故障)不缓存: // 那是瞬时态不是环境事实,留给下个 ≥2 宽波次重探。 if (!laneEnvCache) { const sb = await agentR(readDbSchemaPromptM(), { label: 'wave:schema', phase: 'Milestone', schema: FIELD_VALUE_SCHEMA }) if (!sb.found || !sb.value) { laneEnvCache = { fail: 'config-vars.yaml 的 database.schema 读不到(lane 库名无基底)' } } else { // 任一不满足 → 降级(与其并行却在共享库上互相清库,不如明示串行)。 const probe = await agentR(laneDbSupportPromptM(), { label: 'wave:lane-db?', phase: 'Milestone', schema: LANE_DB_PROBE_SCHEMA }) laneEnvCache = (!probe.scriptsEnv || !probe.ymlPlaceholder) ? { fail: `目标项目不支持 lane 测试库(scriptsEnv=${probe.scriptsEnv}, ymlPlaceholder=${probe.ymlPlaceholder};${probe.detail || ''}——建议重跑 skeleton 升级 scripts/ 与 application*.yml 占位)` } : { baseSchema: sb.value } } } if (laneEnvCache.fail) return degrade(laneEnvCache.fail) const baseSchema = laneEnvCache.baseSchema // (6) 建 lane worktrees + 写 fail-closed marker(补 7)。lane 序号 = 本波 wave 下标,与执行段 // ctxs 的 laneDbSchema 取号严格同序(marker 内容必须 == c.dbSchema)。 for (const [i, m] of wave.entries()) { const branch = m.id === 'frontend-phase' ? 'frontend-phase' : `module-${m.id}` const path = laneRoot(m.id) const add = await agentR(createLaneWorktreePromptM(branch, path, def.branch), { label: `wave:lane-add:${m.id}`, phase: 'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!add.success) return degrade(`建 lane worktree 失败(${m.id}:${add.error || ''})`) created.push(path) const mk = await agentR(writeLaneDbMarkerPromptM(path, laneDbSchema(baseSchema, i)), { label: `wave:lane-marker:${m.id}`, phase: 'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!mk.success) return degrade(`写 lane DB marker 失败(${m.id}:${mk.error || ''})`) } log(`wave: 并行准备就绪——宽 ${wave.length}(${wave.map(m => m.id).join('、')}),maxV=${maxV},lane 库基底 ${baseSchema}`) return { defBranch: def.branch, maxV, baseSchema } } catch (e) { return degrade(`波次前置串行段异常(${String(e?.message || e)})`) } } // ── 主循环:依赖波次执行(Phase D Task 9 Step 2;伪代码修正见附录补 14)──────── // useParallel 总开关(计划 D4 / 附录补 1):ARGS.parallel 缺省或 modules !== true → 全串行—— // 不调 scheduler(免 Schedule 阶段噪声)、不建 lane;serialChainDeps 链式图上 nextWave 每轮恰出 // 1 个,主根 legacy 路径与 Phase A 重构后行为一致。逃生口:coding-start 传 parallel:{modules:false}。 const useParallel = ARGS?.parallel?.modules === true const results = [] // doneSet:nextWave 的依赖消解集——必须以 routed 中 done:true 的模块初始化(runScheduler 标注的 // deps 允许指向已完成模块 id,frontend-phase 的"依赖全部后端"硬规则尤其依赖此初值)。 const doneSet = new Set(routed.modules.filter(m => m.done).map(m => m.id)) let remaining = useParallel ? await runScheduler(todo, routed.modules) : serialChainDeps(todo) // ── 前端 jsdom-only 重叠(设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)── // frontendOverlap 开 + 存在待跑 frontend-phase(有 feItems)时:把 frontend-phase 从波次图剔除,起 phase1 // (前端 worktree,只跑 skeleton + jsdom,不依赖后端)与后端波次循环**并发**;屏障(后端全部 done)后再 // reconcile(合 default→frontend-phase)+ 标准 runModule(feStage='e2e')(主根全栈,保住补 8/9/10)。缺省关 / // phase1 失败 / 后端未全 done → 完全回退现行终波路径,phase1 产物(fe-code-done)落 tag、resume 续跑,绝不新增 halt 点。 const useFrontendOverlap = ARGS?.parallel?.frontendOverlap === true const feModule = routed.modules.find(m => m.id === 'frontend-phase' && !m.done && (m.feItems || []).length) const overlapOn = useFrontendOverlap && !!feModule const feDecisions = [] // phase1/phase2 共享决策收集器(RESUME flush 在 phase2 milestone 覆盖两段) const fePending = [] // 后端未全 done 时,frontend-phase 的 pending 记账(并入下方 pending) let fe1Promise = null if (overlapOn) { remaining = remaining.filter(m => m.id !== 'frontend-phase') // 从波次图剔除,后端波次独立推进 const feCtx = makeCtx({ lane: 'fe', root: laneRoot('frontend-phase'), dbSchema: '', vBase: 0, decisions: feDecisions }) fe1Promise = runFrontendPhase1(feModule, feCtx) // 不 await:与下方后端波次循环并发 log(`fe-overlap: 前端 phase1 已与后端波次并发启动(worktree ${laneRoot('frontend-phase')})`) } while (remaining.length) { let wave = nextWave(remaining, doneSet) if (!useParallel) wave = wave.slice(0, 1) // 防御:串行闸下恒单宽(链式图本就每轮恰出 1 个) // 本轮消耗集(补 14):按波次成员记账、**绝不**以 rs 反查——runtime-null 槽位没有 r.module,反查 // 会把故障模块留在 remaining,下轮 deps 不满足触发"图卡死降级全序"误重跑。降级被裁掉的成员**不** // 入此集(本轮未跑,留在 remaining 等下波)。 const consumed = new Set() // 占用感知(每波,含宽 1):恢复失败的模块已结构化 halt(其余照常调度),波次边界收敛会停在本波末。 const swept = await laneOccupancySweep(wave) wave = swept.wave for (const h of swept.halted) { results.push(h); consumed.add(h.module) } // ≥2 宽波次的并行准备:任一失败 → 本波次降级 maxWidth=1(被裁掉的成员留在 remaining 等下波)。 let prep = null if (useParallel && wave.length >= 2) { prep = await prepareParallelWave(wave) if (!prep) wave = wave.slice(0, 1) } if (wave.length) { for (const m of wave) consumed.add(m.id) if (wave.length === 1 || !useParallel) { // 单宽波次(含 frontend-phase 终波)/ 并行关闭 → 主根 legacy 路径,不建 lane——makeCtx() 缺省 // 即串行语义,与 Phase A 重构后行为一致。 const r = await runModule(wave[0], makeCtx()) results.push(r) if (r.status === 'done') doneSet.add(r.module) } else { // lane ctx 预先构造(thunk 外):runtime-null 槽位仍能从 ctxs[i] 抢救出已登记的 decisions // 与 lane 路径(补 12 的 loop-end 增量口径需要前者)。 const ctxs = wave.map((m, i) => makeCtx({ lane: i, root: laneRoot(m.id), dbSchema: laneDbSchema(prep.baseSchema, i), vBase: vBase(prep.maxV, i), })) // parallel():结果数组与 thunk 数组按位次对齐(runtime 保证);thunk 异常被静默折成 null、 // 调用本身永不 reject——runModule 永不 throw,null 只剩 runtime 级故障(补 14)。 const rs = await parallel(wave.map((m, i) => () => runModule(m, ctxs[i]))) for (const [i, r] of rs.entries()) { if (r) { results.push(r) if (r.status === 'done') doneSet.add(r.module) continue } // runtime-null 槽位 → 记 halted;lane 整树保留取证(与 runModule catch 同口径的 reason 前缀)。 const reason = `[lane 保留取证: ${ctxs[i].root}] runtime-null` log(`⛔ HALT — 模块 ${wave[i].id}:${reason}(parallel() 槽位为 null,runtime 级故障)`) results.push({ module: wave[i].id, status: 'halted', reason, decisions: ctxs[i].decisions }) } } } remaining = remaining.filter(m => !consumed.has(m.id)) // halt 波次边界收敛:无取消原语,兄弟模块跑完本波(接受算力浪费)后在波次边界停下——比串行 // "单模块即断"粗一档;RESUME 记账把同波次全部 halt 原因逐条列出(见 loop-end)。 if (results.some(r => r.status === 'halted')) break } // ── 前端 jsdom-only 重叠:屏障 + phase2 ──(overlap 关时整段跳过,现行终波路径不变) if (overlapOn) { const fe1 = await fe1Promise // 屏障:等前端 phase1(与后端波次并发)收束 // 后端是否全部 done:无 halted(含 sweep/runtime-null)且 remaining 已空(无 pending 后端)。 const backendAllDone = !results.some(r => r.status === 'halted') && remaining.length === 0 if (fe1.status === 'halted') { results.push(fe1) // phase1 halt:phase2 不跑;已完成 FE 的 fe-code-done 已保留,resume 续跑 } else if (!backendAllDone) { // 后端未全 done(halt / 还有 pending):phase1 产物(fe-code-done)已落 tag,phase2 留待 resume。 fePending.push({ module: 'frontend-phase', status: 'pending', blockedBy: ['<全部后端 done>'], decisions: fe1.decisions }) log('fe-overlap: 后端未全部完成 → 前端 phase2 延后;phase1 产物已保留,resume 续跑') } else { // 后端全部 done + phase1 done → phase2:reconcile(合 default→frontend-phase + 移除 phase1 worktree)→ // 标准 runModule(feStage='e2e')(主根全栈,skeleton 幂等跳过,behaviorGate/testGate/milestone 原样)。 phase('Frontend') const def = await agentR(detectDefaultBranchPromptM(), { label:'fe-overlap:default', phase:'Milestone', schema: DEFAULT_BRANCH_SCHEMA }) await bestEffortAction(removeLaneWorktreePromptM(laneRoot('frontend-phase')), { label:'fe-overlap:rm-worktree', phase:'Milestone', okMsg:'fe-overlap: 已移除 phase1 worktree', failTag:'fe-overlap phase1 worktree 移除' }) const rec = await agentR(mergeDefaultIntoFrontendPromptM('frontend-phase', def.branch), { label:'fe-overlap:reconcile', phase:'Milestone', schema: ACTION_RESULT_SCHEMA }) if (!rec.success) { // reconcile merge 冲突/失败:与 milestone merge 同口径,结构化 halt 留人工(phase1 产物已在 frontend-phase 分支)。 const reason = `fe-overlap reconcile(merge ${def.branch}→frontend-phase)失败:${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}` log(`⛔ HALT — frontend-phase:${reason}`) results.push({ module: 'frontend-phase', status: 'halted', reason, decisions: fe1.decisions }) } else { feModule.feStage = 'e2e' // 让 runModule 前端段走 e2e(jsdom 已在 phase1 绿) const fe2 = await runModule(feModule, makeCtx({ decisions: feDecisions })) results.push(fe2) if (fe2.status === 'done') doneSet.add(fe2.module) } } } // pending:图余量(Task 10)——halt 收敛后 remaining 中每项标注 blockedBy(尚未满足的依赖), // caller / coding-start 据此告知"修好后还有哪些待跑、各自被谁挡着",而非线性 slice 误导 // (波次图下 halt 模块之后的 docs/02 顺位可能与 halt 毫无依赖关系)。 // fePending:前端 jsdom-only 重叠下「phase1 done 但后端未全 done」的 frontend-phase pending(并入图余量)。 const pending = remaining.map(m => ({ module: m.id, status: 'pending', blockedBy: (m.deps || []).filter(d => !doneSet.has(d)), })).concat(fePending) // Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表 + 全流程自主决策日志 // (decisions:stage 缺值时未停而自主取的默认/解读,供 coding-start / 人工事后审阅,可能含错误假设)。 // 注:decisions 仅覆盖**本次运行实际新跑**的 stage;resume 时被 req-done/milestone tag 跳过的已完成功能, // 其决策不会重新登记于此——需到对应 docs/superpowers/specs|plans/-.md 产物显著位置查阅。 // 续跑 handoff(best-effort,静默):把本次运行结果落进 docs/superpowers/RESUME.md, // 使下次 coding-start 重跑能复盘「上次为何 halt / 做过哪些自主假设 / 还剩哪些模块」。 // 硬中断(进程被杀,到不了这里)时不写——那种情况无 halt 原因可记,且 tag+工件已够 Router 续跑。 // halts:同波次可能多个模块同时 halt(波次边界收敛跑完本波才停),一律逐条记账,绝不只取第一个。 const halts = results.filter(r => r.status === 'halted') // loop-end 汇总口径(附录补 12):= 「flush 失败/未 flush 模块(含 halted)的 r.decisions ∪ 全局 sink 残量」。 // flush 成功的模块已被逐模块 RESUME 条目覆盖,不再重复列出——同一条决策绝不既丢失又重复。 // 全局残量 = autonomousDecisions:模块作用域决策都进 c.decisions,这里只剩"漏传 dec"的兜底记录。 const unflushedDecisions = [ ...results.filter(r => !resumeFlushed.has(r.module)).flatMap(r => r.decisions || []), ...autonomousDecisions, ] const decDigest = decisionDigestMd(unflushedDecisions, '(无未记录的自主默认;已完成模块的决策见上方各模块条目)') // loop-end 的 recordResume 统一过 withMainRootLock(补 13):波次循环已结束本无竞态,但保持 // "RESUME 追加必持锁"的单一纪律避免遗忘(recordResume 自身及调用链内绝不再取锁)。 if (halts.length) { // RESUME halt 条目(Task 10):逐条列出同波次全部 halt 原因——lane 模式 reason 自带 // 「[lane 保留取证: ]」前缀(即保留的 lane 路径);待跑模块逐个标注 blockedBy。 const haltList = halts.map(h => ` - \`${h.module}\`:${h.reason || '(空)'}`).join('\n') const pendList = pending.length ? pending.map(p => ` - \`${p.module}\`${(p.blockedBy || []).length ? `(blockedBy: ${p.blockedBy.map(b => `\`${b}\``).join('、')})` : '(无未满足依赖,修复阻塞点后即可跑)'}`).join('\n') : ' - 无' await withMainRootLock(() => recordResume([ '## ⛔ HALT — 模块 ' + halts.map(h => '`' + h.module + '`').join('、') + '()', '', '- **halt 模块与原因**(同波次全部列出;lane 模式 reason 自带保留 lane 路径前缀,取证后人工移除):', haltList, '- **本次自主默认决策**(仅未被逐模块条目覆盖的增量;已完成模块的决策见上方各模块条目,重跑前请复核):', decDigest, '- **halt 后未跑的待办模块**(blockedBy = 尚未满足的依赖,重跑时由调度图自动消解):', pendList, '- **下一步**:人工修复阻塞点后重跑 `/erp-workflow:coding-start`;Router 按 git tag 续跑,已完成模块自动跳过。', ].join('\n'))) } else if (results.length) { const doneList = results.filter(r => r.status === 'done').map(r => `\`${r.module}\``).join('、') || '无' await withMainRootLock(() => recordResume([ '## ✅ 全部完成()', '', `- **本次完成模块**:${doneList}`, '- **本次自主默认决策**(仅未被逐模块条目覆盖的增量):', decDigest, ].join('\n'))) } if (halts.length) log(`⛔ 本次运行 halt — 模块 ${halts.map(h => h.module).join('、')}(原因逐条见上方 ⛔ 日志与 RESUME.md);待跑:${pending.map(p => p.module).join('、') || '无'}`) else if (results.length) log(`✅ 本次运行完成 ${results.length} 个模块,无 halt`) // 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行, // 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。 // 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`, // 否则 Workflow 拿不到 results / pending。语法检查用 `node lib/check-workflow-syntax.mjs`(附录补 18)。 // decisions 口径(附录补 12):results 各 r.decisions 拼接 ∪ 全局 sink 残量(漏传 dec 的兜底)。 return { results, pending, decisions: [...results.flatMap(r => r.decisions || []), ...autonomousDecisions] }