Commit 163161eca7c486ff1c6cf8b93b63402669586d6e

Authored by yanghl
1 parent 2337cc32

perf(coding): ETA 播报 + 微步骤合并 + report 瘦身 + 行为门跨轮复用

实测口径(同一 ERP 项目 13 次运行 / 1012 个子代理会话 / 2988 分钟,按子代理
transcript 首末 timestamp 统计):test-gate 13.3%、module-report 11.7%(均值
14.6 分钟)、behavior 11.6%、tdd 合计 22.4%;微步骤 574 个会话只占 6.6% 时间
却占 57% 会话数。四项改动照此下刀。

1) ETA 播报(新增 STAGE_MIN / etaLog / itemMin / moduleMin)
   左侧 Phases 面板的分母是 harness 侧「已派生子代理数」,脚本无 API 预声明
   总量,串行链上只能 0/1→1/2 地长;运行时又禁 Date.now()(会破坏 resume
   重放),拿不到真实计时。故用 log() narrator 行按「剩余段数 × 实测分布」
   静态外推:STAGE_MIN 存各 stage 的 [p25, 中位数, p75],etaLog 挂在
   runStage/runAction 的重试循环里,开跑与每次重跑各播一行「预计 / 再需
   X–Y 分钟」;gate / behavior 走 agentR,另在 attempt 级显式挂。表外的
   微步骤(0.2 分钟级)静默,不刷屏。纯显示,绝不参与控制流。

2) 微步骤合并(会话数)
   - docs/08 勾选 2 会话 → 1:原「读勾选态」与「写勾选+commit」之间没有任何
     JS 决策,纯白烧一次往返。合并为 flipDocs08CheckboxPromptM,三种非错误
     终态经 ACTION_RESULT 的 detail 回传(not-found / already-checked /
     flipped),调用方分支日志逐条对应。删 CHECKBOX_STATE_SCHEMA 与两个旧
     prompt。每个 REQ/FE 省 1 个会话。
   - 分支就位 3 会话 → 1:exists? → checkout|create → head 本是一条确定性
     git 链。合并为 ensureBranchCheckedOutPromptM + BRANCH_READY_SCHEMA
     (branch 必填 = 实测 HEAD,明写「据实回填、绝不改写成期望值」)。JS 侧
     HEAD 不符仲裁重切循环原样保留,合并只是它的第一次尝试。lane 模式不走
     这条(worktree add 已上分支)。每个模块省 2 个会话。
   - runAction 加可选 schema(缺省仍 ACTION_RESULT_SCHEMA),只要含 success
     布尔,失败/仲裁/重试语义一字不变。

3) module-report 瘦身
   原实现让报告子代理 Glob + 按 attempt 升序通读**全部** test-gate 证据(前端
   还要加全部 behavior-r*-a*.md)去自证「最后一份绿」——可红态根本走不到
   report(testGate / runBehaviorGate 会先 halt),JS 才是这个事实的第一手
   持有者。改为 testGate 返回 {...g, attempts}、runBehaviorGate 绿态返回
   {rounds, attempts, routes/controls/authState},经 runModule 的 reportFacts
   透传给 reportPrompt(module, c, facts):前置改成「门禁事实由编排层给定」,
   子代理只读点名的那一两份(末次 green;attempts>1 才加首次 red),flake
   标注也由 JS 判定后写死进 prompt。§ ⑤ / § ⑧ 的通配符通读同样收敛。

4) 行为门跨 round 复用(carry)
   实测本门 40+ 分钟里约 18 分钟花在「读 spec → 查表 → 反查组件 → 推导期望
   → 写 runner」,而 fix 只改 frontend/ 源码——期望(来自 prototype/REQ/
   docs05)与 runner(DOM/locator 驱动)在同一前端阶段内不失效。新增
   .tmp/behavior-gate/frontend-phase/carry/ 存 expectations.md + run.mjs +
   sentinel.sql 跨 round/attempt 存活:r1a1 全量推导后必须落 carry;r>1 或
   attempt>1 时 step1 / step4 整节跳过、sentinel 直接灌 carry 那份,只在有
   明确证据(runner 自身 bug / 路由表真变了)时增量修补并同步回 carry。
   不复用的部分写成硬约束:运行栈每次冷起、库副本每次重建、种子每次重灌、
   证据每轮独立文件——复用的只是「推导结果与脚本」。

验证:lib/check-workflow-syntax.mjs 语法门 + 悬空引用 grep 自检 + 把
behaviorGatePrompt / reportPrompt / 两个合并版微步骤用 stub 渲染出实际
prompt 眼验(含 reuse、flake、后端/前端各分支变体)。coding.mjs 无行为测试,
真实收益仍需目标项目实跑确认。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Showing 1 changed file with 223 additions and 98 deletions
workflows/coding.mjs
@@ -208,12 +208,17 @@ const FIELD_VALUE_SCHEMA = { type:&#39;object&#39;, additionalProperties:false, @@ -208,12 +208,17 @@ const FIELD_VALUE_SCHEMA = { type:&#39;object&#39;, additionalProperties:false,
208 value:{type:'string'}, 208 value:{type:'string'},
209 lineNumber:{type:'integer'} } } 209 lineNumber:{type:'integer'} } }
210 210
211 -// CHECKBOX_STATE_SCHEMA:docs/08 功能行勾选态;state 必填——只 require found 时 cb.state 缺失会静默走 checked 分支。  
212 -const CHECKBOX_STATE_SCHEMA = { type:'object', additionalProperties:false,  
213 - required:['found','state'], properties:{  
214 - found:{type:'boolean'},  
215 - state:{type:'string', enum:['checked','unchecked']},  
216 - lineNumber:{type:'integer'} } } 211 +// (原 CHECKBOX_STATE_SCHEMA 已随 docs/08 勾选的 read-then-write 两段合并为单会话而删除,
  212 +// 见 flipDocs08CheckboxPromptM——终态经 ACTION_RESULT 的 detail 回传。)
  213 +
  214 +// BRANCH_READY_SCHEMA:ensureBranchCheckedOutPromptM(exists?+checkout|create+head 三合一)的回执。
  215 +// branch 必填——它是**实测 HEAD**,上层的分支不符仲裁循环靠它判定,缺失会让不符静默通过。
  216 +const BRANCH_READY_SCHEMA = { type:'object', additionalProperties:false,
  217 + required:['success','branch'], properties:{
  218 + success:{type:'boolean'},
  219 + branch:{type:'string'},
  220 + created:{type:'boolean'},
  221 + error:{type:'string'} } }
