2026-06-02-frontend-behavior-gate.md 33.4 KB

前端行为门(behavior-gate)— 最终设计(综合评审后)

⚠️ 已作废(SUPERSEDED) —— 本文描述的是阶段级、只读、red 即 halt 的行为门(frontend-phase 末尾跑一次)。 该设计已被 per-FE 版取代:行为验收并入每个 FE 的 reviewWithFixLoop、成为可 fix 的验收维度(verify→fix→重验循环), 并新增前端骨架占位阶段(runFrontendSkeleton + FeStub 全量 lazy 路由)保证中途可构建。 现行设计见 2026-06-02-frontend-behavior-in-review-loop.md;本文仅作历史保留,勿据此实现。

本文是 5 维对抗式评审后的收敛版。锁定决策(用户拍板)默认保留;评审给出的有依据调整已并入; 无依据 / 过度工程的建议在文末「拒绝的建议」记明理由。所有改动可追溯到 changeLog。

用户目标

确保前端「每个按钮 / 点击都真的生效、每段文字都显示正确内容(right context)」。在全自动静默的 coding.mjs 编码阶段中,新增一个 headless 自动化门来达成。

锁定决策(用户拍板,默认不可推翻)

  1. 机制 / 位置:headless,新增 behavior-gate stage 嵌入 coding.mjs;阶段级,跑在 frontend testGate 变绿之后、report/runMilestone 之前;仅作用于末尾的 frontend-phase 聚合模块。
  2. 期望来源:门运行时即时推导(从 prototype/ + REQ 卡片 + docs/05),不预先持久化为契约; 但把推导出的期望写进已提交的证据报告供事后审计。
  3. 数据:全栈 + 种子库。setup-test-db DROP+CREATE → Flyway 建 schema,门用 docs/03 生成 FK 有序 INSERT 种子(带可辨识 sentinel 值),起后端 + 前端,断言动态文字等于 sentinel。
  4. 失败语义:两层。交互缺陷(死控件 / 点击触发 JS 或 console 错误 / 应发的 docs05 调用未发)= 硬 RED halt;文字 / 内容不符 = 走既有 adjudicate 并记入 decisions;渲染出的绑定垃圾归为交互层。
  5. 证据:即时推导但证据落盘。门把推导期望 + 逐控件判定 + 截图写入 docs/superpowers/module-reports/frontend-phase-behavior-gate-r<attempt>.md 并 commit (与 *-test-gate-r*.md 同构)。
  6. 生成测试:临时。生成的 Playwright spec + 种子 + runner 写入已被 .gitignore 忽略的 .tmp/behavior-gate/,跑完即弃;只提交证据报告(避免生成套件悄悄变成永久契约源)。

关于锁定决策 4 的两处微调(不推翻,仅细化,见 changeLog C8 / C11)

  • 「绑定垃圾 = 硬 halt」收窄为高置信子集null/undefined/[object Object]/NaN/lorem 出现在 数据绑定位)才硬 halt;「双花括号未渲染 / 空占位 / 疑似 i18n key」降级为 textIssue 走 adjudicate (否则误杀合法文案,且与「文字不符走 adjudicate」自相矛盾)。
  • 「sentinel 文字不符」从「一律走 adjudicate」改为按来源二分:绑定 sentinel 的动态文字不符是客观 可验证 bug,allowContinue:false(仲裁只许 retry/halt);i18n / 字面 / 语义等价类才 allowContinue:true。 这两处都是把锁定决策落到「不误报 + 不放行真 bug」的可实现状态,不改变其交互硬 / 文字软的总分层。

0. 运行时硬事实(设计成立的前提,已核查代码)

