Commit 1b944be85d7ebdfefbe5f02d680f888748063b51

Authored by zichun
1 parent 4e007af8

coding.mjs: converge halts via adjudicator + in-stage decisions, keep hard safety boundaries

缺值类(A)改为 stage 自主决策继续并登记 decisions[];结构违约/重试耗尽(B/C)经新增 adjudicate() 仲裁
retry/continue/halt(runStage/runAction 包装,ADJUDICATE_MAX=3);dirty 工作树自主恢复(带分支护栏,
非功能分支/越界改动仍 halt)。review 软5/硬8 轮;docs/08 checkbox 降级为纯可视化(req-done tag 为真值)。

硬边界保持: merge 冲突 / invalid-projectRoot / assertSafeId 注入护栏 / 红色测试绝不 continue 跳过
(verify/reverify/tdd/report allowContinue:false)。JS HALT throw 37->26。
Showing 1 changed file with 355 additions and 97 deletions
workflows/coding.mjs
@@ -34,13 +34,32 @@ const REVIEW_SCHEMA = { type:'object', additionalProperties:false, @@ -34,13 +34,32 @@ const REVIEW_SCHEMA = { type:'object', additionalProperties:false,
34 locator:{type:'string'}, 34 locator:{type:'string'},
35 severity:{type:'string', enum:['blocker','high','medium','low']} } } } } } 35 severity:{type:'string', enum:['blocker','high','medium','low']} } } } } }
36 36
37 -// STAGE_RESULT_SCHEMA:派生 stage 统一返回,status=halt 时 JS 立即 throw HALT。 37 +// STAGE_RESULT_SCHEMA:派生 stage 统一返回。
  38 +// status=halt 不再立即 fail-fast——先经 adjudicate() 仲裁(retry/continue/halt)才可能终止。
  39 +// decisions[]:stage 自主决策日志(缺值时不再停下,而是挑最有依据的默认/解读并登记于此),上层汇总进结果供人工事后审阅。
38 const STAGE_RESULT_SCHEMA = { type:'object', additionalProperties:false, 40 const STAGE_RESULT_SCHEMA = { type:'object', additionalProperties:false,
39 required:['status'], properties:{ 41 required:['status'], properties:{
40 status:{type:'string', enum:['ok','halt']}, 42 status:{type:'string', enum:['ok','halt']},
41 reason:{type:'string'}, 43 reason:{type:'string'},
42 artifactPath:{type:'string'}, 44 artifactPath:{type:'string'},
43 - summary:{type:'string'} } } 45 + summary:{type:'string'},
  46 + decisions:{ type:'array', items:{ type:'object', additionalProperties:false,
  47 + required:['question','choice','rationale'],
  48 + properties:{
  49 + question:{type:'string'},
  50 + choice:{type:'string'},
  51 + rationale:{type:'string'},
  52 + confidence:{type:'string', enum:['high','medium','low']} } } } } }
  53 +
  54 +// ADJUDICATE_SCHEMA:仲裁子代理在确定性 halt 之前的裁决——
  55 +// retry = 失败疑似一次性/可纠正,携 guidance 重跑上游;
  56 +// continue = 缺陷不阻断正确性、可安全前进(降级为口头建议);
  57 +// halt = 确属不可恢复(结构性缺失无旁证 / git 树需人工 / 会污染源码或伪造业务语义)。
  58 +const ADJUDICATE_SCHEMA = { type:'object', additionalProperties:false,
  59 + required:['action','rationale'], properties:{
  60 + action:{type:'string', enum:['retry','continue','halt']},
  61 + guidance:{type:'string'},
  62 + rationale:{type:'string'} } }
