Commit 543ea15abc14f012a1beb370ee72c96f4d230fa8

Authored by yanghl
1 parent e779913a

perf(coding): prototype 渲染门幂等跳过 + behavior carry 跨 run 复用 + Task 授权降级为既定约定

三项省时改造,都不改验收强度:

- prototype 渲染门幂等:证据头部钉一行 `- prototypeCommit: <哈希>`(git log -1 -- prototype/
  的**路径限定** tip,不是仓库 HEAD,否则任何无关提交都让判据失效),新增
  prototypePreviewStatePromptM 微步骤在 runPrototypePreview 开头比对,未变则整门跳过。
  本门原先无条件跑——一次增量里 resume 三次就渲染三次同一份原型(每次 3–7 分钟)。
  判不准一律 exists:false:过期截图基线会流到下游行为门比对,多跑一次只是几分钟。

- behavior carry 跨 run 复用:原先每个新 run 都从 behaviorRound=1 起步、入口无条件删掉整个
  frontend-phase/(含 carry),carry 永远活不过一次 run,每次 resume 重烧约 18 分钟重推导。
  拆成 mustReuse(同 run 后续轮,carry 必是本 run 刚写的 → 无条件复用)/ mayReuse(新 run 首跑,
  按 expectations.md 头部 carryScope + carrySpecs 短哈希比对 + 更新版本 spec 探测,通过才复用)。
  失效条件收敛于 spec——门的期望全部派生自 spec/prototype/docs05,两次 run 之间只有
  frontend/ 源码 fix。**运行栈仍每次冷起、库副本仍每次重建**,原纪律一字未动。

- Task 工具授权降级为既定约定:原文让每个 stage 用 ToolSearch select:Task 探测再按自主决策
  处理,同一个结论被反复探测、推理、登记——实测某项目 RESUME.md 2320 条决策里 123 条(5.3%)
  都在说这一件事,还随 handoff 反复读回上下文。现在直接写死「常态没有,用 Bash run_in_background
  detached 子进程即满足护栏意图」,不探测、不写 decisions[]、不升级、不 halt。
  adjudicatePromptM 补同口径的「已知非阻塞情形」——仲裁只套 microStepContract,拿不到 stage 侧
  授权,2026-06-13 曾自己复核后判 halt(BAC-DeliverMode-Query),42b6ed8e 只补了 stage 侧。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Showing 1 changed file with 97 additions and 13 deletions