这些是评审核查出的、与原 DESIGN 隐含假设冲突的事实,最终设计必须自洽

  • F1(无既有 e2e 起栈可复用):项目不存在 playwright.config / webServer / reuseExistingServer / e2e:ci(全仓 grep 无生成物)。唯一 e2e 契约是 scripts/test.mjs 第 65 行的单条 shell 命令 {{e2e_cmd}},来源是 scope-lock E.3 用 AskUserQuestion 填进 docs/04 §零自由字符串(无则记 )。coding.mjs 里的 pnpm e2e:ci 只是 prompt 的 fallback 默认值,无任何 Plan 产物保证它存在或 自带 webServer。→ 「复用既有 e2e 起栈」是空对象,必须显式探测 + 自负起栈。
  • F2(无常驻进程原语):所有命令执行都经 agent() 派子会话,子会话跑命令、返回 exit_code 后即结束。 前台 mvn spring-boot:run / vite 永不退出会把子会话挂死。Bash 工具的 run_in_background 句柄随子 会话结束而失去,跨 agent() 不可见。→ 门绝不在 JS 编排层跨多个 agent() 管理常驻进程;起栈→跑→ teardown 必须收敛进单个子会话内的一条命令
  • F3(schema 由 Flyway 在 Spring Boot 启动时才建)setup-test-db.mjs 只 DROP+CREATE 空库 (模板第 110 行注释 + README 第 166 行 + db-init SKILL)。Spring Boot 启动时 Flyway 才 apply sql/migrations/V*.sql。→ 种子 INSERT 必须排在后端起好(Flyway apply 完 + 健康检查就绪)之后,否则 「table doesn't exist」确定性失败
  • F4(schema 守卫不判测试库)setup-test-db.mjs 第 81 行只校验 schema 匹配 /^[A-Za-z0-9_$]+$/ 标识符,不判它是不是测试库;config-vars 第 20 行 schema 是自由文本 + 口头建议「推荐含 test/_dev」; README 第 164 行明说「按该值无条件 DROP+CREATE」。同一 schema 同时驱动开发期 apply-ddl。→ 「config 已 把守测试库」是假前提,删除该措辞,测试库判定由门自负(见 §5)。
  • F5(无种子机检):db-init 的 lib/validate-ddl.mjs 只对 DDL↔docs/03 做 5 维机检(表/列/类型/ 索引/外键),没有任何工具校验种子 INSERT。FK 拓扑序 / NOT NULL / UNIQUE / enum 值域 / 列类型长度全靠 门子代理推导。→ 种子失败必须单独归类(seedError),不混进交互层 RED。
  • F6(运行时禁用 time/random builtin)coding.mjs 由 Workflow 运行时执行,禁用 Date.now() / Math.random() / new Date();「今天」交子代理解析(见 dateFromArtifactPath 注释,第 134 行);顶层 return 是结果通道;agent/phase/parallel/log/adjudicate 是注入全局。→ sentinel / 端口 / 临时 目录名等不得在 mjs 编排层用 time/random 拼,由子代理在自身上下文确定性生成。
  • F7(证据命名不带日期前缀)frontend-phase-behavior-gate-r<attempt>.md*-test-gate-r*.md 同构、不带 YYYY-MM-DD 前缀,正好绕开 dateFromArtifactPath 的解析。.tmp/ 已被 gitignore 模板 忽略(gitignore-append-template)。→ 无需改 .gitignore;无需解析「今天」。
  • F8(adjudicate 签名)adjudicate(site, context, grp, round) 四参;contextallowContinue:false 时仲裁不得选 continue(第 528 行);ADJUDICATE_MAX = 3recordDecisions(site, decisions) 把 stage 自主决策汇总进全局 autonomousDecisions

1. 插桩点(精确到行,避免误触发后端模块)

顶层循环现状(coding.mjs:1357-1362):

if (module.feItems.length) {                        // 前端段(仅末尾 frontend-phase 聚合模块)
  phase('Frontend')
  await featureLoop(module.feItems, 'frontend')
  phase('Gate')
  await testGate(module, 'frontend')
}                                                    // ← behavior-gate 插在这里(if 闭合前)

改为(在 testGate(module,'frontend') 之后、if 闭合之前插入):

if (module.feItems.length) {
  phase('Frontend')
  await featureLoop(module.feItems, 'frontend')
  phase('Gate')
  await testGate(module, 'frontend')
  phase('Behavior')
  await runBehaviorGate(module)                      // 仅 frontend-phase 段;testGate 绿后跑
}
  • 放进 if (module.feItems.length) 块内 → 纯后端模块(feItems 恒空)不会触发,与「仅作用于末尾 frontend-phase 聚合模块」一致。
  • runBehaviorGate 入口加二次保险守卫(与 runMilestone/reportPromptid==='frontend-phase' 判别 惯例一致):const fe = module?.id === 'frontend-phase'; if (!fe) { log('behavior-gate skip: 非 frontend-phase'); return }
  • meta.phases{ title:'Behavior' }(插在 { title:'Gate' }{ title:'Milestone' } 之间)。