44 63
45 const GATE_SCHEMA = { type:'object', additionalProperties:false, 64 const GATE_SCHEMA = { type:'object', additionalProperties:false,
46 required:['status'], properties:{ status:{type:'string',enum:['green','red']}, 65 required:['status'], properties:{ status:{type:'string',enum:['green','red']},
@@ -134,8 +153,10 @@ function featureStageContract(phase) { @@ -134,8 +153,10 @@ function featureStageContract(phase) {
134 '## 硬约束(非交互子代理)', 153 '## 硬约束(非交互子代理)',
135 '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。', 154 '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
136 '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md` → `docs/05-API接口契约.md` → `prototype/`(前端布局/交互权威)→ `src/styles/tokens.css`(前端色值)→ `CLAUDE.md` → 现有代码。', 155 '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md` → `docs/05-API接口契约.md` → `prototype/`(前端布局/交互权威)→ `src/styles/tokens.css`(前端色值)→ `CLAUDE.md` → 现有代码。',
137 - '- 仍查不到 → **不要编造、不要留 `【人工填写:】` / `TBD` / `TODO` 占位**;把具体阻塞点(缺哪个值、应在哪个 Plan 闸门锁定、为何无法继续)写进产物。',  
138 - '- 然后让本步骤以非零结果 / 显式 throw 结束,由上层 Workflow 转为带诊断的 halt(fail-fast)。', 156 + '- 仍查不到时——**优先自主决策继续,不要停下**:基于现有代码约定 / 技术规范 / 同类实现,挑选**最有依据的解读或合理默认值**,把该决策写进产物显著位置,并在返回的 `decisions[]` 中逐条登记 `{question, choice, rationale, confidence}`(这是默认动作,项目目标是全自动静默、尽可能少 halt)。',
  157 + '- 红线:**绝不**留 `【人工填写:】` / `TBD` / `TODO` 占位;**绝不**编造与现有约定/技术规范冲突的"事实";自主默认必须可被现有证据支撑且记入 `decisions[]`。',
  158 + '- 仅当缺失的是**无法自洽决策的硬事实**(如某表结构 / 业务主键语义完全缺失且无任何旁证,任何默认都可能污染源码或伪造业务语义)时,才以 `status:halt` 结束并把阻塞点写清;上层会再经仲裁评估能否继续,halt 是最后手段而非首选。',
  159 + '- 输出纪律:本次若做过任何自主默认 / 解读,成功返回(status:ok)**必须**带 `decisions[]`(逐条 `{question,choice,rationale,confidence}`,与上面登记要求一致);完全没有自主决策时才可省略——别照抄"输出"段里不含 decisions 的最简示例而漏登记。',
139 '- 全部输出文档**使用中文**。', 160 '- 全部输出文档**使用中文**。',
140 `- **阶段 = ${fe ? '前端(frontend)' : '后端(backend)'}**。路径作用域:${fe 161 `- **阶段 = ${fe ? '前端(frontend)' : '后端(backend)'}**。路径作用域:${fe
141 ? '实现文件必须落在 `frontend/` 下;命中 `backend/` / `sql/` / `scripts/` 即越界,硬停。' 162 ? '实现文件必须落在 `frontend/` 下;命中 `backend/` / `sql/` / `scripts/` 即越界,硬停。'
@@ -463,6 +484,126 @@ function microStepContract() { @@ -463,6 +484,126 @@ function microStepContract() {
463 ].join('\n') 484 ].join('\n')
464 } 485 }
465 486
  487 +// ============================================================================
  488 +// 仲裁 / 自主决策基础设施(halt 收敛)
  489 +// 设计:原先每个"缺值 / 结构违约 / 重试耗尽"点都直接 throw HALT 让整阶段 fail-fast。
  490 +// 现在改为先经 adjudicate() 仲裁——retry(带 guidance 重跑)/ continue(降级前进)/ halt(确属不可恢复)。
  491 +// stage 自身也被要求优先自主决策继续(见 featureStageContract),其默认/解读记入 decisions[] 汇总。
  492 +// 仅 git 树冲突 / 配置错 / 安全护栏(assertSafeId)保持硬 halt——这些不可由 LLM 安全代决。
  493 +// ============================================================================
  494 +
  495 +const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限;超出则确定性 halt(防无限循环)
  496 +const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : ''
  497 +
  498 +// 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。
  499 +const autonomousDecisions = []
  500 +function recordDecisions(site, decisions) {
  501 + if (!Array.isArray(decisions)) return
  502 + for (const d of decisions) {
  503 + if (!d) continue
  504 + autonomousDecisions.push({ site, question:d.question, choice:d.choice, rationale:d.rationale, confidence:d.confidence })
  505 + log(`decision ${site}: ${d.question || '?'} → ${d.choice || '?'} (${d.confidence || '?'})`)
  506 + }
  507 +}
  508 +
  509 +function adjudicatePromptM(site, context) {
  510 + const ctx = typeof context === 'string' ? context : JSON.stringify(context, null, 2)
  511 + return [
  512 + `# 仲裁:\`${site}\` 触发潜在 halt,请裁决 retry / continue / halt`,
  513 + microStepContract(),
  514 + '',
  515 + '## 你的角色',
  516 + '你是 ERP 编码 Workflow 的**仲裁子代理**。某上游步骤触发了一个原本会让整阶段 fail-fast 停下的护栏。',
  517 + '项目目标是全自动静默、尽可能少停。请在**不损坏 git 工作树、不伪造业务事实、不污染源码**的前提下,尽量让流程继续。',
  518 + '',
  519 + '## 触发上下文',
  520 + '```',
  521 + ctx,
  522 + '```',
  523 + '',
  524 + '## 裁决口径',
  525 + '- `retry`:失败疑似一次性 / 可纠正(子代理输出不符 schema 约定、git 命令瞬时失败、上游漏给某字段)。**必须**在 `guidance` 写清"重跑时要修正什么",下游会把它原样注入重跑提示。',
  526 + '- `continue`:缺陷不阻断正确性、可安全前进(reviewer 的非必须建议 / 可降级为口头建议的 issue / 纯可视化副作用缺失 / 已可由后续 verify / test-gate 兜底的疑虑)。在 `rationale` 说明为何安全。',
  527 + '- `halt`:确属不可恢复——结构性缺失且无任何旁证、git 树冲突需人工、继续会污染源码 / 伪造业务语义。在 `rationale` 写清人工需要做什么。',
  528 + '- 若上下文含 `"allowContinue": false`,**不得**选 continue(如红色测试不可跳过),只在 retry / halt 间选。',
  529 + '## 输出(ADJUDICATE_SCHEMA)',
  530 + '- `{ "action": "retry|continue|halt", "guidance": "<retry 时给下游的纠正指令,其余可空字符串>", "rationale": "<裁决理由>" }`',
  531 + ].join('\n')
  532 +}
  533 +
  534 +async function adjudicate(site, context, grp, round) {
  535 + const verdict = await agent(adjudicatePromptM(site, context),
  536 + {label:`adjudicate:${site}:r${round}`, phase: grp, schema: ADJUDICATE_SCHEMA})
  537 + log(`adjudicate ${site} r${round}: ${verdict.action}${verdict.rationale ? ' — ' + verdict.rationale : ''}`)
  538 + return verdict
  539 +}
  540 +
  541 +// runStage:跑一个 STAGE_RESULT 派生 stage(spec/plan/tdd/verify/fix/report)。
  542 +// ① 登记 decisions[];② status:halt 或 validate() 报结构问题 → 经 adjudicate 决定 retry/continue/halt。
  543 +// makePrompt(guidanceTail) 接收仲裁追加指令串(adjGuidance 已格式化);validate(res) 返回 null=通过 / 问题串。
  544 +// allowContinue=false:本 stage 的 halt 代表**硬正确性边界**(功能测试红色 verify/reverify、路径越界/卡死 tdd、
  545 +// test-gate 红 report),仲裁只许 retry/halt,**绝不 continue 放行**残缺/越界状态去 approve / milestone。
  546 +async function runStage(makePrompt, { site, grp, label, validate, allowContinue = true }) {
  547 + let guidance = ''
  548 + for (let round = 1; round <= ADJUDICATE_MAX; round++) {
  549 + const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA})
  550 + recordDecisions(site, res.decisions)
  551 + let problem = null
  552 + if (res.status === 'halt') problem = `stage 返回 status:halt;reason: ${res.reason || '(空)'}`
  553 + else if (validate) { try { problem = validate(res) } catch (e) { problem = String(e?.message || e) } }
  554 + if (!problem) return res
  555 + const verdict = await adjudicate(site, { problem, stageResult: res, allowContinue }, grp, round)
  556 + if (verdict.action === 'continue' && allowContinue) return res
  557 + if (verdict.action !== 'retry') throw new Error(`HALT ${site}: ${verdict.rationale || problem}`)
  558 + guidance = verdict.guidance || '' // retry:带 guidance 重跑
  559 + }
  560 + throw new Error(`HALT ${site}-adjudication-exhausted: ${ADJUDICATE_MAX} 轮仲裁仍未解决`)
  561 +}
  562 +
  563 +// runAction:跑一个 ACTION_RESULT 微步骤(git / 文件写),失败时经 adjudicate 决定 retry/continue/halt。
  564 +// allowContinue=true 时 continue 视为"接受失败并前进"(仅用于纯可视化等可安全跳过的副作用)。
  565 +async function runAction(makePrompt, { site, grp, label, allowContinue = false }) {
  566 + let guidance = ''
  567 + for (let round = 1; round <= ADJUDICATE_MAX; round++) {
  568 + const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: ACTION_RESULT_SCHEMA})
  569 + if (r.success) return r
  570 + const verdict = await adjudicate(site,
  571 + { problem:`action 失败:${r.error || ''}${r.detail ? '\n' + r.detail : ''}`, allowContinue }, grp, round)
  572 + if (verdict.action === 'continue' && allowContinue) return r
  573 + if (verdict.action === 'halt' || verdict.action === 'continue')
  574 + throw new Error(`HALT ${site}: ${verdict.rationale || r.error || ''}`)
  575 + guidance = verdict.guidance || '' // retry:带 guidance 重跑
  576 + }
  577 + throw new Error(`HALT ${site}-adjudication-exhausted: ${ADJUDICATE_MAX} 轮仲裁仍未解决`)
  578 +}
  579 +
  580 +// recoverDirtyWorktreePromptM:branchSetup / milestone 前置的"工作树干净"被打破时的自主恢复(class D 部分)。
  581 +// 子代理检查脏文件——全是本阶段合法产物 → 自动 commit 后继续;含越界/不明改动 → 不提交、返回失败让上层 halt。
  582 +// **分支护栏(branch)**:自动 commit 只允许发生在目标功能分支上。若当前 HEAD 不在 branch(如里程碑后 HEAD
  583 +// 停在默认分支、resume 时残留落在默认分支),绝不 add -A/commit——否则会把绕过 review/test-gate 的改动
  584 +// 直接提交进默认分支,且该改动对模块 `<default>...HEAD` 三点 diff 不可见(污染 cross-module / 完成报告)。
  585 +function recoverDirtyWorktreePromptM(dirty, branch, scopeHint) {
  586 + const list = (dirty || []).map(p => `- ${p}`).join('\n') || '(调用方未给清单,请自行 `git status --porcelain` 复核)'
  587 + return [
  588 + '# 工作树不干净——判定能否自主提交后继续',
  589 + microStepContract(),
  590 + '',
  591 + '## 背景',
  592 + `分支切换 / 里程碑前要求工作树干净,当前存在未提交改动。${scopeHint || ''}`,
  593 + '在**不丢失工作、不混入越界改动、不提交到错误分支**的前提下尽量让流程继续。',
  594 + '',
  595 + '## 脏文件清单',
  596 + list,
  597 + '',
  598 + '## 流程',
  599 + `0. **分支护栏(必须先做)**:跑 \`git -C ${ROOT} rev-parse --abbrev-ref HEAD\`。若当前分支 **!= \`${branch}\`**(目标功能分支),**绝不提交**——直接返回 \`{ "success": false, "error": "dirty-on-wrong-branch", "detail": "HEAD=<当前分支>, expected ${branch};拒绝把残留提交到非功能分支,留给人工" }\`。只有当前已在 \`${branch}\` 才继续 step 1。`,
  600 + `1. 逐一检查改动(\`git -C ${ROOT} status --porcelain\`,必要时 \`git -C ${ROOT} diff\`)。`,
  601 + `2. **全部都是本阶段合法产物**(spec/plan/verify/review/report/源码/migration,且落在当前阶段路径作用域内)→ \`git -C ${ROOT} add -A\` 后 \`git -C ${ROOT} commit -m "chore: 自动提交上一步残留改动"\`,返回 \`{ "success": true, "detail": "committed-in-scope" }\`。`,
  602 + '3. 含**越界 / 不明 / 与本阶段无关**的改动(手工临时文件、其它模块代码、构建产物等)→ **不要提交**,返回 `{ "success": false, "error": "dirty-out-of-scope", "detail": "<可疑文件 + 原因>" }`。',
  603 + '## 输出(ACTION_RESULT_SCHEMA)',
  604 + ].join('\n')
  605 +}
  606 +
466 // ── 微步骤:可重用 read(多个 orchestrator 共用)── 607 // ── 微步骤:可重用 read(多个 orchestrator 共用)──
467 function detectDefaultBranchPromptM() { 608 function detectDefaultBranchPromptM() {
468 return [ 609 return [
@@ -845,21 +986,33 @@ async function runBranchSetup(module) { @@ -845,21 +986,33 @@ async function runBranchSetup(module) {
845 986
846 const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) 987 const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
847 988
  989 + // 工作树脏:先自主恢复(in-scope 残留 → 自动 commit);含越界改动则恢复失败 → halt(留给人工)。
848 const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) 990 const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
849 - if (!wt.clean) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${(wt.dirty || []).join(', ')}`) 991 + if (!wt.clean) {
  992 + const rec = await agent(recoverDirtyWorktreePromptM(wt.dirty, branch, `分支 setup 前置(目标分支 ${branch})。`),
  993 + {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
  994 + if (!rec.success) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`)
  995 + log(`branch-setup: ${id} 自动提交脏工作树残留(${rec.detail || ''})`)
  996 + }
850 997
851 const exists = await agent(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) 998 const exists = await agent(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
852 -  
853 if (exists.exists) { 999 if (exists.exists) {
854 - const r = await agent(checkoutExistingBranchPromptM(branch), {label: lbl('checkout'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})  
855 - if (!r.success) throw new Error(`HALT branchSetup-checkout ${branch}: ${r.error || ''}`) 1000 + await runAction(g => checkoutExistingBranchPromptM(branch) + g, {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('checkout')})
856 } else { 1001 } else {
857 - const r = await agent(createBranchFromPromptM(def.branch, branch), {label: lbl('create'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})  
858 - if (!r.success) throw new Error(`HALT branchSetup-create ${branch}: ${r.error || ''}`) 1002 + await runAction(g => createBranchFromPromptM(def.branch, branch) + g, {site:`branchSetup-create:${branch}`, grp:'Milestone', label: lbl('create')})
859 } 1003 }
860 1004
861 - const head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})  
862 - if (head.branch !== branch) throw new Error(`HALT branchSetup-branch-mismatch ${branch}: HEAD on ${head.branch}`) 1005 + // HEAD 确认:不符则经仲裁重切(retry)或留人工(halt)。
  1006 + let head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  1007 + for (let adj = 1; head.branch !== branch && adj <= ADJUDICATE_MAX; adj++) {
  1008 + const verdict = await adjudicate(`branchSetup-branch-mismatch:${branch}`,
  1009 + { problem:`分支 setup 后 HEAD 在 ${head.branch},期望 ${branch}` }, 'Milestone', adj)
  1010 + if (verdict.action !== 'retry')
  1011 + throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${verdict.rationale || `HEAD on ${head.branch}`}`)
  1012 + await runAction(g => checkoutExistingBranchPromptM(branch) + g, {site:`branchSetup-recheckout:${branch}`, grp:'Milestone', label: lbl('recheckout')})
  1013 + head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  1014 + }
  1015 + if (head.branch !== branch) throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${ADJUDICATE_MAX} 轮后 HEAD 仍在 ${head.branch}`)
863 1016
864 log(`branch-setup: ${id} → ${branch}`) 1017 log(`branch-setup: ${id} → ${branch}`)
865 } 1018 }
@@ -874,14 +1027,20 @@ async function runMilestone(module) { @@ -874,14 +1027,20 @@ async function runMilestone(module) {
874 const targetTag = `milestone/${phaseId}` 1027 const targetTag = `milestone/${phaseId}`
875 const lbl = (k) => `milestone:${k}:${phaseId}` 1028 const lbl = (k) => `milestone:${k}:${phaseId}`
876 1029
877 - // step 1: worktree clean precondition 1030 + // step 1: worktree clean precondition(脏树先自主恢复 in-scope 残留;含越界改动则 halt 留人工)
878 const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA}) 1031 const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
879 - if (!wt.clean) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${(wt.dirty || []).join(', ')}`) 1032 + if (!wt.clean) {
  1033 + const rec = await agent(recoverDirtyWorktreePromptM(wt.dirty, branch, `里程碑前置(阶段 ${phaseId},应在功能分支 ${branch})。`),
  1034 + {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
  1035 + if (!rec.success) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`)
  1036 + log(`milestone: ${phaseId} 自动提交脏工作树残留(${rec.detail || ''})`)
  1037 + }