workflows/coding.mjs
... ... @@ -328,7 +328,14 @@ function featureStageContract(phase, c) {
328 328 return [
329 329 '## 硬约束(非交互子代理)',
330 330 '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
331   - '- **测试执行子会话的物理实现(授权)**:下文「派发 Agent 子会话跑测试 / 绝不在主会话直接跑测试」的护栏,其**真实意图**是「测试执行与你的主推理流隔离、只回收结构化结果(不把全文 stdout 灌进上下文)」。本运行环境若**没有** Agent/Task 子会话派发工具(用 ToolSearch `select:Task` 无匹配即属此情形),则以 **`Bash` 的 `run_in_background` 隔离后台子进程**作为该子会话的物理实现:派一个 detached 子进程跑测试命令、输出重定向到 `.tmp/` 日志文件,你只读回 `{command, exit_code, failing_assertion / passed / failed}` 等结构化摘要。这**已满足**护栏意图,故**绝不**因「缺 Agent 派发工具」而 halt——这不是硬事实缺失、不是缺值阻塞,retry 也不会改变工具面,halt 只会空耗一次重跑。**仍然禁止**:把测试命令在主推理流里内联同步执行并把全文 stdout 读进上下文。下文出现「Agent 子会话」一律按本条解释为「隔离后台子进程子会话」。',
  331 + // 「Agent 子会话」在本项目运行环境里恒不可用(Workflow 派生的子代理工具面只有编排类,无 Task 派发)。
  332 + // 2026-06-13 曾因此连炸 3 次 HALT(BAC-Position-Query / DeliverMode-Query / CustomerGrade-Add),42b6ed8
  333 + // 补了本授权条款后再未复发。但原文让每个 stage「用 ToolSearch select:Task 探测」并按自主决策处理,
  334 + // 于是同一个结论被重复探测、重复推理、重复登记——实测某项目 RESUME.md 2320 条决策里 123 条(5.3%)
  335 + // 都在说这一件事,还随 handoff 反复读回上下文。故降级为**既定执行约定**:不必探测、不登记、不升级。
  336 + '- **测试执行子会话的物理实现(既定约定,不是自主决策)**:下文「派发 Agent 子会话跑测试 / 绝不在主会话直接跑测试」的护栏,其**真实意图**是「测试执行与你的主推理流隔离、只回收结构化结果(不把全文 stdout 灌进上下文)」。本项目运行环境**常态没有** Agent/Task 子会话派发工具,**不必特意用 ToolSearch 去探测确认**——直接以 **`Bash` 的 `run_in_background` 隔离后台子进程**作为该子会话的物理实现:派一个 detached 子进程跑测试命令、输出重定向到 `.tmp/` 日志文件,你只读回 `{command, exit_code, failing_assertion / passed / failed}` 等结构化摘要。这**已满足**护栏意图。(工具面里确实有 Task/Agent 派发工具时,用它当然也可以,等价。)',
  337 + ' - **不要登记、不要升级、不要 halt**:这条是既定约定,**不是**你做的自主决策——**绝不**写进 `decisions[]`,**绝不**为此向仲裁升级,**绝不**因「缺 Agent 派发工具」而 halt。它不是硬事实缺失、不是缺值阻塞,retry 也不会改变工具面。',
  338 + ' - **仍然禁止**:把测试命令在主推理流里内联同步执行并把全文 stdout 读进上下文。下文出现「Agent 子会话」一律按本条解释为「隔离后台子进程子会话」。',
332 339 '- **后台等待纪律(硬约束,违反即前功尽弃)**:把测试/闸门派到后台子进程后,必须在**同一回合内**等到终态——用重复的 `Bash` 调用轮询(`sleep N` 后读日志尾/exit 标记文件,长跑闸门每 60~120s 一轮,多少轮都行)。**绝不**使用 Monitor / ScheduleWakeup / 任何「装个监听器然后结束回合等通知」的模式:你是 Workflow 派生子代理,**结束回合=宣告完成**,会被判「未调 StructuredOutput」使整个模块 halt,任何通知都不会把你唤醒(AGT test-gate 2026-07-11 实锤教训)。拿到终态先写证据再调 StructuredOutput,一个回合内闭环。',
333 340 '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md` → `docs/05-API接口契约.md` → `prototype/`(前端布局/交互权威)→ `src/styles/tokens.css`(前端色值)→ `CLAUDE.md` → 现有代码。',
334 341 '- 仍查不到时——**优先自主决策继续,不要停下**:基于现有代码约定 / 技术规范 / 同类实现,挑选**最有依据的解读或合理默认值**,把该决策写进产物显著位置,并在返回的 `decisions[]` 中逐条登记 `{question, choice, rationale, confidence}`(这是默认动作,项目目标是全自动静默、尽可能少 halt)。',
... ... @@ -936,6 +943,10 @@ function prototypePreviewPrompt(feItems, attempt, c) {
936 943 '',
937 944 '## step3 证据落盘 + commit',
938 945 `- 写 \`${evidence}\`:按页一小节(原型相对路径 / 截图链接 / controlsExercised / jsErrors / consoleErrors / 视口决策),头部写 pagesFound-Rendered 汇总。渲染失败 / JS 错用**红字**标注为**阻断项**(上层据此 halt——回 /erp-workflow:plan-start 或 /add-req 修原型后重跑,**不在 coding 改**);console.error 标注为 advisory。`,
  946 + // 幂等判据(省时改造):本门无条件重跑曾让同一份 prototype/ 被渲染 3 次(每次 3–7 分钟)。
  947 + // 证据头部钉一行 prototypeCommit,下次开跑前的 state 微步骤据它与 prototype/ 当前 tip 比对决定是否跳过。
  948 + // 必须是 prototype/ 的**路径限定** tip(不是仓库 HEAD)——否则任何无关提交都会让判据失效、退化回每次重跑。
  949 + `- **头部第一行必须逐字写**(幂等判据,下次开跑靠它决定能否跳过本门,格式错=白跑一次):\`- prototypeCommit: <哈希>\`,其中 \`<哈希>\` = \`git -C ${c.root} log -1 --format=%H -- prototype/\` 的完整输出(40 位)。该命令无输出(\`prototype/\` 从未提交)→ 写 \`- prototypeCommit: none\`。`,
939 950 commitBlock(`${evidence} ${assets}`, 'docs(preview:prototype): 静态原型渲染基线 + 交互冒烟证据', undefined, c),
940 951 '',
941 952 '## 输出(必须符合下发的 PROTOTYPE_PREVIEW JSON schema)',
... ... @@ -947,6 +958,26 @@ function prototypePreviewPrompt(feItems, attempt, c) {
947 958 ].filter(Boolean).join('\n')
948 959 }
949 960  
  961 +// preview 幂等判定:prototype/ 自上次渲染证据以来没变过 → 整门可跳过。
  962 +// 动机:本门原先无条件跑,实测同一份 prototype/ 在一次增量里被渲染 3 次(每次 3–7 分钟,STAGE_MIN.preview);
  963 +// resume 越多浪费越大。判据取 prototype/ 的**路径限定** git tip 与证据头部记录的 prototypeCommit 比对——
  964 +// 确定性、无需重跑渲染。判不准一律 exists:false(宁可白跑一次,不可跳过真的变更过的原型)。
  965 +function prototypePreviewStatePromptM(c) {
  966 + const evidence = 'docs/superpowers/module-reports/prototype-preview.md'
  967 + return [
  968 + '# 检测静态原型渲染门是否可跳过(prototype/ 自上次渲染以来未变)',
  969 + microStepContract(c.root),
  970 + '',
  971 + `1. 跑 \`git -C ${c.root} log -1 --format=%H -- prototype/\` 取 \`prototype/\` 的路径限定 tip 哈希(记为 CUR;无输出记 \`none\`)。`,
  972 + `2. Read \`${c.root}/${evidence}\`。文件不存在 → \`{ "exists": false }\`(从未跑过本门)。`,
  973 + '3. 从该文件**头部**找逐字形如 `- prototypeCommit: <哈希>` 的行,取其值(记为 REC)。找不到该行 → `{ "exists": false }`(旧版证据无判据,按未跑过处理)。',
  974 + '4. `CUR === REC` 且两者都不是 `none` → `{ "exists": true }`(原型未变,渲染基线仍有效,本门可跳过)。',
  975 + '5. 其余一切情形(不相等 / 任一为 `none` / 哈希格式异常 / 文件读取失败)→ `{ "exists": false }`。',
  976 + '- **判不准就返回 false**:跳过一次本该跑的渲染门,会让过期截图基线流到下游行为门比对;多跑一次只是几分钟。两者不对称,别赌。',
  977 + '## 输出(EXISTS_SCHEMA)',
  978 + ].join('\n')
  979 +}
  980 +
950 981 // ---- 前端行为验收(阶段级行为门,v3)----
951 982 // 设计权威:docs/design/2026-06-05-frontend-behavior-stage-gate.md。
952 983 // 时机:featureLoop(frontend) 把本轮 feItems 全部跑完静态 review(req-done tag 已打)之后、testGate 之前,
... ... @@ -990,7 +1021,15 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
990 1021 // 改 frontend/ 源码——期望(来自 prototype/REQ/docs05)与 runner(DOM/locator 驱动)在同一前端阶段内
991 1022 // 不随 fix 失效。故只重跑不重推导。**运行栈仍绝不复用**(每次冷起,契约未变)。
992 1023 const carryDir = `${c.root}/.tmp/behavior-gate/frontend-phase/carry`
993   - const reuse = behaviorRound > 1 || attempt > 1
  1024 + // 复用分三态(省时改造 · 跨 run):
  1025 + // - mustReuse(round>1 || attempt>1):同一次 run 内的后续轮,carry 必定是本 run 刚写的 → 无条件复用。
  1026 + // - mayReuse(round1 && attempt1):本次是新一轮 coding.mjs run 的首跑。原先这里**无条件删掉整个
  1027 + // frontend-phase/ 目录(含 carry)**,而每个新 run 都从 behaviorRound=1 起步——carry 于是永远活不过
  1028 + // 一次 run,每次 resume 都要重烧约 18 分钟重推导(见本文件下方 carry 注释里的实测拆分)。
  1029 + // 改为:保留 carry,让子代理按「作用域 FE 集 + 各 FE spec 的 git tip」校验它是否仍适用,通过才复用。
  1030 + // 失效条件收敛于 spec——门的期望完全派生自 spec/prototype/docs05,而 fix 只改 frontend/ 源码。
  1031 + const mustReuse = behaviorRound > 1 || attempt > 1
  1032 + const mayReuse = !mustReuse
994 1033 const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-r${behaviorRound}-a${attempt}.md`
995 1034 return [
996 1035 `# behavior — 前端阶段级行为验收(headless,frontend-phase, behaviorRound=${behaviorRound}, attempt=${attempt})`,
... ... @@ -1008,19 +1047,41 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1008 1047 '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)',
1009 1048 '- **冷起栈(运行时硬约束)**:本门**不经 playwright.config 跑**(项目的 `webServer` 只管前端,起不了后端 + 测试库副本 + 种子),故**没有可复用的起栈入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。',
1010 1049 '- **前端就绪判定照抄后端那套**:spawn 前端后**必须轮询到端口真正可连**才开始驱动页面,不允许起完就跑。前端 dev server 未就绪却直接连,会产出一批「控件找不到 / 页面空白」的假 RED,归因时极难与真缺陷区分——这是本项目 e2e 侧最贵的一类假红灯。超时未就绪 → `envError.kind="stack-not-ready"`(走 retry),**不要**记成 `interactionFailures`。',
1011   - `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1
1012   - ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子/carry),再新建本轮子目录 \`${tmpDir}/\` 与派生物暂存区 \`${carryDir}/\`。`
1013   - : `本次 behaviorRound=${behaviorRound} / attempt=${attempt} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建;**\`${carryDir}/\` 绝不删**——它装着上一次已推导好的 runner/种子/期望,本次要复用(见下方「派生物复用」)。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`,
  1050 + `- **入口清目录(跑前第一步,去串味)**:${mayReuse
  1051 + ? `本次是本 run 的首轮首次 → 删除 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 下**除 \`carry/\` 外**的全部子目录(即历史 \`r*/\` 残留 runner 与种子),再新建本轮子目录 \`${tmpDir}/\`。**\`${carryDir}/\` 此时绝不删**——它可能是上一次 coding.mjs run 留下的、仍然适用的推导结果,是否复用由下方「carry 校验」判定,判定不通过时才由你清掉重推。`
  1052 + : `本次 behaviorRound=${behaviorRound} / attempt=${attempt} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建;**\`${carryDir}/\` 绝不删**——它装着本 run 已推导好的 runner/种子/期望,本次要复用(见下方「派生物复用」)。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`,
1014 1053 // 派生物复用(省时改造):只重跑不重推导。首次跑完必须落 carry,后续轮次必须先读 carry。
1015   - reuse
  1054 + mustReuse
1016 1055 ? [
1017   - `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:全部 FE 作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / 组件文件反查表)与 - `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:全部 FE ${carryDir}/run.mjs- `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:全部 FE 、- `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:全部 FE ${carryDir}/sentinel.sql- `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:全部 FE 。`,
  1056 + `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:清单内 FE 的作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / 组件文件反查表)与 + `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:清单内 FE 的${carryDir}/run.mjs+ `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:清单内 FE 的、+ `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:清单内 FE 的${carryDir}/sentinel.sql+ `- **派生物复用(本次必须先做,${behaviorRound > 1 ? `上一轮 fix 只改了 \`frontend/\` 源码` : `上一次是同轮 attempt 重试`})**:先 Read \`${carryDir}/expectations.md\`(上次已推导的:清单内 FE 的。`,
1018 1057 ` - **三者齐备 → 直接复用**:把 \`${carryDir}/run.mjs\` / \`sentinel.sql\` 复制进本轮 \`${tmpDir}/\` 后使用,**跳过 step1 / step4 的重新推导**(期望以 \`expectations.md\` 为准),直接进 step2 起栈。这是本次的默认路径。`,
1019 1058 ` - **只在有明确证据时才改**:上次证据显示 runner 自身有 bug(选择器过时 / 断言写错 / 漏驱动),或 \`frontend/\` 的路由表在上一轮 fix 中真的变了 → 就地**增量修补**复制过来的 runner,并把修补同步回 \`${carryDir}/run.mjs\`;**绝不**因为"想重写得更好"而从头重推导。`,
1020 1059 ` - 缺文件 / 内容不可用 → 按首次流程完整推导,跑完照样落回 \`${carryDir}/\`(缺失是异常,在证据报告里注明)。`,
1021 1060 ` - **不复用的部分(硬约束)**:运行栈(每次冷起)、数据库副本(每次重建)、演示/sentinel 数据的**注入动作**(每次重灌)、证据报告(每次独立文件)。复用的只是"推导结果与脚本"。`,
1022 1061 ].join('\n')
1023   - : `- **派生物落盘(本次是首次推导,必须做)**:step1/step4 推导完成后,把 ① 推导结果写成 \`${carryDir}/expectations.md\`(全部 FE 作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / route→组件文件反查表)、② runner 写一份到 \`${carryDir}/run.mjs\`、③ sentinel 种子写一份到 \`${carryDir}/sentinel.sql\`。后续 behaviorRound / attempt 会直接复用这三份**跳过重新推导**(fix 只改 \`frontend/\` 源码,不使这些失效),省掉每轮十几分钟的重复侦查。carry 与本轮 \`${tmpDir}/\` 同属临时区、跑完即弃,**不提交**。`,
  1062 + : [
  1063 + // 跨 run 复用:carry 可能来自上一次 coding.mjs run。它的有效性只取决于「作用域 FE 集」与
  1064 + // 「各 FE spec 内容」——门的全部期望都派生自 spec/prototype/docs05,而两次 run 之间发生的
  1065 + // 只有 frontend/ 源码 fix(不影响期望)。故校验这两项即可,不必因为"换了一次 run"就重推。
  1066 + `- **carry 校验(本次是本 run 首跑,先判能否复用上一次 run 留下的推导结果)**:Read \`${carryDir}/expectations.md\`、\`${carryDir}/run.mjs\`、\`${carryDir}/sentinel.sql\`。任一缺失 → 判**失效**,直接走下方「完整推导」。`,
  1067 + ` - 三者齐备时,从 \`expectations.md\` **头部**取两行判据(首次推导时按下方「落盘」格式写入,格式不符即判失效):`,
  1068 + ' - `- carryScope: <逗号分隔的 FE 列表>` —— 与本次清单**逐项比对(集合相等,顺序无关)**。不等 → 失效。',
  1069 + ` - \`- carrySpecs: <FE>=<短哈希> ...\` —— 对其中每个 FE,跑 \`git -C ${c.root} log -1 --format=%h -- <该 FE 当前采用的 spec 文件路径>\`,与记录值比对。任一不等 → 失效。`,
  1070 + ` - 再补一道**新版本探测**:对清单内每个 FE,Glob \`${c.root}/docs/superpowers/specs/*-<FE-NN>.md\`,若存在**日期前缀比 carrySpecs 记录的那份更新**的 spec 文件(add-req 重出规格会这样)→ 判**失效**(记录的哈希没变,但作用域真值已换文件)。`,
  1071 + ` - **校验通过 → 复用**:把 \`run.mjs\` / \`sentinel.sql\` 复制进 \`${tmpDir}/\` 使用,**跳过 step1 / step4 的重新推导**(期望以 \`expectations.md\` 为准),直接进 step2 起栈。在证据报告里注明「carry 复用(来自上一次 run),校验项:scope 相等 + specs 哈希一致 + 无更新版本 spec」。`,
  1072 + ` - **判失效 → 完整推导**:先删除并重建 \`${carryDir}/\`(清掉过期派生物,防新旧混用),再按 step1/step4 从头推导,跑完按下方格式落盘。`,
  1073 + ' - **判不准(读不到、格式怪、哈希取不到)一律按失效处理**:多花约 18 分钟重推,比拿过期期望跑出一份看似 green 的假证据便宜得多。',
  1074 + '',
  1075 + `- **派生物落盘(首次推导 / 判失效重推后必须做)**:把 ① 推导结果写成 \`${carryDir}/expectations.md\`、② runner 写一份到 \`${carryDir}/run.mjs\`、③ sentinel 种子写一份到 \`${carryDir}/sentinel.sql\`。`,
  1076 + ` - \`expectations.md\` **头两行必须逐字按以下格式**(下一次 run 的校验全靠它,写错=下次白白重推 18 分钟):`,
  1077 + ' ```',
  1078 + ` - carryScope: ${(feItems || []).join(', ')}`,
  1079 + ' - carrySpecs: <FE-NN>=<短哈希> <FE-NN>=<短哈希> ...',
  1080 + ' ```',
  1081 + ` 其中短哈希 = \`git -C ${c.root} log -1 --format=%h -- <该 FE 采用的 spec 文件路径>\`(同一 FE 有多份规格时取**最新日期**那份,与 step1 取作用域用的是同一份)。哈希取不到(spec 未提交)→ 该项写 \`none\`,这会让下次校验必然失效并重推,是预期行为。`,
  1082 + ' - 正文接着写:清单内 FE 的作用域并集 / 逐路由预期控件与文字来源 / 每路由所需角色 / sentinel 值表 / route→组件文件反查表。',
  1083 + ` - 后续 behaviorRound / attempt **以及下一次 coding.mjs run** 都会复用这三份跳过重新推导,省掉每次十几分钟的重复侦查。carry 与本轮 \`${tmpDir}/\` 同属临时区、**不提交**。`,
  1084 + ].join('\n'),
