Commit 29f3a2dee329d4e8672c371d1dc6f0e75cdef168

Authored by zichun
1 parent 095364cd

coding.mjs: halt 可见性——原因一律先 log 再抛 + agent() null 防御 + coding-start 步骤6 强制横幅

任何 halt 的原因必须直达终端,而不是埋在 Workflow 返回值/RESUME.md/TypeError 里:

- haltError():HALT 抛出前先 log(/workflows 进度叙述行可见)。启动期 4 处
  (invalid-projectRoot / assertSafeId / router-violation×2)换用;模块循环 catch 补
  「⛔ HALT — 模块 <id>:<原因>」;loop-end 补最终状态 log(halt 模块/原因/待跑清单)。
- agent() null 防御:断网/机器休眠时子代理返回 null,不再以 TypeError 形态 halt——
  adjudicate null→确定性默认 retry;runStage/runAction null→经仲裁重试(上限
  ADJUDICATE_MAX);其余 32 处直调点换 agentR()(null→带 label 的诊断 halt);
  flipDocs08Checkbox 保持裸 agent + null 容忍,守住其「绝不 halt」契约。
- coding-start 步骤 6:Workflow 通知返回后第一段输出必须是 halt/完成横幅
  (halt 原因全文不许截断或转述 + 已完成/pending 清单 + [ERP-HALT] 终止标记)。
skills/coding/coding-start/SKILL.md
... ... @@ -129,6 +129,39 @@ Workflow({
129 129  
130 130 > `[ERP-HALT]` 标记供 Stop 钩子(`hooks/scripts/auto-continue.sh`)识别:Workflow 一旦转后台,主循环应停下等通知,**不**被自动续跑机制误推去重启 coding-start。逐 REQ 手动推进的会话则无此标记,由 Stop 钩子自动续跑,直到打印 `[ERP-HALT]`(全部完成 / 硬护栏)才停。
131 131  
  132 +### 步骤 6:Workflow 完成/halt 通知返回后——先输出最终状态横幅
  133 +
  134 +Workflow 的 task-notification 返回本会话后,**第一段输出必须是下面的横幅**(之后才允许做任何诊断动作——读 RESUME.md、读 output 文件、查代码都排在横幅之后),保证用户在终端第一眼看到 halt 原因全文:
  135 +
  136 +- 结果含 `status:'halted'` 模块:
  137 +
  138 +```
  139 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  140 + [coding-start] ⛔ Coding Workflow HALT
  141 +
  142 + 模块:<halted.module>
  143 + 原因:<halted.reason 全文,不许截断或转述>
  144 + 已完成:<status done 的模块列表 或 无>
  145 + 未跑(pending):<pending 列表 或 无>
  146 +
  147 + 下一步:按原因修复后重跑 /erp-workflow:coding-start(Router 按 tag 续跑)。
  148 + [ERP-HALT] 已停在硬护栏点。
  149 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  150 +```
  151 +
  152 +- 全部完成:
  153 +
  154 +```
  155 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  156 + [coding-start] ✅ Coding 阶段全部完成
  157 +
  158 + 完成模块:<列表>
  159 + 自主默认决策:<条数> 条(详见 docs/superpowers/RESUME.md 末尾条目,建议复核)
  160 +
  161 + [ERP-HALT] 编码阶段结束。
  162 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  163 +```
  164 +
132 165 本 skill 到此结束,**不调用任何下游 skill**。
133 166  
134 167 ## 参考
... ...
workflows/coding.mjs
... ... @@ -198,7 +198,7 @@ const ARGS = typeof args === &#39;string&#39; ? (() =&gt; { try { return JSON.parse(args) }
198 198 const ROOT = ARGS?.projectRoot || '.'
199 199 // ROOT 必须是绝对路径——相对 '.' 会绑定到子代理隐式 cwd,无保证。
200 200 if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) {
201   - throw new Error(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`)
  201 + throw haltError(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`)
202 202 }
203 203 // 插件根(coding-start 透传):用于调用插件 lib/*.mjs(如 req-ledger 建需求台账基线)。
204 204 // 缺省(旧 coding-start 未传)则相关增强静默跳过——由 /add-req 首跑兜底建基线。
... ... @@ -919,6 +919,7 @@ const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限
919 919 // - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。
920 920 const BEHAVIOR_STAGE_MAX = 3
921 921 const BEHAVIOR_ATTEMPT_MAX = 2
  922 +const NULL_AGENT_PROBLEM = 'agent() 返回 null(子代理终端 API 错误 / 被用户跳过)——典型为断网或机器休眠;可重试'
922 923 const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : ''
923 924  
924 925 // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。
... ... @@ -932,6 +933,21 @@ function recordDecisions(site, decisions) {
932 933 }
933 934 }
934 935  
  936 +// halt 可见性:HALT 在抛出前先 log(出现在 /workflows 进度叙述行与 transcript),
  937 +// 保证终端能直接看到 halt 原因,而不是只埋在 Workflow 返回值 / RESUME.md 里。
  938 +// 顶层 function 声明会提升,:201 等早于本定义的调用点也可用。
  939 +function haltError(reason) {
  940 + log(`⛔ ${reason}`)
  941 + return new Error(reason)
  942 +}
  943 +
  944 +// 直调 agent() 的非 runner 点位统一防御:null(断网/休眠/被跳过)→ 带诊断的 halt,而非下游 TypeError。
  945 +async function agentR(prompt, opts) {
  946 + const r = await agent(prompt, opts)
  947 + if (!r) throw haltError(`HALT agent-null ${opts?.label || '?'}: ${NULL_AGENT_PROBLEM}`)
  948 + return r
  949 +}
  950 +