880 1038
881 // step 2: detect default branch 1039 // step 2: detect default branch
882 const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA}) 1040 const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
883 1041
884 // step 3: merge (idempotent — skip if already an ancestor) 1042 // step 3: merge (idempotent — skip if already an ancestor)
  1043 + // merge 冲突保持**硬 halt**:自动 abort/stash/改文件均不安全,把树留给人工(设计原则不变)。
885 const merged = await agent(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA}) 1044 const merged = await agent(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA})
886 if (!merged.alreadyMerged) { 1045 if (!merged.alreadyMerged) {
887 const r = await agent(executeMergePromptM(def.branch, branch, phaseId), {label: lbl('merge'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA}) 1046 const r = await agent(executeMergePromptM(def.branch, branch, phaseId), {label: lbl('merge'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
@@ -889,34 +1048,51 @@ async function runMilestone(module) { @@ -889,34 +1048,51 @@ async function runMilestone(module) {
889 } 1048 }
890 1049
891 // step 4: docs/08 field (idempotent — read first, only write if at initial '—') 1050 // step 4: docs/08 field (idempotent — read first, only write if at initial '—')
892 - const field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})  
893 - if (!field.found) throw new Error(`HALT milestone-docs08-missing ${phaseId}: 字段不存在(docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`})`) 1051 + let field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
  1052 + for (let adj = 1; !field.found && adj <= ADJUDICATE_MAX; adj++) {
  1053 + const verdict = await adjudicate(`milestone-docs08-missing:${phaseId}`,
  1054 + { problem:`docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`} 里程碑字段未找到` }, 'Milestone', adj)
  1055 + if (verdict.action !== 'retry') throw new Error(`HALT milestone-docs08-missing ${phaseId}: ${verdict.rationale || '字段不存在'}`)
  1056 + field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
  1057 + }
  1058 + if (!field.found) throw new Error(`HALT milestone-docs08-missing ${phaseId}: ${ADJUDICATE_MAX} 轮仲裁后仍未找到字段`)
