diff --git a/workflows/coding.mjs b/workflows/coding.mjs index 915f7a7..b8d0ee6 100644 --- a/workflows/coding.mjs +++ b/workflows/coding.mjs @@ -208,12 +208,17 @@ const FIELD_VALUE_SCHEMA = { type:'object', additionalProperties:false, 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'} } } +// (原 CHECKBOX_STATE_SCHEMA 已随 docs/08 勾选的 read-then-write 两段合并为单会话而删除, +// 见 flipDocs08CheckboxPromptM——终态经 ACTION_RESULT 的 detail 回传。) + +// BRANCH_READY_SCHEMA:ensureBranchCheckedOutPromptM(exists?+checkout|create+head 三合一)的回执。 +// branch 必填——它是**实测 HEAD**,上层的分支不符仲裁循环靠它判定,缺失会让不符静默通过。 +const BRANCH_READY_SCHEMA = { type:'object', additionalProperties:false, + required:['success','branch'], properties:{ + success:{type:'boolean'}, + branch:{type:'string'}, + created:{type:'boolean'}, + error:{type:'string'} } } const ALREADY_MERGED_SCHEMA = { type:'object', additionalProperties:false, required:['alreadyMerged'], properties:{ alreadyMerged:{type:'boolean'} } } @@ -978,6 +983,12 @@ function behaviorGateContract(c) { 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}` + // carry:跨 behaviorRound / attempt **存活**的派生物暂存区(run.mjs / sentinel.sql / expectations.md)。 + // 实测:本门 40+ 分钟里约 18 分钟花在"读 spec → 查表 → 反查组件 → 推导期望 → 写 runner",而 fix 只 + // 改 frontend/ 源码——期望(来自 prototype/REQ/docs05)与 runner(DOM/locator 驱动)在同一前端阶段内 + // 不随 fix 失效。故只重跑不重推导。**运行栈仍绝不复用**(每次冷起,契约未变)。 + const carryDir = `${c.root}/.tmp/behavior-gate/frontend-phase/carry` + const reuse = behaviorRound > 1 || attempt > 1 const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-r${behaviorRound}-a${attempt}.md` return [ `# behavior — 前端阶段级行为验收(headless,frontend-phase, behaviorRound=${behaviorRound}, attempt=${attempt})`, @@ -994,8 +1005,18 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { '## 运行机制(无常驻进程跨会话;冷起栈→跑→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/\` 之外的任何路径。`, + ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子/carry),再新建本轮子目录 \`${tmpDir}/\` 与派生物暂存区 \`${carryDir}/\`。` + : `本次 behaviorRound=${behaviorRound} / attempt=${attempt} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建;**\`${carryDir}/\` 绝不删**——它装着上一次已推导好的 runner/种子/期望,本次要复用(见下方「派生物复用」)。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`, + // 派生物复用(省时改造):只重跑不重推导。首次跑完必须落 carry,后续轮次必须先读 carry。 + reuse + ? [ + `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:全部 FE 作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / 组件文件反查表)与 \`${carryDir}/run.mjs\`、\`${carryDir}/sentinel.sql\`。`, + ` - **三者齐备 → 直接复用**:把 \`${carryDir}/run.mjs\` / \`sentinel.sql\` 复制进本轮 \`${tmpDir}/\` 后使用,**跳过 step1 / step4 的重新推导**(期望以 \`expectations.md\` 为准),直接进 step2 起栈。这是本次的默认路径。`, + ` - **只在有明确证据时才改**:上次证据显示 runner 自身有 bug(选择器过时 / 断言写错 / 漏驱动),或 \`frontend/\` 的路由表在上一轮 fix 中真的变了 → 就地**增量修补**复制过来的 runner,并把修补同步回 \`${carryDir}/run.mjs\`;**绝不**因为"想重写得更好"而从头重推导。`, + ` - 缺文件 / 内容不可用 → 按首次流程完整推导,跑完照样落回 \`${carryDir}/\`(缺失是异常,在证据报告里注明)。`, + ` - **不复用的部分(硬约束)**:运行栈(每次冷起)、数据库副本(每次重建)、演示/sentinel 数据的**注入动作**(每次重灌)、证据报告(每次独立文件)。复用的只是"推导结果与脚本"。`, + ].join('\n') + : `- **派生物落盘(本次是首次推导,必须做)**:step1/step4 推导完成后,把 ① 推导结果写成 \`${carryDir}/expectations.md\`(全部 FE 作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / route→组件文件反查表)、② runner 写一份到 \`${carryDir}/run.mjs\`、③ sentinel 种子写一份到 \`${carryDir}/sentinel.sql\`。后续 behaviorRound / attempt 会直接复用这三份**跳过重新推导**(fix 只改 \`frontend/\` 源码,不使这些失效),省掉每轮十几分钟的重复侦查。carry 与本轮 \`${tmpDir}/\` 同属临时区、跑完即弃,**不提交**。`, `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 本门起的全部子进程**并透传结构化结果。**绝不**让前台 gradle bootRun / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`, // runner 的 status 必须是**算出来的**。曾实际发生:生成的 run.mjs 把 status 初始化为 'red' // 后再未赋值,恒红且不带任何分类信号——若子代理照单全收,上层 runBehaviorGate 的 @@ -1016,7 +1037,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { ' - 根因不可归到 `frontend/` 源码(依赖 / 工具链 / 无法定位)→ `envError.kind="build-failed"`(如能定位仍填 `rootCausePath`)。', ' - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。', '', - '## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)', + `## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`, + reuse ? `- 本次已从 \`${carryDir}/expectations.md\` 取得分母与逐路由归属,**不要**重新逐 FE Read spec、不要重新对账 router(那是上一次已做完的确定性推导,重做只是重复烧时间)。仅当 carry 缺失/不可用时才回退执行本节。` : '', '- 分母来源 = 全部 FE spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。', `- 与 \`${c.root}/frontend/\` router 配置对账:FE 作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属任何 FE 作用域的路由记证据(不入分母、不断言)。`, '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。', @@ -1026,7 +1048,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { `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。', + `4) **此时才跑 sentinel 种子**:${reuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : '按 `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 烧时间)。`, @@ -1043,7 +1065,8 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) { '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。', '- `routesReached` / `controlsEnumerated` 据实填(空覆盖必须可见)。', '', - '## step4 推导期望', + `## step4 推导期望${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`, + reuse ? `- 期望以 \`${carryDir}/expectations.md\` 为准(含每控件预期效果、每文字区域预期内容与来源、sentinel 值表)。**不要**重新读 prototype/REQ/docs05 推导——上一轮 fix 只改了 \`frontend/\` 实现,不改这些期望的来源。` : '', '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。', '', '## step5 断言(三层:交互/文字/样式 + 可观测效果白名单 + 硬问题带源码 locator)', @@ -1220,6 +1243,70 @@ const PROTOTYPE_PREVIEW_ATTEMPT_MAX = 2 const NULL_AGENT_PROBLEM = 'agent() 返回 null(子代理终端 API 错误 / 被用户跳过)——典型为断网或机器休眠;可重试' const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' +// ── 进度 ETA(narrator 行)────────────────────────────────────────────────── +// 动机:左侧 Phases 面板的分母 = 该分组**已派生**的子代理数(harness 侧计数;脚本无 API 预声明 +// 总量),串行链上它只能 0/1→1/2 地长,看不出"还剩多少 / 还要多久"。故用 log() narrator 行补 +// 上「总量 + 预计分钟」——纯显示,绝不参与任何控制流判定。 +// STAGE_MIN:各 stage 的实测耗时 `[p25, 中位数, p75]`(分钟),键 = site/label 的 +// `` 或 `:` 前缀。样本 = 本机同一 ERP 项目 13 次 coding 运行的子代理 +// transcript(首末 timestamp 差;n 见行尾)。运行时禁 Date.now()(会破坏 resume 重放), +// 所以这里只能按"剩余段数 × 实测分布"静态外推,不做真实计时——是量级参考,不是承诺。 +const STAGE_MIN = { + 'spec:backend': [1.2, 3.4, 5.9], // n=31 + 'spec:frontend': [3.8, 4.7, 6.9], // n=15 + 'plan:backend': [2.8, 5.1, 6.5], // n=20 + 'plan:frontend': [3.3, 5.6, 6.6], // n=14 + 'tdd:backend': [3.5, 10.8, 25.6], // n=21 + 'tdd:frontend': [11.3, 19.4, 33.9], // n=14 + 'verify:backend': [3.7, 5.7, 7.7], // n=23 + 'verify:frontend':[2.8, 3.2, 5.1], // n=16 + 'review:backend': [2.4, 3.2, 4.0], // n=21(每轮) + 'review:frontend':[3.5, 4.2, 5.6], // n=14(每轮) + 'fix:backend': [2.4, 3.2, 4.0], // fix 轮与 review 同量级 + 'fix:frontend': [3.5, 4.2, 5.6], + gate: [2.5, 9.4, 11.8], // n=45(每 attempt) + seed: [22.0, 23.5, 25.0], // n=2 + report: [1.4, 4.9, 14.7], // n=24 + behavior: [10.2, 18.4, 45.0], // n=13(每 behaviorRound × attempt) + 'behavior-env': [10.2, 18.4, 45.0], + 'behavior-reverify': [2.1, 2.3, 2.4], // n=4 + preview: [3.1, 4.8, 6.9], // n=12 + skeleton: [3.8, 5.7, 5.7], // n=5 +} +const MICRO_MIN = 0.25 // tag / docs08 勾选 / 分支类微步骤(不在表内 → 不播报) +const MICRO_PER_ITEM = 3 // 每 item 的微步骤:docs08 查 + docs08 勾 + req-done tag +const fmtMin = (m) => m >= 90 ? `~${Math.round(m / 6) / 10} 小时` : `~${Math.round(m)} 分钟` +const fmtRange = (r) => `${Math.round(r[0])}–${Math.round(r[2])} 分钟` +// etaOf:site(`::` / `:`)→ 三分位数组;表外(微步骤 / 仲裁)返回 null。 +function etaOf(site) { + const p = String(site || '').split(':') + return STAGE_MIN[`${p[0]}:${p[1]}`] || STAGE_MIN[p[0]] || null +} +// etaLog:每个可计时 stage 在**开跑**与**每次重跑**时各播报一行——"预计 X–Y 分钟" / +// "第 n 次重跑,再需 X–Y 分钟"。表外的微步骤静默(0.2 分钟级,播报只是噪声)。 +// 纯显示,绝不参与控制流;round 由调用方的重试循环下标透传。 +function etaLog(site, round = 1, note = '') { + const r = etaOf(site) + if (!r) return + const tail = note ? `(${note})` : '' + log(round <= 1 + ? `eta ${site}: 预计 ${fmtRange(r)}${tail}` + : `eta ${site}: 第 ${round} 次重跑,再需 ${fmtRange(r)}${tail}`) +} +// itemMin:单个 REQ/FE 的全链预计(spec+plan+tdd+verify+review 1 轮 + 微步骤),取中位数,顺利路径。 +const itemMin = (ph, q = 1) => ['spec', 'plan', 'tdd', 'verify', 'review'] + .reduce((s, k) => s + STAGE_MIN[`${k}:${ph}`][q], 0) + MICRO_MIN * MICRO_PER_ITEM +// moduleMin:单模块预计(顺利路径:review 1 轮、gate 1 次过、behavior 1 轮绿、零仲裁重试)。 +// q:0=p25(顺风)/ 1=中位数 / 2=p75(逆风),用于给出区间而非单点。 +function moduleMin(m, q = 1) { + let t = MICRO_MIN * 2 // 分支准备 + cross-module 记账 + const nb = (m.reqs || []).length, nf = (m.feItems || []).length + if (nb) t += nb * itemMin('backend', q) + STAGE_MIN.gate[q] + STAGE_MIN.seed[q] + STAGE_MIN.report[q] + if (nf) t += STAGE_MIN.preview[q] + STAGE_MIN.skeleton[q] + nf * itemMin('frontend', q) + + STAGE_MIN.behavior[q] + STAGE_MIN.gate[q] + STAGE_MIN.report[q] + return t +} + // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记后随结果回传供人工事后审阅。 // sink 参数:模块作用域调用传 c.decisions(per-module 收集器,并行交错下水位线语义失效的替代); // 缺省落全局 autonomousDecisions(仅剩"漏传 dec 的兜底"语义,顶层 return 时作为残量并入)。 @@ -1299,6 +1386,7 @@ async function adjudicate(site, context, grp, round, root = ROOT) { async function runStage(makePrompt, { site, grp, label, validate, allowContinue = false, dec, root = ROOT }) { let guidance = '' for (let round = 1; round <= ADJUDICATE_MAX; round++) { + etaLog(site, 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) @@ -1322,10 +1410,13 @@ async function runStage(makePrompt, { site, grp, label, validate, allowContinue // 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 }) { +// schema:缺省 ACTION_RESULT_SCHEMA;合并版微步骤(如 BRANCH_READY_SCHEMA)在 success 之外还带 +// 回执字段,只要 schema 含 `success` 布尔,本函数的失败/仲裁/重试语义就逐字不变。 +async function runAction(makePrompt, { site, grp, label, allowContinue = false, dec, root = ROOT, schema = ACTION_RESULT_SCHEMA }) { let guidance = '' for (let round = 1; round <= ADJUDICATE_MAX; round++) { - const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: ACTION_RESULT_SCHEMA}) + etaLog(site, round) // 表内的 action(seed 等)才播报;微步骤静默 + const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, 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}`) @@ -1479,16 +1570,6 @@ function worktreeCleanPromptM(c) { ].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 [ @@ -1502,6 +1583,27 @@ function currentBranchPromptM(c) { } // ── 微步骤:分支生命周期 action ── +// ensureBranchCheckedOutPromptM:合并版(原为 exists? → checkout|create → head 三个子代理会话)。 +// 三段之间没有任何 JS 决策——JS 只是把"存在与否"这个布尔转手喂回下一个会话,再派第三个会话读回 +// HEAD 确认。git 层面本就是一条确定性链(rev-parse --verify → checkout / checkout -b → rev-parse +// --abbrev-ref),合成单会话后每个模块省两次会话往返,且 HEAD 由同一会话现场回报(比事后另起一 +// 个会话读更贴近真值)。JS 侧保留原有的 HEAD 不符仲裁重切循环(本函数只是它的第一次尝试)。 +function ensureBranchCheckedOutPromptM(branch, fromBranch, c) { + return [ + `# 确保 HEAD 落在本地分支 \`${branch}\`(不存在则从 \`${fromBranch}\` 新建)`, + microStepContract(c.root), + '', + `1. 跑 \`git -C ${c.root} rev-parse --verify refs/heads/${branch}\`(用 2>/dev/null 抑制 stderr)判断分支是否已存在。`, + `2. 按结果二选一——**只跑一条路径**:`, + ` - exit=0(已存在)→ 跑 \`git -C ${c.root} checkout ${branch}\`,\`created\` 填 false。`, + ` - 非 0(不存在)→ 按序跑 \`git -C ${c.root} checkout ${fromBranch}\` 再 \`git -C ${c.root} checkout -b ${branch}\`,\`created\` 填 true。`, + `3. 跑 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\`,把 stdout 第一行去空白后填进 \`branch\`(**据实回填**——即便与期望不符也如实填,绝不改写成期望值,上层据此仲裁)。`, + '## 输出(BRANCH_READY_SCHEMA)', + '- 全部成功:`{ "success": true, "branch": "<第 3 步实测 HEAD>", "created": }`', + '- 任一步失败:`{ "success": false, "error": "", "branch": "<能读到就填实测 HEAD,读不到填空串>" }`', + ].join('\n') +} + function checkoutExistingBranchPromptM(branch, c) { return [ `# 切到已存在的本地分支 \`${branch}\``, @@ -1514,20 +1616,10 @@ function checkoutExistingBranchPromptM(branch, c) { ].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), +// 为什么必须有这一步:runBranchSetup 对**新建**分支以默认分支为起点(ensureBranchCheckedOutPromptM 的新建路径), // 但对**已存在**分支只做 checkout(主根)/ worktree add(lane 分支 2),二者都不带任何同步动作。 // 于是「上一轮跑完留下的功能分支」会停在它当时的位置,其后提交到默认分支的一切——新 REQ 卡片、 // 新 migration、docs 增量——对本轮**全部不可见**。子代理据残缺事实作业不会报错,只会自行发明 @@ -1946,40 +2038,31 @@ function writeDocs08FieldPromptM(fe, id, targetValue, phaseId, lineNumber) { ].join('\n') } -// ── 微步骤:docs/08 功能行 checkbox(reviewer approve 后的可观测 side effect;read-then-write)── -function readDocs08CheckboxPromptM(fe, id, c) { +// ── 微步骤:docs/08 功能行 checkbox(reviewer approve 后的可观测 side effect)─────── +// 合并版(原为 read-then-write 两个子代理会话):定位 + 判态 + 勾选 + commit 收进**一个**会话。 +// 拆两段的唯一理由曾是"让 JS 拿到 lineNumber 再喂写入方",但写入方本就带无行号的回退定位分支, +// 行号只是加速器不是必要输入——两段之间没有任何 JS 决策,纯白烧一次会话往返(每个 REQ/FE 一次)。 +// 三种非错误终态经 detail 回传(not-found / already-checked / flipped),调用方据此打日志, +// 与拆分版的分支日志逐条对应;任何真失败仍走 success:false(cosmetic,调用方只 log 不阻断)。 +function flipDocs08CheckboxPromptM(fe, id, phase, c) { const section = fe ? '§ 三' : '§ 二' - const kind = fe ? '功能' : 'REQ' + const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}` 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} ...\`)`, + `# 把 docs/08 ${scope} 勾选为 \`[x]\` 并 commit(含定位与判态)`, 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"\`。`, + `1. ${locator}`, + `2. 按命中结果分流——**只有第 3 种才写文件**:`, + ` - 找不到该行 → 直接返回 \`{ "success": true, "detail": "not-found" }\`(不写、不 commit)。`, + ` - 命中 \`- [x] ${id} ...\`(已勾选)→ 直接返回 \`{ "success": true, "detail": "already-checked" }\`(幂等,不写、不 commit)。`, + ` - 命中 \`- [ ] ${id} ...\` → 只替换该行的**第一个** \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本;其余行一律不动。`, + `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" }\`。`, '## 输出(ACTION_RESULT_SCHEMA)', - '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "" }`', + '- 三种非错误终态按上文填 `detail`(`not-found` / `already-checked` / `flipped`),`success` 恒 true。', + '- 任一步真失败(文件读不到 / 行内容与预期不符 / git 失败):`{ "success": false, "error": "" }`。', ].join('\n') } @@ -2177,10 +2260,23 @@ function writeCrossModuleLogPromptM(moduleId, items, c) { } // ---- 模块完成报告(原 module-report)---- -function reportPrompt(module, c) { +// facts(Task:module-report 瘦身):调用方把**已在内存里的**门禁事实透传进来—— +// gate = testGate 的返回(status/attempts/failures),behavior = runBehaviorGate 绿态回执。 +// 原实现让报告子代理 Glob + 通读**全部** attempt / behaviorRound 证据来自证"最后一份绿", +// 而 JS 才是这个事实的第一手持有者(红根本走不到 report——testGate/behaviorGate 会先 halt)。 +// 实测 module-report 均值 14.6 分钟、占全流程墙钟 11.7%,通读证据是其中的大头。改为: +// 事实由入参给定,子代理只按给定的**文件名**读它真正要摘要的那一两份(末次 green;flake 时加首次 red)。 +function reportPrompt(module, c, facts = {}) { const id = module?.id ?? '' const fe = id === 'frontend-phase' const phaseId = fe ? 'frontend-phase' : id + const gate = facts.gate || {} + const bh = facts.behavior || {} + const gAttempts = Number(gate.attempts) || 1 + const gateEvidence = `${phaseId}-test-gate-r${gAttempts}.md` + const gateFlake = gAttempts > 1 + const bhRounds = Number(bh.rounds) || 1 + const bhEvidence = `frontend-phase-behavior-r${bhRounds}-a${Number(bh.attempts) || 1}.md` return [ `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`, '', @@ -2189,10 +2285,12 @@ function reportPrompt(module, 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,需在 § ⑤ 标注。`, + '## 前置(门禁事实由编排层给定,**不要**再 Glob 通读全部证据自证)', + `- 上游 test-gate 已 green(编排层判定:共 ${gAttempts} 个 attempt,末次 \`${gateEvidence}\` = green)。红态根本走不到本步——testGate 会先 halt,故**不需要**你重新校验绿不绿。`, + `- 只需 Read 末次证据 \`${c.root}/docs/superpowers/module-reports/${gateEvidence}\` 取 § ⑤ 要用的摘要${gateFlake ? `;另 Read 首次 \`${phaseId}-test-gate-r1.md\`(attempt>1 = flake,§ ⑤ 需要首次失败用例)` : '(单 attempt,无 flake,无需读其它 attempt 文件)'}。该文件缺失 → halt(证据链断裂)。`, + gateFlake ? `- **flake 标注(编排层已判定)**:attempt 数 = ${gAttempts} 且末次 green → § ⑤ 顶部必须写 \`flake-detected: r1 red, r${gAttempts} green\`,并附首次失败用例与末次绿色记录链接。` : '', fe - ? `- **验证阶段级行为门绿**:Glob \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`,按 behaviorRound → attempt 数字升序读取。**最后一份必须非 RED(绿)**;最后一份 red 或一份证据都没有 → 立即 halt(绝不在行为红 / 无行为证据上打 milestone)。注意 per-FE 行为证据(\`reviews/--behavior-*\`)已不再产生,不要校验。` + ? `- 阶段级行为门已 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、无复验文件)'}。` : '', '', '## 收集输入(取摘要而非正文)', @@ -2202,14 +2300,14 @@ function reportPrompt(module, c) { `- § ② "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 未覆盖角色集**。`, + `- § ⑤:摘要上方"前置"已点名的那一两份 test-gate 证据(${gateFlake ? `\`${phaseId}-test-gate-r1.md\` + \`${gateEvidence}\`` : `仅 \`${gateEvidence}\``})。**另把行为门末次证据 \`${bhEvidence}\` 的 flake / 环境 race(envError)/ 行为 fix 轮数(=${bhRounds - 1})/ 样式 styleIssues 修复轮次 / 文字 continue 记录纳入 § ⑤ 汇总**${bhRounds > 1 ? '(含复验文件)' : ''}。`, + `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外按行为门末次证据 \`${bhEvidence}\` 的逐 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\`,并附首次失败用例与最终绿色记录链接。`, + `- § ⑤:摘要上方"前置"已点名的那一两份 test-gate 证据(${gateFlake ? `\`${id}-test-gate-r1.md\` + \`${gateEvidence}\`` : `仅 \`${gateEvidence}\``})——不要再按通配符通读全部 attempt。`, `- § ⑥ 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**。', @@ -2256,19 +2354,13 @@ async function runBranchSetup(module, c) { 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}) + // 主根模式:exists?+checkout|create+head 三合一(lane 模式跳过——worktree add 已把 lane 放上目标 + // 分支,且 checkout 语义在"分支被别的 worktree 持有"时会 fatal,绝不能在主根重复执行)。 + // lane 模式仍需读回 HEAD 供下方仲裁循环判定,走原单读微步骤。 + let head = c.lane == null + ? await runAction(g => ensureBranchCheckedOutPromptM(branch, def.branch, c) + g, + {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('ready'), dec: c.decisions, root: c.root, schema: BRANCH_READY_SCHEMA}) + : 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) @@ -2619,6 +2711,10 @@ async function featureLoop(items, phase, c, feStage = 'all') { return r.exists } + // 段级进度 narrator(左侧面板分母只随派生增长,这里补真总量 + 预计)。放在并行分叉之前, + // 使并行批与串行链都能看到本段总量;并行批的实际墙钟按波次宽度缩短,这行是其串行上界。 + log(`eta ${grp} 段: ${items.length} 个 ${phase === 'backend' ? 'REQ' : 'FE'} × ${fmtMin(itemMin(phase))}/项 ≈ ${fmtMin(items.length * itemMin(phase))}(串行口径)`) + // ── Phase F(Task 12/13):后端 REQ 的 feature 级受限并行(设计见 featureBatchRun 头注释)。 // frontend phase 不进准入(附录补 2);闸关 / 单 feature 时走下方现行串行链,一字不差。 if (useFeatureParallel && phase === 'backend' && items.length >= 2) { @@ -2626,16 +2722,19 @@ async function featureLoop(items, phase, c, feStage = 'all') { return } - for (const id of items) { + for (const [i, id] of items.entries()) { + const left = () => `(${i + 1}/${items.length},余 ${items.length - i - 1} 项 ≈ ${fmtMin((items.length - i - 1) * itemMin(phase))})` // 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 + log(`featureLoop skip ${phase}:${id} — tag ${isPhase1 ? 'fe-code-done' : 'req-done'}/${id} 已存在 ${left()}`); continue } + 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))}` : '(本段最后一项)'}`) 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)) + log(`eta ${grp} ${id} 完成 ${left()}`) } } @@ -2857,13 +2956,10 @@ const REVIEW_HARD_ROUNDS = 10 // 注意: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,不阻断`) + const r = await agent(flipDocs08CheckboxPromptM(fe, id, phase, c), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA}) + if (!r) { log(`docs08-checkbox ${id}: 子代理无返回(不阻断)`); return } + if (!r.success) { log(`docs08-checkbox ${phase}:${id}: 勾选失败(${r.error || ''}),跳过——cosmetic,不阻断`); return } + if (r.detail === 'not-found') log(`docs08-checkbox ${phase}:${id}: 未找到功能行,跳过可视化勾选(req-done tag 仍是完成真值)`) } // 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 // red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。 async function testGate(module, phase, c) { let attempt = 1 + const site = `gate:${phase}:${module.id}` + etaLog(site, 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 + etaLog(site, 2, 'red → 防 flaky 自动重跑') 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 间选。 @@ -2952,10 +3051,13 @@ async function testGate(module, phase, c) { if (verdict.action !== 'retry') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`) attempt += 1 // retry:再跑一个独立 attempt 证据文件 + etaLog(site, attempt, '仲裁判 retry(辨 flake)') 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 + // attempts 回传给 module-report:报告不必再 Glob + 通读全部 attempt 证据去自证"最后一份绿", + // JS 这里就是那个事实的第一手持有者(见 reportPrompt 的 gateFacts)。 + return { ...g, attempts: attempt } } // ---- 前端阶段级行为验收控制流(runBehaviorGateOnce + runBehaviorGate)---- @@ -3065,14 +3167,17 @@ async function runPrototypePreview(feItems, c) { async function runBehaviorGateOnce(feItems, behaviorRound, c) { const lbl = (a) => `behavior:frontend-phase:r${behaviorRound}:a${a}` let attempt = 1 + etaLog('behavior:frontend-phase', 1, `r${behaviorRound},冷起全栈 + 枚举 ${(feItems || []).length} 个 FE`) 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) + const withAttempts = () => { bg = { ...bg, attempts: attempt }; return bg } // attempts 供 module-report 定位证据文件 // 内部 envError / 空覆盖重试:attempt 1→BEHAVIOR_ATTEMPT_MAX(沿用 testGate 思路);仍异常 → adjudicate(allowContinue:false)。 // build-failed 是确定性失败(重起不自愈)→ 跳过自动重起,直接进下方仲裁循环。 while (behaviorEnvBlocked(bg).blocked && !isBuildFailed(bg) && attempt < BEHAVIOR_ATTEMPT_MAX) { attempt += 1 + etaLog('behavior:frontend-phase', attempt, `r${behaviorRound},环境 race 自动重起全栈`) 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) @@ -3091,13 +3196,14 @@ async function runBehaviorGateOnce(feItems, behaviorRound, c) { allowContinue:false }, 'Behavior', adj, c.root) if (verdict.action !== 'retry') throw new Error(`HALT behavior-env frontend-phase: ${verdict.rationale || reason}`) attempt += 1 + etaLog('behavior:frontend-phase', attempt, `r${behaviorRound},仲裁判 retry`) 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 + return withAttempts() } // runBehaviorGate:阶段级行为门主循环(被顶层 frontend 段调用,phase('Behavior') 下)。green 才正常返回(放行进 testGate)。 @@ -3107,6 +3213,8 @@ 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++) { + if (behaviorRound > 1) + log(`eta behavior: 第 ${behaviorRound}/${BEHAVIOR_STAGE_MAX} 轮(上轮非绿,fix 已改源码)——本轮重跑整门再需 ${fmtRange(STAGE_MIN.behavior)}`) const bg = await runBehaviorGateOnce(feItems, behaviorRound, c) // 1) coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 runBehaviorGateOnce 兜底)。 @@ -3199,7 +3307,9 @@ async function runBehaviorGate(feItems, c) { // 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 + // 绿态事实回传 module-report(同 testGate 的 attempts:报告不再重新 Glob 自证行为门绿)。 + return { rounds: behaviorRound, attempts: bg.attempts || 1, routesPlanned: bg.routesPlanned, + routesReached: bg.routesReached, controlsEnumerated: bg.controlsEnumerated, authState: bg.authState || '' } } // 6) 分流:无 locator 的硬问题 → adjudicate(allowContinue:false) retry/halt(绝不静默丢弃、绝不放行)。 @@ -3281,6 +3391,14 @@ for (const m of routed.modules) { const todo = routed.modules.filter(m => !m.done) log(`coding: ${todo.length}/${routed.modules.length} modules to run`) +// ETA 总览(顺利路径中位数外推;并行开启时是串行上界,实际按波次宽度缩短)。 +{ + const nb = todo.reduce((s, m) => s + (m.reqs || []).length, 0) + const nf = todo.reduce((s, m) => s + (m.feItems || []).length, 0) + const tot = todo.reduce((s, m) => s + moduleMin(m), 0) + const lo = todo.reduce((s, m) => s + moduleMin(m, 0), 0), hi = todo.reduce((s, m) => s + moduleMin(m, 2), 0) + log(`eta 总览: 待跑 ${nb} 个后端 REQ + ${nf} 个 FE,全串行顺利路径 ${fmtMin(tot)}(区间 ${fmtMin(lo)}–${fmtMin(hi)};每轮 review-fix / gate 重跑 / behavior 重跑各另加)`) +} // ============================================================================ // 调度器(Phase B):LLM 出依赖边 + JS 拓扑波次。Phase D 的波次主循环消费——useParallel 开启时 @@ -3477,8 +3595,12 @@ async function runFrontendPhase1(feModule, c) { } async function runModule(module, c) { + // 门禁事实收集器:testGate / runBehaviorGate 的绿态回执,末尾透传给 reportPrompt—— + // 报告不必再 Glob 通读全部证据文件去自证"最后一份绿"(JS 才是第一手持有者,红态走不到 report)。 + const reportFacts = {} try { if (c.lane == null) phase('Milestone') + log(`eta ${module.id}: ${(module.reqs || []).length} 个后端 REQ + ${(module.feItems || []).length} 个 FE ≈ ${fmtMin(moduleMin(module))}(区间 ${fmtMin(moduleMin(module, 0))}–${fmtMin(moduleMin(module, 2))},顺利路径)`) await runBranchSetup(module, c) if (module.reqs.length) { // 后端段(frontend-phase 模块 reqs 为空 → 跳过) if (c.lane == null) phase('Backend') @@ -3486,7 +3608,8 @@ async function runModule(module, c) { if (c.lane == null) phase('Gate') // 起栈互斥(补 9):testGate 整段包锁——其 e2e 段 Playwright globalSetup 按固定端口冷起后端, // 不可单拆。串行模式锁恒空闲,行为不变(统一纪律,免按模式分叉)。 - await withStackLock(() => testGate(module, 'backend', c)) + // 返回值(status/attempts)透传给 module-report:报告据此免去通读全部 attempt 证据自证绿。 + reportFacts.gate = await withStackLock(() => testGate(module, 'backend', c)) // 演示种子生成 stage(Seed):在 testGate 后跑——此时本模块 schema(含 tdd 新增的 V migration) // 已终态且全绿,按它生成的种子才不会撞结构。allowContinue:false——e2e 基线(globalSetup 注入)与行为门 // step2 子项③(演示种子注入)都依赖该种子,坏种子放行会让整个前端阶段(行为验收/e2e)在脏数据上全线误判。 @@ -3502,6 +3625,7 @@ async function runModule(module, c) { // 截图归档为可视化基线 + 跑原型自带交互点击冒烟。与行为门(测实现)正交;advisory(原型属上游 // plan 权威,coding 不改原型源码,渲染/JS 问题只记证据不阻断)。静态渲染、不起全栈。终波主根执行。 if (c.lane == null) phase('Preview') + 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))})`) await runPrototypePreview(module.feItems, c) if (c.lane == null) phase('Frontend') // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy @@ -3518,15 +3642,16 @@ async function runModule(module, c) { if (c.lane == null) phase('Behavior') // 行为门虽也冷起全栈,但只在 frontend-phase 终波(宽 1、主根)跑——无兄弟 lane 可互杀, // 不进起栈互斥(补 9 范围仅 Seed / testGate)。 - await runBehaviorGate(module.feItems, c) + reportFacts.behavior = await runBehaviorGate(module.feItems, c) if (c.lane == null) phase('Gate') // 阶段级 testGate(全量回归 vitest+playwright),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。 - await withStackLock(() => testGate(module, 'frontend', c)) + reportFacts.gate = 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, { + // report allowContinue:false:报告产物是 milestone 的前置(runMilestone 校验其 § ⑫),绝不 continue + // 放行去打 milestone。注意"最后一次 test-gate 必须 green"已不再由报告子代理自证——红态在 + // testGate / runBehaviorGate 内就 halt 了,根本走不到这里;绿态事实经 reportFacts 直接给定。 + await runStage(g => reportPrompt(module, c, reportFacts) + 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