935 951 function adjudicatePromptM(site, context) {
936 952 const ctx = typeof context === 'string' ? context : JSON.stringify(context, null, 2)
937 953 return [
... ... @@ -960,6 +976,10 @@ function adjudicatePromptM(site, context) {
960 976 async function adjudicate(site, context, grp, round) {
961 977 const verdict = await agent(adjudicatePromptM(site, context),
962 978 {label:`adjudicate:${site}:r${round}`, phase: grp, schema: ADJUDICATE_SCHEMA})
  979 + if (!verdict) { // 仲裁子代理自身遭遇终端 API 错误 → 默认 retry(确定性,防 null 级联)
  980 + log(`adjudicate ${site} r${round}: 仲裁子代理无返回(终端 API 错误/被跳过),默认 retry`)
  981 + return { action: 'retry', guidance: '', rationale: 'adjudicate agent 无返回,默认 retry' }
  982 + }
963 983 log(`adjudicate ${site} r${round}: ${verdict.action}${verdict.rationale ? ' — ' + verdict.rationale : ''}`)
964 984 return verdict
965 985 }
... ... @@ -974,6 +994,12 @@ async function runStage(makePrompt, { site, grp, label, validate, allowContinue
974 994 let guidance = ''
975 995 for (let round = 1; round <= ADJUDICATE_MAX; round++) {
976 996 const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA})
  997 + if (!res) {
  998 + const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round)
  999 + if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`)
  1000 + guidance = verdict.guidance || ''
  1001 + continue
  1002 + }
977 1003 recordDecisions(site, res.decisions)
978 1004 let problem = null
979 1005 if (res.status === 'halt') problem = `stage 返回 status:halt;reason: ${res.reason || '(空)'}`
... ... @@ -993,6 +1019,12 @@ async function runAction(makePrompt, { site, grp, label, allowContinue = false }
993 1019 let guidance = ''
994 1020 for (let round = 1; round <= ADJUDICATE_MAX; round++) {
995 1021 const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: ACTION_RESULT_SCHEMA})
  1022 + if (!r) {
  1023 + const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round)
  1024 + if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`)
  1025 + guidance = verdict.guidance || ''
  1026 + continue
  1027 + }
996 1028 if (r.success) return r
997 1029 const verdict = await adjudicate(site,
998 1030 { problem:`action 失败:${r.error || ''}${r.detail ? '\n' + r.detail : ''}`, allowContinue }, grp, round)
... ... @@ -1489,18 +1521,18 @@ async function runBranchSetup(module) {
1489 1521 const branch = fe ? 'frontend-phase' : `module-${id}`
1490 1522 const lbl = (k) => `branch:${k}:${id}`
1491 1523  
1492   - const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  1524 + const def = await agentR(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
1493 1525  
1494 1526 // 工作树脏:先自主恢复(in-scope 残留 → 自动 commit);含越界改动则恢复失败 → halt(留给人工)。
1495   - const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  1527 + const wt = await agentR(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
1496 1528 if (!wt.clean) {
1497   - const rec = await agent(recoverDirtyWorktreePromptM(wt.dirty, branch, `分支 setup 前置(目标分支 ${branch})。`),
  1529 + const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `分支 setup 前置(目标分支 ${branch})。`),
1498 1530 {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
1499 1531 if (!rec.success) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`)
1500 1532 log(`branch-setup: ${id} 自动提交脏工作树残留(${rec.detail || ''})`)
1501 1533 }
1502 1534  
1503   - const exists = await agent(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
  1535 + const exists = await agentR(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
1504 1536 if (exists.exists) {
1505 1537 await runAction(g => checkoutExistingBranchPromptM(branch) + g, {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('checkout')})
1506 1538 } else {
... ... @@ -1508,14 +1540,14 @@ async function runBranchSetup(module) {
1508 1540 }
1509 1541  
1510 1542 // HEAD 确认:不符则经仲裁重切(retry)或留人工(halt)。
1511   - let head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  1543 + let head = await agentR(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
1512 1544 for (let adj = 1; head.branch !== branch && adj <= ADJUDICATE_MAX; adj++) {
1513 1545 const verdict = await adjudicate(`branchSetup-branch-mismatch:${branch}`,
1514 1546 { problem:`分支 setup 后 HEAD 在 ${head.branch},期望 ${branch}` }, 'Milestone', adj)
1515 1547 if (verdict.action !== 'retry')
1516 1548 throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${verdict.rationale || `HEAD on ${head.branch}`}`)
1517 1549 await runAction(g => checkoutExistingBranchPromptM(branch) + g, {site:`branchSetup-recheckout:${branch}`, grp:'Milestone', label: lbl('recheckout')})
1518   - head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  1550 + head = await agentR(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
1519 1551 }
1520 1552 if (head.branch !== branch) throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${ADJUDICATE_MAX} 轮后 HEAD 仍在 ${head.branch}`)
1521 1553  
... ... @@ -1533,32 +1565,32 @@ async function runMilestone(module) {
1533 1565 const lbl = (k) => `milestone:${k}:${phaseId}`
1534 1566  
1535 1567 // step 1: worktree clean precondition(脏树先自主恢复 in-scope 残留;含越界改动则 halt 留人工)
1536   - const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  1568 + const wt = await agentR(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
1537 1569 if (!wt.clean) {
1538   - const rec = await agent(recoverDirtyWorktreePromptM(wt.dirty, branch, `里程碑前置(阶段 ${phaseId},应在功能分支 ${branch})。`),
  1570 + const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `里程碑前置(阶段 ${phaseId},应在功能分支 ${branch})。`),
1539 1571 {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
1540 1572 if (!rec.success) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`)
1541 1573 log(`milestone: ${phaseId} 自动提交脏工作树残留(${rec.detail || ''})`)
1542 1574 }
1543 1575  
1544 1576 // step 2: detect default branch
1545   - const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  1577 + const def = await agentR(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
1546 1578  
1547 1579 // step 3: merge (idempotent — skip if already an ancestor)
1548 1580 // merge 冲突保持**硬 halt**:自动 abort/stash/改文件均不安全,把树留给人工(设计原则不变)。
1549   - const merged = await agent(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA})
  1581 + const merged = await agentR(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA})
1550 1582 if (!merged.alreadyMerged) {
1551   - const r = await agent(executeMergePromptM(def.branch, branch, phaseId), {label: lbl('merge'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
  1583 + const r = await agentR(executeMergePromptM(def.branch, branch, phaseId), {label: lbl('merge'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
1552 1584 if (!r.success) throw new Error(`HALT milestone-merge ${phaseId}: ${r.error || ''}${r.detail ? '\n' + r.detail : ''}`)
1553 1585 }
1554 1586  
1555 1587 // step 4: docs/08 field (idempotent — read first, only write if at initial '—')
1556   - let field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
  1588 + let field = await agentR(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
1557 1589 for (let adj = 1; !field.found && adj <= ADJUDICATE_MAX; adj++) {
1558 1590 const verdict = await adjudicate(`milestone-docs08-missing:${phaseId}`,
1559 1591 { problem:`docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`} 里程碑字段未找到` }, 'Milestone', adj)
1560 1592 if (verdict.action !== 'retry') throw new Error(`HALT milestone-docs08-missing ${phaseId}: ${verdict.rationale || '字段不存在'}`)
1561   - field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
  1593 + field = await agentR(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
1562 1594 }
1563 1595 if (!field.found) throw new Error(`HALT milestone-docs08-missing ${phaseId}: ${ADJUDICATE_MAX} 轮仲裁后仍未找到字段`)
1564 1596 if (field.value === '—') {
... ... @@ -1575,12 +1607,12 @@ async function runMilestone(module) {
1575 1607 // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则
1576 1608 // `git checkout milestone/<id>` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug,
1577 1609 // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。)
1578   - let rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
  1610 + let rpt = await agentR(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
1579 1611 for (let adj = 1; !rpt.found && adj <= ADJUDICATE_MAX; adj++) {
1580 1612 const verdict = await adjudicate(`milestone-report-missing:${phaseId}`,
1581 1613 { problem:`未找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件` }, 'Milestone', adj)
1582 1614 if (verdict.action !== 'retry') throw new Error(`HALT milestone-report-missing ${phaseId}: ${verdict.rationale || '报告文件缺失'}`)
1583   - rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
  1615 + rpt = await agentR(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
1584 1616 }
1585 1617 if (!rpt.found) throw new Error(`HALT milestone-report-missing ${phaseId}: ${ADJUDICATE_MAX} 轮仲裁后仍无报告文件`)
1586 1618 if (rpt.currentTagValue === '{{milestone_tag}}') {
... ... @@ -1595,7 +1627,7 @@ async function runMilestone(module) {
1595 1627 // else: 已是 targetTag → 静默跳过(resume 幂等)
1596 1628  
1597 1629 // step 6: annotated tag (idempotent — tag exists 时静默跳过)
1598   - const tag = await agent(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
  1630 + const tag = await agentR(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
1599 1631 if (!tag.exists) {
1600 1632 await runAction(g => createTagPromptM(phaseId, fe) + g, {site:`milestone-tag:${phaseId}`, grp:'Milestone', label: lbl('tag')})
1601 1633 }
... ... @@ -1609,15 +1641,15 @@ async function runCrossModule(module) {
1609 1641 const id = module?.id ?? '<module>'
1610 1642 const lbl = (k) => `xmod:${k}:${id}`
1611 1643  
1612   - const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  1644 + const def = await agentR(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
1613 1645  
1614   - const changed = await agent(collectCrossModuleChangedPromptM(def.branch), {label: lbl('diff'), phase: 'Milestone', schema: CHANGED_FILES_SCHEMA})
  1646 + const changed = await agentR(collectCrossModuleChangedPromptM(def.branch), {label: lbl('diff'), phase: 'Milestone', schema: CHANGED_FILES_SCHEMA})
1615 1647 if (!changed.files.length) {
1616 1648 log(`cross-module-log: 模块 ${id} 无文件改动,跳过`)
1617 1649 return
1618 1650 }
1619 1651  
1620   - const classified = await agent(classifyCrossModulePromptM(id, changed.files), {label: lbl('classify'), phase: 'Milestone', schema: CROSS_CLASSIFY_SCHEMA})
  1652 + const classified = await agentR(classifyCrossModulePromptM(id, changed.files), {label: lbl('classify'), phase: 'Milestone', schema: CROSS_CLASSIFY_SCHEMA})
1621 1653 if (!classified.crossModule.length) {
1622 1654 log(`cross-module-log: 模块 ${id} 无跨模块改动,跳过`)
1623 1655 return
... ... @@ -1636,7 +1668,7 @@ async function runFrontendSkeleton(feItems) {
1636 1668 const lbl = (k) => `fe-skeleton:${k}`
1637 1669  
1638 1670 // step 1: 检查 router 是否已声明全 FE 路由;已建则只确保补记 tag 存在。
1639   - const state = await agent(frontendSkeletonStatePromptM(feItems),
  1671 + const state = await agentR(frontendSkeletonStatePromptM(feItems),
1640 1672 {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA})
1641 1673 if (state.exists) {
1642 1674 log('fe-skeleton: router 已声明全部 FE 路由,确保 fe-skeleton-done tag 存在')
... ... @@ -1682,7 +1714,7 @@ async function featureLoop(items, phase) {
1682 1714 const grp = phase === 'backend' ? 'Backend' : 'Frontend'
1683 1715 for (const id of items) {
1684 1716 // 入口 dedup:req-done/<id> 已存在 → 已 approve,整段 skip。
1685   - const done = await agent(checkReqDoneTagPromptM(id), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA})
  1717 + const done = await agentR(checkReqDoneTagPromptM(id), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA})
1686 1718 if (done.exists) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue }
1687 1719  
1688 1720 const spec = await runStage(g => deriveSpecPrompt(id, phase) + g, {
... ... @@ -1743,11 +1775,12 @@ const REVIEW_HARD_ROUNDS = 10
1743 1775 // flipDocs08Checkbox:approve 后把功能行 [ ]→[x]。纯可视化;任何缺失/异常/写失败都降级为日志,绝不 halt。
1744 1776 async function flipDocs08Checkbox(fe, id, phase, grp) {
1745 1777 const cb = await agent(readDocs08CheckboxPromptM(fe, id), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA})
  1778 + if (!cb) { log(`docs08-checkbox ${id}: 读取子代理无返回(不阻断)`); return }
1746 1779 if (!cb.found) { log(`docs08-checkbox ${phase}:${id}: 未找到功能行,跳过可视化勾选(req-done tag 仍是完成真值)`); return }
1747 1780 if (cb.state === 'checked') return
1748 1781 if (cb.state !== 'unchecked') { log(`docs08-checkbox ${phase}:${id}: state 异常 (${JSON.stringify(cb.state)}),跳过勾选`); return }
1749 1782 const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
1750   - if (!wr.success) log(`docs08-checkbox ${phase}:${id}: 勾选写入失败(${wr.error || ''}),跳过——cosmetic,不阻断`)
  1783 + if (!wr || !wr.success) log(`docs08-checkbox ${phase}:${id}: 勾选写入失败(${(wr && wr.error) || ''}),跳过——cosmetic,不阻断`)
1751 1784 }
1752 1785  
1753 1786 async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
... ... @@ -1759,7 +1792,7 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1759 1792 for (let round = 1; round <= REVIEW_HARD_ROUNDS; round++) {
1760 1793 const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || ''
1761 1794 // opts.phase = grp('Backend'/'Frontend')是 harness UI 分组;domain phase 见 agents/code-reviewer.md。
1762   - const r = await agent(
  1795 + const r = await agentR(
1763 1796 reviewPrompt(id, phase, round, lastVerifySummary, specPath) + adjGuidance(reviewGuidance),
1764 1797 {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'erp-workflow:code-reviewer'}
1765 1798 )
... ... @@ -1819,10 +1852,10 @@ async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
1819 1852 // red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。
1820 1853 async function testGate(module, phase) {
1821 1854 let attempt = 1
1822   - let g = await agent(gatePrompt(module, phase, attempt), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  1855 + let g = await agentR(gatePrompt(module, phase, attempt), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
1823 1856 if (g.status === 'red') { // 自动重试 1 次(防 flaky)
1824 1857 attempt = 2
1825   - g = await agent(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  1858 + g = await agentR(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
1826 1859 }
1827 1860 // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。
1828 1861 for (let adj = 1; g.status === 'red' && adj <= ADJUDICATE_MAX; adj++) {
... ... @@ -1831,7 +1864,7 @@ async function testGate(module, phase) {
1831 1864 if (verdict.action !== 'retry')
1832 1865 throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`)
1833 1866 attempt += 1 // retry:再跑一个独立 attempt 证据文件
1834   - g = await agent(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA})
  1867 + g = await agentR(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA})
1835 1868 }
1836 1869 if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`)
1837 1870 return g
... ... @@ -1865,7 +1898,7 @@ const isBuildFailed = (r) =&gt; !!(r.envError &amp;&amp; r.envError.kind === &#39;build-failed&#39;
1865 1898 async function runBehaviorGateOnce(feItems, behaviorRound) {
1866 1899 const lbl = (a) => `behavior:frontend-phase:r${behaviorRound}:a${a}`
1867 1900 let attempt = 1
1868   - let bg = await agent(behaviorGatePrompt(feItems, behaviorRound, attempt),
  1901 + let bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt),
1869 1902 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
1870 1903 recordDecisions('behavior:frontend-phase', bg.decisions)
1871 1904  
... ... @@ -1873,7 +1906,7 @@ async function runBehaviorGateOnce(feItems, behaviorRound) {
1873 1906 // build-failed 是确定性失败(重起不自愈)→ 跳过自动重起,直接进下方仲裁循环。
1874 1907 while (behaviorEnvBlocked(bg).blocked && !isBuildFailed(bg) && attempt < BEHAVIOR_ATTEMPT_MAX) {
1875 1908 attempt += 1
1876   - bg = await agent(behaviorGatePrompt(feItems, behaviorRound, attempt),
  1909 + bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt),
1877 1910 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
1878 1911 recordDecisions('behavior:frontend-phase', bg.decisions)
1879 1912 }
... ... @@ -1891,7 +1924,7 @@ async function runBehaviorGateOnce(feItems, behaviorRound) {
1891 1924 allowContinue:false }, 'Behavior', adj)
1892 1925 if (verdict.action !== 'retry') throw new Error(`HALT behavior-env frontend-phase: ${verdict.rationale || reason}`)
1893 1926 attempt += 1
1894   - bg = await agent(behaviorGatePrompt(feItems, behaviorRound, attempt),
  1927 + bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt),
1895 1928 {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
1896 1929 recordDecisions('behavior:frontend-phase', bg.decisions)
1897 1930 envState = behaviorEnvBlocked(bg)
... ... @@ -2045,7 +2078,7 @@ phase(&#39;Router&#39;)
2045 2078 const ID_PATTERN = /^[A-Za-z0-9_-]+$/
2046 2079 function assertSafeId(kind, value) {
2047 2080 if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
2048   - throw new Error(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`)
  2081 + throw haltError(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`)
2049 2082 }
2050 2083 }
2051 2084 function routerViolation(modules) {
... ... @@ -2059,16 +2092,16 @@ function routerViolation(modules) {
2059 2092 return null
2060 2093 }
2061 2094  
2062   -let routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA})
  2095 +let routed = await agentR(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA})
2063 2096 for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) {
2064 2097 const violation = routerViolation(routed.modules)
2065 2098 if (!violation) break
2066 2099 const verdict = await adjudicate('router-violation', { problem: violation }, 'Router', adj)
2067   - if (verdict.action !== 'retry') throw new Error(`HALT router-violation: ${verdict.rationale || violation}`)
2068   - routed = await agent(routerPrompt(ROOT) + adjGuidance(verdict.guidance || ''), {label:`router:r${adj + 1}`, phase:'Router', schema: ROUTER_SCHEMA})
  2100 + if (verdict.action !== 'retry') throw haltError(`HALT router-violation: ${verdict.rationale || violation}`)
  2101 + routed = await agentR(routerPrompt(ROOT) + adjGuidance(verdict.guidance || ''), {label:`router:r${adj + 1}`, phase:'Router', schema: ROUTER_SCHEMA})
2069 2102 }
2070 2103 const finalViolation = routerViolation(routed.modules)
2071   -if (finalViolation) throw new Error(`HALT router-violation: ${ADJUDICATE_MAX} 轮仲裁后仍违例:${finalViolation}`)
  2104 +if (finalViolation) throw haltError(`HALT router-violation: ${ADJUDICATE_MAX} 轮仲裁后仍违例:${finalViolation}`)
2072 2105 // id 安全护栏:最终选定的 routed 必须全部通过(assertSafeId 硬 halt)。
2073 2106 for (const m of routed.modules) {
2074 2107 assertSafeId('module-id', m.id)
... ... @@ -2142,7 +2175,9 @@ for (const [idx, module] of todo.entries()) {
2142 2175 ].join('\n'))
2143 2176 }
2144 2177 } catch (e) {
2145   - results.push({ module: module.id, status:'halted', reason: String(e.message||e) })
  2178 + const reason = String(e.message || e)
  2179 + log(`⛔ HALT — 模块 ${module.id}:${reason}`)
  2180 + results.push({ module: module.id, status:'halted', reason })
2146 2181 haltedAtIdx = idx
2147 2182 break // 整阶段 fail-fast:halt 后停,等人工修复后重跑 coding-start
2148 2183 }
... ... @@ -2187,6 +2222,9 @@ if (halted) {
2187 2222 ].join('\n'))
2188 2223 }
2189 2224  
  2225 +if (halted) log(`⛔ 本次运行 halt — 模块 ${halted.module};原因:${halted.reason || '(空)'};待跑:${pending.map(p => p.module).join('、') || '无'}`)
  2226 +else if (results.length) log(`✅ 本次运行完成 ${results.length} 个模块,无 halt`)
  2227 +
2190 2228 // 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行,
2191 2229 // 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。
2192 2230 // 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`,
... ...