894 if (field.value === '—') { 1059 if (field.value === '—') {
895 - const r = await agent(writeDocs08FieldPromptM(fe, id, targetTag, phaseId, field.lineNumber), {label: lbl('field-write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})  
896 - if (!r.success) throw new Error(`HALT milestone-docs08-write ${phaseId}: ${r.error || ''}`) 1060 + await runAction(g => writeDocs08FieldPromptM(fe, id, targetTag, phaseId, field.lineNumber) + g,
  1061 + {site:`milestone-docs08-write:${phaseId}`, grp:'Milestone', label: lbl('field-write')})
897 } else if (field.value !== targetTag) { 1062 } else if (field.value !== targetTag) {
898 - throw new Error(`HALT milestone-docs08-unexpected ${phaseId}: 字段当前 = ${JSON.stringify(field.value)}(行 ${field.lineNumber || '?'}),期望 '—' 或 '${targetTag}'`) 1063 + const verdict = await adjudicate(`milestone-docs08-unexpected:${phaseId}`,
  1064 + { problem:`docs/08 里程碑字段当前 = ${JSON.stringify(field.value)}(行 ${field.lineNumber || '?'}),期望 '—' 或 '${targetTag}'`, allowContinue:true }, 'Milestone', 1)
  1065 + if (verdict.action === 'halt') throw new Error(`HALT milestone-docs08-unexpected ${phaseId}: ${verdict.rationale || JSON.stringify(field.value)}`)
  1066 + log(`milestone ${phaseId}: docs/08 字段非预期值(${JSON.stringify(field.value)}),仲裁判放行`)
899 } 1067 }
900 // else: 已是 targetTag → 静默跳过(续跑场景) 1068 // else: 已是 targetTag → 静默跳过(续跑场景)
901 1069
902 // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则 1070 // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则
903 // `git checkout milestone/<id>` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug, 1071 // `git checkout milestone/<id>` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug,
904 // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。) 1072 // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。)
905 - const rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})  
906 - if (!rpt.found) throw new Error(`HALT milestone-report-missing ${phaseId}: 没有找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件`) 1073 + let rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
  1074 + for (let adj = 1; !rpt.found && adj <= ADJUDICATE_MAX; adj++) {
  1075 + const verdict = await adjudicate(`milestone-report-missing:${phaseId}`,
  1076 + { problem:`未找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件` }, 'Milestone', adj)
  1077 + if (verdict.action !== 'retry') throw new Error(`HALT milestone-report-missing ${phaseId}: ${verdict.rationale || '报告文件缺失'}`)
  1078 + rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
  1079 + }
  1080 + if (!rpt.found) throw new Error(`HALT milestone-report-missing ${phaseId}: ${ADJUDICATE_MAX} 轮仲裁后仍无报告文件`)
907 if (rpt.currentTagValue === '{{milestone_tag}}') { 1081 if (rpt.currentTagValue === '{{milestone_tag}}') {
908 - const r = await agent(updateReportPromptM(rpt.path, targetTag, phaseId), {label: lbl('report'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})  
909 - if (!r.success) throw new Error(`HALT milestone-report-update ${phaseId}: ${r.error || ''}`) 1082 + await runAction(g => updateReportPromptM(rpt.path, targetTag, phaseId) + g,
  1083 + {site:`milestone-report-update:${phaseId}`, grp:'Milestone', label: lbl('report')})
910 } else if (rpt.currentTagValue !== targetTag) { 1084 } else if (rpt.currentTagValue !== targetTag) {
911 - throw new Error(`HALT milestone-report-unexpected ${phaseId}: ${rpt.path} § ⑫ 当前 = ${JSON.stringify(rpt.currentTagValue)}`) 1085 + const verdict = await adjudicate(`milestone-report-unexpected:${phaseId}`,
  1086 + { problem:`${rpt.path} § ⑫ 当前 = ${JSON.stringify(rpt.currentTagValue)},期望占位符 {{milestone_tag}} 或 ${targetTag}`, allowContinue:true }, 'Milestone', 1)
  1087 + if (verdict.action === 'halt') throw new Error(`HALT milestone-report-unexpected ${phaseId}: ${verdict.rationale || JSON.stringify(rpt.currentTagValue)}`)
  1088 + log(`milestone ${phaseId}: 报告 § ⑫ 非预期值(${JSON.stringify(rpt.currentTagValue)}),仲裁判放行`)
912 } 1089 }
913 // else: 已是 targetTag → 静默跳过(resume 幂等) 1090 // else: 已是 targetTag → 静默跳过(resume 幂等)
914 1091
915 // step 6: annotated tag (idempotent — tag exists 时静默跳过) 1092 // step 6: annotated tag (idempotent — tag exists 时静默跳过)
916 const tag = await agent(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA}) 1093 const tag = await agent(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
917 if (!tag.exists) { 1094 if (!tag.exists) {
918 - const r = await agent(createTagPromptM(phaseId, fe), {label: lbl('tag'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})  
919 - if (!r.success) throw new Error(`HALT milestone-tag ${phaseId}: ${r.error || ''}`) 1095 + await runAction(g => createTagPromptM(phaseId, fe) + g, {site:`milestone-tag:${phaseId}`, grp:'Milestone', label: lbl('tag')})
920 } 1096 }
921 1097
922 log(`milestone: ${phaseId} → ${targetTag}`) 1098 log(`milestone: ${phaseId} → ${targetTag}`)
@@ -942,8 +1118,8 @@ async function runCrossModule(module) { @@ -942,8 +1118,8 @@ async function runCrossModule(module) {
942 return 1118 return
943 } 1119 }
944 1120
945 - const r = await agent(writeCrossModuleLogPromptM(id, classified.crossModule), {label: lbl('write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})  
946 - if (!r.success) throw new Error(`HALT crossModule-write ${id}: ${r.error || ''}`) 1121 + await runAction(g => writeCrossModuleLogPromptM(id, classified.crossModule) + g,
  1122 + {site:`crossModule-write:${id}`, grp:'Milestone', label: lbl('write')})
947 1123
948 log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`) 1124 log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`)
949 } 1125 }
@@ -959,8 +1135,9 @@ async function runCrossModule(module) { @@ -959,8 +1135,9 @@ async function runCrossModule(module) {
959 // verify / tdd 的 HALT throw,让模块主循环 try/catch 捕获不到,残缺模块照样被推进到 milestone。 1135 // verify / tdd 的 HALT throw,让模块主循环 try/catch 捕获不到,残缺模块照样被推进到 milestone。
960 // 顺序 for-await 让 throw 自然冒泡到主循环 try → catch → break,使 fail-fast 真正生效。 1136 // 顺序 for-await 让 throw 自然冒泡到主循环 try → catch → break,使 fail-fast 真正生效。
961 // 1137 //
962 -// 派生 stage 全部 schema 化:spec/plan/tdd/verify/fix 共用 STAGE_RESULT_SCHEMA,  
963 -// sub-agent 写 `{status:'halt', reason}` 时 JS 立即抛 HALT,让"无法继续"不再混入"成功返回"。 1138 +// 派生 stage 全部 schema 化:spec/plan/tdd/verify/fix 共用 STAGE_RESULT_SCHEMA,统一经 runStage 跑:
  1139 +// stage 优先自主决策继续(缺值挑默认/解读并记入 decisions[]);返回 status:halt 或结构校验失败时不再立即
  1140 +// fail-fast,而是经 adjudicate 仲裁 retry/continue/halt(最多 ADJUDICATE_MAX 轮),把"无法继续"收敛为最后手段。
964 // 功能级 dedup 真值 = `req-done/<id>` git tag:featureLoop 入口先 check,存在则 skip(Router 文档/ 1141 // 功能级 dedup 真值 = `req-done/<id>` git tag:featureLoop 入口先 check,存在则 skip(Router 文档/
965 // LLM 自审失误不再导致已 approve 的 REQ 被重新 spec→plan→tdd 污染源码 / 撞 V<n>)。 1142 // LLM 自审失误不再导致已 approve 的 REQ 被重新 spec→plan→tdd 污染源码 / 撞 V<n>)。
966 // 1143 //
@@ -976,113 +1153,188 @@ async function featureLoop(items, phase) { @@ -976,113 +1153,188 @@ async function featureLoop(items, phase) {
976 const done = await agent(checkReqDoneTagPromptM(id), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA}) 1153 const done = await agent(checkReqDoneTagPromptM(id), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA})
977 if (done.exists) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue } 1154 if (done.exists) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue }
978 1155
979 - const spec = await agent(deriveSpecPrompt(id, phase), {label:`spec:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})  
980 - if (spec.status === 'halt') throw new Error(`HALT spec ${phase}:${id}: ${spec.reason || ''}`)  
981 - if (!spec.artifactPath) throw new Error(`HALT spec-no-artifactPath ${phase}:${id}: spec returned ok but no artifactPath`)  
982 - // 日期一致性自校验:spec 文件名首段必须可被解析为 YYYY-MM-DD(dateFromArtifactPath 会抛)。  
983 - dateFromArtifactPath(spec.artifactPath)  
984 -  
985 - const plan = await agent(planPrompt(id, phase, spec.artifactPath), {label:`plan:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})  
986 - if (plan.status === 'halt') throw new Error(`HALT plan ${phase}:${id}: ${plan.reason || ''}`)  
987 - if (!plan.artifactPath) throw new Error(`HALT plan-no-artifactPath ${phase}:${id}`)  
988 - if (dateFromArtifactPath(plan.artifactPath) !== dateFromArtifactPath(spec.artifactPath)) {  
989 - throw new Error(`HALT plan-date-mismatch ${phase}:${id}: plan ${plan.artifactPath} 与 spec ${spec.artifactPath} 日期前缀不一致`)  
990 - }  
991 -  
992 - const impl = await agent(tddPrompt(id, phase, plan.artifactPath), {label:`tdd:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})  
993 - if (impl.status === 'halt') throw new Error(`HALT tdd ${phase}:${id}: ${impl.reason || ''}`)  
994 -  
995 - const v0 = await agent(verifyPrompt(id, phase, impl.summary || '', spec.artifactPath, 0), {label:`verify:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})  
996 - if (v0.status === 'halt') throw new Error(`HALT verify ${phase}:${id}: ${v0.reason || ''}`) 1156 + const spec = await runStage(g => deriveSpecPrompt(id, phase) + g, {
  1157 + site:`spec:${phase}:${id}`, grp, label:`spec:${phase}:${id}`,
  1158 + validate: r => {
  1159 + if (!r.artifactPath) return 'spec 返回 ok 但缺 artifactPath(流程靠它定位 spec 并派生下游日期前缀)'
  1160 + dateFromArtifactPath(r.artifactPath) // 文件名日期前缀非法 → 抛,被 runStage 捕获转为仲裁
  1161 + return null
  1162 + },
  1163 + })
  1164 + // spec 经仲裁 continue 时 artifactPath 仍可能不带合法日期前缀——防御取值,避免重算抛出把 continue 变成隐式 halt。
  1165 + let specDate = null
  1166 + try { specDate = dateFromArtifactPath(spec.artifactPath) } catch { specDate = null }
  1167 +
  1168 + const plan = await runStage(g => planPrompt(id, phase, spec.artifactPath) + g, {
  1169 + site:`plan:${phase}:${id}`, grp, label:`plan:${phase}:${id}`,
  1170 + validate: r => {
  1171 + if (!r.artifactPath) return 'plan 返回 ok 但缺 artifactPath'
  1172 + if (specDate && dateFromArtifactPath(r.artifactPath) !== specDate)
  1173 + return `plan 日期前缀与 spec 不一致:plan=${r.artifactPath} / spec=${spec.artifactPath}`
  1174 + return null
  1175 + },
  1176 + })
  1177 +
  1178 + // tdd allowContinue:false:tddPrompt 的 halt = 路径作用域越界护栏 / 同测试卡死 10 次——硬边界,
  1179 + // 仲裁不得 continue 放行(越界把前端实现混进后端分支 / 卡死等于测试没真过)。
  1180 + const impl = await runStage(g => tddPrompt(id, phase, plan.artifactPath) + g, {
  1181 + site:`tdd:${phase}:${id}`, grp, label:`tdd:${phase}:${id}`, allowContinue: false,
  1182 + })
  1183 +
  1184 + // verify allowContinue:false:verifyPrompt 的 halt = 功能测试红色(exit!=0 / failed>0)——与 test-gate 红同级硬边界,
  1185 + // 绝不 continue 放行红色实现进 review→approve→打 req-done tag(否则红色功能被永久标记完成、resume 跳过)。
  1186 + const v0 = await runStage(g => verifyPrompt(id, phase, impl.summary || '', spec.artifactPath, 0) + g, {
  1187 + site:`verify:${phase}:${id}`, grp, label:`verify:${phase}:${id}`, allowContinue: false,
  1188 + })
997 1189
998 const reviewResult = await reviewWithFixLoop(id, phase, v0, spec.artifactPath) 1190 const reviewResult = await reviewWithFixLoop(id, phase, v0, spec.artifactPath)
999 log(`review approved ${phase}:${id} after ${reviewResult.rounds} round(s)`) 1191 log(`review approved ${phase}:${id} after ${reviewResult.rounds} round(s)`)
1000 1192
1001 - // approve 后落地 dedup 真值:req-done/<id> tag。  
1002 - const tagR = await agent(createReqDoneTagPromptM(id, phase), {label:`reqdone:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})  
1003 - if (!tagR.success) throw new Error(`HALT req-done-tag ${phase}:${id}: ${tagR.error || ''}`) 1193 + // approve 后落地 dedup 真值:req-done/<id> tag(失败经仲裁重试,确不可恢复才 halt)。
  1194 + await runAction(g => createReqDoneTagPromptM(id, phase) + g, {
  1195 + site:`req-done-tag:${phase}:${id}`, grp, label:`reqdone:${phase}:${id}`,
  1196 + })
1004 } 1197 }
1005 } 1198 }
1006 1199
1007 -// 有界 5 轮修复;超出 → throw(终止态,非对话框)。approve 后独立 micro step flip docs/08 checkbox。 1200 +// review→fix 循环。halt 收敛点:
  1201 +// - 软上限 REVIEW_SOFT_ROUNDS 轮起每轮经仲裁决定**再延一轮(retry) 或 收尾(halt)**——禁止 continue(approve 只能来自
  1202 +// reviewer,仲裁不得在仍有未修 must-fix 时凌驾它放行);绝对硬上限 REVIEW_HARD_ROUNDS 防无限循环。
  1203 +// - reviewer 契约小瑕疵不再直接 halt:缺 locator 的 issue 降级为口头建议丢弃;若一条可定位 issue 都不剩(无可执行
  1204 +// must-fix),经仲裁决定 continue(视为无 must-fix → approve)/ retry(带 guidance 重判)/ halt。
  1205 +// - fix 经 runStage(默认仲裁,可 continue 跳过——未修的 must-fix 由后续 reviewer 重新 flag 兜底);
  1206 +// reverify 经 runStage 但 allowContinue:false(复验红色 = 修复没生效,绝不放行)。
  1207 +// - approve 后的 docs/08 checkbox 是纯可视化副作用(req-done tag 才是完成真值),缺失/写失败一律 log 跳过不 halt。
  1208 +const REVIEW_SOFT_ROUNDS = 5
  1209 +const REVIEW_HARD_ROUNDS = 8
  1210 +
  1211 +// flipDocs08Checkbox:approve 后把功能行 [ ]→[x]。纯可视化;任何缺失/异常/写失败都降级为日志,绝不 halt。
  1212 +async function flipDocs08Checkbox(fe, id, phase, grp) {
  1213 + const cb = await agent(readDocs08CheckboxPromptM(fe, id), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA})
  1214 + if (!cb.found) { log(`docs08-checkbox ${phase}:${id}: 未找到功能行,跳过可视化勾选(req-done tag 仍是完成真值)`); return }
  1215 + if (cb.state === 'checked') return
  1216 + if (cb.state !== 'unchecked') { log(`docs08-checkbox ${phase}:${id}: state 异常 (${JSON.stringify(cb.state)}),跳过勾选`); return }
  1217 + const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
  1218 + if (!wr.success) log(`docs08-checkbox ${phase}:${id}: 勾选写入失败(${wr.error || ''}),跳过——cosmetic,不阻断`)
  1219 +}
  1220 +
1008 async function reviewWithFixLoop(id, phase, verifyResult, specPath) { 1221 async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1009 const grp = phase === 'backend' ? 'Backend' : 'Frontend' 1222 const grp = phase === 'backend' ? 'Backend' : 'Frontend'
1010 const fe = isFrontend(phase) 1223 const fe = isFrontend(phase)
1011 let lastVerify = verifyResult 1224 let lastVerify = verifyResult
1012 let lastIssuesCount = 0 1225 let lastIssuesCount = 0
1013 - for (let round = 1; round <= 5; round++) { 1226 + let reviewGuidance = '' // 仲裁 retry 时注入下一轮 review 的纠正指令
  1227 + for (let round = 1; round <= REVIEW_HARD_ROUNDS; round++) {
1014 const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || '' 1228 const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || ''
1015 // opts.phase = grp('Backend'/'Frontend')是 harness UI 分组;domain phase 见 agents/code-reviewer.md。 1229 // opts.phase = grp('Backend'/'Frontend')是 harness UI 分组;domain phase 见 agents/code-reviewer.md。
1016 const r = await agent( 1230 const r = await agent(
1017 - reviewPrompt(id, phase, round, lastVerifySummary, specPath), 1231 + reviewPrompt(id, phase, round, lastVerifySummary, specPath) + adjGuidance(reviewGuidance),
1018 {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'} 1232 {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'}
1019 ) 1233 )
  1234 + reviewGuidance = '' // 已消费
  1235 +
1020 if (r.verdict === 'approve') { 1236 if (r.verdict === 'approve') {
1021 - const cb = await agent(readDocs08CheckboxPromptM(fe, id), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA})  
1022 - if (!cb.found) throw new Error(`HALT docs08-checkbox-missing ${phase}:${id}: docs/08 ${fe?'§ 三':'§ 二'} 中找不到 \`- [ ] ${id} ...\` / \`- [x] ${id} ...\` 行`)  
1023 - if (cb.state !== 'checked' && cb.state !== 'unchecked') {  
1024 - throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`)  
1025 - }  
1026 - if (cb.state === 'unchecked') {  
1027 - const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})  
1028 - if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`)  
1029 - } 1237 + await flipDocs08Checkbox(fe, id, phase, grp)
1030 return { id, phase, approved:true, rounds:round } 1238 return { id, phase, approved:true, rounds:round }
1031 } 1239 }
1032 - // request-changes 必须带 must-fix 清单(结构化对象数组);否则 fix 步无法定位 → 直接 halt 暴露 reviewer 契约违例。  
1033 - if (!Array.isArray(r.issues) || r.issues.length === 0) {  
1034 - throw new Error(`HALT review-empty-issues ${phase}:${id} r${round}: reviewer 返回 request-changes 但 issues 为空,无法驱动 fix 步`)  
1035 - }  
1036 - lastIssuesCount = r.issues.length  
1037 - // 每个 issue 必须含 locator(locator 校验由 fix sub-agent 在 git cat-file 阶段再做一次硬把关)。  
1038 - const missingLocator = r.issues.filter(x => !x || typeof x.locator !== 'string' || !x.locator.trim())  
1039 - if (missingLocator.length) {  
1040 - throw new Error(`HALT review-issue-no-locator ${phase}:${id} r${round}: ${missingLocator.length} 个 issue 缺 locator,reviewer 契约违例(issue summaries: ${missingLocator.map(x=>x?.summary||'').join(' | ')})`) 1240 +
  1241 + // request-changes:保留带 locator 的 must-fix;缺 locator 的降级丢弃(fix 步无从定位)。
  1242 + const issues = Array.isArray(r.issues) ? r.issues.filter(x => x && typeof x.locator === 'string' && x.locator.trim()) : []
  1243 + const dropped = (Array.isArray(r.issues) ? r.issues.length : 0) - issues.length
  1244 + if (dropped > 0) log(`review ${phase}:${id} r${round}: 丢弃 ${dropped} 个缺 locator 的 issue(降级为口头建议)`)
  1245 + if (issues.length === 0) {
  1246 + // 无任何可执行 must-fix(空 issues 或全缺 locator)→ 仲裁,而非直接 halt。
  1247 + const verdict = await adjudicate(`review-no-actionable:${phase}:${id}:r${round}`,
  1248 + { problem:'reviewer 判 request-changes 但无任何带 locator 的可执行 must-fix(无法驱动 fix 步)',
  1249 + reviewerIssues: r.issues || [] }, grp, round)
  1250 + if (verdict.action === 'continue') { await flipDocs08Checkbox(fe, id, phase, grp); return { id, phase, approved:true, rounds:round } }
  1251 + if (verdict.action === 'halt') throw new Error(`HALT review-no-actionable ${phase}:${id} r${round}: ${verdict.rationale || ''}`)
  1252 + reviewGuidance = verdict.guidance || '' // retry:带 guidance 重判(进入下一轮)
  1253 + continue
1041 } 1254 }
  1255 + lastIssuesCount = issues.length
1042 1256
1043 - const fixR = await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA})  
1044 - if (fixR.status === 'halt') throw new Error(`HALT fix ${phase}:${id} r${round}: ${fixR.reason || ''}`) 1257 + await runStage(g => fixPrompt(id, phase, issues) + g, {
  1258 + site:`fix:${phase}:${id}:r${round}`, grp, label:`fix:${phase}:${id}:r${round}`,
  1259 + })