2. 新增 schema(不杂交 GATE × STAGE_RESULT,复用既有词汇)

评审 C5 指出原 schema 把 GATE 的 status:green|red 与 STAGE 的 decisions[]/artifactPath 杂交、且 decisions 重复定义。收敛做法:复用 STAGE_RESULT 已有的 decisions[] 形状与 artifactPath 命名 (不另起 evidencePath),只新增行为门特有的两层结果数组

const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false,
  required:['status','routesPlanned','routesReached','controlsEnumerated'],
  properties:{
    status:{ type:'string', enum:['green','red'] },
    // 覆盖率计数(C20:空覆盖必须可见,绝不静默放行)
    routesPlanned:{ type:'integer' },        // step1 路由真值(router 配置)声明的路由数
    routesReached:{ type:'integer' },        // 实际成功导航到达(鉴权后非登录页 / 非空壳)的路由数
    controlsEnumerated:{ type:'integer' },   // 枚举到的非 inert 可交互控件总数
    authState:{ type:'string' },             // C12:以何角色登录、覆盖了哪些角色、未覆盖角色集
    // 交互层失败(硬边界);kind 细分让仲裁能区分「门自身能力不足」与「真死控件」
    interactionFailures:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['page','control','kind','detail'],
      properties:{
        page:{type:'string'}, control:{type:'string'},
        kind:{type:'string', enum:[
          'no-observable-effect',  // 点击无任何可观测效果(真死控件)
          'js-error',              // 点击触发未捕获 JS 异常
          'console-error',         // 点击触发 console.error
          'missing-docs05-call',   // 应发的 docs/05 端点调用未发
          'binding-garbage' ]},    // 高置信渲染垃圾(null/undefined/[object Object]/NaN/lorem 在绑定位)
        detail:{type:'string'} } } },
    // 文字层问题(软边界,按 source 在 JS 侧分流 allowContinue)
    textIssues:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['page','region','expected','actual','source'],
      properties:{
        page:{type:'string'}, region:{type:'string'},
        expected:{type:'string'}, actual:{type:'string'},
        source:{type:'string', enum:['sentinel','i18n','literal','semantic']} } } },
    // 覆盖缺口(C13/C15/C18:到不了的路由 / 多步深层控件未达 / 动态路由无种子可实例化)
    coverageGaps:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['page','reason'],
      properties:{
        page:{type:'string'},
        reason:{type:'string', enum:['unreachable-auth','unreachable-no-route','deep-control-not-driven','dynamic-route-no-seed']},
        detail:{type:'string'} } } },
    // 起栈 / 种子 / 鉴权环境失败(与业务断言失败严格区分;走 retry 不当死控件)
    envError:{ type:'object', additionalProperties:false,
      required:['kind'],
      properties:{
        kind:{type:'string', enum:['port-conflict','stack-not-ready','seed-error','auth-failed','timeout','none']},
        detail:{type:'string'},
        ports:{type:'string'}, pids:{type:'string'} } },  // C17/C19:写进证据便于人工清理残留
    decisions:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['question','choice','rationale'],
      properties:{ question:{type:'string'}, choice:{type:'string'},
        rationale:{type:'string'}, confidence:{type:'string', enum:['high','medium','low']} } } },
    artifactPath:{ type:'string' } } }   // 证据报告路径(复用 STAGE_RESULT 命名,不叫 evidencePath)

additionalProperties:falsedecisions[] 逐项形状与 STAGE_RESULT 完全一致,由 recordDecisions 汇总。


3. 门内部流水线(runBehaviorGate,JS 编排,每 attempt 一个子会话)

关键运行时收敛(F2):整个「探测 → setup-db → 起后端 → 等就绪 → 种子 → 起前端 → 鉴权 → 枚举 → 断言 → teardown」必须由门生成的临时 runner(.tmp/behavior-gate/r<attempt>/run.mjs)在 单个子会话内的一条命令 里完成,runner 用 spawn 起进程树、轮询就绪、finally 里 kill 全部子进程并透传结构化结果。JS 编排层只 负责:渲染 prompt → 派子会话跑 runner → 收 BEHAVIOR_GATE_SCHEMA → 控制流(flake/halt/adjudicate)。

behaviorGatePrompt(module, attempt) 指示门子代理在子会话内执行:

step 0 — 探测起栈能力(F1)