217 222
218 const ALREADY_MERGED_SCHEMA = { type:'object', additionalProperties:false, 223 const ALREADY_MERGED_SCHEMA = { type:'object', additionalProperties:false,
219 required:['alreadyMerged'], properties:{ alreadyMerged:{type:'boolean'} } } 224 required:['alreadyMerged'], properties:{ alreadyMerged:{type:'boolean'} } }
@@ -978,6 +983,12 @@ function behaviorGateContract(c) { @@ -978,6 +983,12 @@ function behaviorGateContract(c) {
978 function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { 983 function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
979 const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(调用方未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)' 984 const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(调用方未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)'
980 const tmpDir = `${c.root}/.tmp/behavior-gate/frontend-phase/r${behaviorRound}` 985 const tmpDir = `${c.root}/.tmp/behavior-gate/frontend-phase/r${behaviorRound}`
  986 + // carry:跨 behaviorRound / attempt **存活**的派生物暂存区(run.mjs / sentinel.sql / expectations.md)。
  987 + // 实测:本门 40+ 分钟里约 18 分钟花在"读 spec → 查表 → 反查组件 → 推导期望 → 写 runner",而 fix 只
  988 + // 改 frontend/ 源码——期望(来自 prototype/REQ/docs05)与 runner(DOM/locator 驱动)在同一前端阶段内
  989 + // 不随 fix 失效。故只重跑不重推导。**运行栈仍绝不复用**(每次冷起,契约未变)。
  990 + const carryDir = `${c.root}/.tmp/behavior-gate/frontend-phase/carry`
  991 + const reuse = behaviorRound > 1 || attempt > 1
981 const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-r${behaviorRound}-a${attempt}.md` 992 const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-r${behaviorRound}-a${attempt}.md`
982 return [ 993 return [
983 `# behavior — 前端阶段级行为验收(headless,frontend-phase, behaviorRound=${behaviorRound}, attempt=${attempt})`, 994 `# behavior — 前端阶段级行为验收(headless,frontend-phase, behaviorRound=${behaviorRound}, attempt=${attempt})`,
@@ -994,8 +1005,18 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -994,8 +1005,18 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
994 '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)', 1005 '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)',
995 '- **冷起栈(运行时硬约束)**:本项目**无既有 e2e webServer / playwright.config 复用入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。', 1006 '- **冷起栈(运行时硬约束)**:本项目**无既有 e2e webServer / playwright.config 复用入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。',
996 `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1 1007 `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1
997 - ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子),再新建本轮子目录 \`${tmpDir}/\`。`  
998 - : `本次 behaviorRound=${behaviorRound} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`, 1008 + ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子/carry),再新建本轮子目录 \`${tmpDir}/\` 与派生物暂存区 \`${carryDir}/\`。`
  1009 + : `本次 behaviorRound=${behaviorRound} / attempt=${attempt} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建;**\`${carryDir}/\` 绝不删**——它装着上一次已推导好的 runner/种子/期望,本次要复用(见下方「派生物复用」)。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`,
  1010 + // 派生物复用(省时改造):只重跑不重推导。首次跑完必须落 carry,后续轮次必须先读 carry。
  1011 + reuse
  1012 + ? [
  1013 + `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:全部 FE 作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / 组件文件反查表)与 \`${carryDir}/run.mjs\`、\`${carryDir}/sentinel.sql\`。`,
  1014 + ` - **三者齐备 → 直接复用**:把 \`${carryDir}/run.mjs\` / \`sentinel.sql\` 复制进本轮 \`${tmpDir}/\` 后使用,**跳过 step1 / step4 的重新推导**(期望以 \`expectations.md\` 为准),直接进 step2 起栈。这是本次的默认路径。`,
  1015 + ` - **只在有明确证据时才改**:上次证据显示 runner 自身有 bug(选择器过时 / 断言写错 / 漏驱动),或 \`frontend/\` 的路由表在上一轮 fix 中真的变了 → 就地**增量修补**复制过来的 runner,并把修补同步回 \`${carryDir}/run.mjs\`;**绝不**因为"想重写得更好"而从头重推导。`,
  1016 + ` - 缺文件 / 内容不可用 → 按首次流程完整推导,跑完照样落回 \`${carryDir}/\`(缺失是异常,在证据报告里注明)。`,
  1017 + ` - **不复用的部分(硬约束)**:运行栈(每次冷起)、数据库副本(每次重建)、演示/sentinel 数据的**注入动作**(每次重灌)、证据报告(每次独立文件)。复用的只是"推导结果与脚本"。`,
  1018 + ].join('\n')
  1019 + : `- **派生物落盘(本次是首次推导,必须做)**:step1/step4 推导完成后,把 ① 推导结果写成 \`${carryDir}/expectations.md\`(全部 FE 作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / route→组件文件反查表)、② runner 写一份到 \`${carryDir}/run.mjs\`、③ sentinel 种子写一份到 \`${carryDir}/sentinel.sql\`。后续 behaviorRound / attempt 会直接复用这三份**跳过重新推导**(fix 只改 \`frontend/\` 源码,不使这些失效),省掉每轮十几分钟的重复侦查。carry 与本轮 \`${tmpDir}/\` 同属临时区、跑完即弃,**不提交**。`,
999 `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 本门起的全部子进程**并透传结构化结果。**绝不**让前台 gradle bootRun / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`, 1020 `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 本门起的全部子进程**并透传结构化结果。**绝不**让前台 gradle bootRun / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`,
1000 // runner 的 status 必须是**算出来的**。曾实际发生:生成的 run.mjs 把 status 初始化为 'red' 1021 // runner 的 status 必须是**算出来的**。曾实际发生:生成的 run.mjs 把 status 初始化为 'red'
1001 // 后再未赋值,恒红且不带任何分类信号——若子代理照单全收,上层 runBehaviorGate 的 1022 // 后再未赋值,恒红且不带任何分类信号——若子代理照单全收,上层 runBehaviorGate 的
@@ -1016,7 +1037,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -1016,7 +1037,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1016 ' - 根因不可归到 `frontend/` 源码(依赖 / 工具链 / 无法定位)→ `envError.kind="build-failed"`(如能定位仍填 `rootCausePath`)。', 1037 ' - 根因不可归到 `frontend/` 源码(依赖 / 工具链 / 无法定位)→ `envError.kind="build-failed"`(如能定位仍填 `rootCausePath`)。',
1017 ' - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。', 1038 ' - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。',
1018 '', 1039 '',
1019 - '## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)', 1040 + `## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`,
  1041 + reuse ? `- 本次已从 \`${carryDir}/expectations.md\` 取得分母与逐路由归属,**不要**重新逐 FE Read spec、不要重新对账 router(那是上一次已做完的确定性推导,重做只是重复烧时间)。仅当 carry 缺失/不可用时才回退执行本节。` : '',
1020 '- 分母来源 = 全部 FE spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。', 1042 '- 分母来源 = 全部 FE spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。',
1021 `- 与 \`${c.root}/frontend/\` router 配置对账:FE 作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属任何 FE 作用域的路由记证据(不入分母、不断言)。`, 1043 `- 与 \`${c.root}/frontend/\` router 配置对账:FE 作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属任何 FE 作用域的路由记证据(不入分母、不断言)。`,
1022 '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。', 1044 '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。',
@@ -1026,7 +1048,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -1026,7 +1048,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1026 `1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。建副本前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`, 1048 `1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。建副本前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`,
1027 '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', 1049 '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
1028 `3) **注入演示种子**:\`node ${c.root}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过源库已灌过的文件,把新 \`sql/seed/*.sql\` 演示数据灌进副本)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`, 1050 `3) **注入演示种子**:\`node ${c.root}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过源库已灌过的文件,把新 \`sql/seed/*.sql\` 演示数据灌进副本)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`,
1029 - '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。', 1051 + `4) **此时才跑 sentinel 种子**:${reuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : '按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)'}。失败 → \`envError.kind="seed-error"\` + 结构化根因,**不**混进交互 RED。`,
1030 ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——数值主键**一律 ≥100000**(固定区间,不再动态扫描既有键:初始数据 1–999 / 演示种子 1000–9999 已由区间约定隔离,sentinel 落 ≥100000 天然不冲突);字符串列**仍逐字段唯一编码**(`_S<NNN>` 样式,如 `CUST_NAME_S001`,抓绑错字段——演示数据已被禁用该样式,故 sentinel 独占)+ 行序号保 UNIQUE;enum 列从 docs/03 值域取并标注。断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', 1052 ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——数值主键**一律 ≥100000**(固定区间,不再动态扫描既有键:初始数据 1–999 / 演示种子 1000–9999 已由区间约定隔离,sentinel 落 ≥100000 天然不冲突);字符串列**仍逐字段唯一编码**(`_S<NNN>` 样式,如 `CUST_NAME_S001`,抓绑错字段——演示数据已被禁用该样式,故 sentinel 独占)+ 行序号保 UNIQUE;enum 列从 docs/03 值域取并标注。断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。',
1031 '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。', 1053 '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。',
1032 `- \`finally\` **硬要求 kill 本门起的全部子进程**,**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动);端口 + pid 写入 \`envError.ports\` / \`envError.pids\`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。`, 1054 `- \`finally\` **硬要求 kill 本门起的全部子进程**,**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动);端口 + pid 写入 \`envError.ports\` / \`envError.pids\`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。`,
@@ -1043,7 +1065,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { @@ -1043,7 +1065,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1043 '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。', 1065 '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。',
1044 '- `routesReached` / `controlsEnumerated` 据实填(空覆盖必须可见)。', 1066 '- `routesReached` / `controlsEnumerated` 据实填(空覆盖必须可见)。',
1045 '', 1067 '',
1046 - '## step4 推导期望', 1068 + `## step4 推导期望${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`,
  1069 + reuse ? `- 期望以 \`${carryDir}/expectations.md\` 为准(含每控件预期效果、每文字区域预期内容与来源、sentinel 值表)。**不要**重新读 prototype/REQ/docs05 推导——上一轮 fix 只改了 \`frontend/\` 实现,不改这些期望的来源。` : '',
1047 '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。', 1070 '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。',
1048 '', 1071 '',
1049 '## step5 断言(三层:交互/文字/样式 + 可观测效果白名单 + 硬问题带源码 locator)', 1072 '## step5 断言(三层:交互/文字/样式 + 可观测效果白名单 + 硬问题带源码 locator)',
@@ -1220,6 +1243,70 @@ const PROTOTYPE_PREVIEW_ATTEMPT_MAX = 2 @@ -1220,6 +1243,70 @@ const PROTOTYPE_PREVIEW_ATTEMPT_MAX = 2
1220 const NULL_AGENT_PROBLEM = 'agent() 返回 null(子代理终端 API 错误 / 被用户跳过)——典型为断网或机器休眠;可重试' 1243 const NULL_AGENT_PROBLEM = 'agent() 返回 null(子代理终端 API 错误 / 被用户跳过)——典型为断网或机器休眠;可重试'
1221 const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' 1244 const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : ''
1222 1245
  1246 +// ── 进度 ETA(narrator 行)──────────────────────────────────────────────────
  1247 +// 动机:左侧 Phases 面板的分母 = 该分组**已派生**的子代理数(harness 侧计数;脚本无 API 预声明
  1248 +// 总量),串行链上它只能 0/1→1/2 地长,看不出"还剩多少 / 还要多久"。故用 log() narrator 行补
  1249 +// 上「总量 + 预计分钟」——纯显示,绝不参与任何控制流判定。
  1250 +// STAGE_MIN:各 stage 的实测耗时 `[p25, 中位数, p75]`(分钟),键 = site/label 的
  1251 +// `<kind>` 或 `<kind>:<phase>` 前缀。样本 = 本机同一 ERP 项目 13 次 coding 运行的子代理
  1252 +// transcript(首末 timestamp 差;n 见行尾)。运行时禁 Date.now()(会破坏 resume 重放),
  1253 +// 所以这里只能按"剩余段数 × 实测分布"静态外推,不做真实计时——是量级参考,不是承诺。
  1254 +const STAGE_MIN = {
  1255 + 'spec:backend': [1.2, 3.4, 5.9], // n=31
  1256 + 'spec:frontend': [3.8, 4.7, 6.9], // n=15
  1257 + 'plan:backend': [2.8, 5.1, 6.5], // n=20
  1258 + 'plan:frontend': [3.3, 5.6, 6.6], // n=14
  1259 + 'tdd:backend': [3.5, 10.8, 25.6], // n=21
  1260 + 'tdd:frontend': [11.3, 19.4, 33.9], // n=14
  1261 + 'verify:backend': [3.7, 5.7, 7.7], // n=23
  1262 + 'verify:frontend':[2.8, 3.2, 5.1], // n=16
  1263 + 'review:backend': [2.4, 3.2, 4.0], // n=21(每轮)
  1264 + 'review:frontend':[3.5, 4.2, 5.6], // n=14(每轮)
  1265 + 'fix:backend': [2.4, 3.2, 4.0], // fix 轮与 review 同量级
  1266 + 'fix:frontend': [3.5, 4.2, 5.6],
  1267 + gate: [2.5, 9.4, 11.8], // n=45(每 attempt)
  1268 + seed: [22.0, 23.5, 25.0], // n=2
  1269 + report: [1.4, 4.9, 14.7], // n=24
  1270 + behavior: [10.2, 18.4, 45.0], // n=13(每 behaviorRound × attempt)
  1271 + 'behavior-env': [10.2, 18.4, 45.0],
  1272 + 'behavior-reverify': [2.1, 2.3, 2.4], // n=4
  1273 + preview: [3.1, 4.8, 6.9], // n=12
  1274 + skeleton: [3.8, 5.7, 5.7], // n=5
  1275 +}
  1276 +const MICRO_MIN = 0.25 // tag / docs08 勾选 / 分支类微步骤(不在表内 → 不播报)
  1277 +const MICRO_PER_ITEM = 3 // 每 item 的微步骤:docs08 查 + docs08 勾 + req-done tag
  1278 +const fmtMin = (m) => m >= 90 ? `~${Math.round(m / 6) / 10} 小时` : `~${Math.round(m)} 分钟`
  1279 +const fmtRange = (r) => `${Math.round(r[0])}–${Math.round(r[2])} 分钟`
  1280 +// etaOf:site(`<kind>:<phase>:<id>` / `<kind>:<id>`)→ 三分位数组;表外(微步骤 / 仲裁)返回 null。
  1281 +function etaOf(site) {
  1282 + const p = String(site || '').split(':')
  1283 + return STAGE_MIN[`${p[0]}:${p[1]}`] || STAGE_MIN[p[0]] || null
  1284 +}
  1285 +// etaLog:每个可计时 stage 在**开跑**与**每次重跑**时各播报一行——"预计 X–Y 分钟" /
  1286 +// "第 n 次重跑,再需 X–Y 分钟"。表外的微步骤静默(0.2 分钟级,播报只是噪声)。
  1287 +// 纯显示,绝不参与控制流;round 由调用方的重试循环下标透传。
  1288 +function etaLog(site, round = 1, note = '') {
  1289 + const r = etaOf(site)
  1290 + if (!r) return
  1291 + const tail = note ? `(${note})` : ''
  1292 + log(round <= 1
  1293 + ? `eta ${site}: 预计 ${fmtRange(r)}${tail}`
  1294 + : `eta ${site}: 第 ${round} 次重跑,再需 ${fmtRange(r)}${tail}`)
  1295 +}
  1296 +// itemMin:单个 REQ/FE 的全链预计(spec+plan+tdd+verify+review 1 轮 + 微步骤),取中位数,顺利路径。
  1297 +const itemMin = (ph, q = 1) => ['spec', 'plan', 'tdd', 'verify', 'review']
  1298 + .reduce((s, k) => s + STAGE_MIN[`${k}:${ph}`][q], 0) + MICRO_MIN * MICRO_PER_ITEM
  1299 +// moduleMin:单模块预计(顺利路径:review 1 轮、gate 1 次过、behavior 1 轮绿、零仲裁重试)。
  1300 +// q:0=p25(顺风)/ 1=中位数 / 2=p75(逆风),用于给出区间而非单点。
  1301 +function moduleMin(m, q = 1) {
  1302 + let t = MICRO_MIN * 2 // 分支准备 + cross-module 记账
  1303 + const nb = (m.reqs || []).length, nf = (m.feItems || []).length
  1304 + if (nb) t += nb * itemMin('backend', q) + STAGE_MIN.gate[q] + STAGE_MIN.seed[q] + STAGE_MIN.report[q]
  1305 + if (nf) t += STAGE_MIN.preview[q] + STAGE_MIN.skeleton[q] + nf * itemMin('frontend', q)
  1306 + + STAGE_MIN.behavior[q] + STAGE_MIN.gate[q] + STAGE_MIN.report[q]
  1307 + return t
  1308 +}
  1309 +
1223 // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记后随结果回传供人工事后审阅。 1310 // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记后随结果回传供人工事后审阅。
1224 // sink 参数:模块作用域调用传 c.decisions(per-module 收集器,并行交错下水位线语义失效的替代); 1311 // sink 参数:模块作用域调用传 c.decisions(per-module 收集器,并行交错下水位线语义失效的替代);
1225 // 缺省落全局 autonomousDecisions(仅剩"漏传 dec 的兜底"语义,顶层 return 时作为残量并入)。 1312 // 缺省落全局 autonomousDecisions(仅剩"漏传 dec 的兜底"语义,顶层 return 时作为残量并入)。
@@ -1299,6 +1386,7 @@ async function adjudicate(site, context, grp, round, root = ROOT) { @@ -1299,6 +1386,7 @@ async function adjudicate(site, context, grp, round, root = ROOT) {
1299 async function runStage(makePrompt, { site, grp, label, validate, allowContinue = false, dec, root = ROOT }) { 1386 async function runStage(makePrompt, { site, grp, label, validate, allowContinue = false, dec, root = ROOT }) {
1300 let guidance = '' 1387 let guidance = ''
1301 for (let round = 1; round <= ADJUDICATE_MAX; round++) { 1388 for (let round = 1; round <= ADJUDICATE_MAX; round++) {
  1389 + etaLog(site, round) // 开跑 / 仲裁后重跑各播报一次预计耗时(纯显示)
1302 const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA}) 1390 const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA})
1303 if (!res) { 1391 if (!res) {
1304 const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root) 1392 const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root)
@@ -1322,10 +1410,13 @@ async function runStage(makePrompt, { site, grp, label, validate, allowContinue @@ -1322,10 +1410,13 @@ async function runStage(makePrompt, { site, grp, label, validate, allowContinue
1322 // runAction:跑一个 ACTION_RESULT 微步骤(git / 文件写),失败时经 adjudicate 决定 retry/continue/halt。 1410 // runAction:跑一个 ACTION_RESULT 微步骤(git / 文件写),失败时经 adjudicate 决定 retry/continue/halt。
1323 // allowContinue=true 时 continue 视为"接受失败并前进"(仅用于纯可视化等可安全跳过的副作用)。 1411 // allowContinue=true 时 continue 视为"接受失败并前进"(仅用于纯可视化等可安全跳过的副作用)。
1324 // dec:与 runStage 调用点签名统一而收(ACTION_RESULT 无 decisions 字段,当前不消费);root:仲裁取证树根(补 11)。 1412 // dec:与 runStage 调用点签名统一而收(ACTION_RESULT 无 decisions 字段,当前不消费);root:仲裁取证树根(补 11)。
1325 -async function runAction(makePrompt, { site, grp, label, allowContinue = false, dec, root = ROOT }) { 1413 +// schema:缺省 ACTION_RESULT_SCHEMA;合并版微步骤(如 BRANCH_READY_SCHEMA)在 success 之外还带
  1414 +// 回执字段,只要 schema 含 `success` 布尔,本函数的失败/仲裁/重试语义就逐字不变。
  1415 +async function runAction(makePrompt, { site, grp, label, allowContinue = false, dec, root = ROOT, schema = ACTION_RESULT_SCHEMA }) {
1326 let guidance = '' 1416 let guidance = ''
1327 for (let round = 1; round <= ADJUDICATE_MAX; round++) { 1417 for (let round = 1; round <= ADJUDICATE_MAX; round++) {
1328 - const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: ACTION_RESULT_SCHEMA}) 1418 + etaLog(site, round) // 表内的 action(seed 等)才播报;微步骤静默
  1419 + const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema})
1329 if (!r) { 1420 if (!r) {
1330 const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root) 1421 const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root)
1331 if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`) 1422 if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`)
@@ -1479,16 +1570,6 @@ function worktreeCleanPromptM(c) { @@ -1479,16 +1570,6 @@ function worktreeCleanPromptM(c) {
1479 ].join('\n') 1570 ].join('\n')
1480 } 1571 }
1481 1572
1482 -function checkBranchExistsPromptM(branch, c) {  
1483 - return [  
1484 - `# 本地分支 \`${branch}\` 是否存在`,  
1485 - microStepContract(c.root),  
1486 - '',  
1487 - `跑 \`git -C ${c.root} rev-parse --verify refs/heads/${branch}\`(用 2>/dev/null 抑制 stderr)。`,  
1488 - '## 输出(EXISTS_SCHEMA)',  
1489 - '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`',  
1490 - ].join('\n')  
1491 -}  
1492 1573
1493 function currentBranchPromptM(c) { 1574 function currentBranchPromptM(c) {
1494 return [ 1575 return [
@@ -1502,6 +1583,27 @@ function currentBranchPromptM(c) { @@ -1502,6 +1583,27 @@ function currentBranchPromptM(c) {
1502 } 1583 }
1503 1584
1504 // ── 微步骤:分支生命周期 action ── 1585 // ── 微步骤:分支生命周期 action ──
  1586 +// ensureBranchCheckedOutPromptM:合并版(原为 exists? → checkout|create → head 三个子代理会话)。
  1587 +// 三段之间没有任何 JS 决策——JS 只是把"存在与否"这个布尔转手喂回下一个会话,再派第三个会话读回
  1588 +// HEAD 确认。git 层面本就是一条确定性链(rev-parse --verify → checkout / checkout -b → rev-parse
  1589 +// --abbrev-ref),合成单会话后每个模块省两次会话往返,且 HEAD 由同一会话现场回报(比事后另起一
  1590 +// 个会话读更贴近真值)。JS 侧保留原有的 HEAD 不符仲裁重切循环(本函数只是它的第一次尝试)。
  1591 +function ensureBranchCheckedOutPromptM(branch, fromBranch, c) {
  1592 + return [
  1593 + `# 确保 HEAD 落在本地分支 \`${branch}\`(不存在则从 \`${fromBranch}\` 新建)`,
  1594 + microStepContract(c.root),
  1595 + '',
  1596 + `1. 跑 \`git -C ${c.root} rev-parse --verify refs/heads/${branch}\`(用 2>/dev/null 抑制 stderr)判断分支是否已存在。`,
  1597 + `2. 按结果二选一——**只跑一条路径**:`,
  1598 + ` - exit=0(已存在)→ 跑 \`git -C ${c.root} checkout ${branch}\`,\`created\` 填 false。`,
  1599 + ` - 非 0(不存在)→ 按序跑 \`git -C ${c.root} checkout ${fromBranch}\` 再 \`git -C ${c.root} checkout -b ${branch}\`,\`created\` 填 true。`,
  1600 + `3. 跑 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\`,把 stdout 第一行去空白后填进 \`branch\`(**据实回填**——即便与期望不符也如实填,绝不改写成期望值,上层据此仲裁)。`,
  1601 + '## 输出(BRANCH_READY_SCHEMA)',
  1602 + '- 全部成功:`{ "success": true, "branch": "<第 3 步实测 HEAD>", "created": <true|false> }`',
  1603 + '- 任一步失败:`{ "success": false, "error": "<which step + stderr 摘要>", "branch": "<能读到就填实测 HEAD,读不到填空串>" }`',
  1604 + ].join('\n')
  1605 +}
  1606 +
1505 function checkoutExistingBranchPromptM(branch, c) { 1607 function checkoutExistingBranchPromptM(branch, c) {
1506 return [ 1608 return [
1507 `# 切到已存在的本地分支 \`${branch}\``, 1609 `# 切到已存在的本地分支 \`${branch}\``,
@@ -1514,20 +1616,10 @@ function checkoutExistingBranchPromptM(branch, c) { @@ -1514,20 +1616,10 @@ function checkoutExistingBranchPromptM(branch, c) {
1514 ].join('\n') 1616 ].join('\n')
1515 } 1617 }
1516 1618
1517 -function createBranchFromPromptM(fromBranch, newBranch, c) {  
1518 - return [  
1519 - `# 从 \`${fromBranch}\` 新建并切到 \`${newBranch}\``,  
1520 - microStepContract(c.root),  
1521 - '',  
1522 - `按序跑:\`git -C ${c.root} checkout ${fromBranch}\`,然后 \`git -C ${c.root} checkout -b ${newBranch}\`。`,  
1523 - '## 输出(ACTION_RESULT_SCHEMA)',  
1524 - '- 全成功:`{ "success": true }`;任一失败:`{ "success": false, "error": "<which step + stderr>" }`',  
1525 - ].join('\n')  
1526 -}  
1527 1619
1528 // syncBranchWithDefaultPromptM:把默认分支合进当前功能分支(幂等)。 1620 // syncBranchWithDefaultPromptM:把默认分支合进当前功能分支(幂等)。
1529 // 1621 //
1530 -// 为什么必须有这一步:runBranchSetup 对**新建**分支以默认分支为起点(createBranchFromPromptM), 1622 +// 为什么必须有这一步:runBranchSetup 对**新建**分支以默认分支为起点(ensureBranchCheckedOutPromptM 的新建路径),
1531 // 但对**已存在**分支只做 checkout(主根)/ worktree add(lane 分支 2),二者都不带任何同步动作。 1623 // 但对**已存在**分支只做 checkout(主根)/ worktree add(lane 分支 2),二者都不带任何同步动作。
1532 // 于是「上一轮跑完留下的功能分支」会停在它当时的位置,其后提交到默认分支的一切——新 REQ 卡片、 1624 // 于是「上一轮跑完留下的功能分支」会停在它当时的位置,其后提交到默认分支的一切——新 REQ 卡片、
1533 // 新 migration、docs 增量——对本轮**全部不可见**。子代理据残缺事实作业不会报错,只会自行发明 1625 // 新 migration、docs 增量——对本轮**全部不可见**。子代理据残缺事实作业不会报错,只会自行发明
@@ -1946,40 +2038,31 @@ function writeDocs08FieldPromptM(fe, id, targetValue, phaseId, lineNumber) { @@ -1946,40 +2038,31 @@ function writeDocs08FieldPromptM(fe, id, targetValue, phaseId, lineNumber) {
1946 ].join('\n') 2038 ].join('\n')
1947 } 2039 }
1948 2040
1949 -// ── 微步骤:docs/08 功能行 checkbox(reviewer approve 后的可观测 side effect;read-then-write)──  
1950 -function readDocs08CheckboxPromptM(fe, id, c) { 2041 +// ── 微步骤:docs/08 功能行 checkbox(reviewer approve 后的可观测 side effect)───────
  2042 +// 合并版(原为 read-then-write 两个子代理会话):定位 + 判态 + 勾选 + commit 收进**一个**会话。
  2043 +// 拆两段的唯一理由曾是"让 JS 拿到 lineNumber 再喂写入方",但写入方本就带无行号的回退定位分支,
  2044 +// 行号只是加速器不是必要输入——两段之间没有任何 JS 决策,纯白烧一次会话往返(每个 REQ/FE 一次)。
  2045 +// 三种非错误终态经 detail 回传(not-found / already-checked / flipped),调用方据此打日志,
  2046 +// 与拆分版的分支日志逐条对应;任何真失败仍走 success:false(cosmetic,调用方只 log 不阻断)。
  2047 +function flipDocs08CheckboxPromptM(fe, id, phase, c) {
1951 const section = fe ? '§ 三' : '§ 二' 2048 const section = fe ? '§ 三' : '§ 二'
1952 - const kind = fe ? '功能' : 'REQ' 2049 + const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}`
1953 const locator = fe 2050 const locator = fe
1954 ? `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。` 2051 ? `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`
1955 : `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section},找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。` 2052 : `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section},找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`
1956 return [ 2053 return [
1957 - `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`, 2054 + `# 把 docs/08 ${scope} 勾选为 \`[x]\` 并 commit(含定位与判态)`,
1958 microStepContract(c.root), 2055 microStepContract(c.root),
1959 '', 2056 '',
1960 - locator,  
1961 - '## 输出(CHECKBOX_STATE_SCHEMA)',  
1962 - `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``,  
1963 - `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``,  
1964 - '- 找不到:`{ "found": false, "state": "unchecked" }`(state 仍必填,避免 schema 失败掩盖真实缺口)。',  
1965 - ].join('\n')  
1966 -}  
1967 -  
1968 -function writeDocs08CheckboxPromptM(fe, id, phase, lineNumber, c) {  
1969 - const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}`  
1970 - const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber))  
1971 - ? `先 Read \`${c.root}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行去掉行首空白后以 \`- [ ] ${id} \` 开头;不满足则返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual=<actual>"}\`。然后只替换第 ${lineNumber} 行的第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。`  
1972 - : `定位 docs/08 ${scope} 中去掉行首空白后以 \`- [ ] ${id} \` 开头的唯一一行,只替换该行第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。`  
1973 - return [  
1974 - `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`,  
1975 - microStepContract(c.root),  
1976 - '',  
1977 - `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`,  
1978 - `1. ${lineGuard}`,  
1979 - `2. 跑 \`git -C ${c.root} add docs/08-模块任务管理.md\`。`,  
1980 - `3. 跑 \`git -C ${c.root} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`, 2057 + `1. ${locator}`,
  2058 + `2. 按命中结果分流——**只有第 3 种才写文件**:`,
  2059 + ` - 找不到该行 → 直接返回 \`{ "success": true, "detail": "not-found" }\`(不写、不 commit)。`,
  2060 + ` - 命中 \`- [x] ${id} ...\`(已勾选)→ 直接返回 \`{ "success": true, "detail": "already-checked" }\`(幂等,不写、不 commit)。`,
  2061 + ` - 命中 \`- [ ] ${id} ...\` → 只替换该行的**第一个** \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本;其余行一律不动。`,
  2062 + `3. 仅在第 2 步真改了文件时:跑 \`git -C ${c.root} add docs/08-模块任务管理.md\`,再跑 \`git -C ${c.root} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`,成功返回 \`{ "success": true, "detail": "flipped" }\`。`,
1981 '## 输出(ACTION_RESULT_SCHEMA)', 2063 '## 输出(ACTION_RESULT_SCHEMA)',
1982 - '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "<step + reason>" }`', 2064 + '- 三种非错误终态按上文填 `detail`(`not-found` / `already-checked` / `flipped`),`success` 恒 true。',
  2065 + '- 任一步真失败(文件读不到 / 行内容与预期不符 / git 失败):`{ "success": false, "error": "<step + reason>" }`。',
1983 ].join('\n') 2066 ].join('\n')
1984 } 2067 }
1985 2068
@@ -2177,10 +2260,23 @@ function writeCrossModuleLogPromptM(moduleId, items, c) { @@ -2177,10 +2260,23 @@ function writeCrossModuleLogPromptM(moduleId, items, c) {
2177 } 2260 }
2178 2261
2179 // ---- 模块完成报告(原 module-report)---- 2262 // ---- 模块完成报告(原 module-report)----
2180 -function reportPrompt(module, c) { 2263 +// facts(Task:module-report 瘦身):调用方把**已在内存里的**门禁事实透传进来——
  2264 +// gate = testGate 的返回(status/attempts/failures),behavior = runBehaviorGate 绿态回执。
  2265 +// 原实现让报告子代理 Glob + 通读**全部** attempt / behaviorRound 证据来自证"最后一份绿",
  2266 +// 而 JS 才是这个事实的第一手持有者(红根本走不到 report——testGate/behaviorGate 会先 halt)。
  2267 +// 实测 module-report 均值 14.6 分钟、占全流程墙钟 11.7%,通读证据是其中的大头。改为:
  2268 +// 事实由入参给定,子代理只按给定的**文件名**读它真正要摘要的那一两份(末次 green;flake 时加首次 red)。
  2269 +function reportPrompt(module, c, facts = {}) {
2181 const id = module?.id ?? '<module>' 2270 const id = module?.id ?? '<module>'
2182 const fe = id === 'frontend-phase' 2271 const fe = id === 'frontend-phase'
2183 const phaseId = fe ? 'frontend-phase' : id 2272 const phaseId = fe ? 'frontend-phase' : id
  2273 + const gate = facts.gate || {}
  2274 + const bh = facts.behavior || {}
  2275 + const gAttempts = Number(gate.attempts) || 1
  2276 + const gateEvidence = `${phaseId}-test-gate-r${gAttempts}.md`
  2277 + const gateFlake = gAttempts > 1
  2278 + const bhRounds = Number(bh.rounds) || 1
  2279 + const bhEvidence = `frontend-phase-behavior-r${bhRounds}-a${Number(bh.attempts) || 1}.md`
2184 return [ 2280 return [
2185 `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`, 2281 `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`,
2186 '', 2282 '',
@@ -2189,10 +2285,12 @@ function reportPrompt(module, c) { @@ -2189,10 +2285,12 @@ function reportPrompt(module, c) {
2189 '## 目标', 2285 '## 目标',
2190 `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`, 2286 `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`,
2191 '', 2287 '',
2192 - '## 前置',  
2193 - `- 验证上游 test-gate 绿:Glob \`${c.root}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`, 2288 + '## 前置(门禁事实由编排层给定,**不要**再 Glob 通读全部证据自证)',
  2289 + `- 上游 test-gate 已 green(编排层判定:共 ${gAttempts} 个 attempt,末次 \`${gateEvidence}\` = green)。红态根本走不到本步——testGate 会先 halt,故**不需要**你重新校验绿不绿。`,
  2290 + `- 只需 Read 末次证据 \`${c.root}/docs/superpowers/module-reports/${gateEvidence}\` 取 § ⑤ 要用的摘要${gateFlake ? `;另 Read 首次 \`${phaseId}-test-gate-r1.md\`(attempt>1 = flake,§ ⑤ 需要首次失败用例)` : '(单 attempt,无 flake,无需读其它 attempt 文件)'}。该文件缺失 → halt(证据链断裂)。`,
  2291 + gateFlake ? `- **flake 标注(编排层已判定)**:attempt 数 = ${gAttempts} 且末次 green → § ⑤ 顶部必须写 \`flake-detected: r1 red, r${gAttempts} green\`,并附首次失败用例与末次绿色记录链接。` : '',
2194 fe 2292 fe
2195 - ? `- **验证阶段级行为门绿**:Glob \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`,按 behaviorRound → attempt 数字升序读取。**最后一份必须非 RED(绿)**;最后一份 red 或一份证据都没有 → 立即 halt(绝不在行为红 / 无行为证据上打 milestone)。注意 per-FE 行为证据(\`reviews/<date>-<FE>-behavior-*\`)已不再产生,不要校验。` 2293 + ? `- 阶段级行为门已 green(编排层判定:behaviorRound=${bhRounds},末次证据 \`${bhEvidence}\`,routesPlanned/Reached=${bh.routesPlanned ?? '?'}/${bh.routesReached ?? '?'},controlsEnumerated=${bh.controlsEnumerated ?? '?'},authState=${bh.authState || '?'})。同样**不要**再 Glob 全部 \`frontend-phase-behavior-r*-a*.md\` 自证——只 Read 末次那一份取 § ⑤/§ ⑧ 需要的逐 FE 小节${bhRounds > 1 ? `;另 Read \`frontend-phase-behavior-reverify-r*.md\`(有 ${bhRounds - 1} 轮行为 fix,§ ⑤ 要记录 fix 轮数与复验结果)` : '(一轮即绿,无 fix、无复验文件)'}。`
2196 : '', 2294 : '',
2197 '', 2295 '',
2198 '## 收集输入(取摘要而非正文)', 2296 '## 收集输入(取摘要而非正文)',
@@ -2202,14 +2300,14 @@ function reportPrompt(module, c) { @@ -2202,14 +2300,14 @@ function reportPrompt(module, c) {
2202 `- § ② "FE 完成清单":扫 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`, 2300 `- § ② "FE 完成清单":扫 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`,
2203 `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`, 2301 `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`,
2204 '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。', 2302 '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。',
2205 - `- § ⑤:把 \`${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 记录一并纳入 § ⑤ 汇总**。`,  
2206 - `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外按阶段级行为证据 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`(取最后一份的逐 FE 小节)汇总各 FE 的 \`coverageGaps\` + 样式 \`styleIssues\` + 文字 \`textIssues\` 的 continue 记录 + 逐控件判定摘要 + authState 未覆盖角色集**。`, 2303 + `- § ⑤:摘要上方"前置"已点名的那一两份 test-gate 证据(${gateFlake ? `\`${phaseId}-test-gate-r1.md\` + \`${gateEvidence}\`` : `仅 \`${gateEvidence}\``})。**另把行为门末次证据 \`${bhEvidence}\` 的 flake / 环境 race(envError)/ 行为 fix 轮数(=${bhRounds - 1})/ 样式 styleIssues 修复轮次 / 文字 continue 记录纳入 § ⑤ 汇总**${bhRounds > 1 ? '(含复验文件)' : ''}。`,
  2304 + `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外按行为门末次证据 \`${bhEvidence}\` 的逐 FE 小节**汇总各 FE 的 \`coverageGaps\` + 样式 \`styleIssues\` + 文字 \`textIssues\` 的 continue 记录 + 逐控件判定摘要 + authState 未覆盖角色集。`,
2207 '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。', 2305 '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。',
2208 ].join('\n') 2306 ].join('\n')
2209 : [ 2307 : [
2210 `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`, 2308 `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`,
2211 `- § ② / § ⑨:读 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`, 2309 `- § ② / § ⑨:读 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`,
2212 - `- § ⑤:把 \`${c.root}/docs/superpowers/module-reports/${id}-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`, 2310 + `- § ⑤:摘要上方"前置"已点名的那一两份 test-gate 证据(${gateFlake ? `\`${id}-test-gate-r1.md\` + \`${gateEvidence}\`` : `仅 \`${gateEvidence}\``})——不要再按通配符通读全部 attempt。`,
2213 `- § ⑥ Migration:\`git -C ${c.root} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`, 2311 `- § ⑥ Migration:\`git -C ${c.root} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`,
2214 `- § ⑦ 跨模块改动:读 \`${c.root}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`, 2312 `- § ⑦ 跨模块改动:读 \`${c.root}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`,
2215 '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。', 2313 '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。',
@@ -2256,19 +2354,13 @@ async function runBranchSetup(module, c) { @@ -2256,19 +2354,13 @@ async function runBranchSetup(module, c) {
2256 log(`branch-setup: ${id} 自动提交脏工作树残留(${rec.detail || ''})`) 2354 log(`branch-setup: ${id} 自动提交脏工作树残留(${rec.detail || ''})`)
2257 } 2355 }
2258 2356
2259 - if (c.lane == null) {  
2260 - // 主根模式:现行 checkout 路径,一行不差(lane 模式跳过——worktree add 已把 lane 放上目标分支,  
2261 - // 且 checkout 语义在"分支被别的 worktree 持有"时会 fatal,绝不能在主根重复执行)。  
2262 - const exists = await agentR(checkBranchExistsPromptM(branch, c), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA})  
2263 - if (exists.exists) {  
2264 - await runAction(g => checkoutExistingBranchPromptM(branch, c) + g, {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('checkout'), dec: c.decisions, root: c.root})  
2265 - } else {  
2266 - await runAction(g => createBranchFromPromptM(def.branch, branch, c) + g, {site:`branchSetup-create:${branch}`, grp:'Milestone', label: lbl('create'), dec: c.decisions, root: c.root})  
2267 - }  
2268 - }  
2269 -  
2270 - // HEAD 确认:不符则经仲裁重切(retry)或留人工(halt)。  
2271 - let head = await agentR(currentBranchPromptM(c), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) 2357 + // 主根模式:exists?+checkout|create+head 三合一(lane 模式跳过——worktree add 已把 lane 放上目标
  2358 + // 分支,且 checkout 语义在"分支被别的 worktree 持有"时会 fatal,绝不能在主根重复执行)。
  2359 + // lane 模式仍需读回 HEAD 供下方仲裁循环判定,走原单读微步骤。
  2360 + let head = c.lane == null
  2361 + ? await runAction(g => ensureBranchCheckedOutPromptM(branch, def.branch, c) + g,
  2362 + {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('ready'), dec: c.decisions, root: c.root, schema: BRANCH_READY_SCHEMA})
  2363 + : await agentR(currentBranchPromptM(c), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
2272 for (let adj = 1; head.branch !== branch && adj <= ADJUDICATE_MAX; adj++) { 2364 for (let adj = 1; head.branch !== branch && adj <= ADJUDICATE_MAX; adj++) {
2273 const verdict = await adjudicate(`branchSetup-branch-mismatch:${branch}`, 2365 const verdict = await adjudicate(`branchSetup-branch-mismatch:${branch}`,
2274 { problem:`分支 setup 后 HEAD 在 ${head.branch},期望 ${branch}` }, 'Milestone', adj, c.root) 2366 { problem:`分支 setup 后 HEAD 在 ${head.branch},期望 ${branch}` }, 'Milestone', adj, c.root)
@@ -2619,6 +2711,10 @@ async function featureLoop(items, phase, c, feStage = &#39;all&#39;) { @@ -2619,6 +2711,10 @@ async function featureLoop(items, phase, c, feStage = &#39;all&#39;) {
2619 return r.exists 2711 return r.exists
2620 } 2712 }
2621 2713
  2714 + // 段级进度 narrator(左侧面板分母只随派生增长,这里补真总量 + 预计)。放在并行分叉之前,
  2715 + // 使并行批与串行链都能看到本段总量;并行批的实际墙钟按波次宽度缩短,这行是其串行上界。
  2716 + log(`eta ${grp} 段: ${items.length} 个 ${phase === 'backend' ? 'REQ' : 'FE'} × ${fmtMin(itemMin(phase))}/项 ≈ ${fmtMin(items.length * itemMin(phase))}(串行口径)`)
  2717 +
2622 // ── Phase F(Task 12/13):后端 REQ 的 feature 级受限并行(设计见 featureBatchRun 头注释)。 2718 // ── Phase F(Task 12/13):后端 REQ 的 feature 级受限并行(设计见 featureBatchRun 头注释)。
2623 // frontend phase 不进准入(附录补 2);闸关 / 单 feature 时走下方现行串行链,一字不差。 2719 // frontend phase 不进准入(附录补 2);闸关 / 单 feature 时走下方现行串行链,一字不差。
2624 if (useFeatureParallel && phase === 'backend' && items.length >= 2) { 2720 if (useFeatureParallel && phase === 'backend' && items.length >= 2) {
@@ -2626,16 +2722,19 @@ async function featureLoop(items, phase, c, feStage = &#39;all&#39;) { @@ -2626,16 +2722,19 @@ async function featureLoop(items, phase, c, feStage = &#39;all&#39;) {
2626 return 2722 return
2627 } 2723 }
2628 2724
2629 - for (const id of items) { 2725 + for (const [i, id] of items.entries()) {
  2726 + const left = () => `(${i + 1}/${items.length},余 ${items.length - i - 1} 项 ≈ ${fmtMin((items.length - i - 1) * itemMin(phase))})`
2630 // dedup:phase1 用 fe-code-done;'all'/'e2e'(phase2)用 req-done。phase2 的 fe-code-done 必已存在 2727 // dedup:phase1 用 fe-code-done;'all'/'e2e'(phase2)用 req-done。phase2 的 fe-code-done 必已存在
2631 // (phase1 落过),spec/plan 经 ensureSpec/runPlanStage 复用既有产物,tdd(e2e) 只处理延后的 e2e 任务。 2728 // (phase1 落过),spec/plan 经 ensureSpec/runPlanStage 复用既有产物,tdd(e2e) 只处理延后的 e2e 任务。
2632 if (isPhase1 ? await isPhase1Done(id) : await isDone(id)) { 2729 if (isPhase1 ? await isPhase1Done(id) : await isDone(id)) {
2633 - log(`featureLoop skip ${phase}:${id} — tag ${isPhase1 ? 'fe-code-done' : 'req-done'}/${id} 已存在`); continue 2730 + log(`featureLoop skip ${phase}:${id} — tag ${isPhase1 ? 'fe-code-done' : 'req-done'}/${id} 已存在 ${left()}`); continue
2634 } 2731 }
  2732 + log(`eta ${grp} ${i + 1}/${items.length} ${id} 开跑 ≈ ${fmtMin(itemMin(phase))}${i + 1 < items.length ? `,其后余 ${items.length - i - 1} 项 ≈ ${fmtMin((items.length - i - 1) * itemMin(phase))}` : '(本段最后一项)'}`)
2635 const spec = await ensureSpec(id) 2733 const spec = await ensureSpec(id)
2636 const plan = await runPlanStage(id, spec) 2734 const plan = await runPlanStage(id, spec)
2637 await runImplChain(id, plan.artifactPath, spec.artifactPath, c, undefined, feStage) 2735 await runImplChain(id, plan.artifactPath, spec.artifactPath, c, undefined, feStage)
2638 await (isPhase1 ? tagFeCodeDone(id) : tagDone(id)) 2736 await (isPhase1 ? tagFeCodeDone(id) : tagDone(id))
  2737 + log(`eta ${grp} ${id} 完成 ${left()}`)
2639 } 2738 }
2640 } 2739 }
2641 2740
@@ -2857,13 +2956,10 @@ const REVIEW_HARD_ROUNDS = 10 @@ -2857,13 +2956,10 @@ const REVIEW_HARD_ROUNDS = 10
2857 // 注意:docs/08 **checkbox** 发生在模块分支上(approve 后逐功能勾选)→ 穿线 c; 2956 // 注意:docs/08 **checkbox** 发生在模块分支上(approve 后逐功能勾选)→ 穿线 c;
2858 // docs/08 **里程碑字段**发生在 merge 后的默认分支上 → 主根专属(见 runMilestone),两组不要搞混。 2957 // docs/08 **里程碑字段**发生在 merge 后的默认分支上 → 主根专属(见 runMilestone),两组不要搞混。
2859 async function flipDocs08Checkbox(fe, id, phase, grp, c) { 2958 async function flipDocs08Checkbox(fe, id, phase, grp, c) {
2860 - const cb = await agent(readDocs08CheckboxPromptM(fe, id, c), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA})  
2861 - if (!cb) { log(`docs08-checkbox ${id}: 读取子代理无返回(不阻断)`); return }  
2862 - if (!cb.found) { log(`docs08-checkbox ${phase}:${id}: 未找到功能行,跳过可视化勾选(req-done tag 仍是完成真值)`); return }  
2863 - if (cb.state === 'checked') return  
2864 - if (cb.state !== 'unchecked') { log(`docs08-checkbox ${phase}:${id}: state 异常 (${JSON.stringify(cb.state)}),跳过勾选`); return }  
2865 - const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber, c), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})  
2866 - if (!wr || !wr.success) log(`docs08-checkbox ${phase}:${id}: 勾选写入失败(${(wr && wr.error) || ''}),跳过——cosmetic,不阻断`) 2959 + const r = await agent(flipDocs08CheckboxPromptM(fe, id, phase, c), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
  2960 + if (!r) { log(`docs08-checkbox ${id}: 子代理无返回(不阻断)`); return }
  2961 + if (!r.success) { log(`docs08-checkbox ${phase}:${id}: 勾选失败(${r.error || ''}),跳过——cosmetic,不阻断`); return }
  2962 + if (r.detail === 'not-found') log(`docs08-checkbox ${phase}:${id}: 未找到功能行,跳过可视化勾选(req-done tag 仍是完成真值)`)
2867 } 2963 }
2868 2964
2869 // rwOpts.flipDocs08(Phase F Task 13):false 时 approve 路径跳过 docs/08 checkbox 勾选——feature 2965 // rwOpts.flipDocs08(Phase F Task 13):false 时 approve 路径跳过 docs/08 checkbox 勾选——feature
@@ -2940,9 +3036,12 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath, c, { flipDoc @@ -2940,9 +3036,12 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath, c, { flipDoc
2940 // red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。 3036 // red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。
2941 async function testGate(module, phase, c) { 3037 async function testGate(module, phase, c) {
2942 let attempt = 1 3038 let attempt = 1
  3039 + const site = `gate:${phase}:${module.id}`
  3040 + etaLog(site, 1, '全量回归 + 冷起栈')
2943 let g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) 3041 let g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
2944 if (g.status === 'red') { // 自动重试 1 次(防 flaky) 3042 if (g.status === 'red') { // 自动重试 1 次(防 flaky)
2945 attempt = 2 3043 attempt = 2
  3044 + etaLog(site, 2, 'red → 防 flaky 自动重跑')
2946 g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) 3045 g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
2947 } 3046 }
2948 // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。 3047 // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。
@@ -2952,10 +3051,13 @@ async function testGate(module, phase, c) { @@ -2952,10 +3051,13 @@ async function testGate(module, phase, c) {
2952 if (verdict.action !== 'retry') 3051 if (verdict.action !== 'retry')
2953 throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`) 3052 throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`)
2954 attempt += 1 // retry:再跑一个独立 attempt 证据文件 3053 attempt += 1 // retry:再跑一个独立 attempt 证据文件
  3054 + etaLog(site, attempt, '仲裁判 retry(辨 flake)')
2955 g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA}) 3055 g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA})
2956 } 3056 }
2957 if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`) 3057 if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`)
2958 - return g 3058 + // attempts 回传给 module-report:报告不必再 Glob + 通读全部 attempt 证据去自证"最后一份绿",
  3059 + // JS 这里就是那个事实的第一手持有者(见 reportPrompt 的 gateFacts)。
  3060 + return { ...g, attempts: attempt }