1024 1085 `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 本门起的全部子进程**并透传结构化结果。**绝不**让前台 gradle bootRun / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`,
1025 1086 // runner 的 status 必须是**算出来的**。曾实际发生:生成的 run.mjs 把 status 初始化为 'red'
1026 1087 // 后再未赋值,恒红且不带任何分类信号——若子代理照单全收,上层 runBehaviorGate 的
... ... @@ -1041,8 +1102,10 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1041 1102 ' - 根因不可归到 `frontend/` 源码(依赖 / 工具链 / 无法定位)→ `envError.kind="build-failed"`(如能定位仍填 `rootCausePath`)。',
1042 1103 ' - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。',
1043 1104 '',
1044   - `## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`,
1045   - reuse ? `- 本次已从 \`${carryDir}/expectations.md\` 取得分母与逐路由归属,**不要**重新逐 FE Read spec、不要重新对账 router(那是上一次已做完的确定性推导,重做只是重复烧时间)。仅当 carry 缺失/不可用时才回退执行本节。` : '',
  1105 + `## step1 路由真值发现(覆盖率分母 = 本次清单内 FE 的作用域路由并集)${mustReuse ? ' —— **本次复用 carry,整节跳过**' : ' —— **carry 校验通过则整节跳过**'}`,
  1106 + mustReuse
  1107 + ? `- 本次已从 \`${carryDir}/expectations.md\` 取得分母与逐路由归属,**不要**重新逐 FE Read spec、不要重新对账 router(那是上一次已做完的确定性推导,重做只是重复烧时间)。仅当 carry 缺失/不可用时才回退执行本节。`
  1108 + : `- **carry 校验通过时整节跳过**:分母与逐路由归属直接取自 \`${carryDir}/expectations.md\`,**不要**重新逐 FE Read spec、不要重新对账 router。**判失效时才执行本节**(下面的内容即完整推导流程),跑完按「派生物落盘」写回 carry。`,