docs/04 §零(e2e 命令)+ frontend/package.json + frontend/playwright.config.*(若存在)+ config-vars.yaml(端口 / 凭据)。判定:

  • (a) 存在 playwright.config 且含 webServer/reuseExistingServer → runner 复用 playwright 自带 webServer 起栈,门只负责 setup-db + 起后端 + 种子(前端交给 playwright)。
  • (b) 不存在 → runner 自负起后端 + 前端(见 step 2)。
  • 无法判定 / 探测失败 → 写 envError.kind='stack-not-ready'走 adjudicate(allowContinue:false), 不静默假设默认命令。

step 1 — 路由真值发现(推导 + 运行时校验对账,C16/C18)

  • 主来源 = frontend/ 的 router 配置(Vue Router / React Router 的 routes 定义,Grep 即可)——SPA 的 运行时路由真值。prototype/ + REQ + docs/05 用于推导每条路由的预期控件清单与文字来源(作为覆盖率 分母),不作为路由真值。
  • 每条路由标注所需角色(C12);带参路由(/orders/:id)用 step 3 种子的已知主键实例化具体 URL,无种子 可实例化的记 coverageGaps[reason='dynamic-route-no-seed']
  • routesPlanned = router 声明的全部路由数。

step 2 — 安全护栏 + setup-db + 起后端 + 等就绪 + 种子 + 起前端(严格时序,F3/F4)