1045 1260
1046 - lastVerify = await agent(  
1047 - verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验,上轮 must-fix: ${r.issues.length} 项)`, specPath, round),  
1048 - {label:`reverify:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA} 1261 + // reverify allowContinue:false:fix 后复验红色 = 修复没真正生效,绝不 continue 放行去 approve。
  1262 + lastVerify = await runStage(
  1263 + g => verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验,上轮 must-fix: ${issues.length} 项)`, specPath, round) + g,
  1264 + { site:`reverify:${phase}:${id}:r${round}`, grp, label:`reverify:${phase}:${id}:r${round}`, allowContinue: false },
1049 ) 1265 )
1050 - if (lastVerify.status === 'halt') throw new Error(`HALT reverify ${phase}:${id} r${round}: ${lastVerify.reason || ''}`) 1266 +
  1267 + if (round >= REVIEW_SOFT_ROUNDS) {
  1268 + // 软上限到顶:仲裁只在"再延一轮(retry) / 收尾(halt)"间选。**禁止 continue 放行 approve**——
  1269 + // 此处仍有 reviewer 认定的可定位 must-fix 未清,仲裁不得凌驾专用 code-reviewer 直接判 approve(approve 只能来自 reviewer 本身)。
  1270 + const verdict = await adjudicate(`review-extend:${phase}:${id}`,
  1271 + { problem:`已 ${round} 轮 review 仍未 approve(上轮 ${lastIssuesCount} 项可定位 must-fix 未清)`,
  1272 + lastVerify: lastVerify.summary || lastVerify.reason || '', allowContinue:false }, grp, round)
  1273 + if (verdict.action !== 'retry') throw new Error(`HALT review-unresolved ${phase}:${id}: ${verdict.rationale || `${round} 轮仍有未修 must-fix`}`)
  1274 + // retry → 继续跑到硬上限(approve 仍由后续轮的 reviewer 决定)
  1275 + }
1051 } 1276 }
1052 - throw new Error(`HALT review-unresolved ${phase}:${id}: 5 轮 review 仍未 approve(最后一次 reverify ${lastVerify?.status || '?'},最后一轮 must-fix ${lastIssuesCount} 项)`) 1277 + throw new Error(`HALT review-unresolved ${phase}:${id}: ${REVIEW_HARD_ROUNDS} 轮 review 仍未 approve(最后一次 reverify ${lastVerify?.status || '?'},最后一轮 must-fix ${lastIssuesCount} 项)`)
1053 } 1278 }
1054 1279
1055 -// flake 重试 1 次:attempt=2 写到独立证据文件 `<id>-test-gate-r2.md`,不覆盖 r1 的 red 证据(report § ⑤ 用得到)。 1280 +// flake 重试:每个 attempt 写独立证据文件 `<id>-test-gate-r<attempt>.md`,不覆盖前一次 red 证据(report § ⑤ 用得到)。
  1281 +// red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。
1056 async function testGate(module, phase) { 1282 async function testGate(module, phase) {
1057 - let g = await agent(gatePrompt(module, phase, 1), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})  
1058 - if (g.status === 'red') { // 自动重试 1 次(防 flaky)  
1059 - g = await agent(gatePrompt(module, phase, 2), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA}) 1283 + let attempt = 1
  1284 + let g = await agent(gatePrompt(module, phase, attempt), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  1285 + if (g.status === 'red') { // 自动重试 1 次(防 flaky)
  1286 + attempt = 2
  1287 + g = await agent(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  1288 + }
  1289 + // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。
  1290 + for (let adj = 1; g.status === 'red' && adj <= ADJUDICATE_MAX; adj++) {
  1291 + const verdict = await adjudicate(`test-gate-red:${phase}:${module.id}`,
  1292 + { problem:`test-gate 第 ${attempt} 次仍 red`, failures: g.failures || [], allowContinue:false }, 'Gate', adj)
  1293 + if (verdict.action !== 'retry')
  1294 + throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`)
  1295 + attempt += 1 // retry:再跑一个独立 attempt 证据文件
  1296 + g = await agent(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA})
