Commit 0588d0dcc496863a40c1de4916d6b0bc07dcc3f4

Authored by zichun
1 parent 3d54e883

coding.mjs: move frontend behavior verification INTO per-FE reviewWithFixLoop (fixable dimension)

Replaces the phase-level read-only behavior-gate with a per-FE acceptance dimension: each FE is approved only when the code-reviewer approves AND runtime behavior verification is green. Behavior defects (dead control / sentinel text mismatch) become fixable must-fix that drive verify->fix->re-verify, not halts.

- reviewWithFixLoop (frontend only, via if(fe)): at the approve gate, behaviorSubGate boots this FE's full stack + seeds sentinels, enumerates this FE's routes, two-tier asserts. Hard issues with a locator -> fixPrompt -> functional reverify -> next behaviorRound; soft text (i18n/literal/semantic) -> adjudicate(continue); behaviorRound bounded by BEHAVIOR_FE_MAX=3, env race by BEHAVIOR_ATTEMPT_MAX=2. Backend featureLoop branch unchanged.

- New runFrontendSkeleton stage (before featureLoop(frontend)): App shell + full lazy router + FeStub placeholders + shared nav, so the app is buildable at every mid-phase point; tdd swaps FeStub->real component per FE. Idempotent via fe-skeleton-done tag.

- BEHAVIOR_GATE_SCHEMA gains build-failed envError kind (sibling-FE-unimpl short-circuit, not a bug) + locator-not-resolvable coverage reason; deriveSpec emits a per-FE route-scope section, reviewer validates it.

- Removed phase-level runBehaviorGate + 'Behavior' phase; kept phase-level testGate (regression). REVIEW_HARD_ROUNDS 8->10.

- Safety: test-DB naming guard pushed into scripts-setup-test-db.mjs template (fail-closed unless name contains test/_dev/_local or ALLOW_NONTEST_DROP=1) + 3 tests.

- agentType stays erp-workflow:code-reviewer. v1 design doc marked SUPERSEDED; v2 design at docs/design/2026-06-02-frontend-behavior-in-review-loop.md.

Verified: wrapped syntax check SYNTAX_OK, 87/87 lib tests pass, no orphan refs, no time/random builtins, top-level return intact. Not yet run end-to-end against a real ERP project.
README.md
@@ -43,9 +43,13 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。 @@ -43,9 +43,13 @@ Claude Code 插件:ERP / 后端管理系统全流程开发框架。
43 │ 10+ 微 agent,全部跳过/分支条件由 JS 判定,幂等) 43 │ 10+ 微 agent,全部跳过/分支条件由 JS 判定,幂等)
44 44
45 └─ B-前端(后端全部打里程碑后,整体 1 个里程碑 tag) 45 └─ B-前端(后端全部打里程碑后,整体 1 个里程碑 tag)
46 - runBranchSetup(frontend-phase) → featureLoop(前端,FE-NN,路径限 frontend/)  
47 - → testGate(frontend) → 前端行为闸 behavior-gate(headless 全栈起栈+种子 sentinel,  
48 - 逐路由枚举控件/文字两层断言:交互失效硬 halt,文字不符按来源仲裁) 46 + runBranchSetup(frontend-phase)
  47 + → 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位,保证中途任意时刻可构建可起)
  48 + → featureLoop(前端,FE-NN,路径限 frontend/):spec → plan → tdd → verify →
  49 + review 循环内并入 per-FE 行为验收 approve 子门(reviewer approve 时才起本 FE 全栈
  50 + +种子 sentinel,枚举本 FE 路由控件/文字两层断言;交互失效/sentinel 错转可 fix
  51 + must-fix→重验,软文字按来源仲裁,行为 green 才打 req-done/<FE>)
  52 + → testGate(frontend,全量回归 vitest+playwright,与 per-FE 行为验收职责正交)
49 → runMilestone(milestone/frontend-phase) 53 → runMilestone(milestone/frontend-phase)
50 54
51 子代理无法弹窗 → 缺值即写阻塞点并 halt(终止态,非对话框);fail-fast 后等人工修复重跑 coding-start 55 子代理无法弹窗 → 缺值即写阻塞点并 halt(终止态,非对话框);fail-fast 后等人工修复重跑 coding-start
docs/design/2026-06-02-frontend-behavior-gate.md
1 # 前端行为门(behavior-gate)— 最终设计(综合评审后) 1 # 前端行为门(behavior-gate)— 最终设计(综合评审后)
2 2
  3 +> ⚠️ **已作废(SUPERSEDED)** —— 本文描述的是**阶段级、只读、red 即 halt** 的行为门(frontend-phase 末尾跑一次)。
  4 +> 该设计已被 **per-FE 版**取代:行为验收并入每个 FE 的 `reviewWithFixLoop`、成为可 fix 的验收维度(verify→fix→重验循环),
  5 +> 并新增前端骨架占位阶段(`runFrontendSkeleton` + `FeStub` 全量 lazy 路由)保证中途可构建。
  6 +> **现行设计见 [`2026-06-02-frontend-behavior-in-review-loop.md`](./2026-06-02-frontend-behavior-in-review-loop.md)**;本文仅作历史保留,勿据此实现。
  7 +