2959 } 3061 }
2960 3062
2961 // ---- 前端阶段级行为验收控制流(runBehaviorGateOnce + runBehaviorGate)---- 3063 // ---- 前端阶段级行为验收控制流(runBehaviorGateOnce + runBehaviorGate)----
@@ -3065,14 +3167,17 @@ async function runPrototypePreview(feItems, c) { @@ -3065,14 +3167,17 @@ async function runPrototypePreview(feItems, c) {
3065 async function runBehaviorGateOnce(feItems, behaviorRound, c) { 3167 async function runBehaviorGateOnce(feItems, behaviorRound, c) {
3066 const lbl = (a) => `behavior:frontend-phase:r${behaviorRound}:a${a}` 3168 const lbl = (a) => `behavior:frontend-phase:r${behaviorRound}:a${a}`
3067 let attempt = 1 3169 let attempt = 1
  3170 + etaLog('behavior:frontend-phase', 1, `r${behaviorRound},冷起全栈 + 枚举 ${(feItems || []).length} 个 FE`)
3068 let bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c), 3171 let bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c),
3069 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA}) 3172 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
3070 recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions) 3173 recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions)
  3174 + const withAttempts = () => { bg = { ...bg, attempts: attempt }; return bg } // attempts 供 module-report 定位证据文件