1060 } 1297 }
1061 - if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${(g.failures||[]).join('; ')}`) 1298 + if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`)
1062 return g 1299 return g
1063 } 1300 }
1064 1301
1065 phase('Router') 1302 phase('Router')
1066 -const routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA})  
1067 -  
1068 // Router 语义断言(feItems/reqs 互斥)+ id 形状硬约束(防 shell 注入:id 直接拼入 `git ... ${id}`)。 1303 // Router 语义断言(feItems/reqs 互斥)+ id 形状硬约束(防 shell 注入:id 直接拼入 `git ... ${id}`)。
  1304 +// id 形状(assertSafeId)是**安全护栏**——失败立即硬 halt,绝不重试绕过。
  1305 +// reqs/feItems 互斥违例可由仲裁带 guidance 重跑 router 纠正(绝对上限 ADJUDICATE_MAX)。
1069 const ID_PATTERN = /^[A-Za-z0-9_-]+$/ 1306 const ID_PATTERN = /^[A-Za-z0-9_-]+$/
1070 function assertSafeId(kind, value) { 1307 function assertSafeId(kind, value) {
1071 if (typeof value !== 'string' || !ID_PATTERN.test(value)) { 1308 if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
1072 throw new Error(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`) 1309 throw new Error(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`)
1073 } 1310 }
1074 } 1311 }
  1312 +function routerViolation(modules) {
  1313 + for (const m of modules) {
  1314 + const isFE = m.id === 'frontend-phase'
  1315 + if (isFE && Array.isArray(m.reqs) && m.reqs.length)
  1316 + return `frontend-phase 聚合模块的 reqs 必须为空,实测含 ${m.reqs.length} 项 (${m.reqs.join(',')})`
  1317 + if (!isFE && Array.isArray(m.feItems) && m.feItems.length)
  1318 + return `后端模块 ${m.id} 的 feItems 必须为空(前端只在 frontend-phase 聚合),实测含 ${m.feItems.length} 项 (${m.feItems.join(',')})`
  1319 + }
  1320 + return null
  1321 +}
  1322 +
  1323 +let routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA})
  1324 +for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) {
  1325 + const violation = routerViolation(routed.modules)
  1326 + if (!violation) break
  1327 + const verdict = await adjudicate('router-violation', { problem: violation }, 'Router', adj)
  1328 + if (verdict.action !== 'retry') throw new Error(`HALT router-violation: ${verdict.rationale || violation}`)
  1329 + routed = await agent(routerPrompt(ROOT) + adjGuidance(verdict.guidance || ''), {label:`router:r${adj + 1}`, phase:'Router', schema: ROUTER_SCHEMA})
  1330 +}
  1331 +const finalViolation = routerViolation(routed.modules)
  1332 +if (finalViolation) throw new Error(`HALT router-violation: ${ADJUDICATE_MAX} 轮仲裁后仍违例:${finalViolation}`)
  1333 +// id 安全护栏:最终选定的 routed 必须全部通过(assertSafeId 硬 halt)。
