Commit 1b944be85d7ebdfefbe5f02d680f888748063b51
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 >= 0 | @@ -1126,9 +1381,12 @@ const pending = haltedAtIdx >= 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 } |