3071 3175
3072 // 内部 envError / 空覆盖重试:attempt 1→BEHAVIOR_ATTEMPT_MAX(沿用 testGate 思路);仍异常 → adjudicate(allowContinue:false)。 3176 // 内部 envError / 空覆盖重试:attempt 1→BEHAVIOR_ATTEMPT_MAX(沿用 testGate 思路);仍异常 → adjudicate(allowContinue:false)。
3073 // build-failed 是确定性失败(重起不自愈)→ 跳过自动重起,直接进下方仲裁循环。 3177 // build-failed 是确定性失败(重起不自愈)→ 跳过自动重起,直接进下方仲裁循环。
3074 while (behaviorEnvBlocked(bg).blocked && !isBuildFailed(bg) && attempt < BEHAVIOR_ATTEMPT_MAX) { 3178 while (behaviorEnvBlocked(bg).blocked && !isBuildFailed(bg) && attempt < BEHAVIOR_ATTEMPT_MAX) {
3075 attempt += 1 3179 attempt += 1
  3180 + etaLog('behavior:frontend-phase', attempt, `r${behaviorRound},环境 race 自动重起全栈`)
3076 bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c), 3181 bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c),
3077 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA}) 3182 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
3078 recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions) 3183 recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions)
@@ -3091,13 +3196,14 @@ async function runBehaviorGateOnce(feItems, behaviorRound, c) { @@ -3091,13 +3196,14 @@ async function runBehaviorGateOnce(feItems, behaviorRound, c) {
3091 allowContinue:false }, 'Behavior', adj, c.root) 3196 allowContinue:false }, 'Behavior', adj, c.root)
3092 if (verdict.action !== 'retry') throw new Error(`HALT behavior-env frontend-phase: ${verdict.rationale || reason}`) 3197 if (verdict.action !== 'retry') throw new Error(`HALT behavior-env frontend-phase: ${verdict.rationale || reason}`)
3093 attempt += 1 3198 attempt += 1
  3199 + etaLog('behavior:frontend-phase', attempt, `r${behaviorRound},仲裁判 retry`)