1075 for (const m of routed.modules) { 1334 for (const m of routed.modules) {
1076 assertSafeId('module-id', m.id) 1335 assertSafeId('module-id', m.id)
1077 for (const r of m.reqs || []) assertSafeId('req-id', r) 1336 for (const r of m.reqs || []) assertSafeId('req-id', r)
1078 for (const f of m.feItems || []) assertSafeId('fe-id', f) 1337 for (const f of m.feItems || []) assertSafeId('fe-id', f)
1079 - const isFE = m.id === 'frontend-phase'  
1080 - if (isFE && Array.isArray(m.reqs) && m.reqs.length) {  
1081 - throw new Error(`HALT router-violation: frontend-phase 聚合模块的 reqs 必须为空,实测含 ${m.reqs.length} 项 (${m.reqs.join(',')})`)  
1082 - }  
1083 - if (!isFE && Array.isArray(m.feItems) && m.feItems.length) {  
1084 - throw new Error(`HALT router-violation: 后端模块 ${m.id} 的 feItems 必须为空(前端只在 frontend-phase 聚合),实测含 ${m.feItems.length} 项 (${m.feItems.join(',')})`)  
1085 - }  
1086 } 1338 }
1087 1339
1088 const todo = routed.modules.filter(m => !m.done) 1340 const todo = routed.modules.filter(m => !m.done)
@@ -1109,8 +1361,11 @@ for (const [idx, module] of todo.entries()) { @@ -1109,8 +1361,11 @@ for (const [idx, module] of todo.entries()) {
1109 await testGate(module, 'frontend') 1361 await testGate(module, 'frontend')
1110 } 1362 }
1111 phase('Milestone') 1363 phase('Milestone')
1112 - const rep = await agent(reportPrompt(module), {label:`report:${module.id}`, phase:'Milestone', schema: STAGE_RESULT_SCHEMA})  
1113 - if (rep.status === 'halt') throw new Error(`HALT report ${module.id}: ${rep.reason || ''}`) 1364 + // report allowContinue:false:reportPrompt 的前置硬验证含"最后一次 test-gate 必须 green,红则 halt"——
  1365 + // 绝不 continue 放行去打 milestone(否则可能在红色测试上 milestone)。
  1366 + await runStage(g => reportPrompt(module) + g, {
  1367 + site:`report:${module.id}`, grp:'Milestone', label:`report:${module.id}`, allowContinue: false,
  1368 + })