3 > 本文是 5 维对抗式评审后的收敛版。锁定决策(用户拍板)默认保留;评审给出的有依据调整已并入; 8 > 本文是 5 维对抗式评审后的收敛版。锁定决策(用户拍板)默认保留;评审给出的有依据调整已并入;
4 > 无依据 / 过度工程的建议在文末「拒绝的建议」记明理由。所有改动可追溯到 changeLog。 9 > 无依据 / 过度工程的建议在文末「拒绝的建议」记明理由。所有改动可追溯到 changeLog。
5 10
docs/design/2026-06-02-frontend-behavior-in-review-loop.md 0 → 100644
  1 +# 前端行为验收并入 reviewWithFixLoop(v2 最终设计:per-FE + fix 循环)
  2 +
  3 +> 状态:可实现(ready-to-implement),含 3 项实现前置依赖。
  4 +> 上游:本设计取代 `docs/design/2026-06-02-frontend-behavior-gate.md` 的「阶段级末尾只读 halt 门」形态。
  5 +> 运行时红线(不可违反):禁用 time/random builtin(`Date.now()` / `Math.random()` / `new Date()`);顶层 `return` 是结果通道;`agent/phase/parallel/log/adjudicate/recordDecisions` 是注入全局;**后端 featureLoop 分支逐字不变**。
  6 +
  7 +---
  8 +
  9 +## 0. 用户拍板的方向(不可推翻)
  10 +
  11 +- 行为验收**并入 per-FE reviewWithFixLoop**,与静态 code-reviewer 并列为另一个验收维度。
  12 +- 行为发现的硬问题**可 fix**(带 locator 的 must-fix),驱动 fix→重验循环,不再 halt。
  13 +- **仅前端 FE** 有此维度;后端 REQ 分支(无 UI)逐字不变。
  14 +- 接受**每个 FE 起一次(或少数几次)全栈**的代价。
  15 +
  16 +本设计在守住上述方向的前提下,落实了 5 维评审里 **确凿的 blocker**:中途可构建性(头号)、起栈成本笛卡尔积爆炸、起栈不可跨子会话复用、locator 不可靠降级=放行、缺 locator 硬问题被现有 filter 静默吞、删阶段门后 report 失去绿前置锚点、测试库护栏只在 LLM 层。
  17 +
  18 +---
  19 +
  20 +## 1. 关键架构决策:行为验收是 reviewer-approve 的「approve 子门」,不是每轮都起栈
  21 +
  22 +这是本设计相对「种子设计」最重要的调整,一次性化解 3 个 blocker(成本笛卡尔积 / 不可复用栈 / 中途构建脆弱性被乘以 N×round)。
  23 +
  24 +**原种子设计**:每个 review round 的 step2 跑一次行为验收、fix 后 step5 再跑一次 → 单 FE 最坏 `REVIEW_HARD_ROUNDS(10) × 2 = 20` 次全栈起栈,N 个 FE 串行 → N×20,墙钟在最坏路径不收敛。
  25 +
  26 +**v2 决策**:把行为验收从「每个 review round 内」**解耦**为「**reviewer 即将 approve 时才触发的 approve 前置子门**」:
  27 +
  28 +```
  29 +reviewWithFixLoop(FE):
  30 + ┌─ 静态 review→fix 循环(与现状几乎不变;后端逐字不变)
  31 + │ round 1..N: reviewer 判 → request-changes 则 filter locator must-fix → fix → reverify(功能测试) → 再 review
  32 + │
  33 + └─ 当某轮 reviewer 判 approve(现 1386 分支)→ 不立即 return,先进【行为 approve 子门】:
  34 + behaviorSubGate(FE):
  35 + for behaviorRound = 1..BEHAVIOR_FE_MAX(=3):
  36 + 跑一次 per-FE 行为验收(runBehaviorGateOnce,内含 envError attempt 重试)
  37 + ├─ envError / 空覆盖 → 内部 attempt 重试;确定性失败(build-failed) → 记 coverageGap 短路,不 retry 不 halt
  38 + ├─ behaviorHard(interactionFailures + sentinel textIssues)为空 → 子门 green → break
  39 + └─ behaviorHard 非空:
  40 + · 有 locator 的 → 合并进 fixPrompt 的 issues,跑 fix(runStage)
  41 + · 软文字(i18n/literal/semantic) → adjudicate(continue 记 decisions / retry / halt),永不阻断 approve
  42 + · 无 locator 的 behaviorHard → adjudicate(allowContinue:false)(retry 重判/重跑 或 halt),绝不静默丢弃、绝不 approve
  43 + fix 后只重跑「本 FE 行为验收」(不必重跑功能 reverify,除非 fix 也动了功能逻辑——见 §6)
  44 + 子门 BEHAVIOR_FE_MAX 轮仍未 green → throw HALT behavior-unresolved
  45 + → 静态 approve ∧ 行为 green ⇒ 才 flipDocs08Checkbox + return{approved:true}
  46 + → featureLoop:1344 在 return 后打 req-done(落点不动)
  47 +```
  48 +
  49 +**收益**:
  50 +
  51 +- 每 FE 行为起栈次数从 `O(review rounds)` 降到 `O(行为 fix 轮)`,典型 **1 次**(一次过)到 **最多 BEHAVIOR_FE_MAX=3 次**。
  52 +- 静态 must-fix 反复震荡阶段**不起全栈**——只有静态已 clean、reviewer 认可后才付全栈代价,与用户「接受每 FE 起一次全栈」精确对齐(典型就是一次)。
  53 +- 中途构建脆弱性不再被 `N×round` 放大,只在每 FE 的 approve 时刻面对一次(仍需 §2 骨架占位保证可构建)。
  54 +
  55 +> **为何不冲突用户决策**:用户要的是「行为验收是 reviewWithFixLoop 内的另一个验收维度、行为问题可 fix、有 fix→重验循环」。本设计完全满足:行为验收在 reviewWithFixLoop 函数内、是 approve 的合取前置、行为硬问题转 must-fix 喂 fix、fix 后重跑行为验收循环。它只是把「行为验收的触发时机」定在「reviewer approve 那一刻」而非「每个 round 顶」——这是控制流优化,不改变验收维度的存在与可 fix 性。
  56 +
  57 +---
  58 +
  59 +## 2. 实现前置依赖 A(blocker):前端骨架全量路由占位阶段——保证任意时刻 app 可构建可起
  60 +
  61 +**问题(已核实)**:`skeleton-gen` 只生成 docs/04 / scripts/*.mjs / tokens.css / .gitignore,**不生成任何 frontend/ 源码、router、路由占位**。frontend/ 全部源码由 coding 期 featureLoop 逐 FE 增量产出。验 FE-N 时 FE-N+1..M 的组件文件不存在 → eager import 路由表编译失败 / 共享 nav 指向未建路由 → 整 SPA 起不来,连 FE-N 页面都加载不出。per-FE 行为验收前移到「前端只建了一部分」的每一轮,直接踩头号风险。
  62 +
  63 +**v2 方案(必做,不是候选)**:在 `featureLoop(frontend)` **之前**新增一个 coding 期 stage `runFrontendSkeleton(module)`,由独立子代理依据 `docs/08 §三` FE 清单 + `frontend/` router 约定一次性生成:
  64 +
  65 +1. **App 外壳**(`frontend/src/App.*` + 入口 `main.*`,若不存在)。
  66 +2. **router 全量路由表**:每个 FE-NN 对应路由都声明,且**全部 lazy import**(`() => import(...)`)。未实现的 FE 路由指向一个最小占位组件 `FeStub`(如 `frontend/src/views/_stub/FeStub.vue`,渲染 `<div data-fe-stub="FE-NN">FE-NN 占位</div>`)。
  67 +3. **共享布局/导航**:导航链接全部指向已声明的路由 path(不指向不存在的 path),保证任意时刻无悬空链接。
  68 +
  69 +落点与时序:
  70 +- 在顶层循环 `if (module.feItems.length)` 段、`phase('Frontend')` 之后、`featureLoop` 之前调用 `await runFrontendSkeleton(module)`。
  71 +- **幂等**:以 git tag `fe-skeleton-done` 或检测 router 文件存在 + 全 FE 路由已声明为 ground truth;已建则 skip(resume 安全)。子代理产出后自行 commit(沿用 commitBlock 习惯)。
  72 +- FE-N 实现时(tddPrompt),把对应路由的占位 import 替换为真组件——这要求 **tddPrompt 前端分支补一句**:「若 router 中本 FE 路由仍指向 `FeStub`,实现完成后把该路由 import 改为本 FE 真组件」(属 frontend/ 路径内,不破坏护栏)。
  73 +
  74 +> **为何这是根因解**:让 router 始终 lazy + 占位齐全 → 任意时刻 `vite build` / dev server 可起、每个 FE 路由可达 → 把「中途起不来」从高频降为罕见 → per-FE 行为验收的 flake/误判面收敛、`build-failed`(依赖 B)成为罕见兜底而非常态。
  75 +
  76 +> **Plan 期 vs coding 期**:放在 coding 期(而非改 skeleton-gen)的理由——FE 清单在 Plan 末尾才稳定、且骨架要落 frontend/ 源码属 coding 范畴;放 coding 期不触碰已锁定的 Plan 闸门,且能用 git tag 幂等。这是新增一个 Plan/coding 边界 stage 的代价,已被本设计接受并显式声明。
  77 +
  78 +---
  79 +
  80 +## 3. 实现前置依赖 B(blocker):BEHAVIOR_GATE_SCHEMA 增 `build-failed` kind + 确定性短路控制流
  81 +
  82 +**问题**:中途构建失败是**确定性编译错误**(缺组件 / import 解析失败 / 类型错),但 `envError.kind` 枚举只有 `port-conflict/stack-not-ready/seed-error/auth-failed/timeout/none`。门面对 `Cannot find module ./views/FE-07.vue` 只能误归类:归 `stack-not-ready` → retry 空转到耗尽后 HALT(编译错永不自愈);归 `interactionFailures` → 假 must-fix 污染源码。两条路都失败。
  83 +
  84 +**v2 方案**:
  85 +
  86 +1. `BEHAVIOR_GATE_SCHEMA.envError.kind` 枚举**新增 `build-failed`**(确定性失败语义;`route-not-buildable` 不单列,统一用 `build-failed` + detail 区分)。
  87 +2. **控制流**(在 per-FE 行为门 helper 内):`build-failed` **既不 retry 也不 halt**——记 `coverageGap`(reason 新增枚举 `build-failed-sibling-unimpl`)+ recordDecisions,**本轮行为门视为「本 FE 行为维度无法判定但非本 FE 缺陷」直接放行 approve**(因为它是「后续 FE 未实现」的预期中途态,不是 FE-N 的 bug;§2 骨架占位让这种情况罕见,一旦发生说明占位未覆盖,留证据供人工)。
  88 +3. `behaviorGatePrompt`(per-FE 版)step0/step2 **明确归因指令**:先 `build` / 起 dev server;若失败,先用 `git` / `Grep` 判断报错根因文件路径——
  89 + - 落在**非本 FE 的 frontend/ 路径**(兄弟 FE / 占位未覆盖)→ 判 `envError.kind="build-failed"`(预期中途态)。
  90 + - 落在**本 FE 路径** → 才可能是本 FE 引入的真构建 bug → 归 `interactionFailures[kind="js-error"]` 或带 locator must-fix。
  91 +
  92 +> 没有这层「确定性失败短路 + 根因归属」,per-FE 行为门无法落地——这是把行为门从「全 FE 已建的安全环境」迁到「部分 FE 已建的敌对环境」的必须保障。
  93 +
  94 +---
  95 +
  96 +## 4. 实现前置依赖 C(blocker):FE-NN → 路由 path 确定性映射,锁进 spec 产物
  97 +
  98 +**问题**:per-FE 只验「本 FE 关联路由」,但 FE→路由关系当前只在 spec 顶部「关联原型」散文 + 子代理对 router 的 Grep 推断,**无结构化真值**。推窄→漏验(假绿);推宽→把别的未实现 FE 的死控件算到本 FE(误 must-fix)。且若 router 全量声明(依赖 A),`routesPlanned = router 全部路由` 会让覆盖率分母被未建路由污染。
  99 +
  100 +**v2 方案**:
  101 +
  102 +1. **deriveSpecPrompt 前端分支**强制在 spec 头部产出结构化小节(不只是散文):
  103 + ```
  104 + ## 行为验收作用域(per-FE 行为门唯一断言依据)
  105 + - 关联路由: [/orders, /orders/:id]
  106 + - 负责控件白名单: [data-testid 约定 或 page+DOM 选择器清单]
  107 + ```
  108 + 并要求 fe-feature-review(code-reviewer)校验该小节存在且与 router 配置一致(缺失 / 不一致 → request-changes)。
  109 +2. **behaviorGatePrompt 改为接收「本 FE 路由清单 + 控件白名单」入参**(per-FE 版必需):
  110 + - `routesPlanned` **只数本 FE 关联路由**(不是 router 全部),未建兄弟路由既不计入分母也不计 coverageGap。
  111 + - 行为门只对白名单内控件判 must-fix;白名单外控件 / 共享控件若属其它未 approve FE → 归 `coverageGap`(reason `deep-control-not-driven` 或 `build-failed-sibling-unimpl`),**绝不**归本 FE 的 interactionFailure。
  112 +3. **空覆盖兜底保留**:`routesReached==0 || controlsEnumerated==0`(针对本 FE 路由子集)仍归 envBlocked,绝不静默判 green。
  113 +
  114 +> 没有这个确定性映射,per-FE 路由作用域无法界定,覆盖率与归因全失真。
  115 +
  116 +---
  117 +
  118 +## 5. 实现前置依赖 D(blocker / 安全):测试库护栏下沉到 setup-test-db.mjs 模板自身
  119 +
  120 +**问题(已核实)**:`scripts-setup-test-db-template.mjs` 只校验 schema 是合法标识符(`/^[A-Za-z0-9_$]+$/`),**不判它是不是测试库**,DROP+CREATE 无条件执行。测试库命名护栏(库名须含 `test/_test/_dev/_local`)当前只在**门子代理生成的 runner 内**(LLM 级 prompt 检查)。per-FE × per-behaviorRound 反复 DROP+CREATE,任一轮子代理漏写护栏 → 对 config-vars 指向的库(可能=开发库)无条件 DROP,反复次数越多撞上漏写的概率越高。真实数据销毁风险。
  121 +
  122 +**v2 方案**:把测试库命名护栏**下沉到 `setup-test-db.mjs` 模板**(确定性 JS 边界,不依赖每个子代理记得复述):在现有标识符校验后追加——
  123 +
  124 +```js
  125 +// 测试库命名护栏:DROP+CREATE 只允许作用于明确的测试/本地库,防误删开发/生产库。
  126 +const ALLOW = process.env.ALLOW_NONTEST_DROP === '1'
  127 +if (!ALLOW && !/(^|_)(test|dev|local)$|(^|_)test_|^test_/.test(DB_SCHEMA) && !/test|_dev|_local/.test(DB_SCHEMA)) {
  128 + console.error(`[setup-test-db] 拒绝:schema=${JSON.stringify(DB_SCHEMA)} 不像测试库(须含 test/_test/_dev/_local),设 ALLOW_NONTEST_DROP=1 显式放行`)
  129 + process.exit(1)
  130 +}
  131 +```
  132 +(具体正则以实现为准,语义=库名须含 `test`/`_test`/`_dev`/`_local` 之一,否则 fail-closed。)
  133 +
  134 +- 这样不论被行为门调用多少次都安全。
  135 +- coding.mjs 行为门控制流里,对「测试库护栏触发的红」**保持不重试不仲裁直接 throw** 的硬边界语义(与现 v1 一致,见 behaviorGatePrompt step2 第 1 条)。
  136 +- 这是 skeleton-gen 模板的一次性改动,**不属于 coding.mjs 改造**,但列为本设计前置(否则反复起栈的安全暴露面不可接受)。
  137 +
  138 +---
  139 +
  140 +## 6. reviewWithFixLoop 改造后的逐轮控制流(实现级)
  141 +
  142 +**仅 `phase==frontend` 改造;`fe=isFrontend(phase)` 现已存在(1373),后端分支逐字不变。**
  143 +
  144 +### 6.1 数据流:两类 must-fix 独立来源,schema 不合并、fix 入参合并
  145 +
  146 +- **review-must-fix**:reviewer 的 `REVIEW_SCHEMA.issues`,照现状 1392 的 locator filter(缺 locator 降级丢弃)。
  147 +- **behavior-hard**:行为门返回的 `BEHAVIOR_GATE_SCHEMA.interactionFailures` + `source=='sentinel'` 的 `textIssues`。
  148 +- **schema 不杂交**(采纳「schema 选型」维度的定调):行为验收**保留独立 `BEHAVIOR_GATE_SCHEMA` 返回**,不压扁进 `REVIEW_SCHEMA.issues`(否则丢失 envError / 空覆盖 / coverageGaps / source 软硬分流这三个赖以正确的区分)。**仅在喂 fix 时**把「有 locator 的 behavior-hard」降维成 `{summary, locator, severity}` 喂现有 `fixPrompt`(fix 步天然吃这形状)。即:**schema 不合并,fix 入参合并**。
  149 +
  150 +### 6.2 approve 闸(显式 AND,钉死落点)
  151 +
  152 +approve 出口(现 1386 `if (r.verdict==='approve')` 分支)**改为合取**:
  153 +
  154 +```
  155 +reviewer.verdict==='approve'
  156 + ∧ behaviorSubGate(FE) 返回 green
  157 + 其中 green ≡ behaviorHard.length===0 ∧ envError∈{none, build-failed} ∧ 本FE覆盖非空(或 build-failed 短路)
  158 +```
  159 +
  160 +只有合取成立才 `flipDocs08Checkbox` + `return {approved:true}`。这保证(采纳「删阶段门」维度 blocker 的钉死):
  161 +- **行为 green 是 reviewWithFixLoop 的 return 前置条件**——req-done tag 落点(featureLoop:1344)**保持不动**,语义自动升级为「静态过+行为过」。
  162 +- **无 locator 的 behavior-hard 绝不 approve**(采纳「控制流/schema」维度 blocker#1):走 adjudicate(allowContinue:false) 决定 retry(重跑行为验收/重判)还是 halt,绝不被 1392 的 locator filter 静默吞掉。
  163 +- flipDocs08Checkbox 翻转自动晚于行为 green(checkbox 纯装饰、resume 只认 req-done tag,无视觉误导)。
  164 +
  165 +### 6.3 behaviorSubGate(approve 子门)逐步
  166 +
  167 +```
  168 +async function behaviorSubGate(id, specPath, feScope):
  169 + // feScope = {routes:[...], controlWhitelist:[...]}(来自 §4 spec 结构化小节)
  170 + for behaviorRound in 1..BEHAVIOR_FE_MAX(=3):
  171 + bg = await runBehaviorGateOnce(id, behaviorRound, feScope) // 见 §7,内含 envError attempt 重试
  172 + // 1) build-failed 短路(依赖 B):兄弟未实现 → 记 coverageGap + decisions,子门视为 green-by-skip,return passed
  173 + if (bg.envError.kind === 'build-failed' && 根因在非本FE路径) { recordDecisions; return {green:true, skipped:true} }
  174 + // 2) envError(其它) / 空覆盖:runBehaviorGateOnce 内部已 attempt 重试;到这里仍 blocked → adjudicate(allowContinue:false) retry/halt
  175 + if (envBlocked(bg)) { adjudicate; 仍 blocked → throw HALT }
  176 + // 3) 软文字:for-of 走 adjudicate;continue→recordDecisions + 加入跨轮 softPassed;sentinel→并入 behaviorHard;retry/halt 同现
  177 + processTextIssues(bg, softPassed) // softPassed 提升到 reviewWithFixLoop 顶层作用域,跨 behaviorRound 持久
  178 + // 4) behaviorHard = interactionFailures + sentinel textIssues
  179 + if (behaviorHard.length === 0) return {green:true}
  180 + // 5) 分流
  181 + const withLoc = behaviorHard.filter(有 locator)
  182 + const noLoc = behaviorHard.filter(无 locator)
  183 + if (noLoc.length) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 }
  184 + if (withLoc.length) {
  185 + await runStage(fixPrompt(id, 'frontend', withLoc降维)) // 复用现有 fix 步
  186 + // fix 后:只重跑本 FE 行为验收(下一轮 behaviorRound);若 fix 同时改了功能逻辑,附带重跑功能 verify(见下)
  187 + }
  188 + throw HALT behavior-unresolved(BEHAVIOR_FE_MAX 轮仍未 green)
  189 +```
  190 +
  191 +- **softPassed Set 提升到 reviewWithFixLoop 顶层作用域**(与 round 同寿命,跨 behaviorRound 持久)——直接照搬现 runBehaviorGate 的 softPassed 语义,否则文字层每轮重新消耗仲裁预算撞 ADJUDICATE_MAX。
  192 +- **行为软文字永不进 approve 闸**,只 recordDecisions(采纳「控制流/schema」维度 high#3)。
  193 +- **fix 后的功能复验**:behaviorSubGate 内的 fix 改的是 frontend/ UI 源码,可能引入功能回归。策略——fix 后先跑一次现有 `verifyPrompt` 功能 reverify(allowContinue:false,复用 runStage),红则当功能回归(与现 reverify 同级硬边界),绿后再重跑行为验收。这把「fix 引入功能回归」纳入兜底,且功能 reverify 是 scoped 组件测试(不起全栈),成本低。
  194 +
  195 +### 6.4 轮次预算与计数(二维,钉死防证据覆盖)
  196 +
  197 +- 静态 review/fix 仍用 `REVIEW_SOFT_ROUNDS=5` / `REVIEW_HARD_ROUNDS=10`(现状不变)。
  198 +- 行为子门独立、更小预算:**新增 `BEHAVIOR_FE_MAX=3`**(每 FE 行为 fix 轮硬上限;超限 throw HALT)。**不**复用 review 的 10 轮驱动起栈,**不**让 `REVIEW_HARD_ROUNDS × BEHAVIOR_GATE_PASS_MAX` 隐式相乘到 120 量级。
  199 +- **runBehaviorGateOnce 内部**的 envError attempt 重试用独立小预算(沿用 testGate 的 attempt 1→2 + ADJUDICATE_MAX 思路;§7)。
  200 +- **二维计数表**(采纳「控制流/schema」维度 medium#5):
  201 + - `behaviorRound`:approve 子门内的行为 fix 轮(1..BEHAVIOR_FE_MAX)。
  202 + - `attempt`:单次 runBehaviorGateOnce 内的环境 race 重试序号。
  203 + - 证据文件名用复合编号:`<date>-<FE>-behavior-r<behaviorRound>-a<attempt>.md`,每次起栈独立证据不互相覆盖、不丢 flake 信号。
  204 +- **单 FE 行为起栈次数硬上界** = `BEHAVIOR_FE_MAX(3) × 每轮 attempt 上限(≤2 + ADJUDICATE_MAX 内)`,量级远小于种子设计的 10×12。典型一次过 = 1 次起栈。
  205 +
  206 +### 6.5 contract 严格分离(采纳「控制流/schema」维度 medium#7)
  207 +
  208 +同一 FE 循环内不同 stage 各自 contract,**绝不混用**:
  209 +- fix / review / verify stage:套 `featureStageContract('frontend')`(硬护栏:命中 backend//sql//scripts/ 即越界硬停)。
  210 +- 行为验收 stage:**仍独立套 `behaviorGateContract()`**(作用域例外:允许运行 setup-test-db / 起后端 / 跑 sql 种子 / 跑 playwright;唯一可写 `.tmp/behavior-gate/<FE>/...` + 证据)。
  211 +- behaviorGateContract **新增一条中途态豁免**(采纳头号维度 low#6):「本门在 per-FE 模式下运行,frontend/ 中本 FE 之外的路由/组件可能尚未实现属预期;遇到指向未建路由的链接/404/编译缺件,一律记 coverageGap 或 envError.kind=build-failed,绝不归为本 FE 的 interactionFailure。本 FE 路由清单是唯一断言作用域。」
  212 +
  213 +---
  214 +
  215 +## 7. per-FE 行为验收子代理(runBehaviorGateOnce + behaviorGatePrompt per-FE 版)要点
  216 +
  217 +**runBehaviorGateOnce(id, behaviorRound, feScope)**:保留现 runBehaviorGate 的失败分层(不推倒重写——采纳「删阶段门」维度 high#4),但 scope 缩到单 FE:
  218 +
  219 +- **复用现 enforceEnv 思路**做内部 envError attempt 重试 + 空覆盖兜底(attempt 1→2,仍异常经 adjudicate(allowContinue:false) retry/halt)。
  220 +- 返回 `BEHAVIOR_GATE_SCHEMA`(含本 FE scope);把「interaction/sentinel 仍非空」作为「本轮未过」返回给 behaviorSubGate 外层。
  221 +- **不在 runBehaviorGateOnce 内嵌 BEHAVIOR_GATE_PASS_MAX 的多次 rerun 收敛**(那是阶段级单次门的设计);交互/文字层 retry 限制为每 behaviorRound 至多 1 次重起,收敛靠外层 behaviorSubGate 推进(采纳成本维度 medium#6)。
  222 +
  223 +**behaviorGatePrompt per-FE 版**(由整 app 阶段级改造):
  224 +
  225 +- `id` 入参从写死 `frontend-phase` 改为本 FE id;新增入参 `specPath` / `behaviorRound` / `attempt` / `feScope`。
  226 +- **起栈**:runner 自起后端+前端(项目无既有 e2e webServer/playwright.config——已核实 F1,**删除「复用既有 webServer」这条死路暗示**,避免实现者照已证伪的假设做;只走「冷起栈」,**明确写死 round 间不复用运行栈、无 HMR**,这是现运行时硬约束,采纳成本维度 blocker#2)。
  227 +- **四段时序不变**:空库→起后端等 Flyway 建 schema+健康就绪→sentinel 种子(FK 有序)→起前端 headless。测试库护栏现由模板兜底(§5),runner 仍可复述但不再是唯一防线。
  228 +- **step0/step2 build 归因**(依赖 B):先 build / 起 dev server,失败按根因路径归 `build-failed`(非本 FE) 或本 FE 真 bug。
  229 +- **step1 路由真值**:`routesPlanned` 只数 `feScope.routes`(本 FE 路由),不数 router 全部(依赖 C)。
  230 +- **枚举**:只驱动 `feScope.routes` + `feScope.controlWhitelist`;非白名单 / 共享未 approve FE 控件 → coverageGap,不归本 FE。
  231 +- **行为硬问题带源码 locator**(采纳 locator 维度 blocker#1 的拆分):
  232 + - A 类(可经 route→router 配置→view 组件文件反查到**组件级文件路径**):locator = 「组件文件 + DOM 选择器 + 失败 kind + 期望端点/期望 sentinel 值 + 实际渲染值」。**fixPrompt 放宽**:locator 允许「文件 + DOM 描述」而非强制 file:line,由 fix 子代理在该组件内 Grep 定位 handler/绑定。
  233 + - B 类(连组件文件都反查不出):**不静默降级为放行**——归 coverageGap 并**计入未覆盖**,使 behaviorSubGate 不能判 green(降级≠放行)。或归 envError(stack-not-ready) 走 retry。
  234 + - **起栈强制 dev/source-map 模式**,runner 注入定位辅助(`data-testid` 约定 / Vue `__file`),把 page+selector 映射到组件文件作为契约前置。
  235 +- **binding-garbage / sentinel-mismatch**:locator 除组件文件外,附带 DOM 路径 + 绑定文本片段 + 期望 sentinel + 实际渲染值(写进 summary,不依赖 file:line),供 fix 在组件内 Grep 该绑定表达式。
  236 +- **临时件隔离 per-FE×per-behaviorRound**:`.tmp/behavior-gate/<FE>/r<behaviorRound>/`(采纳「删阶段门」维度 medium#5);每轮跑前清空本子目录,runner `finally` 必须 kill 本 FE 起的全部子进程并按本 FE 端口集回收;FE 间起栈端口先探测占用 + 动态回退。每次 coding-start 首个 FE 行为验收前清一次 `.tmp/behavior-gate/` 整目录入口(去跨 resume 串味)。
  237 +- **证据**:`docs/superpowers/reviews/<date>-<FE>-behavior-r<behaviorRound>-a<attempt>.md`(与 review 报告同目录);截图归档到版本管理的 assets。
  238 +- **确定性端口/pid 回收前置**(采纳安全维度 high#3):起栈前先按既知端口 + `.tmp/*.pid` 强制回收上一 attempt 残留(编排层 + runner 双保险),对反复 port-conflict 设独立硬上限直接 halt 提示人工清理,避免连环 retry 烧时间。
  239 +
  240 +---
  241 +
  242 +## 8. 删除 / 改写的阶段级门引用(实现级清单)
  243 +
  244 +### 8.1 删除(顶层 frontend 段)
  245 +- 删 `phase('Behavior')`(1644)+ `await runBehaviorGate(module)`(1645)。
  246 +- `runBehaviorGate`(1465-1582)**改造为** per-FE 的 `runBehaviorGateOnce` + `behaviorSubGate`(被 reviewWithFixLoop 调用),**不推倒重写**——保留 enforceEnv / 空覆盖兜底 / interaction 分层 / 软文字 source 分流 / softPassed 语义,只把 scope 缩到单 FE、把「硬问题转 must-fix locator」作为新增出口。
  247 +- `meta.phases`:**彻底删除 `{ title: 'Behavior' }`**(12 行)——行为验收并入 Frontend phase 内,所有行为相关 `agent()`/`adjudicate()` 的 phase 入参从 `'Behavior'` 统一改为 `'Frontend'`(与 reviewWithFixLoop 现有 grp 一致)。不保留 'Behavior' 作 UI 分组(否则成无 `phase()` 驱动的孤儿)(采纳「删阶段门」维度 medium#3)。
  248 +
  249 +### 8.2 reportPrompt 前端分支改写(采纳「删阶段门」维度 high#2 + 控制流维度 medium#6)
  250 +- **删/改 1096 绿前置**:原 Glob `frontend-phase-behavior-gate-r*.md`「最后一份非 RED」整条**删除**(阶段级文件已不再产生,留之必断链或不确定 halt)。改为:**对每个 `req-done/<FE>` tag 视为行为已过**(因 per-FE 行为 green 已是 req-done 前置,report 不必再独立校验行为绿,避免双真值)。可选加一句轻量校验:每个 FE 存在对应 `<date>-<FE>-behavior-r*-a*.md` 证据且最后一份非 RED。
  251 +- **改 1106 §⑤**:把 `frontend-phase-behavior-gate-r*.md` 汇总改为按 per-FE 证据目录 `docs/superpowers/reviews/<date>-FE-*-behavior-r*-a*.md` 汇总 flake / 环境 race / 文字 continue。
  252 +- **改 1107 §⑧**:偏离清单的 behavior-gate coverageGaps / textIssues continue / 逐控件判定 / authState 来源,从阶段级文件改为 per-FE 证据汇总。
  253 +- 注意 testGate 的 `frontend-phase-test-gate-r*.md` 绿前置(1094)**保留不动**——testGate(全量回归)不并入 per-FE 循环。
  254 +
  255 +### 8.3 保留不变
  256 +- **阶段级 testGate(全量回归 vitest+playwright)保留**(1642-1643)——职责正交,per-FE 行为验收不替代全量回归。
  257 +- 后端 featureLoop / featureLoop backend 分支 / runMilestone / runCrossModule / 顶层 backend 段:**逐字不变**。
  258 +
  259 +---
  260 +
  261 +## 9. README / SKILL 文案改动
  262 +
  263 +- **README**(46-49 行):把「→ testGate(frontend) → 前端行为闸 behavior-gate(…逐路由枚举…)→ runMilestone」改为:「featureLoop(前端,FE-NN,每个 FE 在 review 循环内并入 per-FE 行为验收 approve 子门:reviewer approve 时起本 FE 全栈+sentinel 种子,枚举本 FE 路由控件/文字,硬问题转可 fix must-fix→重验,行为 green 才打 req-done) → testGate(frontend,全量回归) → runMilestone」。补一句「前端骨架占位阶段保证中途可构建」。
  264 +- **coding-start SKILL**(步骤 0 横幅,26 行):把「前端行为闸 behavior-gate(…逐路由枚举:交互失效→halt,文字不符→仲裁)」改为「前端功能循环内含 per-FE 行为验收(reviewer approve 时起本 FE 全栈验『按钮真生效/文字对』,硬问题可 fix 重验,不再是末尾独立门)」。
  265 +
  266 +---
  267 +
  268 +## 10. 残留风险(接受 / 已缓解)
  269 +
  270 +1. **冷起栈墙钟**:round 间不复用栈是现运行时硬约束(无跨子会话常驻进程原语)。已用「approve 子门 + BEHAVIOR_FE_MAX=3」把起栈次数压到典型 1 次/FE、最坏 3 次/FE 来控成本,而非靠不可行的栈复用。每次起栈含全量 Flyway apply(随 migration 增多单调增长),计入墙钟预算。若 N 很大仍可能数小时——这是用户已接受「每 FE 起一次全栈」的直接代价,本设计已把它从 N×20 降到 N×(1~3)。
  271 +2. **locator 可靠性**:A 类(组件文件级)映射可行;B 类不可降级放行而是计入未覆盖阻断 approve。仍可能出现 fix 在组件内 Grep 不中绑定行、多轮修不中逼近 BEHAVIOR_FE_MAX 后 halt 转人工——这是「运行时 DOM→源码」固有难度的残留,已用「附 DOM 路径+绑定片段+期望/实际值」最大化命中率、用比 review 更紧的 BEHAVIOR_FE_MAX=3 控制空转成本。
  272 +3. **骨架占位覆盖不全**:若 runFrontendSkeleton 漏建某 FE 的路由占位,验该 FE 前的某个兄弟 FE 时仍可能 build-failed;已用 build-failed 短路(不 halt、记证据)兜底,但会留覆盖盲点供人工。
  273 +4. **per-FE 库状态与阶段级 testGate 隔离**:行为门入口 DROP+CREATE 自带空库语义,跑完不为 testGate 留状态(testGate 自带 setup-test-db 重置);二者共用同一物理测试库,时序上串行无并发争用。
  274 +5. **deriveSpec 的 FE→路由结构化小节依赖 LLM 正确产出 + reviewer 校验**:若两者都失误,feScope 可能不准;已让 reviewer 把「小节存在且与 router 一致」作为 request-changes 项兜底,但非确定性。
  275 +
  276 +---
  277 +
  278 +## 11. 实现顺序建议
  279 +
  280 +1. 前置 D(setup-test-db 模板护栏)——独立、最安全、先做。
  281 +2. 前置 C(deriveSpecPrompt FE→路由结构化小节 + reviewer 校验)——为 feScope 入参铺路。
  282 +3. 前置 A(runFrontendSkeleton 骨架占位 stage + tddPrompt 占位替换指令)——保证中途可构建。
  283 +4. 前置 B(BEHAVIOR_GATE_SCHEMA 增 build-failed + 归因控制流)。
  284 +5. 主改造(reviewWithFixLoop 加 behaviorSubGate / runBehaviorGate→runBehaviorGateOnce per-FE / 二维计数 / softPassed 提升 / contract 分离)。
  285 +6. 删除阶段级门 + reportPrompt 改写 + meta.phases 删 Behavior + phase 入参改 Frontend。
  286 +7. README / coding-start SKILL 文案。
  287 +
  288 +每步后端分支必须逐字不变(diff 校验);运行时红线(time/random builtin / 顶层 return / 注入全局)每步复核。
lib/setup-test-db-template.test.mjs
@@ -11,7 +11,7 @@ import { fileURLToPath } from &#39;node:url&#39; @@ -11,7 +11,7 @@ import { fileURLToPath } from &#39;node:url&#39;
11 11
12 const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs', import.meta.url)) 12 const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs', import.meta.url))
13 13
14 -function runWithSchema(schemaLine) { 14 +function runWithSchema(schemaLine, env = {}) {
15 const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-')) 15 const dir = mkdtempSync(join(tmpdir(), 'erp-stdb-'))
16 mkdirSync(join(dir, 'scripts')) 16 mkdirSync(join(dir, 'scripts'))
17 copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs')) 17 copyFileSync(TEMPLATE, join(dir, 'scripts', 'setup-test-db.mjs'))
@@ -19,7 +19,7 @@ function runWithSchema(schemaLine) { @@ -19,7 +19,7 @@ function runWithSchema(schemaLine) {
19 join(dir, 'config-vars.yaml'), 19 join(dir, 'config-vars.yaml'),
20 ['database:', ' host: 127.0.0.1', ' port: 1', ' user: root', ' password: x', ' ' + schemaLine, ''].join('\n'), 20 ['database:', ' host: 127.0.0.1', ' port: 1', ' user: root', ' password: x', ' ' + schemaLine, ''].join('\n'),
21 ) 21 )
22 - return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8' }) 22 + return spawnSync('node', [join(dir, 'scripts', 'setup-test-db.mjs')], { encoding: 'utf8', env: { ...process.env, ...env } })
23 } 23 }
24 24
25 // ROBUST-3:空 schema 不应进到 DROP DATABASE `` —— 守卫应先拦下。 25 // ROBUST-3:空 schema 不应进到 DROP DATABASE `` —— 守卫应先拦下。
@@ -50,3 +50,24 @@ test(&#39;setup-test-db: a valid identifier schema passes the guard (no false positi @@ -50,3 +50,24 @@ test(&#39;setup-test-db: a valid identifier schema passes the guard (no false positi
50 // 连不上 127.0.0.1:1 → 非零退出;关键是错误不来自 schema 守卫。 50 // 连不上 127.0.0.1:1 → 非零退出;关键是错误不来自 schema 守卫。
51 assert.doesNotMatch(r.stderr, /database\.schema 非法|schema 非法或未填/, 'stderr: ' + r.stderr) 51 assert.doesNotMatch(r.stderr, /database\.schema 非法|schema 非法或未填/, 'stderr: ' + r.stderr)
52 }) 52 })
  53 +
  54 +// 前置依赖 D(安全):测试库命名护栏——非测试库名默认 fail-closed,防误删开发/生产库。
  55 +test('setup-test-db: a non-test schema fails closed by default (D non-test guard)', () => {
  56 + const r = runWithSchema('schema: erp_prod')
  57 + assert.equal(r.status, 1)
  58 + assert.match(r.stderr, /不像测试库|ALLOW_NONTEST_DROP/, '应是测试库命名护栏报错 — stderr: ' + r.stderr)
  59 +})
  60 +
  61 +// D:测试库名(含 test / _test / _dev / _local)应通过命名护栏,错误不来自该护栏。
  62 +test('setup-test-db: test-like schema names pass the naming guard (erp_test / erp_dev)', () => {
  63 + for (const name of ['erp_test', 'erp_dev', 'erp_local', 'test_db']) {
  64 + const r = runWithSchema('schema: ' + name)
  65 + assert.doesNotMatch(r.stderr, /不像测试库/, `${name} 不应被命名护栏拒绝 — stderr: ` + r.stderr)
  66 + }
  67 +})
  68 +
  69 +// D:ALLOW_NONTEST_DROP=1 显式放行非测试库名(错误不再来自命名护栏)。
  70 +test('setup-test-db: ALLOW_NONTEST_DROP=1 explicitly bypasses the naming guard', () => {
  71 + const r = runWithSchema('schema: erp_prod', { ALLOW_NONTEST_DROP: '1' })
  72 + assert.doesNotMatch(r.stderr, /不像测试库/, '显式放行后不应再被命名护栏拒绝 — stderr: ' + r.stderr)
  73 +})
skills/coding/coding-start/SKILL.md
@@ -21,9 +21,11 @@ allowed-tools: Read Glob Workflow Bash(git rev-parse *) Bash(git tag *) @@ -21,9 +21,11 @@ allowed-tools: Read Glob Workflow Bash(git rev-parse *) Bash(git tag *)
21 每个模块: 21 每个模块:
22 后端功能循环 spec → plan → tdd → verify → review(≤5轮) 22 后端功能循环 spec → plan → tdd → verify → review(≤5轮)
23 后端测试闸 test-gate(RED 自动重试 1 次,仍 RED → halt) 23 后端测试闸 test-gate(RED 自动重试 1 次,仍 RED → halt)
24 - 前端功能循环 同一流水线,phase=frontend(FE-NN,限 frontend/)  
25 - 前端测试闸 test-gate  
26 - 前端行为闸 behavior-gate(headless 全栈起栈+种子 sentinel,逐路由枚举:交互失效→halt,文字不符→仲裁) 24 + 前端骨架占位 router 全量 lazy 路由表 + FeStub 占位(保证中途可构建)
  25 + 前端功能循环 同一流水线,phase=frontend(FE-NN,限 frontend/);review 循环内含
  26 + per-FE 行为验收(reviewer approve 时起本 FE 全栈验「按钮真生效/文字对」,
  27 + 硬问题可 fix 重验,行为 green 才打 req-done;不再是末尾独立门)
  28 + 前端测试闸 test-gate(全量回归)
27 跨模块记录 → 模块报告 → 里程碑(merge --no-ff + milestone/<id> tag) 29 跨模块记录 → 模块报告 → 里程碑(merge --no-ff + milestone/<id> tag)
28 任一模块 halt → fail-fast 停在该模块,修复后重跑本入口即可续跑 30 任一模块 halt → fail-fast 停在该模块,修复后重跑本入口即可续跑
29 31
skills/plan/skeleton-gen/templates/scripts-setup-test-db-template.mjs
@@ -83,6 +83,16 @@ if (!/^[A-Za-z0-9_$]+$/.test(DB_SCHEMA)) { @@ -83,6 +83,16 @@ if (!/^[A-Za-z0-9_$]+$/.test(DB_SCHEMA)) {
83 process.exit(1) 83 process.exit(1)
84 } 84 }
85 85
  86 +// 测试库命名护栏(确定性 JS 边界,唯一防线):本脚本无条件 DROP + CREATE schema;
  87 +// per-FE × per-behaviorRound 反复起栈会反复 DROP,对 config-vars 指向的库(可能 = 开发/生产库)
  88 +// 误删风险随次数放大。故只允许作用于"明确像测试/本地库"的库名——库名须含 test / _test / _dev / _local
  89 +// 之一(不区分大小写),否则 fail-closed;确需对非测试库执行时,显式设 ALLOW_NONTEST_DROP=1 放行。
  90 +// 不依赖任何调用方(如行为门 runner)记得复述同等检查——模板是唯一防线。
  91 +if (process.env.ALLOW_NONTEST_DROP !== '1' && !/test|_dev|_local/i.test(DB_SCHEMA)) {
  92 + console.error(`[setup-test-db] 拒绝:schema=${JSON.stringify(DB_SCHEMA)} 不像测试库(库名须含 test/_test/_dev/_local),设 ALLOW_NONTEST_DROP=1 显式放行`)
  93 + process.exit(1)
  94 +}
  95 +
86 console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) 96 console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`)
87 97
88 const sql = 98 const sql =
workflows/coding.mjs
@@ -9,8 +9,10 @@ export const meta = { @@ -9,8 +9,10 @@ export const meta = {
9 description: 'Run the entire ERP coding phase autonomously and silently: per-module backend+frontend feature loops, test gate, milestone tag.', 9 description: 'Run the entire ERP coding phase autonomously and silently: per-module backend+frontend feature loops, test gate, milestone tag.',
10 phases: [ 10 phases: [
11 { title: 'Router' }, { title: 'Backend' }, { title: 'Frontend' }, 11 { title: 'Router' }, { title: 'Backend' }, { title: 'Frontend' },
12 - { title: 'Gate' }, { title: 'Behavior' }, { title: 'Milestone' }, 12 + { title: 'Gate' }, { title: 'Milestone' },
13 ], 13 ],
  14 + // 注:'Behavior' phase 已删除——前端行为验收并入 per-FE reviewWithFixLoop 的 approve 子门,
  15 + // 所有行为相关 agent()/adjudicate() 的 phase 入参统一用 'Frontend'(与 reviewWithFixLoop grp 一致)。
14 } 16 }
15 17
16 const ROUTER_SCHEMA = { type:'object', additionalProperties:false, 18 const ROUTER_SCHEMA = { type:'object', additionalProperties:false,
@@ -65,24 +67,27 @@ const GATE_SCHEMA = { type:&#39;object&#39;, additionalProperties:false, @@ -65,24 +67,27 @@ const GATE_SCHEMA = { type:&#39;object&#39;, additionalProperties:false,
65 required:['status'], properties:{ status:{type:'string',enum:['green','red']}, 67 required:['status'], properties:{ status:{type:'string',enum:['green','red']},
66 failures:{type:'array',items:{type:'string'}} } } 68 failures:{type:'array',items:{type:'string'}} } }
67 69
68 -// BEHAVIOR_GATE_SCHEMA:前端行为门(headless behavior-gate)返回。 70 +// BEHAVIOR_GATE_SCHEMA:前端行为门(per-FE behavior 子门)返回。
69 // 不杂交 GATE×STAGE_RESULT——复用既有词汇但独立成型:交互层 / 文字层 / 覆盖率 / 环境错误分别结构化, 71 // 不杂交 GATE×STAGE_RESULT——复用既有词汇但独立成型:交互层 / 文字层 / 覆盖率 / 环境错误分别结构化,
70 -// JS 据 source/kind 分流(交互硬 halt,文字按 source 二分 allowContinue,envError 走 retry)。  
71 -// 设计:见 docs/design/2026-06-02-frontend-behavior-gate.md § 2。 72 +// JS 据 source/kind 分流(交互硬边界转 must-fix,文字按 source 二分 allowContinue,envError 走 retry,
  73 +// build-failed 确定性短路)。设计:见 docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 3/6/7。
72 const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false, 74 const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false,
73 required:['status','routesPlanned','routesReached','controlsEnumerated'], properties:{ 75 required:['status','routesPlanned','routesReached','controlsEnumerated'], properties:{
74 status:{type:'string', enum:['green','red']}, 76 status:{type:'string', enum:['green','red']},
75 - routesPlanned:{type:'integer'}, // router 声明的路由数(覆盖率分母来源)  
76 - routesReached:{type:'integer'}, // 实际带鉴权加载成功的路由数  
77 - controlsEnumerated:{type:'integer'}, // live 枚举到的控件数(空覆盖必须可见) 77 + routesPlanned:{type:'integer'}, // 本 FE 关联路由数(覆盖率分母来源;per-FE 只数 feScope.routes,不数 router 全部)
  78 + routesReached:{type:'integer'}, // 实际带鉴权加载成功的本 FE 路由数
  79 + controlsEnumerated:{type:'integer'}, // live 枚举到的本 FE 白名单控件数(空覆盖必须可见)
78 authState:{type:'string'}, // 以何角色登录 / 覆盖角色 / 未覆盖角色集 80 authState:{type:'string'}, // 以何角色登录 / 覆盖角色 / 未覆盖角色集
  81 + // interactionFailures.locator:行为硬问题的源码定位(组件文件 [+ DOM 描述])。per-FE 行为门必须反查到
  82 + // 组件文件路径才能转 must-fix 喂 fix;反查不出(B 类)→ 不入 interactionFailures,归 coverageGap(不放行)。
79 // 交互层硬边界:no-observable-effect / js-error / console-error / missing-docs05-call / binding-garbage 83 // 交互层硬边界:no-observable-effect / js-error / console-error / missing-docs05-call / binding-garbage
80 interactionFailures:{ type:'array', items:{ type:'object', additionalProperties:false, 84 interactionFailures:{ type:'array', items:{ type:'object', additionalProperties:false,
81 required:['page','control','kind','detail'], 85 required:['page','control','kind','detail'],
82 properties:{ 86 properties:{
83 page:{type:'string'}, control:{type:'string'}, 87 page:{type:'string'}, control:{type:'string'},
84 kind:{type:'string', enum:['no-observable-effect','js-error','console-error','missing-docs05-call','binding-garbage']}, 88 kind:{type:'string', enum:['no-observable-effect','js-error','console-error','missing-docs05-call','binding-garbage']},
85 - detail:{type:'string'} } } }, 89 + detail:{type:'string'},
  90 + locator:{type:'string'} } } }, // 组件文件路径 [+ DOM 选择器/绑定片段描述];有则可转 must-fix 喂 fix
86 // 文字层软边界:source 决定 allowContinue(sentinel 客观 bug 不可 continue;i18n/literal/semantic 可 adjudicate continue) 91 // 文字层软边界:source 决定 allowContinue(sentinel 客观 bug 不可 continue;i18n/literal/semantic 可 adjudicate continue)
87 textIssues:{ type:'array', items:{ type:'object', additionalProperties:false, 92 textIssues:{ type:'array', items:{ type:'object', additionalProperties:false,
88 required:['page','region','expected','actual','source'], 93 required:['page','region','expected','actual','source'],
@@ -91,18 +96,21 @@ const BEHAVIOR_GATE_SCHEMA = { type:&#39;object&#39;, additionalProperties:false, @@ -91,18 +96,21 @@ const BEHAVIOR_GATE_SCHEMA = { type:&#39;object&#39;, additionalProperties:false,
91 expected:{type:'string'}, actual:{type:'string'}, 96 expected:{type:'string'}, actual:{type:'string'},
92 source:{type:'string', enum:['sentinel','i18n','literal','semantic']} } } }, 97 source:{type:'string', enum:['sentinel','i18n','literal','semantic']} } } },
93 // 覆盖率缺口:写证据 + recordDecisions,不单独 halt(空覆盖由 controlsEnumerated==0 兜底) 98 // 覆盖率缺口:写证据 + recordDecisions,不单独 halt(空覆盖由 controlsEnumerated==0 兜底)
  99 + // build-failed-sibling-unimpl:兄弟 FE 未实现导致本 FE 之外路由/组件编译缺件(预期中途态,不归本 FE 缺陷)
  100 + // locator-not-resolvable:行为硬问题连组件文件都反查不出(B 类),计入未覆盖阻断 approve,不静默放行
94 coverageGaps:{ type:'array', items:{ type:'object', additionalProperties:false, 101 coverageGaps:{ type:'array', items:{ type:'object', additionalProperties:false,
95 required:['page','reason','detail'], 102 required:['page','reason','detail'],
96 properties:{ 103 properties:{
97 page:{type:'string'}, 104 page:{type:'string'},
98 - reason:{type:'string', enum:['unreachable-auth','unreachable-no-route','deep-control-not-driven','dynamic-route-no-seed']}, 105 + reason:{type:'string', enum:['unreachable-auth','unreachable-no-route','deep-control-not-driven','dynamic-route-no-seed','build-failed-sibling-unimpl','locator-not-resolvable']},
99 detail:{type:'string'} } } }, 106 detail:{type:'string'} } } },
100 - // 环境错误(与业务断言失败严格区分,走 retry):none 表示无环境问题 107 + // 环境错误(与业务断言失败严格区分):none 表示无环境问题;build-failed 是确定性短路(既不 retry 也不 halt)。
  108 + // build-failed 时 rootCausePath 写报错根因文件路径——落在非本 FE 路径=兄弟未实现(短路放行),落在本 FE=真构建 bug。
101 envError:{ type:'object', additionalProperties:false, 109 envError:{ type:'object', additionalProperties:false,
102 required:['kind'], 110 required:['kind'],
103 properties:{ 111 properties:{
104 - kind:{type:'string', enum:['port-conflict','stack-not-ready','seed-error','auth-failed','timeout','none']},  
105 - detail:{type:'string'}, ports:{type:'string'}, pids:{type:'string'} } }, 112 + kind:{type:'string', enum:['port-conflict','stack-not-ready','seed-error','auth-failed','timeout','build-failed','none']},
  113 + detail:{type:'string'}, ports:{type:'string'}, pids:{type:'string'}, rootCausePath:{type:'string'} } },
106 // decisions[]:复用 STAGE_RESULT 形状,缺值自主决策日志 114 // decisions[]:复用 STAGE_RESULT 形状,缺值自主决策日志
107 decisions:{ type:'array', items:{ type:'object', additionalProperties:false, 115 decisions:{ type:'array', items:{ type:'object', additionalProperties:false,
108 required:['question','choice','rationale'], 116 required:['question','choice','rationale'],
@@ -288,6 +296,21 @@ function deriveSpecPrompt(id, phase) { @@ -288,6 +296,21 @@ function deriveSpecPrompt(id, phase) {
288 fe 296 fe
289 ? '- 规格至少含:关联 REQ + 关联原型;组件树(按页面 / 区域分块,推导自 prototype DOM);页面状态机(loading / empty / error / 正常 / 表单提交中 至少 5 态);消费的后端端点(对齐 docs/05);业务规则前端复刻清单(逐条:规则 / 触发时机 / 报错文案 / 来源 REQ);Design Tokens 引用清单(`var(--color-*)`)。' 297 ? '- 规格至少含:关联 REQ + 关联原型;组件树(按页面 / 区域分块,推导自 prototype DOM);页面状态机(loading / empty / error / 正常 / 表单提交中 至少 5 态);消费的后端端点(对齐 docs/05);业务规则前端复刻清单(逐条:规则 / 触发时机 / 报错文案 / 来源 REQ);Design Tokens 引用清单(`var(--color-*)`)。'
290 : '- 规格覆盖:goal / 输入输出 / 业务规则 / 约束 / schema / API 引用 / acceptance criteria。', 298 : '- 规格覆盖:goal / 输入输出 / 业务规则 / 约束 / schema / API 引用 / acceptance criteria。',
  299 + fe
  300 + ? [
  301 + '',
  302 + '## 行为验收作用域结构化小节(per-FE 行为门唯一断言依据,**强制写到 spec 头部**)',
  303 + '- 在 spec 文件头部(紧随标题/关联 REQ 之后)写一个**结构化小节**,标题逐字为 `## 行为验收作用域`,内含两条机器可读清单:',
  304 + ' ```',
  305 + ' ## 行为验收作用域',
  306 + ' - 关联路由: [/orders, /orders/:id]',
  307 + ' - 负责控件白名单: [data-testid=order-submit, /orders 页 "提交" 按钮, ...]',
  308 + ' ```',
  309 + `- **关联路由**:从 \`${ROOT}/frontend/\` router 配置(用 Grep 定位)取本 FE 真正负责渲染的路由 path(与 router 一致;带参动态路由保留 \`:id\` 占位)。**只列本 FE 路由**,不要列兄弟 FE / 共享路由。`,
  310 + '- **负责控件白名单**:本 FE 页面上"点了必须有可观测效果 / 显示必须正确"的控件清单(优先 `data-testid` 约定;无 testid 时用 `<页面> + DOM 选择器/可见文案` 描述)。行为门只对白名单内控件判 must-fix;白名单外 / 共享控件归 coverageGap,绝不算本 FE 缺陷。',
  311 + '- 该小节是**确定性映射**(fe-feature-review 会校验其存在且与 router 一致,缺失/不一致 → request-changes);推不出路由(router 尚未声明本 FE 路由)→ 按硬约束登记 decisions 取最有依据的占位 path 或 halt(不要留空)。',
  312 + ].join('\n')
  313 + : '',
291 '', 314 '',
292 commitBlock('<spec artifactPath>', `docs(spec:${id}): 派生规格`), 315 commitBlock('<spec artifactPath>', `docs(spec:${id}): 派生规格`),
293 '', 316 '',
@@ -366,6 +389,9 @@ function tddPrompt(id, phase, planPath) { @@ -366,6 +389,9 @@ function tddPrompt(id, phase, planPath) {
366 fe 389 fe
367 ? '- jsdom 类型用 vitest/jest 写组件单测;e2e 类型在 `frontend/e2e/` 写 Playwright(headless)。实现时:色值用 `var(--color-*)`(不硬编码 hex),业务校验按 spec 在 form-level 复刻。' 390 ? '- jsdom 类型用 vitest/jest 写组件单测;e2e 类型在 `frontend/e2e/` 写 Playwright(headless)。实现时:色值用 `var(--color-*)`(不硬编码 hex),业务校验按 spec 在 form-level 复刻。'
368 : '', 391 : '',
  392 + fe
  393 + ? `- **占位替换(保证中途可构建 + per-FE 行为门可达本 FE 路由)**:前端骨架阶段已在 router 里为本 FE 路由声明 lazy import 但指向占位组件 \`FeStub\`。本 FE 实现完成后,**必须**把 router 中本 FE 路由的 import 从 \`FeStub\` 改为本 FE 真组件(用 Grep 在 \`${ROOT}/frontend/\` router 定位本 FE 路由 path 的 import 行;仍在 \`frontend/\` 路径内,不破坏护栏)。改完确保 router 该路由 lazy import 指向真组件、可构建可达。`
  394 + : '',
369 '', 395 '',
370 '## 护栏', 396 '## 护栏',
371 '- **绝不**在主会话直接跑测试(mvn / pnpm / playwright / scripts/test.mjs)——必须通过 Agent 子会话。', 397 '- **绝不**在主会话直接跑测试(mvn / pnpm / playwright / scripts/test.mjs)——必须通过 Agent 子会话。',
@@ -441,6 +467,7 @@ function reviewPrompt(id, phase, round, lastVerifySummary, specPath) { @@ -441,6 +467,7 @@ function reviewPrompt(id, phase, round, lastVerifySummary, specPath) {
441 `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}\`。`, 467 `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}\`。`,
442 fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '', 468 fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '',
443 `- **phase = ${fe ? 'frontend → 附加前端 7 维 checklist。其中仅"颜色对比度"(§3 子项)与"响应式"(§4)为主观/best-effort,绝不单独触发 request-changes;a11y 的 label/键盘可达/危险操作确认等客观项仍可作 must-fix(与 agents/code-reviewer.md §3-4 对齐,避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`, 469 `- **phase = ${fe ? 'frontend → 附加前端 7 维 checklist。其中仅"颜色对比度"(§3 子项)与"响应式"(§4)为主观/best-effort,绝不单独触发 request-changes;a11y 的 label/键盘可达/危险操作确认等客观项仍可作 must-fix(与 agents/code-reviewer.md §3-4 对齐,避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`,
  470 + fe ? `- **行为验收作用域小节校验(per-FE 行为门前置真值,必查)**:spec \`${specPath}\` 头部**必须**含逐字标题为 \`## 行为验收作用域\` 的结构化小节,且其 \`关联路由:\` 清单与 \`${ROOT}/frontend/\` router 配置一致(本 FE 路由都在 router 声明、无悬空/错配)。该小节缺失 或 与 router 不一致 → **必须 request-changes**,把"补齐/对齐 行为验收作用域小节"列入 issues(locator 指向 spec 文件路径)。这是 approve 前置——行为门只能据此确定本 FE 路由作用域。` : '',
444 round > 1 && lastVerifySummary 471 round > 1 && lastVerifySummary
445 ? `\n## 上轮 fix 后复验摘要(round ${round - 1})\n${lastVerifySummary}\n\n你必须把"上轮 must-fix 在本轮 diff 中是否真的被修"作为本轮裁决的核心维度。已修的不要再次纳入 must-fix;未修 / 修得不对,单点列入 issues。` 472 ? `\n## 上轮 fix 后复验摘要(round ${round - 1})\n${lastVerifySummary}\n\n你必须把"上轮 must-fix 在本轮 diff 中是否真的被修"作为本轮裁决的核心维度。已修的不要再次纳入 must-fix;未修 / 修得不对,单点列入 issues。`
446 : '', 473 : '',
@@ -518,106 +545,191 @@ function gatePrompt(module, phase, attempt = 1) { @@ -518,106 +545,191 @@ function gatePrompt(module, phase, attempt = 1) {
518 ].filter(Boolean).join('\n') 545 ].filter(Boolean).join('\n')
519 } 546 }
520 547
521 -// ---- 前端行为门(headless behavior-gate)----  
522 -// 设计权威:docs/design/2026-06-02-frontend-behavior-gate.md。frontend testGate 绿后、report/milestone 前跑,  
523 -// 仅 frontend-phase 聚合模块触发。门是**跨栈只读验证 + 临时产物**的第三类 stage:不套 featureStageContract('frontend') 548 +// ---- 前端行为验收(per-FE behavior 子门)----
  549 +// 设计权威:docs/design/2026-06-02-frontend-behavior-in-review-loop.md。
  550 +// 不再是阶段级末尾独立门——并入 per-FE reviewWithFixLoop 的 approve 子门:某轮 reviewer 判 approve 时才触发,
  551 +// 起本 FE 全栈 + sentinel 种子,枚举本 FE 路由控件/文字,硬问题转可 fix must-fix→重验,行为 green 才放行 approve。
  552 +// 门是**跨栈只读验证 + 临时产物**的第三类 stage:不套 featureStageContract('frontend')
524 // (其路径护栏命中 backend/sql/scripts 即越界硬停,与门必须运行 setup-test-db / 起后端 / 生成 SQL 种子自相矛盾)。 553 // (其路径护栏命中 backend/sql/scripts 即越界硬停,与门必须运行 setup-test-db / 起后端 / 生成 SQL 种子自相矛盾)。
525 554
526 // behaviorGateContract:门的硬约束。非交互;证据报告用中文但 spec/sentinel/SQL 可英文标识符; 555 // behaviorGateContract:门的硬约束。非交互;证据报告用中文但 spec/sentinel/SQL 可英文标识符;
527 // 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端前端 / 跑 playwright, 556 // 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端前端 / 跑 playwright,
528 -// 唯一**可写** = .tmp/behavior-gate/r<attempt>/ + 证据报告及 assets;改 frontend//backend//sql/ 源码即越界硬停。 557 +// 唯一**可写** = .tmp/behavior-gate/<FE>/r<behaviorRound>/ + 证据报告及 assets;改 frontend//backend//sql/ 源码即越界硬停。
529 function behaviorGateContract() { 558 function behaviorGateContract() {
530 return [ 559 return [
531 - '## 硬约束(非交互行为子代理)', 560 + '## 硬约束(非交互行为验收子代理)',
532 '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', 561 '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
533 - '- 你是**跨栈只读验证门**:用真实运行(起后端 + 起前端 headless + Playwright 枚举)证明「每个按钮/点击真的生效、每段文字显示正确内容」,**不是**实现功能、**不是**改源码。',  
534 - '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json / playwright.config.*)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。',  
535 - `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${ROOT}/scripts/setup-test-db.mjs\`、起后端服务(spring-boot:run 等)、起前端 headless(vite / playwright webServer)、跑 Playwright;唯一允许**写入**的路径是 \`${ROOT}/.tmp/behavior-gate/r<attempt>/\`(spec/种子 SQL/runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-behavior-gate-r<attempt>.md\` + 其 assets(截图归档到 \`${ROOT}/docs/superpowers/module-reports/assets/...\`)。`, 562 + '- 你是**跨栈只读验证门**:用真实运行(起后端 + 起前端 headless + Playwright 枚举)证明「本 FE 每个按钮/点击真的生效、每段文字显示正确内容」,**不是**实现功能、**不是**改源码。',
  563 + '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。',
  564 + `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${ROOT}/scripts/setup-test-db.mjs\`、起后端服务(spring-boot:run 等)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${ROOT}/.tmp/behavior-gate/<FE>/r<behaviorRound>/\`(spec/种子 SQL/runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/reviews/<date>-<FE>-behavior-r<behaviorRound>-a<attempt>.md\` + 其 assets(截图归档到 \`${ROOT}/docs/superpowers/reviews/assets/...\`)。`,
536 `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${ROOT}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`, 565 `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${ROOT}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`,
  566 + '- **per-FE 中途态豁免(关键)**:本门在 **per-FE 模式**下运行——`frontend/` 中**本 FE 之外**的路由/组件可能尚未实现,属预期中途态。遇到指向未建路由的链接 / 404 / 编译缺件(兄弟 FE 或骨架占位未覆盖),一律记 `coverageGaps[reason="build-failed-sibling-unimpl"]` 或 `envError.kind="build-failed"`(按根因路径归属,见 step0/step2),**绝不**归为本 FE 的 `interactionFailures`。**本 FE 路由清单(feScope.routes)是唯一断言作用域**;白名单外 / 共享控件归 coverageGap,不算本 FE 缺陷。',
537 '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。', 567 '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。',
538 '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。', 568 '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。',
539 - '- **运行时确定性**:sentinel 值 / 端口 / 临时目录名一律由你确定性派生(按列类型 / config-vars 端口 / attempt 序号),**绝不**依赖时间戳 / 随机数。', 569 + '- **运行时确定性**:sentinel 值 / 端口 / 临时目录名一律由你确定性派生(按列类型 / config-vars 端口 / FE id / behaviorRound / attempt 序号),**绝不**依赖时间戳 / 随机数。',
540 ].join('\n') 570 ].join('\n')
541 } 571 }
542 572
543 -// behaviorGatePrompt:门子代理的完整流水线提示(step0-6 + schema)。  
544 -// attempt:1 = 首跑;2.. = flake / 环境 race retry。每 attempt 独立 .tmp 子目录 + 独立证据文件。  
545 -function behaviorGatePrompt(module, attempt) {  
546 - const id = module?.id ?? 'frontend-phase'  
547 - const tmpDir = `${ROOT}/.tmp/behavior-gate/r${attempt}`  
548 - const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-gate-r${attempt}.md` 573 +// behaviorGatePrompt:per-FE 行为验收子代理的完整流水线提示(step0-6 + schema)。
  574 +// id:本 FE id(如 FE-07);specPath:本 FE spec(含 ## 行为验收作用域 小节,feScope 来源 + 日期前缀);
  575 +// behaviorRound:approve 子门内的行为 fix 轮(1..BEHAVIOR_FE_MAX);attempt:本轮内环境 race 重试序号(1..)。
  576 +// 每 (FE × behaviorRound × attempt) 独立 .tmp 子目录 + 独立证据文件,绝不互相覆盖(不丢 flake 信号)。
  577 +function behaviorGatePrompt(id, specPath, behaviorRound, attempt) {
  578 + const safeId = id ?? 'FE'
  579 + const tmpDir = `${ROOT}/.tmp/behavior-gate/${safeId}/r${behaviorRound}`
  580 + const date = (() => { try { return dateFromArtifactPath(specPath) } catch { return '<date>' } })()
  581 + const evidence = `docs/superpowers/reviews/${date}-${safeId}-behavior-r${behaviorRound}-a${attempt}.md`
549 return [ 582 return [
550 - `# behavior-gate — 前端行为门(headless,attempt=${attempt})`, 583 + `# behavior — 前端 per-FE 行为验收(headless,FE=${safeId}, behaviorRound=${behaviorRound}, attempt=${attempt})`,
551 '', 584 '',
552 behaviorGateContract(), 585 behaviorGateContract(),
553 '', 586 '',
554 '## 目标', 587 '## 目标',
555 - `用真实全栈运行证明前端 \`${id}\` 的「每个按钮/点击都真的生效、每段文字都显示正确内容(right context)」。`,  
556 - `单个子会话内**收敛完成**:起栈 → 逐路由枚举 + 两层断言 → teardown。期望即时推导(prototype/ + REQ + docs/05),**不**持久化为契约,但推导期望写进已提交证据报告。`,  
557 - attempt > 1 ? `- 本次 = 第 ${attempt} 次(上一次 red 或 envError;本轮用于辨识 flake / 等环境就绪);证据**写到独立文件 r${attempt}** 不要覆盖前一次。` : '',  
558 - '',  
559 - '## 运行机制(无常驻进程跨会话;起栈→跑→teardown 收敛进单 runner)',  
560 - `- **入口清目录(跑前第一步,去串味,§7/C25)**:${attempt === 1  
561 - ? `本次 attempt=1 → 先删除整个 \`${ROOT}/.tmp/behavior-gate/\` 目录(清掉所有历史 attempt 残留 runner/种子/spec,避免跨 resume 串味),再新建本 attempt 子目录 \`${tmpDir}/\`。`  
562 - : `本次 attempt=${attempt} → 仅删除/清空本 attempt 子目录 \`${tmpDir}/\`(保证幂等,不动其它 attempt 的已提交证据无关的临时残留),再新建。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`,  
563 - `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 全部子进程**并透传结构化结果。**绝不**让前台 spring-boot:run / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`,  
564 - `- \`${tmpDir}/\`(含子目录)已被仓库 \`.gitignore\` 忽略,是唯一临时写区;跑完即弃,只提交证据报告 + assets。`,  
565 - '',  
566 - '## step0 探测起栈能力',  
567 - `- 读 \`${ROOT}/docs/04-技术规范.md § 零\` + \`${ROOT}/frontend/package.json\` + \`${ROOT}/frontend/playwright.config.*\` + \`${ROOT}/config-vars.yaml\`。`,  
568 - '- (a) 有 `webServer` / `reuseExistingServer` → 复用 playwright 起前端;(b) 无 → runner 自负起**后端 + 前端**(项目通常无既有 e2e 起栈,须显式探测 + 自负起栈);无法判定 / 起栈失败 → `envError.kind="stack-not-ready"`。',  
569 - '',  
570 - '## step1 路由真值发现(覆盖率分母)',  
571 - `- 主来源 = \`${ROOT}/frontend/\` 的 router 配置(Vue Router / React Router \`routes\`,用 Grep 定位);\`routesPlanned\` = router 声明的路由数。`,  
572 - '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**(作覆盖率分母);每路由标注所需登录角色。', 588 + `用真实全栈运行证明本 FE \`${safeId}\` 的「每个按钮/点击都真的生效、每段文字都显示正确内容(right context)」。`,
  589 + `单个子会话内**收敛完成**:冷起栈 → 逐**本 FE 路由**枚举 + 两层断言 → teardown。期望即时推导(prototype/ + REQ + docs/05),**不**持久化为契约,但推导期望写进已提交证据报告。`,
  590 + `- 本 FE 行为验收作用域唯一真值 = spec \`${specPath}\` 头部的 \`## 行为验收作用域\` 小节(\`关联路由:\` + \`负责控件白名单:\`)。先 Read 该 spec 取出 feScope;缺该小节 → \`envError.kind="stack-not-ready"\` 并在 detail 写明(不应出现:reviewer 已校验它存在)。`,
  591 + behaviorRound > 1 || attempt > 1 ? `- 本次 = behaviorRound ${behaviorRound} / attempt ${attempt}(上一次 red / envError / fix 后重验);证据**写到独立文件 r${behaviorRound}-a${attempt}** 不要覆盖前一次。` : '',
  592 + '',
  593 + '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)',
  594 + '- **冷起栈(运行时硬约束)**:本项目**无既有 e2e webServer / playwright.config 复用入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。',
  595 + `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1
  596 + ? `本次是本 FE 首轮首次 → 先删除整个 \`${ROOT}/.tmp/behavior-gate/${safeId}/\` 目录(清掉本 FE 历史残留 runner/种子/spec),再新建本轮子目录 \`${tmpDir}/\`。`
  597 + : `本次 behaviorRound=${behaviorRound} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`,
  598 + `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 本 FE 起的全部子进程**并透传结构化结果。**绝不**让前台 spring-boot:run / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`,
  599 + `- **确定性端口/pid 回收前置**:起栈前先按既知端口 + \`${tmpDir}/*.pid\` 强制回收上一 attempt 残留(编排层 + runner 双保险);端口先探测占用,占用则回收或退到动态空闲端口 + 把 baseURL 注入下游。`,
  600 + `- \`${ROOT}/.tmp/behavior-gate/\`(含子目录)已被仓库 \`.gitignore\` 忽略,是唯一临时写区;跑完即弃,只提交证据报告 + assets。`,
  601 + '',
  602 + '## step0 探测 + build 归因(确定性短路前置,依赖 build-failed kind)',
  603 + `- 读 \`${ROOT}/docs/04-技术规范.md § 零\` + \`${ROOT}/frontend/package.json\` + \`${ROOT}/config-vars.yaml\`。`,
  604 + '- runner 自负冷起后端 + 前端 headless(无既有 webServer 可复用)。**起 dev / source-map 模式**(注入定位辅助:`data-testid` 约定 / Vue `__file`),便于把 page+selector 映射回组件文件。',
  605 + '- **build / 起 dev server 失败时先归因**:用 `git` / `Grep` 判断报错根因文件路径——',
  606 + ` - 落在**非本 FE 的 \`frontend/\` 路径**(兄弟 FE 组件缺失 / 骨架占位未覆盖 / 指向未建路由)→ \`envError.kind="build-failed"\` + \`rootCausePath=<非本FE路径>\`(**预期中途态**,不是本 FE bug)。`,
  607 + ' - 落在**本 FE 路径**(feScope 关联组件)→ 才是本 FE 引入的真构建 bug → 归 `interactionFailures[kind="js-error"]`(带 locator=组件文件)。',
  608 + ' - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。',
  609 + '',
  610 + '## step1 路由真值发现(覆盖率分母 = 本 FE 路由,不数 router 全部)',
  611 + '- 分母来源 = spec `## 行为验收作用域` 小节的 `关联路由:` 清单(**只数本 FE 路由**);`routesPlanned` = 本 FE 关联路由数。**不要**把 router 全部路由计入分母(router 含兄弟 FE + 占位路由)。',
  612 + '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**本 FE 每路由的预期控件与文字来源**;每路由标注所需登录角色。',
573 '- 带参动态路由用**种子已知主键**实例化;无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。', 613 '- 带参动态路由用**种子已知主键**实例化;无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。',
  614 + '- **未建兄弟路由既不计入分母也不计 coverageGap**(属预期中途态,按 step0 归 build-failed 短路)。',
574 '', 615 '',
575 '## step2 安全护栏 + 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)', 616 '## step2 安全护栏 + 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)',
576 - `1) **测试库安全护栏(确定性,先于一切)**:读 config-vars 的数据库名;若**不匹配测试库命名**(库名须含或以 \`test\` / \`_test\` / \`_dev\` / \`_local\` 结尾)→ runner 非零退出,返回 \`status:red\` + \`envError.kind\` 留空走 HALT 语义(在 detail 写明「测试库护栏:库名 <x> 非测试库,拒绝 DROP,留人工确认」)。**绝不**对非测试库跑 setup-test-db。`, 617 + `1) **测试库安全护栏**:测试库命名护栏现已下沉到 \`${ROOT}/scripts/setup-test-db.mjs\` 模板自身(确定性 JS 边界,库名须含 test/_test/_dev/_local,否则 fail-closed,\`ALLOW_NONTEST_DROP=1\` 显式放行)。runner 可复述但模板是唯一防线;若模板因测试库护栏非零退出 → 返回 \`status:red\` + 在 detail 写明「测试库护栏触发」(上层对此**不重试不仲裁直接 halt**,留人工确认)。`,
577 `2) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程。`, 618 `2) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程。`,
578 '3) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。', 619 '3) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
579 '4) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。', 620 '4) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。',
580 ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——字符串列逐字段唯一编码(如 `CUST_NAME_S001`,抓绑错字段)+ 行序号保 UNIQUE;数值列用高位魔数;enum 列从 docs/03 值域取并标注。插入前扫 Flyway / config-vars 既有初始数据(admin_init 等)键,sentinel 主键偏移到不冲突区;断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。', 621 ' - **sentinel 规则**:按列类型派生类型合法且可辨识的值——字符串列逐字段唯一编码(如 `CUST_NAME_S001`,抓绑错字段)+ 行序号保 UNIQUE;数值列用高位魔数;enum 列从 docs/03 值域取并标注。插入前扫 Flyway / config-vars 既有初始数据(admin_init 等)键,sentinel 主键偏移到不冲突区;断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。',
581 - '5) **起前端 headless**:(a) playwright webServer / (b) spawn + 轮询 ready;端口同样探测 + 动态回退。',  
582 - '- `finally` **硬要求 kill 全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。', 622 + '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。',
  623 + '- `finally` **硬要求 kill 本 FE 起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。',
583 '', 624 '',
584 '## step2.5 鉴权 bootstrap(确定性前置)', 625 '## step2.5 鉴权 bootstrap(确定性前置)',
585 '- 用 config-vars `admin_init` 或种子已知凭据,经 `docs/05` 登录端点**真实登录**拿 JWT,注入 Playwright `storageState`;`authState` 记角色覆盖(覆盖 / 未覆盖角色集)。', 626 '- 用 config-vars `admin_init` 或种子已知凭据,经 `docs/05` 登录端点**真实登录**拿 JWT,注入 Playwright `storageState`;`authState` 记角色覆盖(覆盖 / 未覆盖角色集)。',
586 '- 登录失败 = `envError.kind="auth-failed"`(环境 race,走 retry),**绝不**当成死控件。', 627 '- 登录失败 = `envError.kind="auth-failed"`(环境 race,走 retry),**绝不**当成死控件。',
587 '', 628 '',
588 - '## step3 枚举(可达性驱动 + 分母对账,非首帧快照)',  
589 - '- 每路由带 `storageState` 加载,收集 DOM 真实控件与文字区域。分母 = step1 推导清单,分子 = live 枚举。', 629 + '## step3 枚举(可达性驱动 + 分母对账,非首帧快照;只驱动本 FE feScope)',
  630 + '- **只枚举/驱动 feScope.routes + feScope.controlWhitelist**(本 FE 白名单控件)。每路由带 `storageState` 加载,收集 DOM 真实控件与文字区域。分母 = step1 本 FE 推导清单,分子 = live 枚举。',
590 '- 分母有但首帧无的控件:runner 尝试**驱动到出现态**(种子保列表非空触发行级操作 / 进多步流程下屏 / 展开 dropdown / 切 tab 后二次枚举);仍不可达 → `coverageGaps[reason="deep-control-not-driven"]`,不静默判 green。到不了的路由 → `coverageGaps[reason="unreachable-auth"|"unreachable-no-route"]`,与「到达了但控件死」严格区分。', 631 '- 分母有但首帧无的控件:runner 尝试**驱动到出现态**(种子保列表非空触发行级操作 / 进多步流程下屏 / 展开 dropdown / 切 tab 后二次枚举);仍不可达 → `coverageGaps[reason="deep-control-not-driven"]`,不静默判 green。到不了的路由 → `coverageGaps[reason="unreachable-auth"|"unreachable-no-route"]`,与「到达了但控件死」严格区分。',
  632 + '- **白名单外 / 共享控件**:若属其它未 approve FE 或共享区 → 归 `coverageGaps[reason="deep-control-not-driven"]`,**绝不**归本 FE 的 `interactionFailures`。',
591 '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。', 633 '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。',
592 - '- `routesReached` / `controlsEnumerated` 据实填(空覆盖必须可见)。', 634 + '- `routesReached` / `controlsEnumerated` 据实填(本 FE 子集空覆盖必须可见)。',
593 '', 635 '',
594 '## step4 推导期望', 636 '## step4 推导期望',
595 '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。', 637 '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。',
596 '', 638 '',
597 - '## step5 断言(两层 + 可观测效果白名单)', 639 + '## step5 断言(两层 + 可观测效果白名单 + 硬问题带源码 locator)',
598 '- **交互层可观测效果白名单**:URL 变化 / docs05 网络调用(`page.on("request")` 比对端点)/ DOM 变更 / 校验信息 / 弹层 / toast / 原生对话框(枚举前注册 `page.on("dialog")`,confirm/alert/beforeunload 计合法效果,防 confirm 阻塞误判 missing-docs05-call)/ 下载(`page.on("download")`)/ 新标签(`page.on("popup")` / `target=_blank`)。', 640 '- **交互层可观测效果白名单**:URL 变化 / docs05 网络调用(`page.on("request")` 比对端点)/ DOM 变更 / 校验信息 / 弹层 / toast / 原生对话框(枚举前注册 `page.on("dialog")`,confirm/alert/beforeunload 计合法效果,防 confirm 阻塞误判 missing-docs05-call)/ 下载(`page.on("download")`)/ 新标签(`page.on("popup")` / `target=_blank`)。',
599 ' - 无任何效果 → `interactionFailures[kind="no-observable-effect"]`;JS 异常 → `js-error`;`console.error` → `console-error`;应发未发网络调用 → `missing-docs05-call`。断言用 auto-waiting / `expect.poll`,**不用**固定 sleep。', 641 ' - 无任何效果 → `interactionFailures[kind="no-observable-effect"]`;JS 异常 → `js-error`;`console.error` → `console-error`;应发未发网络调用 → `missing-docs05-call`。断言用 auto-waiting / `expect.poll`,**不用**固定 sleep。',
600 '- **文字层**:动态文字格对比该 region 字段的唯一 sentinel(抓绑错字段)。', 642 '- **文字层**:动态文字格对比该 region 字段的唯一 sentinel(抓绑错字段)。',
601 - '- **绑定垃圾分级**:`null` / `undefined` / `[object Object]` / `NaN` / `lorem` 出现在绑定位 → `interactionFailures[kind="binding-garbage"]`(硬 halt);双花括号未渲染 / 空占位 `—` / 疑似 i18n key → `textIssues`(走 adjudicate;i18n 类额外加载真实 locale 比对)。',  
602 - '- **文字不符按来源分流到 source**:绑定 sentinel 不符 → `source="sentinel"`(客观 bug);i18n key / 字面 / 语义类 → `source="i18n"|"literal"|"semantic"`。',  
603 - '',  
604 - `## step6 证据落盘 + commit(运行时行为,沿用 test-gate 证据 commit 习惯)`,  
605 - `- 写 \`${evidence}\`:推导期望 / 逐控件判定 / routesPlanned-Reached-controlsEnumerated / authState(含未覆盖角色集)/ coverageGaps / 截图。`,  
606 - `- 截图归档到**已纳入版本管理**的 \`docs/superpowers/module-reports/assets/...\`(**不要**引用 \`.tmp\` 防断链)。`, 643 + '- **绑定垃圾分级**:`null` / `undefined` / `[object Object]` / `NaN` / `lorem` 出现在绑定位 → `interactionFailures[kind="binding-garbage"]`;双花括号未渲染 / 空占位 `—` / 疑似 i18n key → `textIssues`(走 adjudicate;i18n 类额外加载真实 locale 比对)。',
  644 + '- **文字不符按来源分流到 source**:绑定 sentinel 不符 → `source="sentinel"`(客观 bug,转 must-fix);i18n key / 字面 / 语义类 → `source="i18n"|"literal"|"semantic"`(软文字,走仲裁,永不阻断 approve)。',
  645 + '- **行为硬问题必须带源码 locator(转 must-fix 喂 fix 的前置)**:',
  646 + ' - **A 类(可反查到组件文件)**:经 route → router 配置 → view 组件文件反查到**组件级文件路径**。`interactionFailures[].locator` = `<组件文件路径>`(可附 DOM 选择器 / 绑定文本片段,写进 `detail`);`detail` 写「失败 kind + 期望端点/期望 sentinel 值 + 实际渲染值 + DOM 路径 + 绑定片段」,供 fix 子代理在该组件内 Grep 定位 handler/绑定。binding-garbage / sentinel-mismatch 同样附 DOM 路径 + 绑定片段 + 期望 sentinel + 实际渲染值。',
  647 + ' - **B 类(连组件文件都反查不出)**:**不静默降级放行**——归 `coverageGaps[reason="locator-not-resolvable"]`(计入未覆盖,使本轮不能判 green),或归 `envError.kind="stack-not-ready"` 走 retry。绝不把无 locator 的硬问题塞进 `interactionFailures` 不带 locator(上层会因无 locator 走 adjudicate(allowContinue:false),绝不放行)。',
  648 + '',
  649 + `## step6 证据落盘 + commit(运行时行为,沿用证据 commit 习惯)`,
  650 + `- 写 \`${evidence}\`:本 FE feScope / 推导期望 / 逐控件判定 / routesPlanned-Reached-controlsEnumerated / authState(含未覆盖角色集)/ coverageGaps / 截图。`,
  651 + `- 截图归档到**已纳入版本管理**的 \`docs/superpowers/reviews/assets/...\`(**不要**引用 \`.tmp\` 防断链)。`,
607 `- 若本次 \`status:red\` 或存在 envError,证据**头部用红字标注原因**。`, 652 `- 若本次 \`status:red\` 或存在 envError,证据**头部用红字标注原因**。`,
608 - commitBlock(`${evidence} docs/superpowers/module-reports/assets`,  
609 - `docs(behavior-gate:r${attempt}): 前端行为门证据`), 653 + commitBlock(`${evidence} docs/superpowers/reviews/assets`,
  654 + `docs(behavior:${safeId}:r${behaviorRound}-a${attempt}): per-FE 行为验收证据`),
610 '', 655 '',
611 '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)', 656 '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)',
612 - '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + 无 envError + 覆盖非空)| `red`。',  
613 - '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(空覆盖必须可见)。',  
614 - '- `interactionFailures` / `textIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举。',  
615 - '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids。', 657 + '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + 无阻断性 envError + 本 FE 覆盖非空)| `red`。',
  658 + '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数本 FE feScope**;空覆盖必须可见)。',
  659 + '- `interactionFailures` / `textIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`。',
  660 + '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。',
616 '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。', 661 '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。',
617 '- 不要返回额外字段(schema 是 `additionalProperties:false`)。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。', 662 '- 不要返回额外字段(schema 是 `additionalProperties:false`)。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。',
618 ].filter(Boolean).join('\n') 663 ].filter(Boolean).join('\n')
619 } 664 }
620 665
  666 +// ---- 前端骨架占位 stage(runFrontendSkeleton 用)----
  667 +// 设计:docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 2(前置依赖 A,blocker)。
  668 +// 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(未实现 FE 路由指向 FeStub 占位)
  669 +// + 不指悬空 path 的共享导航——保证「前端只建了一部分」的任意时刻 app 仍可构建可起、每个 FE 路由可达。
  670 +// 由此 per-FE 行为门的「可构建前提」成立、tddPrompt 的占位替换有真值起点、build-failed 退化为罕见兜底。
  671 +// feItems:本前端阶段的全部 FE-NN(来自 Router 的 frontend-phase 聚合模块),即 router 全量路由表的清单。
  672 +function frontendSkeletonPrompt(feItems) {
  673 + const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(Router 未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)'
  674 + return [
  675 + '# fe-skeleton — 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位)',
  676 + '',
  677 + featureStageContract('frontend'),
  678 + '',
  679 + '## 目标',
  680 + '在逐 FE 实现开始**之前**,一次性建出前端「可构建可起」的骨架:App 外壳 + router **全量** lazy 路由表(每个 FE 路由都声明,未实现的指向占位组件 `FeStub`)+ 不指悬空 path 的共享导航。',
  681 + '保证后续「只建了一部分 FE」的任意时刻 `vite build` / dev server 都能起、每个 FE 路由都可达(加载到占位);逐 FE 实现时再把对应路由的 import 从 `FeStub` 换成真组件。',
  682 + '',
  683 + `## 本前端阶段 FE 清单(router 全量路由表必须覆盖的全部 FE)`,
  684 + `- ${list}`,
  685 + '',
  686 + '## 收集上下文(确定技术栈 + 目录约定 + 路由)',
  687 + `- \`${ROOT}/docs/04-技术规范.md § 零\`(\`frontend.ui_lib\` / framework / 构建工具)+ \`§ 二 前端规范\`(§ 2.1 目录约定 = 落盘位置 / 路由库 / 入口文件名)。`,
  688 + `- \`${ROOT}/docs/08-模块任务管理.md § 三\`(前端阶段元数据 + \`功能:\` 下全部 \`FE-NN\` 行;与上面清单核对,以本提示给出的清单为准)。`,
  689 + `- \`${ROOT}/docs/01-需求清单/\` 各 FE 关联 REQ + \`${ROOT}/prototype/\`(页面/路由结构权威)+ \`${ROOT}/docs/05-API接口契约.md\`,据此推导每个 FE-NN 对应的**路由 path**(带参动态路由保留 \`:id\` 占位)。`,
  690 + `- 用 Grep 在 \`${ROOT}/frontend/\` 探测现有 App 外壳 / 入口 / router 是否已存在(幂等:已存在则按需补齐,不重复创建/不覆盖已实现的真组件)。`,
  691 + '',
  692 + '## 产出(全部落在 `frontend/` 路径内——遵守前端阶段路径作用域护栏)',
  693 + '1. **App 外壳 + 入口**:`frontend/src/App.*` 与入口 `frontend/src/main.*`(按 framework / docs/04 约定的扩展名;不存在才创建)。挂载共享布局 + `<router-view>`(或等价 outlet)。',
  694 + '2. **router 全量路由表**(按 docs/04 § 2.1 约定的路由文件位置,如 `frontend/src/router/index.*`):',
  695 + ' - **每个** FE-NN 对应路由都声明,**全部用 lazy import**(`component: () => import(...)` 或 framework 等价的动态 import;**绝不** eager `import X from ...` 顶部静态引入,否则未建组件会让整表编译失败)。',
  696 + ' - **未实现的 FE 路由全部指向占位组件 `FeStub`**:`component: () => import("../views/_stub/FeStub.vue")`(或 framework 等价)。逐 FE 实现后由 tdd stage 把对应路由 import 换成真组件。',
  697 + ' - 路由 path 取自上面推导的 FE→path 映射;带参路由用 `:id` 等占位。',
  698 + '3. **占位组件 `FeStub`**:`frontend/src/views/_stub/FeStub.vue`(framework 非 Vue 时落对应等价文件,如 `FeStub.tsx`),最小渲染一个带 `data-fe-stub` 属性的元素(如 `<div data-fe-stub>占位</div>`;行为门据 `data-fe-stub` 识别占位态)。**不实现任何业务逻辑**。',
  699 + '4. **共享布局/导航**:导航链接**全部指向已在 router 声明的路由 path**(不指向任何不存在的 path),保证任意时刻无悬空链接。',
  700 + '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。',
  701 + '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。',
  702 + '',
  703 + '## 自检(可构建)',
  704 + '- 推断本项目前端 build / typecheck 命令(docs/04 § 零 / `frontend/package.json` scripts)。若可在子会话内安全跑(不挂死),**派 Agent 子会话**跑一次 build / dev-server 就绪探测确认骨架可构建可起;不可行则至少静态核对「全部 FE 路由已声明 + 全 lazy + 导航无悬空 path + FeStub 存在」。',
  705 + '- 占位符扫描:`TBD` / `TODO` / `【人工填写:】` → 命中即修。',
  706 + '',
  707 + commitBlock('frontend/', 'feat(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位',
  708 + '- commit 失败 → halt,把 stderr 摘要写进 reason。'),
  709 + '',
  710 + '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
  711 + '- 成功:`{ "status": "ok", "summary": "<已声明的 FE 路由数 / 入口与 router 文件路径摘要>" }`(artifactPath 可省)。',
  712 + '- 任一护栏 / 缺值(如无法推导某 FE 的路由 path 且无任何旁证)→ `{ "status": "halt", "reason": "<具体阻塞点>" }`。',
  713 + '- 做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。',
  714 + ].filter(Boolean).join('\n')
  715 +}
  716 +
  717 +// fe-skeleton 幂等判定:检测 router 是否已声明本阶段全部 FE 路由(全量 + 全 lazy)。
  718 +// fe-skeleton-done tag 是首选 ground truth(下面 runFrontendSkeleton 先查 tag);此 prompt 用于 tag 缺失时
  719 +// 的二次确认(resume / tag 被手工删除场景),避免无谓重建已建好的骨架。
  720 +function frontendSkeletonStatePromptM(feItems) {
  721 + const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)'
  722 + return [
  723 + '# 检测前端骨架是否已建(router 已声明全部 FE 路由 + 全 lazy)',
  724 + microStepContract(),
  725 + '',
  726 + `用 Grep / Read 检查 \`${ROOT}/frontend/\`:是否已存在 router 配置文件,且其中**本阶段全部 FE 路由**(对应 FE:${list})都已声明、全部为 lazy import(\`() => import(...)\`),且占位组件 \`FeStub\`(\`frontend/src/views/_stub/FeStub.*\`)存在。`,
  727 + '- 全部满足(骨架已建齐)→ `{ "exists": true }`',
  728 + '- 任一缺失(无 router / 缺某 FE 路由 / 存在 eager import / 无 FeStub)→ `{ "exists": false }`',
  729 + '## 输出(EXISTS_SCHEMA)',
  730 + ].join('\n')
  731 +}
  732 +
621 // ---- 微步骤 prompt builders(runBranchSetup / runMilestone / runCrossModule 用)---- 733 // ---- 微步骤 prompt builders(runBranchSetup / runMilestone / runCrossModule 用)----
622 // 每个 prompt 单职责、短文本;返回严格 schema;执行(action)步统一返回 ACTION_RESULT_SCHEMA。 734 // 每个 prompt 单职责、短文本;返回严格 schema;执行(action)步统一返回 ACTION_RESULT_SCHEMA。
623 function microStepContract() { 735 function microStepContract() {
@@ -639,7 +751,12 @@ function microStepContract() { @@ -639,7 +751,12 @@ function microStepContract() {
639 // ============================================================================ 751 // ============================================================================
640 752
641 const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限;超出则确定性 halt(防无限循环) 753 const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限;超出则确定性 halt(防无限循环)
642 -const BEHAVIOR_GATE_PASS_MAX = ADJUDICATE_MAX * 4 // 行为门 ①②③ 整体收敛轮上限:每次文字层 retry 跳回从头过硬门,超出则确定性 halt(防无限循环) 754 +// per-FE 行为子门预算(二维,钉死防证据覆盖;设计 §6.4):
  755 +// - BEHAVIOR_FE_MAX = approve 子门内的行为 fix 轮硬上限(每 FE);超限 throw HALT。**不**复用 review 的 10 轮、
  756 +// **不**让 REVIEW_HARD_ROUNDS × 行为重试隐式相乘——典型一次过(1 轮),最坏 3 轮。
  757 +// - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。
  758 +const BEHAVIOR_FE_MAX = 3
  759 +const BEHAVIOR_ATTEMPT_MAX = 2
643 const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : '' 760 const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : ''
644 761
645 // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。 762 // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。
@@ -978,6 +1095,19 @@ function createTagPromptM(phaseId, fe) { @@ -978,6 +1095,19 @@ function createTagPromptM(phaseId, fe) {
978 ].join('\n') 1095 ].join('\n')
979 } 1096 }
980 1097
  1098 +// fe-skeleton-done:前端骨架占位 stage 的幂等真值 tag(runFrontendSkeleton resume 跳过用)。
  1099 +function createFeSkeletonTagPromptM() {
  1100 + return [
  1101 + '# 打 annotated tag `fe-skeleton-done`(前端骨架占位已建,幂等真值)',
  1102 + microStepContract(),
  1103 + '',
  1104 + `先用 \`git -C ${ROOT} tag -l fe-skeleton-done\` 检查;已存在则视为成功(幂等)直接返回 success。`,
  1105 + `否则跑 \`git -C ${ROOT} tag -a fe-skeleton-done -m "chore(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位已建"\`。`,
  1106 + '## 输出(ACTION_RESULT_SCHEMA)',
  1107 + '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "<stderr>" }`',
  1108 + ].join('\n')
  1109 +}
  1110 +
981 function findReportPromptM(phaseId) { 1111 function findReportPromptM(phaseId) {
982 return [ 1112 return [
983 `# 找最新的 \`${phaseId}\` 完成报告并读取 § ⑫ 的 milestone tag 字段当前值`, 1113 `# 找最新的 \`${phaseId}\` 完成报告并读取 § ⑫ 的 milestone tag 字段当前值`,
@@ -1093,7 +1223,7 @@ function reportPrompt(module) { @@ -1093,7 +1223,7 @@ function reportPrompt(module) {
1093 '## 前置', 1223 '## 前置',
1094 `- 验证上游 test-gate 绿:Glob \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`, 1224 `- 验证上游 test-gate 绿:Glob \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`,
1095 fe 1225 fe
1096 - ? `- **验证上游 behavior-gate(前端行为门)绿**:Glob \`${ROOT}/docs/superpowers/module-reports/frontend-phase-behavior-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须非 RED**(status:green / 无 envError);最后一份 red 立即 halt。各 attempt 的 flake / 环境 race / 文字 continue 记录纳入 § ⑤ 汇总。` 1226 + ? `- **前端行为验收已并入 per-FE review 循环**(reviewer approve 子门,行为 green 是 \`req-done/<FE>\` 的前置真值)——report **不再**校验阶段级 behavior-gate 文件(已不再产生)。**对每个 \`req-done/<FE>\` tag 即视为该 FE 行为已过**(避免双真值)。可选轻量校验:每个 FE 存在对应 per-FE 行为证据 \`${ROOT}/docs/superpowers/reviews/<date>-<FE>-behavior-r*-a*.md\` 且最后一份非 RED;缺证据不 halt(仅在 § ⑤/⑧ 标注)。`
1097 : '', 1227 : '',
1098 '', 1228 '',
1099 '## 收集输入(取摘要而非正文)', 1229 '## 收集输入(取摘要而非正文)',
@@ -1103,8 +1233,8 @@ function reportPrompt(module) { @@ -1103,8 +1233,8 @@ function reportPrompt(module) {
1103 `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`, 1233 `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`,
1104 `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`, 1234 `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`,
1105 '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。', 1235 '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。',
1106 - `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。**另把 \`frontend-phase-behavior-gate-r*.md\` 各 attempt(按序)的 flake / 环境 race(envError)/ 文字 continue 记录一并纳入 § ⑤ 汇总**。`,  
1107 - `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外纳入 behavior-gate 报告的 \`coverageGaps\` + 文字 \`textIssues\` 的 continue 记录 + 逐控件判定摘要 + authState 未覆盖角色集**。`, 1236 + `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。**另把 per-FE 行为证据 \`${ROOT}/docs/superpowers/reviews/<date>-FE-*-behavior-r*-a*.md\`(按 FE → behaviorRound → attempt 排序)的 flake / 环境 race(envError,含 build-failed 短路)/ 文字 continue 记录一并纳入 § ⑤ 汇总**。`,
  1237 + `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外按 per-FE 行为证据 \`${ROOT}/docs/superpowers/reviews/<date>-FE-*-behavior-r*-a*.md\` 汇总各 FE 的 \`coverageGaps\` + 文字 \`textIssues\` 的 continue 记录 + 逐控件判定摘要 + authState 未覆盖角色集**。`,
1108 '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。', 1238 '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。',
1109 ].join('\n') 1239 ].join('\n')
1110 : [ 1240 : [
@@ -1274,6 +1404,39 @@ async function runCrossModule(module) { @@ -1274,6 +1404,39 @@ async function runCrossModule(module) {
1274 log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`) 1404 log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`)
1275 } 1405 }
1276 1406
  1407 +// ---- runFrontendSkeleton:前端骨架占位 stage 的 JS 编排(设计 § 2,前置依赖 A)----
  1408 +// 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(FeStub 占位)+ 无悬空导航。
  1409 +// 幂等(resume 安全):先查 git tag `fe-skeleton-done`,已存在则 skip;tag 缺失时再二次确认 router 是否已声明
  1410 +// 全 FE 路由(手工删 tag / 残留场景),已建则补打 tag 后 skip;都未建才派子代理生成,成功后打 tag。
  1411 +async function runFrontendSkeleton(feItems) {
  1412 + const lbl = (k) => `fe-skeleton:${k}`
  1413 +
  1414 + // step 1: tag 幂等(首选 ground truth)
  1415 + const tag = await agent(checkTagExistsPromptM('fe-skeleton-done'),
  1416 + {label: lbl('tag?'), phase: 'Frontend', schema: EXISTS_SCHEMA})
  1417 + if (tag.exists) { log('fe-skeleton: tag fe-skeleton-done 已存在,跳过骨架生成'); return }
  1418 +
  1419 + // step 2: tag 缺失时二次确认 router 是否已声明全 FE 路由(手工删 tag / resume 残留);已建则只补打 tag。
  1420 + const state = await agent(frontendSkeletonStatePromptM(feItems),
  1421 + {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA})
  1422 + if (state.exists) {
  1423 + log('fe-skeleton: router 已声明全部 FE 路由(tag 缺失但骨架已建),补打 fe-skeleton-done tag')
  1424 + await runAction(g => createFeSkeletonTagPromptM() + g,
  1425 + {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag-backfill')})
  1426 + return
  1427 + }
  1428 +
  1429 + // step 3: 派子代理生成骨架(成功后子代理自行 commit;此处仅经 runStage 仲裁 halt 收敛)。
  1430 + await runStage(g => frontendSkeletonPrompt(feItems) + g,
  1431 + {site:'fe-skeleton', grp:'Frontend', label: lbl('gen')})
  1432 +
  1433 + // step 4: 打 fe-skeleton-done tag(幂等真值,resume 跳过)。
  1434 + await runAction(g => createFeSkeletonTagPromptM() + g,
  1435 + {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag')})
  1436 +
  1437 + log(`fe-skeleton: 已生成前端骨架(覆盖 ${(feItems || []).length} 个 FE 路由),打 fe-skeleton-done tag`)
  1438 +}
  1439 +
1277 // ============================================================================ 1440 // ============================================================================
1278 // 编排逻辑(结构按 plan 骨架;featureLoop / reviewWithFixLoop / testGate / 顶层循环) 1441 // 编排逻辑(结构按 plan 骨架;featureLoop / reviewWithFixLoop / testGate / 顶层循环)
1279 // ============================================================================ 1442 // ============================================================================
@@ -1356,7 +1519,7 @@ async function featureLoop(items, phase) { @@ -1356,7 +1519,7 @@ async function featureLoop(items, phase) {
1356 // reverify 经 runStage 但 allowContinue:false(复验红色 = 修复没生效,绝不放行)。 1519 // reverify 经 runStage 但 allowContinue:false(复验红色 = 修复没生效,绝不放行)。
1357 // - approve 后的 docs/08 checkbox 是纯可视化副作用(req-done tag 才是完成真值),缺失/写失败一律 log 跳过不 halt。 1520 // - approve 后的 docs/08 checkbox 是纯可视化副作用(req-done tag 才是完成真值),缺失/写失败一律 log 跳过不 halt。
1358 const REVIEW_SOFT_ROUNDS = 5 1521 const REVIEW_SOFT_ROUNDS = 5
1359 -const REVIEW_HARD_ROUNDS = 8 1522 +const REVIEW_HARD_ROUNDS = 10
1360 1523
1361 // flipDocs08Checkbox:approve 后把功能行 [ ]→[x]。纯可视化;任何缺失/异常/写失败都降级为日志,绝不 halt。 1524 // flipDocs08Checkbox:approve 后把功能行 [ ]→[x]。纯可视化;任何缺失/异常/写失败都降级为日志,绝不 halt。
1362 async function flipDocs08Checkbox(fe, id, phase, grp) { 1525 async function flipDocs08Checkbox(fe, id, phase, grp) {
@@ -1374,6 +1537,9 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) { @@ -1374,6 +1537,9 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1374 let lastVerify = verifyResult 1537 let lastVerify = verifyResult
1375 let lastIssuesCount = 0 1538 let lastIssuesCount = 0
1376 let reviewGuidance = '' // 仲裁 retry 时注入下一轮 review 的纠正指令 1539 let reviewGuidance = '' // 仲裁 retry 时注入下一轮 review 的纠正指令
  1540 + // softPassed 提升到 reviewWithFixLoop 顶层作用域(与本 FE review 同寿命,跨 behaviorRound 持久)——
  1541 + // 行为软文字一旦被仲裁 continue 放行(降级),重跑后即便仍在 textIssues 也不再追问,避免反复消耗仲裁预算。
  1542 + const behaviorSoftPassed = new Set()
1377 for (let round = 1; round <= REVIEW_HARD_ROUNDS; round++) { 1543 for (let round = 1; round <= REVIEW_HARD_ROUNDS; round++) {
1378 const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || '' 1544 const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || ''
1379 // opts.phase = grp('Backend'/'Frontend')是 harness UI 分组;domain phase 见 agents/code-reviewer.md。 1545 // opts.phase = grp('Backend'/'Frontend')是 harness UI 分组;domain phase 见 agents/code-reviewer.md。
@@ -1384,6 +1550,13 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) { @@ -1384,6 +1550,13 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1384 reviewGuidance = '' // 已消费 1550 reviewGuidance = '' // 已消费
1385 1551
1386 if (r.verdict === 'approve') { 1552 if (r.verdict === 'approve') {
  1553 + // approve 闸显式 AND(设计 §6.2):reviewer.verdict==='approve' ∧ behaviorSubGate green(仅前端)。
  1554 + // 后端逐字不变(无行为维度);前端:静态 approve 后**不立即 return**,先进 per-FE 行为 approve 子门——
  1555 + // 起本 FE 全栈验「按钮真生效/文字对」,硬问题转可 fix must-fix→重验,行为 green 才放行;
  1556 + // 行为 green ⇒ 才 flipDocs08Checkbox + return(req-done tag 落点 featureLoop 不动,语义自动升级为「静态过+行为过」)。
  1557 + if (fe) {
  1558 + await behaviorSubGate(id, specPath, grp, behaviorSoftPassed)
  1559 + }
1387 await flipDocs08Checkbox(fe, id, phase, grp) 1560 await flipDocs08Checkbox(fe, id, phase, grp)
1388 return { id, phase, approved:true, rounds:round } 1561 return { id, phase, approved:true, rounds:round }
1389 } 1562 }
@@ -1397,7 +1570,11 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) { @@ -1397,7 +1570,11 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1397 const verdict = await adjudicate(`review-no-actionable:${phase}:${id}:r${round}`, 1570 const verdict = await adjudicate(`review-no-actionable:${phase}:${id}:r${round}`,
1398 { problem:'reviewer 判 request-changes 但无任何带 locator 的可执行 must-fix(无法驱动 fix 步)', 1571 { problem:'reviewer 判 request-changes 但无任何带 locator 的可执行 must-fix(无法驱动 fix 步)',
1399 reviewerIssues: r.issues || [] }, grp, round) 1572 reviewerIssues: r.issues || [] }, grp, round)
1400 - if (verdict.action === 'continue') { await flipDocs08Checkbox(fe, id, phase, grp); return { id, phase, approved:true, rounds:round } } 1573 + // continue 视为「无 must-fix → 静态 approve」——前端仍须先过行为 approve 子门(行为 green 是任何 approve return 的前置)。
  1574 + if (verdict.action === 'continue') {
  1575 + if (fe) await behaviorSubGate(id, specPath, grp, behaviorSoftPassed)
  1576 + await flipDocs08Checkbox(fe, id, phase, grp); return { id, phase, approved:true, rounds:round }
  1577 + }
1401 if (verdict.action === 'halt') throw new Error(`HALT review-no-actionable ${phase}:${id} r${round}: ${verdict.rationale || ''}`) 1578 if (verdict.action === 'halt') throw new Error(`HALT review-no-actionable ${phase}:${id} r${round}: ${verdict.rationale || ''}`)
1402 reviewGuidance = verdict.guidance || '' // retry:带 guidance 重判(进入下一轮) 1579 reviewGuidance = verdict.guidance || '' // retry:带 guidance 重判(进入下一轮)
1403 continue 1580 continue
@@ -1449,136 +1626,170 @@ async function testGate(module, phase) { @@ -1449,136 +1626,170 @@ async function testGate(module, phase) {
1449 return g 1626 return g
1450 } 1627 }
1451 1628
1452 -// ---- 前端行为门控制流(runBehaviorGate)----  
1453 -// 设计:docs/design/2026-06-02-frontend-behavior-gate.md § 4。  
1454 -// 仅 frontend-phase 触发(入口二次保险);每 attempt 独立 .tmp 子目录(门子代理负责清/写)。  
1455 -// 失败分层:  
1456 -// - envError != none(端口/起栈未就绪/种子/鉴权/超时)= 环境 race:同 testGate 跑 attempt=2;仍 envError →  
1457 -// adjudicate(allowContinue:false, retry/halt);retry 再起独立 attempt;绝不当死控件。  
1458 -// - 空覆盖(controlsEnumerated==0 || routesReached==0)→ 绝不 green,归 env race 走 retry/halt。  
1459 -// - interactionFailures(含 binding-garbage)= 交互硬边界:attempt=1 出现不立刻 throw,先跑 attempt=2 辨 flake;  
1460 -// 仍非空 → adjudicate(allowContinue:false, retry/halt),绝不 continue。  
1461 -// - textIssues(软边界):逐条 for-of —— source=='sentinel' → adjudicate(allowContinue:false)(客观 bug,只许 retry/halt);  
1462 -// source∈{i18n,literal,semantic} → adjudicate(allowContinue:true)(continue 时 recordDecisions 记入 autonomousDecisions)。  
1463 -// - coverageGaps:写证据 + recordDecisions,不单独 halt。  
1464 -// RED 在 milestone tag 前 throw 冒泡到顶层 try/catch → break,绝不带红进里程碑。  
1465 -async function runBehaviorGate(module) {  
1466 - // 入口二次保险:仅 frontend-phase 聚合模块跑行为门(同 runMilestone / reportPrompt 惯例)。  
1467 - const fe = module?.id === 'frontend-phase'  
1468 - if (!fe) { log(`behavior-gate: ${module?.id ?? '<module>'} 非 frontend-phase,跳过行为门`); return }  
1469 - const lbl = (a) => `behavior:frontend-phase:r${a}` 1629 +// ---- 前端 per-FE 行为验收控制流(runBehaviorGateOnce + behaviorSubGate)----
  1630 +// 设计:docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 6.3 / 7。
  1631 +// 行为验收并入 per-FE reviewWithFixLoop 的 approve 子门——reviewer 即将 approve 时才触发,绝不每 review round 起栈。
  1632 +// behaviorSubGate 失败分层(per-FE 缩 scope,保留原 runBehaviorGate 的分层语义):
  1633 +// - build-failed(兄弟 FE 未实现 / 占位未覆盖,根因落非本 FE 路径)= 确定性短路:记 coverageGap + decisions,
  1634 +// 本轮行为门视为「本 FE 非缺陷」直接放行 approve(预期中途态,不 retry 不 halt)。
  1635 +// - envError(其它) / 空覆盖 = 环境 race:runBehaviorGateOnce 内部 attempt 1→2 重试;仍异常 → adjudicate(allowContinue:false)。
  1636 +// - 软文字(i18n/literal/semantic) → adjudicate(continue 记 decisions + 跨 behaviorRound softPassed;sentinel 并入 behaviorHard);永不阻断 approve。
  1637 +// - behaviorHard = interactionFailures + sentinel textIssues:有 locator → 降维喂 fixPrompt 跑 fix(fix 后功能 reverify + 下一轮重跑行为);
  1638 +// 无 locator → adjudicate(allowContinue:false) retry/halt,绝不静默丢弃、绝不 approve。
  1639 +// - BEHAVIOR_FE_MAX 轮仍未 green → throw HALT behavior-unresolved(冒泡到顶层 try/catch → fail-fast)。
  1640 +
  1641 +// envBlocked / ifails:per-FE bg 的环境/空覆盖与交互失败判定(build-failed 不计 envBlocked——它走确定性短路分支)。
  1642 +function behaviorEnvBlocked(r) {
  1643 + const k = r.envError && r.envError.kind
  1644 + const ev = (k && k !== 'none' && k !== 'build-failed') ? r.envError : null
  1645 + const emptyCov = (Number(r.controlsEnumerated) === 0) || (Number(r.routesReached) === 0)
  1646 + return { ev, emptyCov, blocked: !!ev || emptyCov }
  1647 +}
  1648 +function behaviorIfails(r) { return Array.isArray(r.interactionFailures) ? r.interactionFailures : [] }
1470 1649
  1650 +// runBehaviorGateOnce:跑一次本 FE 行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。
  1651 +// 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 behaviorSubGate 推进)。
  1652 +// behaviorRound:approve 子门内的行为 fix 轮;内部 attempt 1..BEHAVIOR_ATTEMPT_MAX(环境 race 重起)+ 仲裁兜底。
  1653 +async function runBehaviorGateOnce(id, specPath, grp, behaviorRound) {
  1654 + const lbl = (a) => `behavior:${id}:r${behaviorRound}:a${a}`
1471 let attempt = 1 1655 let attempt = 1
1472 - let bg = await agent(behaviorGatePrompt(module, attempt), {label: lbl(attempt), phase:'Behavior', schema: BEHAVIOR_GATE_SCHEMA})  
1473 - recordDecisions('behavior-gate:frontend-phase', bg.decisions) 1656 + let bg = await agent(behaviorGatePrompt(id, specPath, behaviorRound, attempt),
  1657 + {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA})
  1658 + recordDecisions(`behavior:${id}`, bg.decisions)
  1659 +
  1660 + // build-failed 短路:根因落非本 FE 路径(兄弟未实现)→ 直接返回(外层据此放行 approve),不重试不仲裁。
  1661 + const isBuildFailedShortCircuit = (r) => r.envError && r.envError.kind === 'build-failed'
  1662 + if (isBuildFailedShortCircuit(bg)) return bg
1474 1663
1475 - // 共享重跑:每次 retry 都开一个独立 attempt(独立 .tmp/r<attempt> 证据),刷新 bg + 记录决策。  
1476 - // 文字层 retry 也走这里,确保重跑后能跳回 ①②③ 重新整体过硬门(见下方收敛循环),绝不拿旧快照继续。  
1477 - const rerun = async () => { 1664 + // 内部 envError / 空覆盖重试:attempt 1→BEHAVIOR_ATTEMPT_MAX(沿用 testGate 思路);仍异常 → adjudicate(allowContinue:false)。
  1665 + while (behaviorEnvBlocked(bg).blocked && attempt < BEHAVIOR_ATTEMPT_MAX) {
1478 attempt += 1 1666 attempt += 1
1479 - bg = await agent(behaviorGatePrompt(module, attempt), {label: lbl(attempt), phase:'Behavior', schema: BEHAVIOR_GATE_SCHEMA})  
1480 - recordDecisions('behavior-gate:frontend-phase', bg.decisions) 1667 + bg = await agent(behaviorGatePrompt(id, specPath, behaviorRound, attempt),
  1668 + {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA})
  1669 + recordDecisions(`behavior:${id}`, bg.decisions)
  1670 + if (isBuildFailedShortCircuit(bg)) return bg
1481 } 1671 }
1482 -  
1483 - // helper:环境 race / 空覆盖归一处理——先跑一次 flake 重试,仍异常则 adjudicate(allowContinue:false)。  
1484 - const envBlocked = (r) => {  
1485 - const ev = r.envError && r.envError.kind && r.envError.kind !== 'none' ? r.envError : null  
1486 - const emptyCov = (Number(r.controlsEnumerated) === 0) || (Number(r.routesReached) === 0)  
1487 - return { ev, emptyCov, blocked: !!ev || emptyCov } 1672 + let envState = behaviorEnvBlocked(bg)
  1673 + for (let adj = 1; envState.blocked && adj <= ADJUDICATE_MAX; adj++) {
  1674 + const reason = envState.ev
  1675 + ? `behavior envError=${envState.ev.kind}: ${envState.ev.detail || ''}`
  1676 + : `behavior 空覆盖:routesReached=${bg.routesReached} controlsEnumerated=${bg.controlsEnumerated}(绝不带空覆盖判 green)`
  1677 + const verdict = await adjudicate(`behavior-env:${id}`,
  1678 + { problem: reason, envError: bg.envError || null, ports:(bg.envError||{}).ports, pids:(bg.envError||{}).pids, allowContinue:false }, grp, adj)
  1679 + if (verdict.action !== 'retry') throw new Error(`HALT behavior-env ${id}: ${verdict.rationale || reason}`)
  1680 + attempt += 1
  1681 + bg = await agent(behaviorGatePrompt(id, specPath, behaviorRound, attempt),
  1682 + {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA})
  1683 + recordDecisions(`behavior:${id}`, bg.decisions)
  1684 + if (isBuildFailedShortCircuit(bg)) return bg
  1685 + envState = behaviorEnvBlocked(bg)
1488 } 1686 }
1489 - const ifails = (r) => Array.isArray(r.interactionFailures) ? r.interactionFailures : []  
1490 -  
1491 - // ① 环境 / 空覆盖硬门:仍异常 → 仲裁(allowContinue:false → retry/halt)。  
1492 - // 抽成闭包,便于文字层 retry 后由收敛循环重新整体校验(绝不带空覆盖 / envError 判 green)。  
1493 - const enforceEnv = async () => {  
1494 - let envState = envBlocked(bg)  
1495 - for (let adj = 1; envState.blocked && adj <= ADJUDICATE_MAX; adj++) {  
1496 - const reason = envState.ev  
1497 - ? `behavior-gate envError=${envState.ev.kind}: ${envState.ev.detail || ''}`  
1498 - : `behavior-gate 空覆盖:routesReached=${bg.routesReached} controlsEnumerated=${bg.controlsEnumerated}(绝不带空覆盖判 green)`  
1499 - const verdict = await adjudicate('behavior-gate-env:frontend-phase',  
1500 - { problem: reason, envError: bg.envError || null, ports:(bg.envError||{}).ports, pids:(bg.envError||{}).pids, allowContinue:false }, 'Behavior', adj)  
1501 - if (verdict.action !== 'retry') throw new Error(`HALT behavior-gate-env frontend-phase: ${verdict.rationale || reason}`)  
1502 - await rerun()  
1503 - envState = envBlocked(bg) 1687 + if (envState.blocked) throw new Error(`HALT behavior-env ${id}: ${ADJUDICATE_MAX} 轮仲裁后仍环境异常 / 空覆盖`)
  1688 + return bg
  1689 +}
  1690 +
  1691 +// behaviorSubGate:reviewer approve 的「行为 approve 子门」。green 才允许 reviewWithFixLoop return approve。
  1692 +// softPassed:由 reviewWithFixLoop 顶层注入,跨 behaviorRound 持久(软文字一旦放行不再追问)。
  1693 +// green ≡ behaviorHard.length===0 ∧ envError∈{none,build-failed} ∧ 本 FE 覆盖非空(或 build-failed 短路)。
  1694 +async function behaviorSubGate(id, specPath, grp, softPassed) {
  1695 + const regionKey = (x) => `${x.page || '?'}::${x.region || '?'}`
  1696 + for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_FE_MAX; behaviorRound++) {
  1697 + const bg = await runBehaviorGateOnce(id, specPath, grp, behaviorRound)
  1698 +
  1699 + // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → 记 coverageGap + decisions,子门 green-by-skip 放行。
  1700 + if (bg.envError && bg.envError.kind === 'build-failed') {
  1701 + recordDecisions(`behavior-build-failed:${id}`, [{
  1702 + question:`本 FE ${id} 行为验收遇 build-failed(根因 ${bg.envError.rootCausePath || '?'})`,
  1703 + choice:'green-by-skip(兄弟 FE 未实现属预期中途态,本 FE 非缺陷,放行 approve)',
  1704 + rationale: bg.envError.detail || '', confidence:'low' }])
  1705 + log(`behavior ${id}: build-failed 短路放行(根因非本 FE:${bg.envError.rootCausePath || '?'}),记证据不阻断`)
  1706 + return
1504 } 1707 }
1505 - if (envState.blocked) throw new Error(`HALT behavior-gate-env frontend-phase: ${ADJUDICATE_MAX} 轮仲裁后仍环境异常 / 空覆盖`)  
1506 - }  
1507 1708
1508 - // ② 交互层硬门(含 binding-garbage):仍非空 → 仲裁(allowContinue:false → retry/halt),绝不 continue。  
1509 - // 每次 retry 重跑后可能新冒环境问题,由收敛循环回到 ① 兜底,避免把环境 race 当死控件。  
1510 - const enforceInteraction = async () => {  
1511 - for (let adj = 1; ifails(bg).length && adj <= ADJUDICATE_MAX; adj++) {  
1512 - const summary = ifails(bg).map(f => `[${f.kind}] ${f.page}:${f.control} — ${f.detail}`).join('; ')  
1513 - const verdict = await adjudicate('behavior-gate-interaction:frontend-phase',  
1514 - { problem:`behavior-gate 交互层失败(含 binding-garbage 硬边界,绝不 continue):${summary}`,  
1515 - interactionFailures: ifails(bg), allowContinue:false }, 'Behavior', adj)  
1516 - if (verdict.action !== 'retry')  
1517 - throw new Error(`HALT behavior-gate-interaction frontend-phase: ${verdict.rationale || summary}`)  
1518 - await rerun()  
1519 - await enforceEnv() // 重跑后先过 ① 环境兜底,再回到本门继续辨交互 1709 + // 2) coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 runBehaviorGateOnce 兜底)。
  1710 + // locator-not-resolvable(B 类硬问题反查不出)计入未覆盖——下面会因 behaviorHard 仍非空或覆盖不足而不 green。
  1711 + for (const cg of (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : [])) {
  1712 + if (!cg) continue
  1713 + recordDecisions(`behavior-coverage:${id}`,
  1714 + [{ question:`覆盖缺口 ${cg.page}(${cg.reason})`, choice:'记录不阻断', rationale: cg.detail || '', confidence:'low' }])
1520 } 1715 }
1521 - if (ifails(bg).length)  
1522 - throw new Error(`HALT behavior-gate-interaction frontend-phase: ${ADJUDICATE_MAX} 轮仲裁后交互层仍有失败`)  
1523 - }  
1524 1716
1525 - // attempt=1 出现环境/交互问题不立刻 throw——先跑独立 attempt 辨 flake,再进入硬门收敛。  
1526 - if (envBlocked(bg).blocked || ifails(bg).length) await rerun()  
1527 -  
1528 - // ①②③ 收敛循环:任何文字层 retry 重跑都跳回此处,重新整体校验  
1529 - // envError → 空覆盖 → interactionFailures → textIssues(while 而非 for-of 快照),  
1530 - // 杜绝「文字 retry 后用旧数组继续、且新 bg 携带非空 interactionFailures/envError 滑过硬门」的逃逸。  
1531 - // softPassed:已被仲裁 continue 放行(降级)的 region,重跑后即便仍在 textIssues 也不再追问,避免死循环。  
1532 - const softPassed = new Set()  
1533 - let converged = false  
1534 - for (let pass = 1; pass <= BEHAVIOR_GATE_PASS_MAX && !converged; pass++) {  
1535 - // ① 环境 / 空覆盖(硬门)  
1536 - await enforceEnv()  
1537 - // ② 交互层(硬门,含 binding-garbage)  
1538 - await enforceInteraction()  
1539 -  
1540 - // ③ 文字层(软边界,按 source 分流):while 取当前 bg 第一条未决 textIssue。  
1541 - // source=='sentinel' → allowContinue:false(门自灌确定值,绑错字段 / 显示错是客观 bug,只许 retry/halt);  
1542 - // source∈{i18n,literal,semantic} → allowContinue:true(continue 时 recordDecisions 记入决策日志)。  
1543 - const regionKey = (x) => `${x.page || '?'}::${x.region || '?'}`  
1544 - const pickIssue = () => (Array.isArray(bg.textIssues) ? bg.textIssues : [])  
1545 - .find(x => x && !softPassed.has(regionKey(x)))  
1546 - let needRerun = false  
1547 - let ti  
1548 - while ((ti = pickIssue())) {  
1549 - const hard = ti.source === 'sentinel'  
1550 - const site = `behavior-gate-text:frontend-phase:${ti.page || '?'}:${ti.region || '?'}` 1717 + // 3) 软文字(i18n/literal/semantic)→ 仲裁 continue 记 decisions + softPassed;sentinel 客观 bug 不在此处放行(下面并入 behaviorHard)。
  1718 + // 永不阻断 approve;retry/halt 同现。一旦有软文字 retry → 重跑本 behaviorRound(continue 进下一轮迭代)。
  1719 + let softRetry = false
  1720 + for (const ti of (Array.isArray(bg.textIssues) ? bg.textIssues : [])) {
  1721 + if (!ti || ti.source === 'sentinel') continue // sentinel 归 behaviorHard,不在软文字处理
  1722 + if (softPassed.has(regionKey(ti))) continue
  1723 + const site = `behavior-text:${id}:${ti.page || '?'}:${ti.region || '?'}`
1551 const verdict = await adjudicate(site, 1724 const verdict = await adjudicate(site,
1552 - { problem:`文字不符(source=${ti.source}${hard ? ',sentinel 客观 bug 不可 continue' : ',可 continue 降级'}):${ti.page}:${ti.region} 期望=${JSON.stringify(ti.expected)} 实际=${JSON.stringify(ti.actual)}`,  
1553 - textIssue: ti, allowContinue: !hard }, 'Behavior', pass)  
1554 - if (verdict.action === 'continue' && !hard) {  
1555 - // continue:把放行决策记入 autonomousDecisions(供人工事后审阅),并标记该 region 已软放行。 1725 + { problem:`文字不符(source=${ti.source},可 continue 降级;永不阻断 approve):${ti.page}:${ti.region} 期望=${JSON.stringify(ti.expected)} 实际=${JSON.stringify(ti.actual)}`,
  1726 + textIssue: ti, allowContinue: true }, grp, behaviorRound)
  1727 + if (verdict.action === 'continue') {
1556 recordDecisions(site, [{ question:`文字不符 ${ti.page}:${ti.region}(source=${ti.source})`, 1728 recordDecisions(site, [{ question:`文字不符 ${ti.page}:${ti.region}(source=${ti.source})`,
1557 choice:'continue(仲裁判可安全前进)', rationale: verdict.rationale || '', confidence:'low' }]) 1729 choice:'continue(仲裁判可安全前进)', rationale: verdict.rationale || '', confidence:'low' }])
1558 - softPassed.add(regionKey(ti))  
1559 - continue // 处理同一 bg 内的下一条 1730 + softPassed.add(regionKey(ti)); continue
1560 } 1731 }
  1732 + if (verdict.action !== 'retry') throw new Error(`HALT ${site}: ${verdict.rationale || `文字不符 source=${ti.source}`}`)
  1733 + softRetry = true; break // retry → 重跑本 behaviorRound(跳到下一轮迭代重起整门)
  1734 + }
  1735 + if (softRetry) continue
  1736 +
  1737 + // 3.5) B 类硬问题(locator-not-resolvable coverageGap):连组件文件都反查不出,不静默放行——
  1738 + // 计入未覆盖阻断 approve,走 adjudicate(allowContinue:false) retry/halt(绝不当 green 放行,降级≠放行)。
  1739 + const bClass = (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : []).filter(cg => cg && cg.reason === 'locator-not-resolvable')
  1740 + if (bClass.length) {
  1741 + const summary = bClass.map(cg => `${cg.page} — ${cg.detail}`).join('; ')
  1742 + const verdict = await adjudicate(`behavior-bclass:${id}`,
  1743 + { problem:`behavior 硬问题连组件文件都反查不出(B 类,不可降级放行,计入未覆盖阻断 approve):${summary}`,
  1744 + coverageGaps: bClass, allowContinue:false }, grp, behaviorRound)
  1745 + if (verdict.action !== 'retry') throw new Error(`HALT behavior-bclass ${id}: ${verdict.rationale || summary}`)
  1746 + continue // retry → 重跑本 FE 行为验收(下一 behaviorRound)
  1747 + }
  1748 +
  1749 + // 4) behaviorHard = interactionFailures(含 binding-garbage)+ source=='sentinel' textIssues。
  1750 + const sentinelHard = (Array.isArray(bg.textIssues) ? bg.textIssues : [])
  1751 + .filter(t => t && t.source === 'sentinel')
  1752 + .map(t => ({ page:t.page, control:t.region, kind:'binding-garbage', detail:`sentinel 不符 期望=${t.expected} 实际=${t.actual}`, locator:t.locator }))
  1753 + const behaviorHard = [...behaviorIfails(bg), ...sentinelHard]
  1754 +
  1755 + // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)→ 子门 green 放行。
  1756 + if (behaviorHard.length === 0) {
  1757 + log(`behavior ${id} green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`)
  1758 + return
  1759 + }
  1760 +
  1761 + // 6) 分流:无 locator 的硬问题 → adjudicate(allowContinue:false) retry/halt(绝不静默丢弃、绝不 approve)。
  1762 + const withLoc = behaviorHard.filter(x => typeof x.locator === 'string' && x.locator.trim())
  1763 + const noLoc = behaviorHard.filter(x => !(typeof x.locator === 'string' && x.locator.trim()))
  1764 + if (noLoc.length) {
  1765 + const summary = noLoc.map(f => `[${f.kind}] ${f.page}:${f.control} — ${f.detail}`).join('; ')
  1766 + const verdict = await adjudicate(`behavior-noloc-hard:${id}`,
  1767 + { problem:`behavior 硬问题无源码 locator(无法转 must-fix 喂 fix,绝不 continue/approve):${summary}`,
  1768 + interactionFailures: noLoc, allowContinue:false }, grp, behaviorRound)
1561 if (verdict.action !== 'retry') 1769 if (verdict.action !== 'retry')
1562 - throw new Error(`HALT ${site}: ${verdict.rationale || `文字不符 source=${ti.source}`}`)  
1563 - // retry:重跑整门取最新判定,并跳回 ①②③ 重新整体过全部硬门(绝不拿旧快照继续)。  
1564 - await rerun()  
1565 - needRerun = true  
1566 - break 1770 + throw new Error(`HALT behavior-noloc-hard ${id}: ${verdict.rationale || summary}`)
  1771 + continue // retry → 重跑本 FE 行为验收(下一 behaviorRound)
1567 } 1772 }
1568 - if (!needRerun) converged = true // 无 retry → 文字层已收敛(全软放行 / 全消失)  
1569 - }  
1570 - if (!converged) throw new Error(`HALT behavior-gate-text frontend-phase: ${BEHAVIOR_GATE_PASS_MAX} 轮收敛后文字层仍未解决`)  
1571 1773
1572 - // ④ coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 ① 兜底)。  
1573 - for (const g of (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : [])) {  
1574 - if (!g) continue  
1575 - recordDecisions('behavior-gate-coverage:frontend-phase',  
1576 - [{ question:`覆盖缺口 ${g.page}(${g.reason})`, choice:'记录不阻断', rationale: g.detail || '', confidence:'low' }])  
1577 - } 1774 + // 7) 有 locator 的硬问题 → 降维成 {summary,locator,severity} 喂现有 fixPrompt 跑 fix(schema 不合并、fix 入参合并)。
  1775 + const fixIssues = withLoc.map(f => ({
  1776 + summary: `[behavior:${f.kind}] ${f.page}:${f.control} — ${f.detail}`,
  1777 + locator: f.locator,
  1778 + severity: 'high',
  1779 + }))
  1780 + await runStage(g => fixPrompt(id, 'frontend', fixIssues) + g, {
  1781 + site:`behavior-fix:${id}:r${behaviorRound}`, grp, label:`behavior-fix:${id}:r${behaviorRound}`,
  1782 + })
1578 1783
1579 - if (bg.status === 'red')  
1580 - throw new Error(`HALT behavior-gate-red frontend-phase: 门返回 status:red 但未归入交互/文字/环境分支——拒绝带红进里程碑`)  
1581 - log(`behavior-gate: frontend-phase green(routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`) 1784 + // 8) fix 后功能复验(allowContinue:false):behaviorSubGate 的 fix 改的是 frontend/ UI 源码,可能引入功能回归——
  1785 + // 先跑 scoped 组件测试 reverify(不起全栈,成本低),红则当功能回归硬边界;绿后下一 behaviorRound 重跑行为验收。
  1786 + await runStage(
  1787 + g => verifyPrompt(id, 'frontend', `(behaviorRound ${behaviorRound} 行为 fix 后功能复验,本轮 must-fix: ${fixIssues.length} 项)`, specPath, REVIEW_HARD_ROUNDS + behaviorRound) + g,
  1788 + { site:`behavior-reverify:${id}:r${behaviorRound}`, grp, label:`behavior-reverify:${id}:r${behaviorRound}`, allowContinue: false },
  1789 + )
  1790 + // 进入下一 behaviorRound → 重跑本 FE 行为验收
  1791 + }
  1792 + throw new Error(`HALT behavior-unresolved ${id}: ${BEHAVIOR_FE_MAX} 轮 per-FE 行为子门仍未 green(硬问题未清)`)
1582 } 1793 }
1583 1794
1584 phase('Router') 1795 phase('Router')
@@ -1638,11 +1849,15 @@ for (const [idx, module] of todo.entries()) { @@ -1638,11 +1849,15 @@ for (const [idx, module] of todo.entries()) {
1638 } 1849 }
1639 if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块) 1850 if (module.feItems.length) { // 前端段(仅末尾 frontend-phase 聚合模块)
1640 phase('Frontend') 1851 phase('Frontend')
  1852 + // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy
  1853 + // 路由表(FeStub 占位)+ 无悬空导航——保证逐 FE 实现中途任意时刻 app 可构建可起、每 FE 路由可达,
  1854 + // 使 per-FE 行为门的可构建前提成立、tddPrompt 的 FeStub→真组件占位替换有真值起点。幂等(fe-skeleton-done tag)。
  1855 + await runFrontendSkeleton(module.feItems)
  1856 + // 前端行为验收已并入 featureLoop→reviewWithFixLoop 的 per-FE approve 子门(reviewer approve 时起本 FE 全栈验
  1857 + // 「按钮真生效/文字对」,硬问题转可 fix must-fix→重验,行为 green 才打 req-done)——不再有阶段级末尾独立行为门。
1641 await featureLoop(module.feItems, 'frontend') 1858 await featureLoop(module.feItems, 'frontend')
1642 phase('Gate') 1859 phase('Gate')
1643 - await testGate(module, 'frontend')  
1644 - phase('Behavior') // 前端行为门:testGate 绿后、report/milestone 前(仅 frontend-phase 聚合)  
1645 - await runBehaviorGate(module) 1860 + await testGate(module, 'frontend') // 阶段级 testGate(全量回归 vitest+playwright)保留,与 per-FE 行为验收职责正交
1646 } 1861 }
1647 phase('Milestone') 1862 phase('Milestone')
1648 // report allowContinue:false:reportPrompt 的前置硬验证含"最后一次 test-gate 必须 green,红则 halt"—— 1863 // report allowContinue:false:reportPrompt 的前置硬验证含"最后一次 test-gate 必须 green,红则 halt"——