3094 bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c), 3200 bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c),
3095 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA}) 3201 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
3096 recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions) 3202 recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions)
3097 envState = behaviorEnvBlocked(bg) 3203 envState = behaviorEnvBlocked(bg)
3098 } 3204 }
3099 if (envState.blocked) throw new Error(`HALT behavior-env frontend-phase: ${ADJUDICATE_MAX} 轮仲裁后仍环境异常 / 空覆盖`) 3205 if (envState.blocked) throw new Error(`HALT behavior-env frontend-phase: ${ADJUDICATE_MAX} 轮仲裁后仍环境异常 / 空覆盖`)
3100 - return bg 3206 + return withAttempts()
3101 } 3207 }
3102 3208
3103 // runBehaviorGate:阶段级行为门主循环(被顶层 frontend 段调用,phase('Behavior') 下)。green 才正常返回(放行进 testGate)。 3209 // runBehaviorGate:阶段级行为门主循环(被顶层 frontend 段调用,phase('Behavior') 下)。green 才正常返回(放行进 testGate)。
@@ -3107,6 +3213,8 @@ async function runBehaviorGate(feItems, c) { @@ -3107,6 +3213,8 @@ async function runBehaviorGate(feItems, c) {
3107 const regionKey = (x) => `${x.page || '?'}::${x.region || '?'}` 3213 const regionKey = (x) => `${x.page || '?'}::${x.region || '?'}`
3108 const softPassed = new Set() 3214 const softPassed = new Set()
3109 for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_STAGE_MAX; behaviorRound++) { 3215 for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_STAGE_MAX; behaviorRound++) {
  3216 + if (behaviorRound > 1)
  3217 + log(`eta behavior: 第 ${behaviorRound}/${BEHAVIOR_STAGE_MAX} 轮(上轮非绿,fix 已改源码)——本轮重跑整门再需 ${fmtRange(STAGE_MIN.behavior)}`)
3110 const bg = await runBehaviorGateOnce(feItems, behaviorRound, c) 3218 const bg = await runBehaviorGateOnce(feItems, behaviorRound, c)
3111 3219
3112 // 1) coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 runBehaviorGateOnce 兜底)。 3220 // 1) coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 runBehaviorGateOnce 兜底)。
@@ -3199,7 +3307,9 @@ async function runBehaviorGate(feItems, c) { @@ -3199,7 +3307,9 @@ async function runBehaviorGate(feItems, c) {
3199 // 5) green 判定:behaviorHard 为空 ∧ 无 B 类/scope-missing 未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 门 green 放行。 3307 // 5) green 判定:behaviorHard 为空 ∧ 无 B 类/scope-missing 未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 门 green 放行。
3200 if (behaviorHard.length === 0) { 3308 if (behaviorHard.length === 0) {
3201 log(`behavior frontend-phase green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`) 3309 log(`behavior frontend-phase green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`)
3202 - return 3310 + // 绿态事实回传 module-report(同 testGate 的 attempts:报告不再重新 Glob 自证行为门绿)。
  3311 + return { rounds: behaviorRound, attempts: bg.attempts || 1, routesPlanned: bg.routesPlanned,
  3312 + routesReached: bg.routesReached, controlsEnumerated: bg.controlsEnumerated, authState: bg.authState || '' }
3203 } 3313 }
3204 3314
3205 // 6) 分流:无 locator 的硬问题 → adjudicate(allowContinue:false) retry/halt(绝不静默丢弃、绝不放行)。 3315 // 6) 分流:无 locator 的硬问题 → adjudicate(allowContinue:false) retry/halt(绝不静默丢弃、绝不放行)。
@@ -3281,6 +3391,14 @@ for (const m of routed.modules) { @@ -3281,6 +3391,14 @@ for (const m of routed.modules) {
3281 3391
3282 const todo = routed.modules.filter(m => !m.done) 3392 const todo = routed.modules.filter(m => !m.done)
3283 log(`coding: ${todo.length}/${routed.modules.length} modules to run`) 3393 log(`coding: ${todo.length}/${routed.modules.length} modules to run`)
  3394 +// ETA 总览(顺利路径中位数外推;并行开启时是串行上界,实际按波次宽度缩短)。
  3395 +{
  3396 + const nb = todo.reduce((s, m) => s + (m.reqs || []).length, 0)
  3397 + const nf = todo.reduce((s, m) => s + (m.feItems || []).length, 0)
  3398 + const tot = todo.reduce((s, m) => s + moduleMin(m), 0)
  3399 + const lo = todo.reduce((s, m) => s + moduleMin(m, 0), 0), hi = todo.reduce((s, m) => s + moduleMin(m, 2), 0)
  3400 + log(`eta 总览: 待跑 ${nb} 个后端 REQ + ${nf} 个 FE,全串行顺利路径 ${fmtMin(tot)}(区间 ${fmtMin(lo)}–${fmtMin(hi)};每轮 review-fix / gate 重跑 / behavior 重跑各另加)`)
  3401 +}
3284 3402
3285 // ============================================================================ 3403 // ============================================================================
3286 // 调度器(Phase B):LLM 出依赖边 + JS 拓扑波次。Phase D 的波次主循环消费——useParallel 开启时 3404 // 调度器(Phase B):LLM 出依赖边 + JS 拓扑波次。Phase D 的波次主循环消费——useParallel 开启时
@@ -3477,8 +3595,12 @@ async function runFrontendPhase1(feModule, c) { @@ -3477,8 +3595,12 @@ async function runFrontendPhase1(feModule, c) {
3477 } 3595 }
3478 3596
3479 async function runModule(module, c) { 3597 async function runModule(module, c) {
  3598 + // 门禁事实收集器:testGate / runBehaviorGate 的绿态回执,末尾透传给 reportPrompt——
  3599 + // 报告不必再 Glob 通读全部证据文件去自证"最后一份绿"(JS 才是第一手持有者,红态走不到 report)。
  3600 + const reportFacts = {}
3480 try { 3601 try {
3481 if (c.lane == null) phase('Milestone') 3602 if (c.lane == null) phase('Milestone')
  3603 + log(`eta ${module.id}: ${(module.reqs || []).length} 个后端 REQ + ${(module.feItems || []).length} 个 FE ≈ ${fmtMin(moduleMin(module))}(区间 ${fmtMin(moduleMin(module, 0))}–${fmtMin(moduleMin(module, 2))},顺利路径)`)
3482 await runBranchSetup(module, c) 3604 await runBranchSetup(module, c)
3483 if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过) 3605 if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过)
3484 if (c.lane == null) phase('Backend') 3606 if (c.lane == null) phase('Backend')
@@ -3486,7 +3608,8 @@ async function runModule(module, c) { @@ -3486,7 +3608,8 @@ async function runModule(module, c) {
3486 if (c.lane == null) phase('Gate') 3608 if (c.lane == null) phase('Gate')
3487 // 起栈互斥(补 9):testGate 整段包锁——其 e2e 段 Playwright globalSetup 按固定端口冷起后端, 3609 // 起栈互斥(补 9):testGate 整段包锁——其 e2e 段 Playwright globalSetup 按固定端口冷起后端,
3488 // 不可单拆。串行模式锁恒空闲,行为不变(统一纪律,免按模式分叉)。 3610 // 不可单拆。串行模式锁恒空闲,行为不变(统一纪律,免按模式分叉)。
3489 - await withStackLock(() => testGate(module, 'backend', c)) 3611 + // 返回值(status/attempts)透传给 module-report:报告据此免去通读全部 attempt 证据自证绿。
  3612 + reportFacts.gate = await withStackLock(() => testGate(module, 'backend', c))
3490 // 演示种子生成 stage(Seed):在 testGate 后跑——此时本模块 schema(含 tdd 新增的 V<n> migration) 3613 // 演示种子生成 stage(Seed):在 testGate 后跑——此时本模块 schema(含 tdd 新增的 V<n> migration)
3491 // 已终态且全绿,按它生成的种子才不会撞结构。allowContinue:false——e2e 基线(globalSetup 注入)与行为门 3614 // 已终态且全绿,按它生成的种子才不会撞结构。allowContinue:false——e2e 基线(globalSetup 注入)与行为门
3492 // step2 子项③(演示种子注入)都依赖该种子,坏种子放行会让整个前端阶段(行为验收/e2e)在脏数据上全线误判。 3615 // step2 子项③(演示种子注入)都依赖该种子,坏种子放行会让整个前端阶段(行为验收/e2e)在脏数据上全线误判。
@@ -3502,6 +3625,7 @@ async function runModule(module, c) { @@ -3502,6 +3625,7 @@ async function runModule(module, c) {
3502 // 截图归档为可视化基线 + 跑原型自带交互点击冒烟。与行为门(测实现)正交;advisory(原型属上游 3625 // 截图归档为可视化基线 + 跑原型自带交互点击冒烟。与行为门(测实现)正交;advisory(原型属上游
3503 // plan 权威,coding 不改原型源码,渲染/JS 问题只记证据不阻断)。静态渲染、不起全栈。终波主根执行。 3626 // plan 权威,coding 不改原型源码,渲染/JS 问题只记证据不阻断)。静态渲染、不起全栈。终波主根执行。
3504 if (c.lane == null) phase('Preview') 3627 if (c.lane == null) phase('Preview')
  3628 + log(`eta 前端段: Preview ${fmtMin(STAGE_MIN.preview[1])} + 骨架 ${fmtMin(STAGE_MIN.skeleton[1])} + ${module.feItems.length} 个 FE × ${fmtMin(itemMin('frontend'))} + Behavior ${fmtMin(STAGE_MIN.behavior[1])} + Gate ${fmtMin(STAGE_MIN.gate[1])} + 报告 ${fmtMin(STAGE_MIN.report[1])} ≈ ${fmtMin(moduleMin(module))}(区间 ${fmtMin(moduleMin(module, 0))}–${fmtMin(moduleMin(module, 2))})`)
3505 await runPrototypePreview(module.feItems, c) 3629 await runPrototypePreview(module.feItems, c)
3506 if (c.lane == null) phase('Frontend') 3630 if (c.lane == null) phase('Frontend')
3507 // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy 3631 // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy
@@ -3518,15 +3642,16 @@ async function runModule(module, c) { @@ -3518,15 +3642,16 @@ async function runModule(module, c) {
3518 if (c.lane == null) phase('Behavior') 3642 if (c.lane == null) phase('Behavior')
3519 // 行为门虽也冷起全栈,但只在 frontend-phase 终波(宽 1、主根)跑——无兄弟 lane 可互杀, 3643 // 行为门虽也冷起全栈,但只在 frontend-phase 终波(宽 1、主根)跑——无兄弟 lane 可互杀,
3520 // 不进起栈互斥(补 9 范围仅 Seed / testGate)。 3644 // 不进起栈互斥(补 9 范围仅 Seed / testGate)。
3521 - await runBehaviorGate(module.feItems, c) 3645 + reportFacts.behavior = await runBehaviorGate(module.feItems, c)
3522 if (c.lane == null) phase('Gate') 3646 if (c.lane == null) phase('Gate')
3523 // 阶段级 testGate(全量回归 vitest+playwright),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。 3647 // 阶段级 testGate(全量回归 vitest+playwright),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。
3524 - await withStackLock(() => testGate(module, 'frontend', c)) 3648 + reportFacts.gate = await withStackLock(() => testGate(module, 'frontend', c))
3525 } 3649 }
3526 if (c.lane == null) phase('Milestone') 3650 if (c.lane == null) phase('Milestone')
3527 - // report allowContinue:false:reportPrompt 的前置硬验证含"最后一次 test-gate 必须 green,红则 halt"——  
3528 - // 绝不 continue 放行去打 milestone(否则可能在红色测试上 milestone)。  
3529 - await runStage(g => reportPrompt(module, c) + g, { 3651 + // report allowContinue:false:报告产物是 milestone 的前置(runMilestone 校验其 § ⑫),绝不 continue
  3652 + // 放行去打 milestone。注意"最后一次 test-gate 必须 green"已不再由报告子代理自证——红态在
  3653 + // testGate / runBehaviorGate 内就 halt 了,根本走不到这里;绿态事实经 reportFacts 直接给定。
  3654 + await runStage(g => reportPrompt(module, c, reportFacts) + g, {
3530 site:`report:${module.id}`, grp:'Milestone', label:`report:${module.id}`, allowContinue: false, dec: c.decisions, root: c.root, 3655 site:`report:${module.id}`, grp:'Milestone', label:`report:${module.id}`, allowContinue: false, dec: c.decisions, root: c.root,
3531 }) 3656 })
3532 // lane 收口(附录补 15):lane 模式 milestone 前先在 lane 树上 wt-clean + 自主恢复——lane 里 tdd/fix 3657 // lane 收口(附录补 15):lane 模式 milestone 前先在 lane 树上 wt-clean + 自主恢复——lane 里 tdd/fix