1114 await runMilestone(module) 1369 await runMilestone(module)
1115 results.push({ module: module.id, status:'done' }) 1370 results.push({ module: module.id, status:'done' })
1116 } catch (e) { 1371 } catch (e) {
@@ -1126,9 +1381,12 @@ const pending = haltedAtIdx &gt;= 0 @@ -1126,9 +1381,12 @@ const pending = haltedAtIdx &gt;= 0
1126 ? todo.slice(haltedAtIdx + 1).map(m => ({ module: m.id, status: 'pending' })) 1381 ? todo.slice(haltedAtIdx + 1).map(m => ({ module: m.id, status: 'pending' }))
1127 : [] 1382 : []
1128 1383
1129 -// Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表。 1384 +// Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表 + 全流程自主决策日志
  1385 +// (decisions:stage 缺值时未停而自主取的默认/解读,供 coding-start / 人工事后审阅,可能含错误假设)。
  1386 +// 注:decisions 仅覆盖**本次运行实际新跑**的 stage;resume 时被 req-done/milestone tag 跳过的已完成功能,
  1387 +// 其决策不会重新登记于此——需到对应 docs/superpowers/specs|plans/<date>-<id>.md 产物显著位置查阅。
1130 // 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行, 1388 // 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行,
1131 // 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。 1389 // 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。
1132 // 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`, 1390 // 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`,
1133 // 否则 Workflow 拿不到 results / pending。 1391 // 否则 Workflow 拿不到 results / pending。
1134 -return { results, pending } 1392 +return { results, pending, decisions: autonomousDecisions }