1046 1109 '- 分母来源 = **本次清单内每个 FE** 的 spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。',
1047 1110 `- 与 \`${c.root}/frontend/\` router 配置对账:清单内 FE 的作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属清单内任何 FE 作用域的路由记证据(不入分母、不断言)——**增量重跑时往轮已验收 FE 的路由就落在这一类,属正常,不是缺口**。`,
1048 1111 '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。',
... ... @@ -1052,7 +1115,7 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1052 1115 `1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。建副本前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`,
1053 1116 '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
1054 1117 `3) **注入演示种子**:\`node ${c.root}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过源库已灌过的文件,把新 \`sql/seed/*.sql\` 演示数据灌进副本)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`,
1055   - `4) **此时才跑 sentinel 种子**:${reuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : '按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)'}。失败 → - `4) **此时才跑 sentinel 种子**:${reuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : '按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)'envError.kind="seed-error"- `4) **此时才跑 sentinel 种子**:${reuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : '按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)' + 结构化根因,**不**混进交互 RED。`,
  1118 + `4) **此时才跑 sentinel 种子**:${mustReuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : `carry 校验通过 → 直接灌 \`${carryDir}/sentinel.sql\`(**不要**重新按 docs/03 推导;库副本每次重建、数据当然要重灌,但 SQL 本身不必重写);判失效 → 按 \`${c.root}/docs/03-数据库设计文档.md\` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)`}。失败 → + `4) **此时才跑 sentinel 种子**:${mustReuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : `carry 校验通过 → 直接灌 \`${carryDir}/sentinel.sql\`(**不要**重新按 docs/03 推导;库副本每次重建、数据当然要重灌,但 SQL 本身不必重写);判失效 → 按 \`${c.root}/docs/03-数据库设计文档.md\` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)`envError.kind="seed-error"+ `4) **此时才跑 sentinel 种子**:${mustReuse ? `直接灌 \`${carryDir}/sentinel.sql\`(上次已派生好,**不要**重新按 docs/03 推导——库副本每次重建,数据当然要重灌,但 SQL 本身不必重写)` : `carry 校验通过 → 直接灌 \`${carryDir}/sentinel.sql\`(**不要**重新按 docs/03 推导;库副本每次重建、数据当然要重灌,但 SQL 本身不必重写);判失效 → 按 \`${c.root}/docs/03-数据库设计文档.md\` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)` + 结构化根因,**不**混进交互 RED。`,
1056 1119 ' - **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_]` 格式。',
1057 1120 '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。',
1058 1121 `- \`finally\` **硬要求 kill 本门起的全部子进程**,**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动);端口 + pid 写入 \`envError.ports\` / \`envError.pids\`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。`,
... ... @@ -1069,8 +1132,10 @@ function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
1069 1132 '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。',
1070 1133 '- `routesReached` / `controlsEnumerated` 据实填(空覆盖必须可见)。',
1071 1134 '',
1072   - `## step4 推导期望${reuse ? ' —— **本次复用 carry,整节跳过**' : ''}`,
1073   - reuse ? `- 期望以 \`${carryDir}/expectations.md\` 为准(含每控件预期效果、每文字区域预期内容与来源、sentinel 值表)。**不要**重新读 prototype/REQ/docs05 推导——上一轮 fix 只改了 \`frontend/\` 实现,不改这些期望的来源。` : '',
  1135 + `## step4 推导期望${mustReuse ? ' —— **本次复用 carry,整节跳过**' : ' —— **carry 校验通过则整节跳过**'}`,
  1136 + mustReuse
  1137 + ? `- 期望以 \`${carryDir}/expectations.md\` 为准(含每控件预期效果、每文字区域预期内容与来源、sentinel 值表)。**不要**重新读 prototype/REQ/docs05 推导——上一轮 fix 只改了 \`frontend/\` 实现,不改这些期望的来源。`
  1138 + : `- **carry 校验通过时整节跳过**:期望以 \`${carryDir}/expectations.md\` 为准(含每控件预期效果、每文字区域预期内容与来源、sentinel 值表)。**不要**重新读 prototype/REQ/docs05 推导——两次 run 之间只有 \`frontend/\` 源码 fix,不改这些期望的来源。**判失效时才执行本节**,跑完写回 carry。`,