runner 内严格四段时序(种子在 schema 存在之后注入):

  1. 测试库安全护栏(确定性,先于一切,F4):读 config-vars.yaml database.schema,若不匹配测试库命名 约定(含/结尾 test/_test/_dev/_local)→ runner 立即非零退出,门返回 envError 并由 JS 层 throw HALT不经 adjudicate,与 assertSafeId 同级硬安全边界)。要求人工显式确认或改用物理隔离的 <schema>_behavior_gate
  2. node scripts/setup-test-db.mjs(DROP+CREATE 空库)。DROP 前确保无旧后端连着该库:先按 .tmp/behavior-gate/*.pid 优雅回收上一轮残留进程(C9/C19)。
  3. 起后端:runner spawn 后端进程;轮询健康探针(/actuator/health 200 或登录端点 200,带宽超时)直到 就绪——Flyway 在此窗口 apply schema(数十秒)。端口取 config-vars.yamlbackend.http_port但先 探测占用:占用则先尝试回收残留 pid,仍占用则改用动态空闲端口并把 baseURL 注入 playwright(C17)。
  4. 此时才跑 docs/03 派生的 FK 有序 INSERT 种子(schema 已存在)。种子失败 → envError.kind='seed-error'
    • 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),不混进交互层 RED(F5/C7)。
  5. 起前端(headless):(a) 分支用 playwright webServer;(b) 分支 runner spawn 前端 dev/preview, 轮询 ready,端口同样先探测占用 + 动态回退。
  6. runner 的 finally 硬要求 kill 全部子进程(覆盖超时 / 异常 / 断言失败路径),把端口 + pid 写进结果 (envError.ports/envError.pids),避免 attempt 间 / 跨 coding-start 端口冲突(C3/C9/C19)。
  7. 种子 sentinel 规则(C10/C14,确定性 + 类型合法 + 不撞既有数据)
    • 按列类型派生类型合法可辨识值:字符串列用 字段名编码 + 行序号(如 CUST_NAME_S001逐字段唯一以抓 「绑错字段」);数值列用约定高位魔数(如 999001);enum 列只能从 docs/03 值域取一个并在证据标注 「enum 列无法 sentinel,改用值域成员校验」;手机/邮箱/金额等带格式列派生格式合法的可辨识值。
    • 多行场景 sentinel 带行序号保证 UNIQUE 不撞。
    • 插入前扫描 Flyway migration / config-vars 里既有初始数据键(如 admin_init.username=admin、字典 数据),sentinel 主键 / 唯一键偏移到不冲突区间;文字断言按 sentinel 行的已知主键定位,而非断言整页 第一条(避免被既有初始数据行误判)。
    • 安全(C19):所有拼进 SQL 的值用参数化 / 严格转义(不裸字符串拼 INSERT;用占位符或对值做白名单); sentinel 用门自生成的受控格式([A-Za-z0-9_]),从文档 / DOM 取任意文本拼 SQL。

step 2.5 — 鉴权 bootstrap(C2/C11/C12,确定性前置步骤,非「风险记录」)

ERP 绝大多数路由在登录后才可达(Spring Security / JWT 已在模板确认)。runner 在枚举前:

  • config-vars.yaml admin_init(A1 已锁的已知账号)或种子里写入的已知凭据,经 docs/05 登录端点真实 登录拿 JWT,注入 Playwright storageState
  • authState 记录「以何角色登录、覆盖了哪些角色、未覆盖角色集」。多角色权限分叉至少覆盖 admin 一遍。
  • 登录失败归类为 envError.kind='auth-failed'(环境 race,走 retry),绝不当死控件 halt。

step 3 — 枚举(可达性驱动,分母对账,非首帧快照,C13/C15)

  • 每条路由用 Playwright 加载(带 storageState)后收集 DOM 真实存在的全部可交互控件 (button/a/input/select/[role=button]/@click 等)与可见文字区域。
  • 覆盖判据 = 可达性驱动有界探索 + 来源对账(不是首帧快照):
    • 分母 = step 1 推导的预期控件清单(从 prototype 链接/表单 action + FE spec 的 5 态状态机)。
    • 分子 = live 枚举到的控件。
    • 分母有、首帧无的控件,runner 尝试驱动到其出现态:种子保证列表非空以触发行级操作、点击进入多步流程 下一屏 / 展开 dropdown / 切 tab 后做二次枚举。仍无法到达的记 coverageGaps[reason='deep-control-not-driven']不静默判 green
    • 到达不了的路由(被重定向回登录 / 空壳)记 coverageGaps[reason='unreachable-auth'|'unreachable-no-route'], 与「到达了但控件死」严格区分(前者 coverage-gap,后者才是 interactionFailure 硬 halt)。
  • inert 过滤(C8)disabled / [aria-disabled=true] / fieldset[disabled] 内 / 计算样式 pointer-events:none 的控件归为 intentionally-inert不纳入「必须有可观测效果」断言集,但记入证据覆盖 清单(标注 inert + 推断禁用原因)。增强:对 disabled 提交类按钮,先用 sentinel 种子 / 输入把表单填到合法 态,观察是否解除 disabled——能解除即证明是活的且有正确门控;始终 disabled 且 spec 未说明的进 textIssues 走 adjudicate,不一律硬 halt
  • routesReached / controlsEnumerated 据实填。

step 4 — 推导期望

每控件给出预期可观测效果;每文字区域给出预期内容 + 来源(literal / sentinel / i18n / semantic)。

step 5 — 断言(两层 + 可观测效果白名单,C6)

  • 交互层:点击 / 交互要求可观测效果,白名单(C6 扩充,避免误判生效按钮为死控件):
    • URL 变化 / docs05 网络调用(page.on('request') 比对预期端点)/ DOM 变更 / 校验信息 / 弹层 / toast;
    • 原生对话框:枚举前 runner 必须注册 page.on('dialog'),「弹出原生 confirm/alert/beforeunload」本身 计为一类合法可观测效果(危险操作的 confirm() 不处理会阻塞 → 误判 missing-docs05-call);
    • 下载 page.on('download') / 新标签 page.on('popup')/target=_blank 也是合法效果。
    • 无任何可观测效果 → interactionFailures[kind='no-observable-effect'];点击触发 JS 异常 → js-error;console.error → console-error;应发未发 docs05 → missing-docs05-call
    • 断言用 Playwright 的 auto-waiting / expect.poll(不用固定 sleep),从机制压低渲染时序 flake(C4)。
  • 文字层:渲染对比推导期望。动态格对比对应字段的唯一 sentinel(不仅「等于某 sentinel」,而是「等于 该 region 推导期望字段的那个唯一 sentinel」,以抓绑错字段,C14)。
  • 绑定垃圾分级(C8,收窄锁定决策 4)null/undefined/[object Object]/NaN/lorem 出现在数据绑定位 → interactionFailures[kind='binding-garbage'](高置信硬 halt);「双花括号未渲染 / 空占位 / 疑似 i18n key(含点号标识符且无对应文案)」→ textIssues[source='i18n'|'literal'] 走 adjudicate。i18n 场景额外 要求 runner 加载真实 locale 资源比对。

step 6 — 证据落盘 + commit(F7)

docs/superpowers/module-reports/frontend-phase-behavior-gate-r<attempt>.md(含推导期望、逐控件判定、 覆盖率计数 routesPlanned/routesReached/controlsEnumeratedauthStatecoverageGaps、截图)并 commit。

  • 截图归档(C19):要 commit 的截图落到已纳入版本管理的目录(如 docs/superpowers/module-reports/assets/frontend-phase-behavior-gate/r<attempt>/),证据报告只引用已提交 路径,引用 .tmp/(避免 commit 后链接断链)。.tmp/behavior-gate/r<attempt>/ 只放 spec/种子/runner/ 原始截图,跑完即弃。

4. 失败 / 控制流(runBehaviorGate,与 testGate 骨架对齐,C2/C21/C22)

评审 C21(失败语义与安全维度)指出原文「interactionFailures 非空即硬 throw」与「RED 自动重试 1 次辨 flake」 自相矛盾——若一返回带 interactionFailures 就 throw,flake 重试根本没机会跑。最终控制流显式分段,并把交互 层硬边界纳入 adjudicate 框架(allowContinue:false),与全仓「halt 经 adjudicator」收敛架构一致(C24)。

1. attempt=1:派子会话跑 runner,收 BEHAVIOR_GATE_SCHEMA。
2. 若 envError.kind != 'none'(端口冲突/起栈未就绪/种子错/鉴权失败/超时):
   归类为环境 race → 与 testGate 同款:attempt=2 重跑一次;仍 envError → adjudicate(allowContinue:false,
   只在 retry/halt 间裁),retry 再起独立 attempt。绝不把环境 race 当死控件。
   测试库护栏触发的 HALT 例外:不重试、不仲裁,直接 throw(assertSafeId 同级)。
3. 空覆盖检查(C20):frontend-phase 存在但 (controlsEnumerated==0 || routesReached==0) →
   绝不 green,归为 envError(stack/auth/seed 起不来) 走 step 2 的 retry/halt;证据报告头部红字标注
   「本次门未覆盖任何控件,原因=<...>」。
4. interactionFailures(交互硬边界):
   - attempt=1 出现时【不立刻 throw】,先按 testGate 同款跑 attempt=2(独立证据文件 r2,辨 flake)。
   - 仅当 attempt=2 后 interactionFailures 仍非空,经 adjudicate(allowContinue:false) 在 retry/halt 间裁;
     retry 用于「断言类红可在同一次起栈内重试单断言 / 环境抖动」,halt 用于真死控件。绝不 continue。
   - 仲裁可据 interactionFailures[].kind 区分「门自身未处理的弹窗类型 / 环境未就绪」与「真死控件」,
     前者倾向 retry,后者 halt。
5. textIssues(文字软边界,按 source 分流 allowContinue,C11/C22):
   for-of textIssues:
   - source=='sentinel':actual≠唯一 sentinel 是客观 bug(门自己灌的确定值,非推导误报)→
     adjudicate(`behavior-text:${page}:${region}`, {expected,actual,source,allowContinue:false}, 'Behavior', round)
     仲裁只许 retry/halt,绝不 continue 放行绑错字段/显示错记录。
   - source ∈ {i18n,literal,semantic}:推导文案有误报风险 →
     adjudicate(..., {allowContinue:true}, ...) retry=重判该条 / continue=recordDecisions 记入
     autonomousDecisions / halt=真内容 bug。
   - round 计数与 ADJUDICATE_MAX 上限按既有 adjudicate 循环惯例配(每条 site 独立计 round,上限 ADJUDICATE_MAX)。
6. coverageGaps:写进证据报告 + recordDecisions(不单独 halt;空覆盖已在 step 3 兜底为 envError)。
7. 全部通过(interactionFailures 空、sentinel textIssues 全消解、覆盖非空)→ 返回 { status:'green', ... }。
8. 行为门 RED 发生在 milestone tag 之前,沿用 report 的 allowContinue:false 纪律,throw 自然冒泡到顶层
   try/catch → break,绝不带红进里程碑。

与既有原语的对齐:交互层借 testGate() 同款 attempt=1→2 retry + adjudicate(allowContinue:false) 骨架;文字层借 reviewWithFixLoop 的逐条 for-of + adjudicate 先例(非 testGate 单裁面)。可选更省实现:把整门 收敛为一次 runStage(...) 调用、让 runStage 现成的 adjudicate(retry/continue/halt)+ADJUDICATE_MAX 兜底 软边界,交互硬边界单独前置判定 throw——但首选上面的显式分段以保证「环境 race 走 retry、死控件走 halt」的清晰。


5. behaviorGateContract(不直接套 featureStageContract('frontend'),C1/C6/C26)

评审 C1/C6/C26 指出:featureStageContract('frontend') 的路径护栏明文「实现文件必须落 frontend/;命中 backend//sql//scripts/ 即越界硬停」(第 161-163 行),而行为门必须运行 scripts/setup-test-db.mjs、 起后端、生成 sql 语义种子——忠实执行该 contract 的子代理会把这些判为越界并自相矛盾。且 contract 还含「全部输出 中文 / 缺值查找顺序 / 绝不留 TBD」等与门职责冲突的条款(门要写英文 sentinel / Playwright spec)。

新增 behaviorGateContract()(第三类:跨栈只读验证 + 临时产物),只保留真正通用的硬约束:

## 硬约束(行为门——只读验证门,非交互子代理)
- 你是 Workflow 派生的非交互子代理,物理上无法弹问,绝不尝试问人。
- 全部输出文档使用中文(证据报告);但生成的 Playwright spec / sentinel / SQL 种子可用英文标识符。
- 作用域例外(关键):本门为【只读验证门】。允许【运行(不可写)】scripts/setup-test-db.mjs、起后端/前端服务、
  跑 playwright;唯一可写位置 = .tmp/behavior-gate/r<attempt>/(spec/种子/runner)+ 证据报告及其 assets 目录。
  改动 frontend/ / backend/ / sql/ 任何【源码】即越界硬停。把「运行 backend 服务」与「写 backend 实现」显式区分。
- 缺值时优先自主决策继续并记入 decisions[](与 featureStageContract 同口径);仅无法自洽的硬事实才 halt。
- sentinel / 端口 / 临时目录名由你在自身上下文确定性生成;【绝不】依赖 mjs 编排层提供 time/random(编排层禁用)。

behaviorGatePrompt(module, attempt) = behaviorGateContract() + commitBlock(证据路径+assets, 'docs(behavior-gate:frontend-phase:r<attempt>): 行为门证据') + step 0-6 指令 + BEHAVIOR_GATE_SCHEMA 输出契约。


6. report 前置接入(C23,闭合「按钮生效/文字正确」证据链)

reportPrompt 前端分支(coding.mjs:947)现仅 Glob ${phaseId}-test-gate-r*.md 并要求「最后一份 green」。 behavior-gate 证据未进绿前置 → milestone tag 指向的 commit 报告对行为门「视而不见」。扩展

  • 前端分支绿前置 Glob 追加 frontend-phase-behavior-gate-r*.md,按 attempt 升序,最后一份必须 status 非 RED(与 test-gate 同纪律);最后一份 red 立即 halt。
  • §⑤ flake 汇总纳入 behavior-gate 各 attempt(红→绿切换标注 flake)。
  • §⑧ 偏离清单纳入行为门的 coverageGaps + textIssues continue 记录 + 逐控件判定摘要 + authState 未覆盖 角色集,让 milestone 真覆盖「按钮生效 / 文字正确」的证据。

7. resume / 幂等(C25,接受全量重跑但收敛非确定性)

  • behavior-gate 夹在 frontend testGatereport 之间,不自打独立 tag(完成真值仍是 milestone/frontend-phase)。接受代价:halt 后人工修复重跑 coding-start 时,Router 见 frontend-phase 无 milestone tag → 整段重跑,各 FE 因 req-done/<FE> 已存在而 skip(OK),但 frontend testGate 与 behavior-gate 完整重跑(含重起栈 + 全量枚举)。
  • 收敛即时推导的逐次漂移:门入口先清 .tmp/behavior-gate/ 整目录(避免跨 resume 串味);每 attempt 用 独立子目录 .tmp/behavior-gate/r<attempt>/(与证据文件 -r<attempt> 对齐),跑前清空保证幂等。证据报告里 已落盘的推导期望可供人工审计;要求 resume 复用上次推导(接受重推,但风险节记明漂移代价)。
  • 源码修复路径语义(对齐 coding.mjs:1148):behavior-gate 触发的修复若需改 FE 源码,须先手动删除对应 req-done/<FE> tag 才会在重跑时重走 review;否则该 FE 跳过 review。门自身不改源码(只读验证门),文字 bug 经 adjudicate;真要改码留给人工 / 重跑,设计在此显式提示。

8. coding.mjs 新增面汇总(实现清单)

新增 位置 说明
BEHAVIOR_GATE_SCHEMA 与其他 schema 同段(第 64 行 GATE_SCHEMA 附近) §2 形状,additionalProperties:false,复用 decisions[]/artifactPath 词汇
behaviorGateContract() microStepContract/featureStageContract 附近 §5,只读验证门作用域例外
behaviorGatePrompt(module, attempt) gatePrompt 附近 §3+§5,contract + commitBlock + step 0-6 + schema
runBehaviorGate(module) testGate 附近 §4 控制流:frontend-phase 守卫 → 清 .tmp → attempt 循环(envError retry / 空覆盖兜底 / interactionFailures attempt2→adjudicate(false) / textIssues 按 source 分流)
meta.phases += {title:'Behavior'} 第 10-13 行 Gate 与 Milestone 之间
顶层插桩 coding.mjs:1361 之后、if 闭合前 phase('Behavior'); await runBehaviorGate(module)
reportPrompt 前端分支扩展 coding.mjs:947/956/957 §6 绿前置 + §⑤/§⑧ 纳入 behavior-gate

运行时约束自洽(F6)runBehaviorGate 编排层不调用 Date.now()/Math.random()/new Date();sentinel/ 端口/目录名由子代理生成;顶层 return 完好;用注入全局 agent/phase/log/adjudicate/recordDecisions。 所有拼进 git/shell 的标识符(attemptNumber()+整数校验后拼路径)仍过既有安全口径;BEHAVIOR_GATE_SCHEMA additionalProperties:false


9. 残留风险(已知、接受或缓解)

  • 全栈 headless 起栈 + 逐路由枚举 + 鉴权 + 多步驱动,单 attempt 墙钟可能数分钟到十几分钟;flake ×2 + adjudicate 再拉长。缓解:仅 testGate 绿后跑 + runner 内单次起栈跑完所有路由(硬约束,绝不每路由重起栈)
    • auto-waiting + 整体墙钟上限(超时归 envError 走 retry)。无法把墙钟压到很低,接受。
  • 即时推导的路由分母 / 文字期望仍有 LLM 非确定性;sentinel 绑定类已收为确定性硬比对,i18n/literal/semantic 类保留 adjudicate(设计自身权衡)。逐次推导漂移已记入 §7。
  • 多角色权限分叉:至少覆盖 admin,未覆盖角色集显式记 authState + §⑧;非 admin 角色专属按钮可能漏测。
  • 动态参数路由无种子可实例化时记 coverageGaps[dynamic-route-no-seed],可降级但显式可见,不当已覆盖。
  • 测试库护栏依赖命名约定(含 test/_dev/_local);若项目用非常规测试库名(如 staging)会被护栏挡下要求人工 确认——宁可误挡不可误删,接受。

拒绝的建议(无依据 / 过度工程,记明理由)

  • 「为 behavior-gate 自打独立幂等 tag(behavior-gate-pass/frontend-phase)」(C25 选项):拒绝。会新增 一类 resume 真值,与现有「milestone/req-done 两级 tag」体系叠加复杂度;用户锁定决策未要求更细断点,且全量 重跑成本已被「仅 testGate 绿后跑」摊薄。改为接受全量重跑 + 在 §7/§9 记明代价。
  • 「种子改为确定性 JS 生成(读 docs/03 解析后排序)而非 LLM 推导」(C-确定性 issue B 选项):部分采纳为 方向但不强制实现。理由:锁定决策 2 明确「门运行时即时推导」,且新建确定性种子生成器是独立大工程(需复刻 db-init 的 docs/03 解析 + FK 拓扑),超出本门范围。采纳其可落地内核——种子失败单独归类 seedError + 结构化根因 + retry 带 guidance(§3 step2.4 / §4),把「同 docs/03 → 同种子」的非确定性收敛到「失败可诊断、 sentinel 规则确定」,不强制改推导机制本身。
  • 「textIssues 一律升级 allowContinue:false」:拒绝。会让 i18n/语义等价类误报变成不可恢复 halt,与锁定 决策 4「文字走 adjudicate 不硬 halt」冲突。改为按 source 二分(sentinel 硬、其余软),既守真 bug 又不卡死。