1074 1139 '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。',
1075 1140 '',
1076 1141 '## step5 断言(三层:交互/文字/样式 + 可观测效果白名单 + 硬问题带源码 locator)',
... ... @@ -1384,6 +1449,14 @@ function adjudicatePromptM(site, context, root) {
1384 1449 '- `continue`:缺陷不阻断正确性、可安全前进(reviewer 的非必须建议 / 可降级为口头建议的 issue / 纯可视化副作用缺失 / 已可由后续 verify / test-gate 兜底的疑虑)。在 `rationale` 说明为何安全。',
1385 1450 '- `halt`:确属不可恢复——结构性缺失且无任何旁证、git 树冲突需人工、继续会污染源码 / 伪造业务语义。在 `rationale` 写清人工需要做什么。',
1386 1451 '- 若上下文含 `"allowContinue": false`,**不得**选 continue(如红色测试不可跳过),只在 retry / halt 间选。',
  1452 + '',
  1453 + // 仲裁只套 microStepContract,拿不到 featureStageContract 里的「测试执行子会话」授权。2026-06-13 因此
  1454 + // 判过 halt(BAC-DeliverMode-Query:仲裁自己 ToolSearch 复核确认无 Task 工具 → 认定结构性能力缺口 →
  1455 + // 要求人工注入工具)。stage 侧已在 42b6ed8 授权,但仲裁侧一直是空的——把同一口径同步过来堵住复发。
  1456 + '## 已知非阻塞情形(**绝不**因下列理由判 halt)',
  1457 + '- **「本环境没有 Agent/Task 子会话派发工具,无法满足『派子会话跑测试』护栏」**:这是本项目运行环境的**常态**,不是结构性能力缺口。护栏的真实意图是「测试执行与主推理流隔离、只回收结构化结果」,用 `Bash` 的 `run_in_background` detached 子进程 + 日志重定向 + 只读回结构化摘要**已经满足**,且该做法已在 stage 侧硬约束里明确授权。',
  1458 + ' - 上游若以此为由请求裁决 → 判 `retry`,`guidance` 写:「按 stage 硬约束「测试执行子会话的物理实现」条执行——用 Bash run_in_background 派 detached 子进程跑测试、输出重定向到 .tmp/ 日志、只读回 {command, exit_code, passed, failed} 结构化摘要;不要再为工具面缺失升级或 halt」。',
  1459 + ' - **绝不**在 `rationale` 里要求人工"为子代理注入 Agent/Task 派发工具"——retry 不会改变工具面,这只会把流程停在一个永远等不到的前提上。',
1387 1460 '## 输出(ADJUDICATE_SCHEMA)',
1388 1461 '- `{ "action": "retry|continue|halt", "guidance": "<retry 时给下游的纠正指令,其余可空字符串>", "rationale": "<裁决理由>" }`',
1389 1462 ].join('\n')
... ... @@ -3120,6 +3193,17 @@ const isBuildFailed = (r) =&gt; !!(r.envError &amp;&amp; r.envError.kind === &#39;build-failed&#39;
3120 3193 // 终波恒宽 1、主根执行(c.lane==null);静态渲染无 DB、无需 ERP_TEST_DB_SCHEMA 注入。
3121 3194 async function runPrototypePreview(feItems, c) {
3122 3195 const lbl = (a) => `preview:proto:a${a}`
  3196 +
  3197 + // step 0(省时改造):prototype/ 自上次渲染证据以来没变过 → 整门跳过。
  3198 + // 本门原先无条件跑:一次增量里 resume 三次就渲染三次同一份原型(每次 3–7 分钟)。
  3199 + // 判据是确定性的 git 路径限定 tip 比对,判不准返回 false(宁可多跑,不可用过期基线)。
  3200 + const fresh = await agentR(prototypePreviewStatePromptM(c),
  3201 + {label: 'preview:proto:state?', phase: 'Preview', schema: EXISTS_SCHEMA})
  3202 + if (fresh.exists) {
  3203 + log('preview: prototype/ 自上次渲染证据以来未变,跳过静态原型渲染门(截图基线沿用)')
  3204 + return
  3205 + }
  3206 +
3123 3207 let attempt = 1
3124 3208 let pv = await agentR(prototypePreviewPrompt(feItems, attempt, c),
3125 3209 {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA})
... ...