coding.mjs 82.6 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242
// workflows/coding.mjs
//
// 整个 ERP Coding(B 阶段)= 一个静默、全自动的 Workflow 脚本。
//
// 设计原则(详见仓库根 README.md 「阶段 B」 节 与 「设计原则」 节):
//  - 所有 stage 都是 agent() 子代理,物理上无法 AskUserQuestion → 编码期结构性静默。
//  - 缺值不再问人:派生 stage 把具体阻塞点写进产物并 throw(fail-fast,合法 halt → 终止态,非对话框)。
//  - 后端 / 前端功能循环由同一份 featureLoop(items, phase) 驱动;phase 切换 reviewer checklist、
//    测试命令、路径作用域(backend/ vs frontend/)、id 格式(REQ-XXX-NNN vs FE-NN)。
//  - **featureLoop 采用顺序 for-await**(不是 pipeline)。两条理由:
//      (1) tdd/fix stage 会在共享工作树 + 同一功能分支上 git commit / 编辑源码;并发会争 .git/index.lock
//          并撞 migration 版本号;
//      (2) pipeline 的语义是"stage 抛异常 → 该 item 掉 null、pipeline 永不 reject",会把
//          reviewWithFixLoop / verify / tdd 的 HALT throw 静默吞掉,使 fail-fast 在功能链层级失效,
//          残缺模块照样会被 testGate/report/milestone 推进。顺序 for-await 让 throw 自然冒泡到
//          模块主循环的 try/catch,被捕获后整阶段 fail-fast break。
//  - 状态账本 = docs/08 §二/§三 + git tag;halt 后重跑 coding-start,router 从账本+tag 重算进度。
//  - reviewer 统一为 agents/code-reviewer.md,review stage 用 agentType:'code-reviewer'。
//
// 运行时约束:Workflow 运行时禁用非确定性内建(取当天日期 / 随机数的 API)。本脚本不调用它们;
// 凡需要"当天日期"的产物路径(<YYYY-MM-DD>-<id>.md),一律由子代理在其自身上下文中解析并落盘,
// 脚本只负责编排,不计算日期 / 随机数。

export const meta = {
  name: 'erp-coding',
  description: 'Run the entire ERP coding phase autonomously and silently: per-module backend+frontend feature loops, test gate, milestone tag.',
  phases: [
    { title: 'Router' }, { title: 'Backend' }, { title: 'Frontend' },
    { title: 'Gate' }, { title: 'Milestone' },
  ],
}

const ROUTER_SCHEMA = { type:'object', additionalProperties:false,
  required:['modules'], properties:{ modules:{ type:'array', items:{
    type:'object', additionalProperties:false,
    required:['id','done','reqs','feItems'],
    properties:{ id:{type:'string'}, done:{type:'boolean'},
      reqs:{type:'array',items:{type:'string'}},
      feItems:{type:'array',items:{type:'string'}} } } } } }

// REVIEW_SCHEMA:reviewer 返回的裁决。issues 改为结构化对象,避免"模糊一行 must-fix"
// 让 fix stage 无从下手就空转 5 轮(见 reviewWithFixLoop 的 must-fix 闸门)。
//  - summary:人类可读的问题摘要(一句)。
//  - locator:必须能让 fix stage 定位到文件(含 `<repo-relative-path>` 或 `<path>:<line>`),
//             否则在 reviewWithFixLoop 里直接判违约 HALT。
//  - severity:blocker/high/medium/low,方便后续把 low/medium 降级为 suggestion 而不卡循环。
const REVIEW_SCHEMA = { type:'object', additionalProperties:false,
  required:['verdict','round','issues'], properties:{
    verdict:{type:'string',enum:['approve','request-changes']},
    round:{type:'integer'},
    issues:{ type:'array', items:{
      type:'object', additionalProperties:false,
      required:['summary','locator','severity'],
      properties:{
        summary:{type:'string'},
        locator:{type:'string'},
        severity:{type:'string', enum:['blocker','high','medium','low']} } } } } }

// STAGE_RESULT_SCHEMA:派生 stage(spec/plan/tdd/verify/fix/report)的统一结构化返回。
//  - status=ok:本步骤产出可用,artifactPath 必填(spec/plan/verify/report 的落盘文件),
//    summary 可放给下游用作 prompt 上下文。tdd/fix 没有单一 artifact,artifactPath 可省。
//  - status=halt:sub-agent 已经决定无法继续(缺值 / 越界 / 重试到顶),把阻塞点写进 reason,
//    JS 端读到立即 throw `HALT …`,让 fail-fast 顺序 for-await 冒泡到模块主循环 try。
//  - 无 schema 时,sub-agent 可以"写一段散文说我跑不下去了,但仍然算成功返回"——这是真正的
//    fail-fast 漏洞;加 schema 后所有派生 stage 都有显式 halt 通道。
const STAGE_RESULT_SCHEMA = { type:'object', additionalProperties:false,
  required:['status'], properties:{
    status:{type:'string', enum:['ok','halt']},
    reason:{type:'string'},
    artifactPath:{type:'string'},
    summary:{type:'string'} } }

const GATE_SCHEMA = { type:'object', additionalProperties:false,
  required:['status'], properties:{ status:{type:'string',enum:['green','red']},
    failures:{type:'array',items:{type:'string'}} } }

// ── 微步骤 schemas(runBranchSetup / runMilestone / runCrossModule 用)─────────
// 这三个阶段是纯机械的 git/文件操作 + 条件跳过;与其让子代理读"1. 2. 3. 若 X 则跳过"的散文
// 流程,不如把"observe → JS branch → execute"切成多个 agent 微步骤,每步带强 schema 返回。
// 这样:(a) 跳过/分支条件由 JS 判定(不再依赖 LLM 读散文条件),idempotency 一致;
//      (b) 每步语义单一、prompt 短,schema 校验阻断畸形返回;
//      (c) action 步统一返回 ACTION_RESULT_SCHEMA(success/error),失败可由 JS 抛错 halt。
const WT_SCHEMA = { type:'object', additionalProperties:false,
  required:['clean'], properties:{
    clean:{type:'boolean'},
    dirty:{type:'array', items:{type:'string'}} } }

const DEFAULT_BRANCH_SCHEMA = { type:'object', additionalProperties:false,
  required:['branch'], properties:{ branch:{type:'string'} } }

const EXISTS_SCHEMA = { type:'object', additionalProperties:false,
  required:['exists'], properties:{ exists:{type:'boolean'} } }

const CURRENT_BRANCH_SCHEMA = { type:'object', additionalProperties:false,
  required:['branch'], properties:{ branch:{type:'string'} } }

const FIELD_VALUE_SCHEMA = { type:'object', additionalProperties:false,
  required:['found','value'], properties:{
    found:{type:'boolean'},
    value:{type:'string'},
    lineNumber:{type:'integer'} } }

// CHECKBOX_STATE_SCHEMA:docs/08 中 `- [ ] REQ-XXX-NNN ...` / `- [x] FE-NN ...` 这类功能行
// 的勾选态。把"审阅 approve 后 flip checkbox"从 reviewPrompt 的隐式 side-effect 改为可观测
// 的 read-then-write micro step(参见 reviewWithFixLoop 的 approve 分支)。
// 注意:state 必填——schema 只 require found 时,sub-agent 返回 {found:true} 而漏掉 state 仍合法,
// 上层 if (cb.state === 'unchecked') 会静默落入"已 checked"分支,docs/08 与 review 裁决悄悄背离。
const CHECKBOX_STATE_SCHEMA = { type:'object', additionalProperties:false,
  required:['found','state'], properties:{
    found:{type:'boolean'},
    state:{type:'string', enum:['checked','unchecked']},
    lineNumber:{type:'integer'} } }

// TAG_REPORT_FRESHNESS_SCHEMA:当 milestone tag 已存在(resume 或前一轮残留)时,校验
// tag 指向的 commit 是否包含已落地的 § ⑫ 值。旧版 bug:tag → § ⑫ commit 的错序会让 tag
// 指向 \`{{milestone_tag}}\` 占位符 commit;新顺序(report → tag)下不会再产生,但保留
// freshness 自检以发现历史残留。
const TAG_REPORT_FRESHNESS_SCHEMA = { type:'object', additionalProperties:false,
  required:['fresh'], properties:{
    fresh:{type:'boolean'},
    tagReportValue:{type:'string'} } }

const ALREADY_MERGED_SCHEMA = { type:'object', additionalProperties:false,
  required:['alreadyMerged'], properties:{ alreadyMerged:{type:'boolean'} } }

const REPORT_PATH_SCHEMA = { type:'object', additionalProperties:false,
  required:['found'], properties:{
    found:{type:'boolean'},
    path:{type:'string'},
    currentTagValue:{type:'string'} } }

const CHANGED_FILES_SCHEMA = { type:'object', additionalProperties:false,
  required:['files'], properties:{
    files:{type:'array', items:{type:'object', additionalProperties:false,
      required:['status','path'],
      properties:{ status:{type:'string'}, path:{type:'string'} } } } } }

const CROSS_CLASSIFY_SCHEMA = { type:'object', additionalProperties:false,
  required:['crossModule'], properties:{
    crossModule:{type:'array', items:{type:'object', additionalProperties:false,
      required:['file','targetModule','reason','impact'],
      properties:{ file:{type:'string'}, targetModule:{type:'string'},
        reason:{type:'string'}, impact:{type:'string'} } } } } }

// 所有 action 步骤(写文件 / git 改写仓库状态)统一返回 success/error;JS 据此抛错 halt。
const ACTION_RESULT_SCHEMA = { type:'object', additionalProperties:false,
  required:['success'], properties:{
    success:{type:'boolean'},
    error:{type:'string'},
    detail:{type:'string'} } }

const ROOT = args?.projectRoot || '.'
// 子代理在 ${ROOT} 路径上跑 git -C / Read / Edit。相对路径 '.' 会绑定到子代理隐式 cwd,无保证。
// 必须由 coding-start 显式传绝对路径;否则 fail-fast 让人工修复入口而不是在错路径上静默打 tag。
if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) {
  throw new Error(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`)
}

// ============================================================================
// Stage prompt builders(纯字符串构造;只用 ROOT / id / phase / 入参,不触非确定性内建)
//
// 每个 prompt 的共同契约(见 featureStageContract):
//  - 子代理是非交互的,物理上无法弹窗;缺任何值都不要"问人"——把具体阻塞点写进产物并失败。
//  - phase=backend 与 phase=frontend 的差异(路径作用域 / id 形态 / 测试命令来源)逐条写明。
//  - 所有输出文档用中文。
// ============================================================================

function isFrontend(phase) { return phase === 'frontend' }

// 从 spec/plan 等 artifactPath 文件名提取 `YYYY-MM-DD` 前缀,下游所有日期相关产物(plan / verify /
// review report)一律复用同一日期,避免长跑或次日 resume 时各 sub-agent 各自解析"今天"导致路径分叉。
// 纯字符串运算,不触发非确定性内建(Workflow runtime 仅禁用 time/random builtin)。
function dateFromArtifactPath(artifactPath) {
  const fname = (artifactPath || '').split('/').pop() || ''
  const m = fname.match(/^(\d{4}-\d{2}-\d{2})-/)
  if (!m) throw new Error(`HALT invalid-artifactPath: 文件名缺少 YYYY-MM-DD 前缀 (${JSON.stringify(artifactPath)})`)
  // 进一步排查 pattern 合法但语义无效的日期(如 9999-99-99-foo.md):
  // 正则只判位数;下面校验年/月/日落在真实日历范围内,防止下游 plan/verify 以无意义日期级联生成产物。
  const [, yStr, moStr, dStr] = m[0].match(/^(\d{4})-(\d{2})-(\d{2})-/) || []
  const y = Number(yStr), mo = Number(moStr), d = Number(dStr)
  if (!(y >= 2024 && y <= 2099) || !(mo >= 1 && mo <= 12) || !(d >= 1 && d <= 31)) {
    throw new Error(`HALT invalid-date-prefix: 文件名日期前缀语义无效 (${JSON.stringify(artifactPath)}),年须在 2024-2099、月 1-12、日 1-31`)
  }
  return m[1]
}

// 所有子代理共享的"非交互静默"硬约束。
function featureStageContract(phase) {
  const fe = isFrontend(phase)
  return [
    '## 硬约束(非交互子代理)',
    '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
    '- 需要具体值时,按此顺序自行获取:(1) 先查 `.env.local`、`docs/07-环境配置.md`、`docs/04-技术规范.md`、`docs/05-API接口契约.md`、`docs/06-UI交互规范.md`、`CLAUDE.md`、现有代码;(2) 仍查不到 → **不要编造、不要留 `【人工填写:】` / `TBD` / `TODO` 占位**,而是把**具体的阻塞点**(缺哪个值、应在哪个 Plan 期闸门锁定、为何无法继续)写进产物,然后让本步骤**失败**(以非零结果 / 显式 throw 结束),由上层 Workflow 转为带诊断的 halt。',
    '- 全部输出文档**使用中文**。',
    `- **阶段 = ${fe ? '前端(frontend)' : '后端(backend)'}**。路径作用域:${fe
      ? '实现文件必须落在 `frontend/`(或 `docs/09-项目目录结构.md § 前端目录结构` 声明的前端根)下;命中 `backend/` / `sql/` / `scripts/` 即越界,硬停。'
      : '产出范围限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约;**禁止**写 `frontend/` 路径下的实现(UI 推迟到前端阶段)。'}`,
    `- id 形态:${fe ? '前端为 `FE-NN`(业务功能粒度,可关联多个 prototype 区域与多个 REQ)。' : '后端为 `REQ-XXX-NNN`。'}`,
  ].join('\n')
}

// Router:读 docs/08 §二/§三 + git tag,重算进度,返回 ROUTER_SCHEMA。
function routerPrompt(root) {
  return [
    '# Coding Router — 从账本重算进度',
    '',
    `项目根:\`${root}\``,
    '',
    '你是 Coding 阶段的路由子代理。**只读不写**(不改任何代码 / 文档),仅从状态账本重算"哪些模块还要跑",返回结构化结果。',
    '',
    '## 读取来源(账本 = docs/08 + git tag,二者一致才算完成)',
    '1. `docs/08-模块任务管理.md § 二`(后端模块元数据):逐个模块取 `id`(英文蛇形 module id)、本模块的 REQ 列表(按 `docs/02-开发计划.md § 二 开发顺序清单` 的顺序,A5 约束保证同模块 REQ 连续),以及该模块的 `里程碑:` 字段。',
    '2. `docs/08-模块任务管理.md § 三`(前端阶段元数据):取 `整体里程碑:` 字段,以及 `功能:` 项下所有 `- [ ] FE-NN ...` / `- [x] FE-NN ...` 行(FE 清单)。前端 item 形如 `FE-NN`。',
    '3. `git -C <root> tag -l "milestone/*"`:列出已打的里程碑 tag。',
    '',
    '## 完成判定(每个模块独立)',
    '- 后端模块 `done = true` 当且仅当:§二 该模块 `里程碑:` 字段 == `milestone/<module_id>` **且** `git tag -l "milestone/<module_id>"` 能查到该 tag。任一缺失 → `done = false`。',
    '- 前端 item(FE-NN)归属一个"逻辑前端模块"。前端阶段整体 `done` 当且仅当 §三 `整体里程碑:` == `milestone/frontend-phase` 且 `git tag -l "milestone/frontend-phase"` 存在。',
    '',
    '## 输出(必须符合下发的 JSON schema)',
    '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:',
    '  - `id`: 模块标识(后端为英文蛇形 module id;前端聚合模块固定用 `frontend-phase`)。',
    '  - `done`: 该模块是否已完成(按上面的判定)。',
    '  - `reqs`: **仅后端模块**填本模块**未完成**后端 REQ 的有序列表(已 `verdict=approve`,见 `docs/superpowers/reviews/*-<REQ>.md` 的 REQ 跳过);模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。',
    '  - `feItems`: **仅前端聚合模块**填——把**全部模块**的**未完成**前端 FE-NN 汇总为一个有序列表(已 approve 的 FE 跳过)放进 `frontend-phase` 这一项。**后端模块 `feItems` 恒为空数组**(前端不分摊到后端模块)。',
    '- 即:后端模块只承载 `reqs`、`feItems=[]`;末尾的 `frontend-phase` 模块只承载 `feItems`、`reqs=[]`。整个项目至多一个前端聚合模块,对应至多一个 `milestone/frontend-phase` tag。',
    '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。',
    '',
    '## 缺值处理',
    '- docs/08 §二/§三 缺失 / 格式不符 / 无法解析 → **不要猜**:把具体的解析失败点写入返回前的诊断并使本步骤失败(让 Workflow halt),由人工修复 Plan 产物后重跑 `coding-start`。',
  ].join('\n')
}

// ---- 功能内循环 stage 1:派生 spec(原 feature-brainstorm / fe-feature-brainstorm)----
function deriveSpecPrompt(id, phase) {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-brainstorm' : 'feature-brainstorm'} — 派生规格 ${id}`,
    '',
    featureStageContract(phase),
    '',
    '## 目标',
    `静默派生 \`${id}\` 的实现规格(无 Q&A)。需求歧义本应在 Plan 期的结构化 per-REQ 表单 / 前端 scope-lock 锁定;这里**只消费已锁定的事实**,不再澄清。`,
    '',
    '## 收集上下文',
    fe
      ? [
          `- 关联 REQ 卡片:\`${ROOT}/docs/01-需求清单/<module>/<REQ>.md\`(提取业务校验规则、acceptance、UI 描述)。`,
          `- 关联 prototype:Read \`${ROOT}/prototype/**/*.html\`(含 anchor 时聚焦相应区域),作为页面布局权威。`,
          `- API 契约:\`${ROOT}/docs/05-API接口契约.md\`,按本 FE 关联的 REQ 过滤出消费的端点。`,
          `- Design Tokens:\`${ROOT}/docs/06-UI交互规范.md § 二\`(色值 / 状态色引用源)。`,
          `- 前端组件库:\`${ROOT}/docs/04-技术规范.md § 零\` 的 \`frontend.ui_lib\`,决定组件选型。`,
        ].join('\n')
      : [
          `- REQ 卡片:\`${ROOT}/docs/01-需求清单/<module>/${id}.md\`。**忽略 UI 描述**(控件类型 / 按钮位置 / 列表布局),但校验规则、业务规则仍要落到后端 DTO + service。`,
          `- 涉及的数据表定义:\`${ROOT}/docs/03-数据库设计文档.md\`(必要时实时查 mysql 只读)。`,
          `- API 契约:\`${ROOT}/docs/05-API接口契约.md\` 中本 REQ 相关端点。`,
        ].join('\n'),
    '',
    '## 写 spec',
    `- 落盘路径:\`docs/superpowers/specs/<当天日期 YYYY-MM-DD>-${id}.md\`(项目根相对)。当天日期由你在自身上下文解析;**spec 是本功能链上唯一会解析"今天"的 stage**,下游 plan/verify/review 的产物日期一律复用本 spec 文件名前缀(脚本会从 artifactPath 读取)。`,
    `- 若已经存在 \`docs/superpowers/specs/*-${id}.md\`(resume 场景),**复用最新一份的日期前缀**,不要起新日期前缀的文件;按需 Edit 已存在的 spec 而不是另起新文件。`,
    fe
      ? '- 规格至少含:关联 REQ + 关联原型;组件树(按页面 / 区域分块,推导自 prototype DOM);页面状态机(loading / empty / error / 正常 / 表单提交中 至少 5 态);消费的后端端点(对齐 docs/05);业务规则前端复刻清单(逐条:规则 / 触发时机 / 报错文案 / 来源 REQ);Design Tokens 引用清单(`var(--color-*)`)。'
      : '- 规格覆盖:goal / 输入输出 / 业务规则 / 约束 / schema / API 引用 / acceptance criteria。',
    '',
    '## commit',
    `- 写完 spec 后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`,
    `  1. \`git -C ${ROOT} add <spec artifactPath>\``,
    `  2. \`git -C ${ROOT} commit -m "docs(spec:${id}): 派生规格"\``,
    '- commit 失败 → halt,把 stderr 摘要写进 reason。',
    '',
    '## 自审(inline 修,无须等待)',
    `- 占位符扫描:\`TBD\` / \`TODO\` / \`【人工填写:】\`${fe ? ' / `controller` / `service` / `SQL` / `migration`(前端 spec 不应出现后端字样)' : ''} → 命中即修;修不掉的缺值按硬约束失败。`,
    '- 内部一致性 / 范围检查(单 plan 能消化吗)/ 歧义检查(任一 requirement 两种解读 → 挑一个写明)。',
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    '- 成功:`{ "status": "ok", "artifactPath": "docs/superpowers/specs/YYYY-MM-DD-' + id + '.md", "summary": "<1-2 句中文摘要>" }`',
    '- 失败:`{ "status": "halt", "reason": "<缺值阻塞点:缺哪个值 / 应在哪个 Plan 闸门锁定 / 为何无法继续>" }`',
    '- `artifactPath` 必须为项目根相对路径(无前导斜杠),文件名首段必须是 `YYYY-MM-DD`;schema 是 `additionalProperties:false`,不要返回额外字段。',
  ].join('\n')
}

// ---- stage 2:spec → 任务级 TDD 计划(原 feature-plan / fe-feature-plan)----
// specPath:调用方传入的 spec artifactPath(含 YYYY-MM-DD 前缀),plan 复用该日期。
function planPrompt(id, phase, specPath) {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-plan' : 'feature-plan'} — 任务级计划 ${id}`,
    '',
    featureStageContract(phase),
    '',
    '## 输入',
    `- 上游 spec:\`${specPath}\`(已由 spec stage 落盘;不存在则 halt)。**plan 文件名日期前缀必须与 spec 一致**:取 spec 文件名首段 \`YYYY-MM-DD\`,写到 plan 路径,不要重新解析"今天"。`,
    fe
      ? `- \`${ROOT}/docs/04-技术规范.md § 一 前端架构\`(路由 / 状态库 / 组件目录约定 / 测试栈);\`${ROOT}/docs/09-项目目录结构.md § 前端目录结构\`(落盘位置)。用 Grep 在 \`${ROOT}/frontend/\` 定位现有文件。`
      : `- \`${ROOT}/docs/04-技术规范.md\` 与 \`${ROOT}/docs/09-项目目录结构.md\`(编码规范 + 目录规范)。用 Grep 在现有代码定位待修改文件。`,
    '',
    '## 计划写作原则',
    '- Plan 告诉 TDD 执行者**做什么**,不是**怎么写代码**(执行者是同模型、全上下文的 tdd stage)。',
    `- Plan 锁定**文件边界 + 测试意图 + ${fe ? 'props 契约' : 'API 形状'} + 完成判据**;代码由 TDD 红绿循环产出。`,
    '- **禁止 dump 整个文件内容**(pom.xml / entity / config / 组件源码)到 plan——避免双 source of truth 漂移。',
    fe ? '- 每个任务标注"测试先行类型" = **jsdom 组件测试** OR **Playwright E2E**。' : '',
    '- DRY、YAGNI、TDD、frequent commits。',
    '',
    '## 任务结构(每个 task = 一个 red-green-commit 单元,4 step)',
    '1. 写失败测试(给 `test_file::test_name` + 测试意图);2. 实现最小代码(给 `impl_file`);3. 子会话验证 PASS;4. commit。任务粒度 2-5 分钟。',
    fe
      ? `- **硬护栏**:每个任务 \`impl_file\` 必须以 \`frontend/\`(或 docs/09 声明的前端根)开头;命中 \`backend/\` / \`sql/\` / \`scripts/\` → 修正后重渲染。`
      : `- **硬护栏**:任务粒度限定后端文件(controller / service / repository / DTO / 校验 / SQL migration);**禁止**生成 \`frontend/\` 路径任务。`,
    '- 允许写死的少数场景:DDL / migration 语句、合同级常量(错误码 / JWT claim / Redis key / 路由 path / API client 签名 / Design Tokens 名)、可选的测试断言 sketch。其余一律散文 + 签名描述。',
    '- 首次出现的类 / 方法 / 组件 / hook / API client 函数必须给出签名;跨 task 的签名 / 错误码 / props 类型必须一致。',
    '',
    '## 写 plan + 自审',
    `- 落盘路径:\`docs/superpowers/plans/<同 spec 的 YYYY-MM-DD>-${id}.md\`,文件头含 Goal / Architecture / Tech Stack + checkbox 任务。`,
    '- 自审:占位符扫描(按硬约束清单);spec coverage(spec 每节至少指向一个 task,补 gap);类型一致性(签名 / 方法名 / 错误码 / props 一致)。',
    '',
    '## commit',
    `- 写完 plan 后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`,
    `  1. \`git -C ${ROOT} add <plan artifactPath>\``,
    `  2. \`git -C ${ROOT} commit -m "docs(plan:${id}): 任务级 TDD 计划"\``,
    '- commit 失败 → halt,把 stderr 摘要写进 reason。',
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    '- 成功:`{ "status": "ok", "artifactPath": "docs/superpowers/plans/YYYY-MM-DD-' + id + '.md", "summary": "<1-2 句中文摘要:任务数 / 涉及文件作用域>" }`',
    '- 失败:`{ "status": "halt", "reason": "<阻塞点描述>" }`',
    '- 日期前缀必须与 spec 同;schema 是 `additionalProperties:false`。',
  ].filter(Boolean).join('\n')
}

// ---- stage 3:按 plan 逐任务 TDD(原 feature-tdd / fe-feature-tdd)----
// planPath:上游 plan artifactPath;ledger 是 prompt 层的显式自约束(无 harness 强制)。
function tddPrompt(id, phase, planPath) {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-tdd' : 'feature-tdd'} — 逐任务 TDD ${id}`,
    '',
    featureStageContract(phase),
    '',
    '## 输入',
    `- 计划文件:\`${planPath}\`(不存在则 halt)。`,
    `- 测试命令来源:\`${ROOT}/docs/04-技术规范.md § 零\`${fe
      ? ' 的 `frontend.unit_test_runner` / `frontend.e2e_runner` / `frontend.test_command` / `frontend.e2e_command`(缺失则默认 `pnpm test:ci` / `pnpm e2e:ci`)。'
      : ' 确认的后端测试命令(如 Maven profile / `./scripts/test.mjs`);缺失则默认 `node scripts/test.mjs`(与 test-gate 一致)。'}`,
    '',
    '## 流程',
    fe ? '' : '- **Schema 改动前置**(仅当 plan 声明需要):第一个任务写 migration 文件 `V<n>__<snake_case>.sql`(`<n>` = 现有 `sql/migrations/V*.sql` 最大版本号 + 1,只含 DDL),**同步**把新 CREATE / ALTER 反向更新到 `docs/03-数据库设计文档.md` 对应表小节(docs/03 是 schema 的 SSoT),migration + docs/03 改动同一 commit。',
    '- 按顺序处理每个代码类任务:(a) 在 `test_file::test_name` 写**失败**测试;(b) **派发 Agent 子会话**跑测试确认失败,子会话只返回 `{command, exit_code, failing_assertion}` JSON;(c) 写**最小**实现使测试通过;(d) 再派子会话确认通过;(e) commit(含 `REQ_ID` / REQ 标签)。',
    fe
      ? '- jsdom 类型用 vitest/jest 写组件单测;e2e 类型在 `frontend/e2e/` 写 Playwright(headless)。实现时:色值用 `var(--color-*)`(不硬编码 hex),业务校验按 spec 在 form-level 复刻。'
      : '',
    '',
    '## 护栏',
    '- **绝不**在主会话直接跑测试(mvn / pnpm / playwright / scripts/test.mjs)——必须通过 Agent 子会话。',
    fe
      ? '- **绝不**写非 `frontend/`(或 docs/09 前端根)路径的 `impl_file`;命中 `backend/` / `sql/` / `scripts/` → 硬停并打印 `不允许写非前端文件:<impl_file>`。'
      : '- **后端阶段路径硬护栏**:任意 `impl_file` 以 `frontend/` 开头 → 硬停并打印 `后端阶段不允许写前端代码:<impl_file>`,不再继续 TDD。',
    '- 每次 commit 含 REQ/FE 标签,不混合无关改同。',
    '',
    '## 同测试重试账本(硬上限 10 次 / 测试)',
    '- 你必须**显式**为每个出现过红色的测试维护一个内存账本 `attempts[<test_file>::<test_name>] = N`,每次该测试的"写失败实现 → 再跑"算 1 次。',
    '- 每次失败跑后,**在自身输出中显式打印一行** JSON:`{ "attempts": { "<test_file>::<test_name>": N } }`(便于 review/审计追溯)。',
    '- 任一测试的 `attempts >= 10` → **立刻 halt**:返回 `{status:"halt", reason:"tdd-test-stuck: <test_file>::<test_name> 已尝试 10 次"}`,把"该测试名 / 最近一次 failing_assertion / 已尝试的修复摘要"写进 reason,**不要**无限重试。',
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    '- 全部任务通过:`{ "status": "ok", "summary": "<完成的任务数 / 引入的文件清单摘要>" }`(artifactPath 可省)。',
    '- 任意护栏 / 账本上限 / 缺值 → `{ "status": "halt", "reason": "<具体阻塞点>" }`。',
  ].filter(Boolean).join('\n')
}

// ---- stage 4:把功能测试派子会话跑,渲染证据(原 feature-verify / fe-feature-verify)----
// specPath:用于复用日期前缀;round:0 = TDD 后初次 verify,1..5 = fix 后 reverify(每轮独立证据文件,
// 避免 reverify 覆盖前轮证据)。
function verifyPrompt(id, phase, implSummary, specPath, round = 0) {
  const fe = isFrontend(phase)
  const suffix = round === 0 ? 'verify' : `verify-r${round}`
  return [
    `# ${fe ? 'fe-feature-verify' : 'feature-verify'} — 证据验证 ${id}${round > 0 ? `(第 ${round} 轮 fix 后复验)` : ''}`,
    '',
    featureStageContract(phase),
    '',
    '## 目标',
    `把 \`${id}\` 的功能测试**派发到 Agent 子会话**执行,按结构化结果渲染证据。**主会话从不直接跑测试,也不自由编写证据。**`,
    `- 上游 spec:\`${specPath}\`(日期前缀来源);本次产物文件名前缀必须 = spec 文件名首段 \`YYYY-MM-DD\`。`,
    implSummary ? `- 上游 TDD 摘要:${implSummary}` : '',
    '',
    '## 流程',
    fe
      ? [
          `- 测试目标:从 plan 取 \`测试先行类型 = jsdom\` 的 test_file → 拼 vitest/jest 过滤模式;\`= e2e\` 的 → 拼 Playwright spec 过滤模式。命令从 \`${ROOT}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 取(缺失默认 \`pnpm test:ci\` / \`pnpm e2e:ci\`)。`,
          '- 派子会话依次跑 unit + e2e,子会话只返回结构化 JSON:`{ unit:{command,exit_code,passed,failed,failed_list,stdout_excerpt}, e2e:{...同结构} }`(`stdout_excerpt` ≤ 30 行)。',
          '- **任一目标 `exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt,不进入 review。',
        ].join('\n')
      : [
          `- 测试目标:从 plan 或项目标准命令确定(Maven profile / pnpm script / pytest path / \`${ROOT}/docs/04-技术规范.md § 零\` 的后端命令)。`,
          '- 派子会话执行,子会话只返回结构化 JSON:`{command, exit_code, passed, failed, failed_list, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行,不塞全文 stdout)。',
          '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt,不进入 review。',
        ].join('\n'),
    `- 证据落盘路径固定为 \`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}-${suffix}.md\`(与 review 报告同目录;round=0 → \`-verify.md\`;round>=1 → \`-verify-r<N>.md\`,**每轮独立文件不覆盖前轮**)。同时把核心结构化结果摘要打印到会话便于上层 review stage 引用,**不要**自行另起目录或自由命名文件。`,
    '',
    '## commit',
    `- 写完证据后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`,
    `  1. \`git -C ${ROOT} add <证据 artifactPath>\``,
    `  2. \`git -C ${ROOT} commit -m "docs(verify:${id}${round > 0 ? `:r${round}` : ''}): 证据验证"\``,
    '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的证据路径)。',
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    `- 全部通过:\`{ "status": "ok", "artifactPath": "docs/superpowers/reviews/YYYY-MM-DD-${id}-${suffix}.md", "summary": "<exit_code / passed / failed / failed_list 摘要 ≤ 200 字>" }\`。`,
    '- 任一红色 / 越界 / 缺值 → `{ "status": "halt", "reason": "<具体阻塞点>", "artifactPath": "<已写入的证据路径(如有)>" }`。',
  ].filter(Boolean).join('\n')
}

// ---- stage 5a:AI 自审 diff(原 feature-review / fe-feature-review)——委托统一 reviewer agent ----
// lastVerifySummary:round>1 时传入上轮 fix 后复验摘要,让 reviewer 看到"上轮 must-fix 真的修了什么"。
// specPath:spec artifactPath(日期前缀来源 + reviewer 上下文输入)。
function reviewPrompt(id, phase, round, lastVerifySummary, specPath) {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-review' : 'feature-review'} — AI 自审 ${id}(第 ${round} 轮)`,
    '',
    featureStageContract(phase),
    '',
    '## 目标',
    `对 \`${id}\` 本轮引入的代码 diff 做 AI 自审,给出 \`approve\` 或 \`request-changes\` 裁决。`,
    '',
    '## 输入给 reviewer',
    `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}\`。`,
    fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '',
    `- **phase = ${fe ? 'frontend → 附加前端 7 维 checklist。其中仅"颜色对比度"(§3 子项)与"响应式"(§4)为主观/best-effort,绝不单独触发 request-changes;a11y 的 label/键盘可达/危险操作确认等客观项仍可作 must-fix(与 agents/code-reviewer.md §3-4 对齐,避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`,
    round > 1 && lastVerifySummary
      ? `\n## 上轮 fix 后复验摘要(round ${round - 1})\n${lastVerifySummary}\n\n你必须把"上轮 must-fix 在本轮 diff 中是否真的被修"作为本轮裁决的核心维度。已修的不要再次纳入 must-fix;未修 / 修得不对,单点列入 issues。`
      : '',
    '',
    '## 输出(必须符合下发的 REVIEW JSON schema)',
    `- \`verdict\`: \`approve\` | \`request-changes\`;\`round\`: 整数(本轮 = ${round})。`,
    `- \`issues\`: 结构化 must-fix 数组。\`approve\` 时必须为空数组 \`[]\`;\`request-changes\` 时**必须非空**,每项形如 \`{ "summary": "<一句问题>", "locator": "<文件路径或 file:line>", "severity": "blocker|high|medium|low" }\`。`,
    `- \`locator\` **必须含可定位文件路径**(项目根相对,例如 \`backend/src/main/java/.../FooController.java\` 或 \`frontend/src/views/Bar.vue:42\`);没有具体文件无法定位 → 该项不是 must-fix(降级为口头建议,不要塞进 issues)。`,
    `- 渲染审阅报告写入 \`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md\`(\`verdict\` 字段与返回值一致)。报告内可写更丰富的建议 / 风险 / 亮点;issues 数组只放硬性 must-fix。`,
    `- **不要**在本步骤里编辑 docs/08 的 \`- [ ]\` checkbox——该 side effect 由上层 Workflow 的 micro step 在 approve 后另行落盘(你只负责裁决)。`,
    '- 不要返回额外字段(schema 是 `additionalProperties:false`)。',
    '',
    '## commit',
    `- 写完审阅报告后必须 commit(milestone 的 worktree-clean 前置依赖此 commit;该 commit 与 verdict 无关,approve 或 request-changes 都要 commit 报告本身):`,
    `  1. \`git -C ${ROOT} add docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md\``,
    `  2. \`git -C ${ROOT} commit -m "docs(review:${id}:r${round}): <verdict>"\``,
    '- commit 失败时仍按 schema 返回 verdict / issues;commit 错误信息打印到日志即可(不要在 schema 中夹带额外字段)。',
  ].filter(Boolean).join('\n')
}

// ---- stage 5b:按 review must-fix 修复并重新 commit(review 循环的 fix 步)----
// issues:结构化对象数组 {summary, locator, severity}(见 REVIEW_SCHEMA)。
function fixPrompt(id, phase, issues) {
  const fe = isFrontend(phase)
  const list = Array.isArray(issues) && issues.length
    ? issues.map((x, i) => `  ${i + 1}. [${x.severity}] ${x.summary} — locator: \`${x.locator}\``).join('\n')
    : '  (上一轮 review 未提供 must-fix 清单——不应出现,调用方会先 halt)'
  return [
    `# ${fe ? 'fe-feature' : 'feature'} fix — 修复 review must-fix ${id}`,
    '',
    featureStageContract(phase),
    '',
    '## 待修复 must-fix(已结构化)',
    list,
    '',
    '## 流程',
    '- 逐项编辑 locator 指向的代码文件(遵守阶段路径作用域护栏)。',
    `- 编辑前必须先校验 locator 文件存在:跑 \`git -C ${ROOT} cat-file -e HEAD:<locator 的文件部分>\`(locator 形如 \`path:line\` 时取 \`path\`)。文件不存在 → halt,把 locator 写进 reason,不要"修一个不存在的文件"。`,
    fe
      ? '- **硬护栏(与 tdd 同款)**:命中 `backend/` / `sql/` / `scripts/` → halt 并把 file 路径写进 reason。'
      : '- **硬护栏(与 tdd 同款)**:任一被编辑文件以 `frontend/` 开头 → halt 并把 file 路径写进 reason。',
    `- 修复后 commit:\`fix(<scope>): 修复 review must-fix ${fe ? `FE: ${id}` : `REQ: ${id}`}\`(不混合无关改动)。`,
    '- 修复完成后本步骤即结束;上层 Workflow 会重新跑 verify + review(下一轮)。',
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    `- 全部修完:\`{ "status": "ok", "summary": "<已修复 ${Array.isArray(issues) ? issues.length : 0} 项的 1-2 句摘要>" }\`。`,
    '- 任意阻塞(locator 文件不存在 / 越界 / 缺值)→ `{ "status": "halt", "reason": "<具体阻塞点 + locator>" }`。',
  ].filter(Boolean).join('\n')
}

// ---- 测试闸(原 test-gate)----
// attempt:1 = 首次跑;2 = 上轮 red 后的 flake 重试。每次 attempt 写到独立证据文件,避免 retry
// 把首次 red 证据覆盖掉(report § ⑤ 失去 flake 信号)。
function gatePrompt(module, phase, attempt = 1) {
  const fe = isFrontend(phase)
  const id = module?.id ?? '<module>'
  const phaseId = fe ? 'frontend-phase' : id
  return [
    `# test-gate — ${fe ? '前端阶段' : `模块 ${id}`} 硬测试闸(phase=${phase}, attempt=${attempt})`,
    '',
    featureStageContract(phase),
    '',
    '## 目标',
    `打里程碑 tag 前的唯一硬测试门。**派发 Agent 子会话**跑测试,绿则通过,红则失败。**绝不**在主会话直接跑测试,红色时**绝不**跳过。`,
    attempt > 1 ? `- 本次 = 第 ${attempt} 次(上一次 red,本轮用于辨识 flaky);证据**写到独立文件**不要覆盖前一次。` : '',
    '',
    '## 命令',
    fe
      ? `- 前端:命令从 \`${ROOT}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 拼接(缺失则 \`pnpm test:ci && pnpm e2e:ci\`),跑 vitest + playwright(含全 FE 回归)。`
      : `- 后端:跑 \`${ROOT}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归)。`,
    '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。',
    '',
    '## 证据 + commit',
    `- 渲染证据写入 \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r${attempt}.md\` 并 commit 到当前分支(每个 attempt 独立文件,retry 不覆盖前一次 red 证据)。`,
    `- 文件头注明 \`attempt: ${attempt}\` + 命令 + 时间窗口(如可从子会话拿到),便于 report § ⑤ 识别 flake。`,
    '',
    '## 输出(必须符合下发的 GATE JSON schema)',
    '- `status`: `green`(`exit_code = 0` 且 `failed = 0`)| `red`;`failures`: 失败用例摘要(green 时可省略 / 空数组)。',
    '- 不要返回额外字段。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。',
  ].filter(Boolean).join('\n')
}

// ---- 微步骤 prompt builders(runBranchSetup / runMilestone / runCrossModule 用)----
// 每个 prompt 单职责、短文本;返回严格 schema;执行(action)步统一返回 ACTION_RESULT_SCHEMA。
function microStepContract() {
  return [
    '## 硬约束(非交互子代理)',
    '- 你是 Workflow 派生的**非交互子代理**,绝不弹问。',
    '- 全部输出**使用中文**。',
    `- 项目根 = \`${ROOT}\`。所有 git 命令必须用 \`git -C ${ROOT} ...\`;Read/Edit/Write 的路径都以 \`${ROOT}\` 为根。`,
    '- 严格按下方"输出"段返回 schema 字段;**不要**在 schema 外追加自由叙述。',
  ].join('\n')
}

// ── 微步骤:可重用 read(多个 orchestrator 共用)──
function detectDefaultBranchPromptM() {
  return [
    '# 检测本地默认分支',
    microStepContract(),
    '',
    `用 \`git -C ${ROOT} rev-parse --verify <name>\` 依次试 \`main\` / \`master\`,取第一个 exit=0 的为默认分支。`,
    '## 输出(DEFAULT_BRANCH_SCHEMA)',
    '- 两者其一存在:`{ "branch": "main" }` 或 `{ "branch": "master" }`',
    '- 都不存在:本步骤失败(返回 schema 失败即可,调用方会 halt)。',
  ].join('\n')
}

function worktreeCleanPromptM() {
  return [
    '# 检查工作树是否干净',
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} status --porcelain\`,按行解析 dirty 文件路径(第 4 字符起)。`,
    '## 输出(WT_SCHEMA)',
    '- 干净:`{ "clean": true }`',
    '- 不干净:`{ "clean": false, "dirty": ["<path>", ...] }`',
  ].join('\n')
}

function checkBranchExistsPromptM(branch) {
  return [
    `# 本地分支 \`${branch}\` 是否存在`,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} rev-parse --verify ${branch}\`(用 2>/dev/null 抑制 stderr)。`,
    '## 输出(EXISTS_SCHEMA)',
    '- exit=0 → `{ "exists": true }`;非 0 → `{ "exists": false }`',
  ].join('\n')
}

function currentBranchPromptM() {
  return [
    '# 当前所在分支',
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} rev-parse --abbrev-ref HEAD\`。`,
    '## 输出(CURRENT_BRANCH_SCHEMA)',
    '- `{ "branch": "<stdout 第一行去空白>" }`',
  ].join('\n')
}

// ── 微步骤:分支生命周期 action ──
function checkoutExistingBranchPromptM(branch) {
  return [
    `# 切到已存在的本地分支 \`${branch}\``,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} checkout ${branch}\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功:`{ "success": true }`',
    '- 失败:`{ "success": false, "error": "<stderr 摘要>" }`',
  ].join('\n')
}

function createBranchFromPromptM(fromBranch, newBranch) {
  return [
    `# 从 \`${fromBranch}\` 新建并切到 \`${newBranch}\``,
    microStepContract(),
    '',
    `按序跑:\`git -C ${ROOT} checkout ${fromBranch}\`,然后 \`git -C ${ROOT} checkout -b ${newBranch}\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 全成功:`{ "success": true }`;任一失败:`{ "success": false, "error": "<which step + stderr>" }`',
  ].join('\n')
}

// ── 微步骤:REQ/FE 完成态 git tag(featureLoop dedup 的唯一 ground truth)──
// req-done/<id> 是功能级 git tag,approve 时打一次;featureLoop 入口先 check,存在就 skip,
// 避免 Router LLM 自审失误导致已 approve 的 REQ 被重新 spec→plan→tdd(撞 V<n>、污染源码)。
function checkReqDoneTagPromptM(id) {
  return [
    `# tag \`req-done/${id}\` 是否存在(功能级 dedup 真值)`,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} tag -l req-done/${id}\`。`,
    '## 输出(EXISTS_SCHEMA)',
    '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`',
  ].join('\n')
}

function createReqDoneTagPromptM(id, phase) {
  return [
    `# 打 annotated tag \`req-done/${id}\`(${phase==='frontend'?'前端 FE':'后端 REQ'} approve 后落地)`,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} tag -a req-done/${id} -m "feature(${id}): approved by code-reviewer (phase=${phase})"\`。`,
    `先用 \`git -C ${ROOT} tag -l req-done/${id}\` 检查;已存在则视为成功(幂等)直接返回 success。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "<stderr>" }`',
  ].join('\n')
}

// ── 微步骤:milestone 专用 ──
function checkAlreadyMergedPromptM(branch, defaultBranch) {
  return [
    `# \`${branch}\` 是否已合入 \`${defaultBranch}\``,
    microStepContract(),
    '',
    `先跑 \`git -C ${ROOT} checkout ${defaultBranch}\` 确保 HEAD 在 ${defaultBranch};然后跑 \`git -C ${ROOT} merge-base --is-ancestor ${branch} HEAD\`。`,
    '## 输出(ALREADY_MERGED_SCHEMA)',
    '- 第二条 exit=0 → `{ "alreadyMerged": true }`(功能分支已是 HEAD 祖先,无需再 merge)',
    '- 非 0 → `{ "alreadyMerged": false }`',
    '- checkout 自身失败 → 整步失败(schema 失败即可)。',
  ].join('\n')
}

function executeMergePromptM(defaultBranch, branch, phaseId) {
  return [
    `# 把 \`${branch}\` 合并进 \`${defaultBranch}\`(已确认尚未合入,已在默认分支)`,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} merge --no-ff ${branch} -m "merge(${phaseId}): integrate ${branch}"\`。`,
    '- 成功 → `{ "success": true }`',
    '- 合并冲突 / 其它失败 → `{ "success": false, "error": "<simplified message>", "detail": "<conflict files newline-separated, 或 stderr 前 30 行>" }`',
    '- **不要**自动 \`git merge --abort\` / 自动 stash / 自动改文件——把树留给人工处理。',
    '## 输出(ACTION_RESULT_SCHEMA)',
  ].join('\n')
}

function readDocs08FieldPromptM(fe, id) {
  if (fe) {
    return [
      '# 读 docs/08 § 三 `整体里程碑:` 字段当前值',
      microStepContract(),
      '',
      `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 三(前端阶段)下的 \`- 整体里程碑: <value>\` 行。`,
      '## 输出(FIELD_VALUE_SCHEMA)',
      '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <该行 1-based 行号> }`',
      '- § 三 或该行不存在:`{ "found": false, "value": "" }`',
    ].join('\n')
  }
  return [
    `# 读 docs/08 § 二 模块 \`${id}\` 的 \`里程碑:\` 字段当前值`,
    microStepContract(),
    '',
    `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 二 中 module id == \`${id}\` 的 bullet 段,取其 \`  - 里程碑: <value>\` 子项。`,
    '## 输出(FIELD_VALUE_SCHEMA)',
    '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <行号> }`',
    `- 模块 \`${id}\` 或该字段不存在:\`{ "found": false, "value": "" }\``,
  ].join('\n')
}

function writeDocs08FieldPromptM(fe, id, targetValue, phaseId, lineNumber) {
  const scope = fe ? `§ 三 整体里程碑` : `§ 二 模块 ${id} 里程碑`
  const oldStr = fe ? '- 整体里程碑: —' : '  - 里程碑: —'
  const newStr = fe ? `- 整体里程碑: ${targetValue}` : `  - 里程碑: ${targetValue}`
  // 后端模块多个 bullet 同时含 `  - 里程碑: —`:必须按调用方传入的精确行号定位,否则在多模块 docs/08
  // 里 Edit 会替换到第一处(通常不是本模块),把别的模块误标 milestone-complete。
  const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber))
    ? `先 Read \`${ROOT}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行字面量等于 \`${oldStr}\`;不等则 halt(返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual=<actual>"}\`)。然后仅替换第 ${lineNumber} 行;其余位置同名行**严禁**改动。`
    : `严禁全局替换:通过定位上下文(${fe ? '§ 三' : `§ 二 中 module_id == \`${id}\` 的 bullet 段`})找到该 bullet 的 \`里程碑\` 子项行,仅替换这一行。`
  return [
    `# 把 docs/08 ${scope} 从 \`—\` 改为 \`${targetValue}\` 并 commit`,
    microStepContract(),
    '',
    `调用方已确认字段当前值 = \`—\`(你不必再读一遍)。`,
    `1. ${lineGuard} Edit \`${ROOT}/docs/08-模块任务管理.md\`:把整行 \`${oldStr}\` 替换为 \`${newStr}\`(精确字符串替换;**只动一处**)。`,
    `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`,
    `3. 跑 \`git -C ${ROOT} commit -m "chore(${phaseId}): record ${targetValue} in docs/08"\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "<step + reason>" }`',
  ].join('\n')
}

// ── 微步骤:docs/08 功能行 checkbox 勾选态(reviewer approve 后的可观测 side effect)──
// 原 reviewPrompt 让 reviewer 顺手 flip checkbox:reviewer 失败时 router/进度判定看不到,且
// reviewer agent 多了一项 file edit 副作用。拆为 read-then-write 两个 micro step:
//   reviewWithFixLoop approve → read → 若 unchecked → write → assert success;
//   已 checked → 静默跳过(resume 幂等)。
function readDocs08CheckboxPromptM(fe, id) {
  if (fe) {
    return [
      `# 读 docs/08 § 三 功能 \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`,
      microStepContract(),
      '',
      `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 三(前端阶段)下的 \`功能:\` 项,从中找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`,
      '## 输出(CHECKBOX_STATE_SCHEMA)',
      `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``,
      `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``,
      `- 找不到:\`{ "found": false }\``,
    ].join('\n')
  }
  return [
    `# 读 docs/08 § 二 REQ \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`,
    microStepContract(),
    '',
    `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 § 二,找以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`,
    '## 输出(CHECKBOX_STATE_SCHEMA)',
    `- \`- [x] ${id} ...\` → \`{ "found": true, "state": "checked", "lineNumber": <行号> }\``,
    `- \`- [ ] ${id} ...\` → \`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``,
    `- 找不到:\`{ "found": false }\``,
  ].join('\n')
}

function writeDocs08CheckboxPromptM(fe, id, phase) {
  const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}`
  return [
    `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`,
    microStepContract(),
    '',
    `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`,
    `1. Edit \`${ROOT}/docs/08-模块任务管理.md\`:把以 \`- [ ] ${id} \` 开头的整行替换为对应的 \`- [x] ${id} ...\`(保留原行 id 之后的全部文本,仅 \`[ ]\` → \`[x]\`,精确字符串替换;只动一处)。`,
    `2. 跑 \`git -C ${ROOT} add docs/08-模块任务管理.md\`。`,
    `3. 跑 \`git -C ${ROOT} commit -m "chore(${phase}:${id}): mark ${id} approved in docs/08"\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 三步全 OK:`{ "success": true }`;任一失败:`{ "success": false, "error": "<step + reason>" }`',
  ].join('\n')
}

function checkTagExistsPromptM(tagName) {
  return [
    `# tag \`${tagName}\` 是否存在`,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} tag -l ${tagName}\`。`,
    '## 输出(EXISTS_SCHEMA)',
    '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`',
  ].join('\n')
}

function createTagPromptM(phaseId, fe) {
  return [
    `# 打 annotated tag \`milestone/${phaseId}\``,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} tag -a milestone/${phaseId} -m "milestone(${phaseId}): ${fe ? '前端' : '后端'}阶段完成"\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功:`{ "success": true }`;失败:`{ "success": false, "error": "<stderr>" }`',
  ].join('\n')
}

// 校验 milestone tag 指向的 commit 中报告 § ⑫ 是否已是 targetTag(而非 placeholder)。
// 用于识别旧 bug 残留:报告 § ⑫ commit 顺序在 tag 之后时,tag 指向占位符版本。
function checkTagReportFreshPromptM(targetTag, reportPath) {
  return [
    `# 校验 tag \`${targetTag}\` 指向的 commit 中 \`${reportPath}\` § ⑫ 是否新鲜`,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} show ${targetTag}:${reportPath}\`。在输出中定位 § ⑫("里程碑"小节)的 tag 字段值。`,
    '## 输出(TAG_REPORT_FRESHNESS_SCHEMA)',
    `- § ⑫ 字段值 == \`${targetTag}\`:\`{ "fresh": true, "tagReportValue": "${targetTag}" }\``,
    `- § ⑫ 字段值 == \`{{milestone_tag}}\` 或其它陈旧值:\`{ "fresh": false, "tagReportValue": "<实际值>" }\``,
    '- `git show` 失败(tag 不存在 / 报告路径不在 tag commit 中)→ 本步骤失败(schema 失败即可)。',
  ].join('\n')
}

function findReportPromptM(phaseId) {
  return [
    `# 找最新的 \`${phaseId}\` 完成报告并读取 § ⑫ 的 milestone tag 字段当前值`,
    microStepContract(),
    '',
    `用 Glob 在 \`${ROOT}/docs/superpowers/module-reports/\` 查找 \`*-${phaseId}.md\`(按文件名 YYYY-MM-DD 日期前缀降序取最新一份)。`,
    'Read 该文件,定位 § ⑫("里程碑"小节)。',
    '## 输出(REPORT_PATH_SCHEMA)',
    `- 找到:\`{ "found": true, "path": "docs/superpowers/module-reports/<file>", "currentTagValue": "<§ ⑫ 当前的字面值(应为 \\\`{{milestone_tag}}\\\` 或 \\\`milestone/${phaseId}\\\` 之一)>" }\``,
    '- 完全没有匹配文件:`{ "found": false }`',
  ].join('\n')
}

function updateReportPromptM(reportPath, targetTag, phaseId) {
  return [
    `# 把 \`${reportPath}\` § ⑫ 的 \`{{milestone_tag}}\` 替换为 \`${targetTag}\` 并 commit`,
    microStepContract(),
    '',
    `1. Edit \`${ROOT}/${reportPath}\`:把字面量 \`{{milestone_tag}}\` 替换为 \`${targetTag}\`(精确替换;如多处出现就全部替换)。`,
    `2. \`git -C ${ROOT} add ${reportPath}\`;3. \`git -C ${ROOT} commit -m "docs(${phaseId}): record ${targetTag} in completion report"\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 全 OK:`{ "success": true }`;失败:`{ "success": false, "error": "<which step + reason>" }`',
  ].join('\n')
}

// ── 微步骤:cross-module 专用 ──
function collectCrossModuleChangedPromptM(defaultBranch) {
  return [
    `# 收集功能分支自 \`${defaultBranch}\` 分叉以来的全部改动文件`,
    microStepContract(),
    '',
    `跑 \`git -C ${ROOT} diff --name-status ${defaultBranch}...HEAD\`(三点 diff)。按行解析每行 \`<status>\\t<path>\`(status 通常为 M/A/D/R/C 等)。`,
    '## 输出(CHANGED_FILES_SCHEMA)',
    '- `{ "files": [ { "status": "M", "path": "backend/.../X.java" }, ... ] }`',
    '- diff 为空 → `{ "files": [] }`',
  ].join('\n')
}

function classifyCrossModulePromptM(moduleId, files) {
  const filesText = files.map(f => `- ${f.status} ${f.path}`).join('\n')
  return [
    `# 把改动文件分类:哪些落在**非本模块 \`${moduleId}\`** 的目录下`,
    microStepContract(),
    '',
    `本模块目录归属请以 \`${ROOT}/docs/09-项目目录结构.md\` 与 \`${ROOT}/docs/08-模块任务管理.md § 二\` 中本模块 bullet 的 \`路径:\` 字段为准。Read 这两份文档以建立"路径 → 模块"映射。`,
    '',
    '## 改动文件清单',
    filesText,
    '',
    '## 判定规则',
    `- 落在本模块路径(\`${moduleId}\`)下 → **不算**跨模块。`,
    '- 落在其它模块路径下 → 算跨模块,给出该文件归属的目标模块 id。',
    '- 落在共享根(如 `docs/`、`scripts/`、`sql/migrations/`、`README.md` 等)→ **不算**跨模块。',
    '',
    '## 输出(CROSS_CLASSIFY_SCHEMA)',
    '- `{ "crossModule": [ { "file": "...", "targetModule": "module_x", "reason": "<本模块哪个 REQ-XXX-NNN 迫使改它,1 句>", "impact": "<目标模块哪些 API/行为/调用方/测试受影响,1-3 句>" }, ... ] }`',
    '- 无跨模块改动:`{ "crossModule": [] }`',
    '- **不要留 `TBD(CC 补)`**:本步骤就是补齐的唯一时机;推不出原因 / 影响 → 整步失败(schema 失败即可,调用方会 halt)。',
  ].join('\n')
}

// dedup-and-rewrite 不再 append:resume / 多次跑同一模块时,append 会产生重复行污染 § ⑦。
// 改为整体重写:读现有行 → 与本次 items 合并 → 按 (file, targetModule) dedup(本次 items 覆盖旧值)
// → 按 (targetModule, file) 排序 → 整表重写。commit 前用 `git diff --quiet` 判定,无变更则跳过 commit。
function writeCrossModuleLogPromptM(moduleId, items) {
  const newRowsJson = JSON.stringify(items, null, 2)
  return [
    `# 把跨模块改动以 dedup-and-rewrite 方式写入 \`docs/superpowers/module-reports/${moduleId}-cross-module.md\``,
    microStepContract(),
    '',
    `目标文件(项目根相对):\`docs/superpowers/module-reports/${moduleId}-cross-module.md\`。`,
    '',
    '## 流程',
    `1. **读现有行**:如果文件存在,用 Read 取出表格内已有的数据行(跳过表头与分隔行)。把每行解析为 \`{ file, targetModule, reason, impact }\`,得到 \`existingRows\`。文件不存在 → \`existingRows = []\`。`,
    '2. **合并 + dedup**:把"本次新增行 JSON"中的项加入 `existingRows`,按 `(file + "\\u0001" + targetModule)` 作为 dedup key——**本次新增项覆盖旧项**(同一 file × targetModule 的最新原因 / 影响为准)。',
    '3. **排序**:按 `(targetModule, file)` 字典序升序。',
    '4. **整体重写**:用 Write 把整个文件重写为:',
    '   ```',
    '   # 跨模块改动日志',
    '   ',
    '   | 文件 | 目标模块 | 原因 | 影响 |',
    '   |---|---|---|---|',
    '   <已排序的全部行>',
    '   ```',
    `5. **空变更跳过 commit**:跑 \`git -C ${ROOT} diff --quiet -- docs/superpowers/module-reports/${moduleId}-cross-module.md\`。`,
    '   - exit_code = 0(无变更)→ 不要 commit,直接返回 `{ "success": true, "detail": "no-diff-skip-commit" }`。',
    `   - exit_code != 0(有变更)→ \`git add\` + \`git commit -m "chore(${moduleId}): record cross-module log"\`。`,
    '',
    '## 本次新增行(JSON,作为合并输入)',
    '```json',
    newRowsJson,
    '```',
    '',
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 写成功且有/无 commit:`{ "success": true, "detail": "<written|no-diff-skip-commit>" }`',
    '- 任一步失败:`{ "success": false, "error": "<step + reason>" }`',
  ].join('\n')
}

// ---- 模块完成报告(原 module-report)----
function reportPrompt(module) {
  const id = module?.id ?? '<module>'
  const fe = id === 'frontend-phase'
  const phaseId = fe ? 'frontend-phase' : id
  return [
    `# module-report — ${fe ? '前端阶段' : `模块 ${id}`} 12 节完成报告`,
    '',
    featureStageContract(fe ? 'frontend' : 'backend'),
    '',
    '## 目标',
    `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`,
    '',
    '## 前置',
    `- 验证上游 test-gate 绿:Glob \`${ROOT}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`,
    '',
    '## 收集输入(取摘要而非正文)',
    fe
      ? [
          '- § ① `module_id = frontend-phase`,`module_name = 前端阶段(整体)`。',
          `- § ② "FE 完成清单":扫 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`,
          `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`,
          '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。',
          `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/frontend-phase-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`,
          '- § ⑧ 偏离清单:额外审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出。',
          '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。',
        ].join('\n')
      : [
          `- § ③ 文件变更:\`git -C ${ROOT} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`,
          `- § ② / § ⑨:读 \`${ROOT}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`,
          `- § ⑤:把 \`${ROOT}/docs/superpowers/module-reports/${id}-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`,
          `- § ⑥ Migration:\`git -C ${ROOT} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`,
          `- § ⑦ 跨模块改动:读 \`${ROOT}/docs/superpowers/module-reports/${id}-cross-module.md\`(如存在;其中不应再有 \`TBD(CC 补)\`,上一步 cross-module-log 已补齐)。`,
          '- § ④ 读写的表:grep 定位涉 SQL 文件后按需读片段,**不全量读 docs/03**。',
        ].join('\n'),
    '',
    '## 渲染 + 验证 + commit',
    '- 渲染 12 节。硬验证:§ ⑧ 必须列举所有偏离(无则写"无偏离")。',
    `- 写入 \`docs/superpowers/module-reports/<当天日期 YYYY-MM-DD>-${phaseId}.md\`(项目根相对;resume 时复用已存在的 \`*-${phaseId}.md\` 最新日期前缀,不要起新文件),连同跨模块日志(如存在)一起 commit 到当前分支(milestone 的 worktree-clean 前置依赖此 commit)。`,
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    `- 成功:\`{ "status": "ok", "artifactPath": "docs/superpowers/module-reports/YYYY-MM-DD-${phaseId}.md", "summary": "<1-2 句中文摘要:测试是否 flake / 主要变更 / 是否有偏离>" }\`。`,
    '- 失败:`{ "status": "halt", "reason": "<阻塞点(如最后一次 test-gate red / 跨模块日志缺失)>" }`。',
  ].join('\n')
}

// ---- runBranchSetup:原 branchSetupPrompt 的散文流程 → JS 编排 + 微步骤 agent ----
// 幂等:分支已存在则 checkout,否则从默认分支新建。条件分支由 JS 判定,子代理只负责执行单一动作。
async function runBranchSetup(module) {
  const id = module?.id ?? '<module>'
  const fe = id === 'frontend-phase'
  const branch = fe ? 'frontend-phase' : `module-${id}`
  const lbl = (k) => `branch:${k}:${id}`

  const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})

  const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  if (!wt.clean) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${(wt.dirty || []).join(', ')}`)

  const exists = await agent(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA})

  if (exists.exists) {
    const r = await agent(checkoutExistingBranchPromptM(branch), {label: lbl('checkout'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!r.success) throw new Error(`HALT branchSetup-checkout ${branch}: ${r.error || ''}`)
  } else {
    const r = await agent(createBranchFromPromptM(def.branch, branch), {label: lbl('create'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!r.success) throw new Error(`HALT branchSetup-create ${branch}: ${r.error || ''}`)
  }

  const head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: CURRENT_BRANCH_SCHEMA})
  if (head.branch !== branch) throw new Error(`HALT branchSetup-branch-mismatch ${branch}: HEAD on ${head.branch}`)

  log(`branch-setup: ${id} → ${branch}`)
}

// ---- runMilestone:原 milestonePrompt 的 6 步散文流程 → JS 编排 ----
// 所有"已是目标态则跳过"的条件由 JS 在 read 结果上判定,子代理只执行确定性动作。
async function runMilestone(module) {
  const id = module?.id ?? '<module>'
  const fe = id === 'frontend-phase'
  const phaseId = fe ? 'frontend-phase' : id
  const branch = fe ? 'frontend-phase' : `module-${id}`
  const targetTag = `milestone/${phaseId}`
  const lbl = (k) => `milestone:${k}:${phaseId}`

  // step 1: worktree clean precondition
  const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  if (!wt.clean) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${(wt.dirty || []).join(', ')}`)

  // step 2: detect default branch
  const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})

  // step 3: merge (idempotent — skip if already an ancestor)
  const merged = await agent(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA})
  if (!merged.alreadyMerged) {
    const r = await agent(executeMergePromptM(def.branch, branch, phaseId), {label: lbl('merge'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!r.success) throw new Error(`HALT milestone-merge ${phaseId}: ${r.error || ''}${r.detail ? '\n' + r.detail : ''}`)
  }

  // step 4: docs/08 field (idempotent — read first, only write if at initial '—')
  const field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
  if (!field.found) throw new Error(`HALT milestone-docs08-missing ${phaseId}: 字段不存在(docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`})`)
  if (field.value === '—') {
    const r = await agent(writeDocs08FieldPromptM(fe, id, targetTag, phaseId, field.lineNumber), {label: lbl('field-write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!r.success) throw new Error(`HALT milestone-docs08-write ${phaseId}: ${r.error || ''}`)
  } else if (field.value !== targetTag) {
    throw new Error(`HALT milestone-docs08-unexpected ${phaseId}: 字段当前 = ${JSON.stringify(field.value)}(行 ${field.lineNumber || '?'}),期望 '—' 或 '${targetTag}'`)
  }
  // else: 已是 targetTag → 静默跳过(续跑场景)

  // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则
  // `git checkout milestone/<id>` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug,
  // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。)
  const rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
  if (!rpt.found) throw new Error(`HALT milestone-report-missing ${phaseId}: 没有找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件`)
  if (rpt.currentTagValue === '{{milestone_tag}}') {
    const r = await agent(updateReportPromptM(rpt.path, targetTag, phaseId), {label: lbl('report'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!r.success) throw new Error(`HALT milestone-report-update ${phaseId}: ${r.error || ''}`)
  } else if (rpt.currentTagValue !== targetTag) {
    throw new Error(`HALT milestone-report-unexpected ${phaseId}: ${rpt.path} § ⑫ 当前 = ${JSON.stringify(rpt.currentTagValue)}`)
  }
  // else: 已是 targetTag → 静默跳过(resume 幂等)

  // step 6: annotated tag (idempotent + stale-tag 自检)
  // 注:HEAD 此刻已包含 § ⑫ 更新 commit(或本来就在 targetTag 上),tag 指向新鲜 commit。
  const tag = await agent(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
  if (tag.exists) {
    // 旧版 bug 残留:tag 可能指向 § ⑫ 仍为占位符的 commit。检查并要求人工 `git tag -f`。
    const fresh = await agent(checkTagReportFreshPromptM(targetTag, rpt.path), {label: lbl('tag-fresh?'), phase: 'Milestone', schema: TAG_REPORT_FRESHNESS_SCHEMA})
    if (!fresh.fresh) {
      throw new Error(`HALT milestone-stale-tag ${phaseId}: tag \`${targetTag}\` 指向的 commit 中 ${rpt.path} § ⑫ = ${JSON.stringify(fresh.tagReportValue || '?')},不是 ${targetTag}(旧版顺序 bug 残留)。请人工跑 \`git -C ${ROOT} tag -f ${targetTag}\` 把 tag 重指到 HEAD 后再重跑 coding-start。`)
    }
  } else {
    const r = await agent(createTagPromptM(phaseId, fe), {label: lbl('tag'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!r.success) throw new Error(`HALT milestone-tag ${phaseId}: ${r.error || ''}`)
  }

  log(`milestone: ${phaseId} → ${targetTag}`)
}

// ---- runCrossModule:原 crossModulePrompt 的"diff → 分类 → 写日志" → JS 编排 ----
// diff 和写文件是机械动作;"按 docs/09 路径归属判定哪些是跨模块"需要 LLM 判断,独立成一步。
async function runCrossModule(module) {
  const id = module?.id ?? '<module>'
  const lbl = (k) => `xmod:${k}:${id}`

  const def = await agent(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})

  const changed = await agent(collectCrossModuleChangedPromptM(def.branch), {label: lbl('diff'), phase: 'Milestone', schema: CHANGED_FILES_SCHEMA})
  if (!changed.files.length) {
    log(`cross-module-log: 模块 ${id} 无文件改动,跳过`)
    return
  }

  const classified = await agent(classifyCrossModulePromptM(id, changed.files), {label: lbl('classify'), phase: 'Milestone', schema: CROSS_CLASSIFY_SCHEMA})
  if (!classified.crossModule.length) {
    log(`cross-module-log: 模块 ${id} 无跨模块改动,跳过`)
    return
  }

  const r = await agent(writeCrossModuleLogPromptM(id, classified.crossModule), {label: lbl('write'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
  if (!r.success) throw new Error(`HALT crossModule-write ${id}: ${r.error || ''}`)

  log(`cross-module-log: 模块 ${id} 更新 ${classified.crossModule.length} 行`)
}

// ============================================================================
// 编排逻辑(结构按 plan 骨架;featureLoop / reviewWithFixLoop / testGate / 顶层循环)
// ============================================================================

// ---- 单功能链(后端 / 前端同构)----
// **顺序 for-await**(不是 pipeline)。理由:
//  - tdd / fix stage 会编辑共享工作树并 git commit;并发会争 .git/index.lock、撞 migration V<n>。
//  - pipeline 的"stage throw → item 掉 null、pipeline 永不 reject"语义会吞掉 reviewWithFixLoop /
//    verify / tdd 的 HALT throw,让模块主循环 try/catch 捕获不到,残缺模块照样被推进到 milestone。
//    顺序 for-await 让 throw 自然冒泡到主循环 try → catch → break,使 fail-fast 真正生效。
//
// 派生 stage 全部 schema 化:spec/plan/tdd/verify/fix 共用 STAGE_RESULT_SCHEMA,
// sub-agent 写 `{status:'halt', reason}` 时 JS 立即抛 HALT,让"无法继续"不再混入"成功返回"。
// 功能级 dedup 真值 = `req-done/<id>` git tag:featureLoop 入口先 check,存在则 skip(Router 文档/
// LLM 自审失误不再导致已 approve 的 REQ 被重新 spec→plan→tdd 污染源码 / 撞 V<n>)。
//
// 语义边界(重要):`req-done/<id>` 表示"该功能在写 tag 时已通过 reviewer approve",**不**表示
// "实现自此再未变化"。若 testGate / 后续模块工作中人工或子代理改动了已 approve 功能的代码,重跑
// coding-start 时此 dedup 会跳过 spec/plan/tdd/verify/review,**不会**再次审阅这些后期改动。
// 这是有意的设计:避免在共享工作树里因为别的模块的 cross-cut 改动反复重跑前面所有 REQ。
// 若需要"approve 后改动必须再次走 review"的语义,请在改动前手动删除对应 `req-done/<id>` tag。
async function featureLoop(items, phase) {
  const grp = phase === 'backend' ? 'Backend' : 'Frontend'
  for (const id of items) {
    // 入口 dedup:req-done/<id> 已存在 → 已 approve,整段 skip。
    const done = await agent(checkReqDoneTagPromptM(id), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA})
    if (done.exists) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue }

    const spec = await agent(deriveSpecPrompt(id, phase), {label:`spec:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})
    if (spec.status === 'halt') throw new Error(`HALT spec ${phase}:${id}: ${spec.reason || ''}`)
    if (!spec.artifactPath) throw new Error(`HALT spec-no-artifactPath ${phase}:${id}: spec returned ok but no artifactPath`)
    // 日期一致性自校验:spec 文件名首段必须可被解析为 YYYY-MM-DD(dateFromArtifactPath 会抛)。
    dateFromArtifactPath(spec.artifactPath)

    const plan = await agent(planPrompt(id, phase, spec.artifactPath), {label:`plan:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})
    if (plan.status === 'halt') throw new Error(`HALT plan ${phase}:${id}: ${plan.reason || ''}`)
    if (!plan.artifactPath) throw new Error(`HALT plan-no-artifactPath ${phase}:${id}`)
    if (dateFromArtifactPath(plan.artifactPath) !== dateFromArtifactPath(spec.artifactPath)) {
      throw new Error(`HALT plan-date-mismatch ${phase}:${id}: plan ${plan.artifactPath} 与 spec ${spec.artifactPath} 日期前缀不一致`)
    }

    const impl = await agent(tddPrompt(id, phase, plan.artifactPath), {label:`tdd:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})
    if (impl.status === 'halt') throw new Error(`HALT tdd ${phase}:${id}: ${impl.reason || ''}`)

    const v0 = await agent(verifyPrompt(id, phase, impl.summary || '', spec.artifactPath, 0), {label:`verify:${phase}:${id}`, phase: grp, schema: STAGE_RESULT_SCHEMA})
    if (v0.status === 'halt') throw new Error(`HALT verify ${phase}:${id}: ${v0.reason || ''}`)

    const reviewResult = await reviewWithFixLoop(id, phase, v0, spec.artifactPath)
    log(`review approved ${phase}:${id} after ${reviewResult.rounds} round(s)`)

    // approve 后落地 dedup 真值:req-done/<id> tag。
    const tagR = await agent(createReqDoneTagPromptM(id, phase), {label:`reqdone:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
    if (!tagR.success) throw new Error(`HALT req-done-tag ${phase}:${id}: ${tagR.error || ''}`)
  }
}

// 有界 5 轮修复;超出 → throw(终止态,非对话框)。
// fix 后再跑 reverify 让 fix-stage 的 commit 有机会被新一轮 verify 看到;
// verify 内部失败 throw 在顺序 for-await 下会冒泡到模块主循环 try。
// approve 后通过独立 micro step 把 docs/08 对应 checkbox flip 为 `[x]`(拆出 reviewer side-effect,
// 写失败可观测;幂等:已 checked 静默跳过)。
async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
  const grp = phase === 'backend' ? 'Backend' : 'Frontend'
  const fe = isFrontend(phase)
  let lastVerify = verifyResult
  let lastIssuesCount = 0
  for (let round = 1; round <= 5; round++) {
    const lastVerifySummary = (lastVerify && (lastVerify.summary || lastVerify.reason)) || ''
    // 命名碰撞警告:opts.phase = grp('Backend'/'Frontend'/'Milestone')是 harness 的 UI 分组,
    // 与 code-reviewer.md 文档里的"domain phase = backend|frontend"是**同名不同物**。
    // reviewer agent 从 prompt 正文 `**phase = ...**` 那一行读 domain phase,**不要**读 opts.phase。
    // 见 agents/code-reviewer.md "Domain phase resolution" 段。
    const r = await agent(
      reviewPrompt(id, phase, round, lastVerifySummary, specPath),
      {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'code-reviewer'}
    )
    if (r.verdict === 'approve') {
      // docs/08 checkbox flip(observable side effect,原 reviewer 隐式 Edit → micro step)
      const cb = await agent(readDocs08CheckboxPromptM(fe, id), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA})
      if (!cb.found) throw new Error(`HALT docs08-checkbox-missing ${phase}:${id}: docs/08 ${fe?'§ 三':'§ 二'} 中找不到 \`- [ ] ${id} ...\` / \`- [x] ${id} ...\` 行`)
      // 防御:即使 schema 已 require state,再做一次 JS 校验,杜绝"found:true 但 state 缺失/枚举外"静默走 checked 分支。
      if (cb.state !== 'checked' && cb.state !== 'unchecked') {
        throw new Error(`HALT docs08-checkbox-state-invalid ${phase}:${id}: cb.state = ${JSON.stringify(cb.state)}`)
      }
      if (cb.state === 'unchecked') {
        const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
        if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`)
      }
      // cb.state === 'checked' → 静默跳过(resume 幂等)
      return { id, phase, approved:true, rounds:round }
    }
    // request-changes 必须带 must-fix 清单(结构化对象数组);否则 fix 步无法定位 → 直接 halt 暴露 reviewer 契约违例。
    if (!Array.isArray(r.issues) || r.issues.length === 0) {
      throw new Error(`HALT review-empty-issues ${phase}:${id} r${round}: reviewer 返回 request-changes 但 issues 为空,无法驱动 fix 步`)
    }
    lastIssuesCount = r.issues.length
    // 每个 issue 必须含 locator(locator 校验由 fix sub-agent 在 git cat-file 阶段再做一次硬把关)。
    const missingLocator = r.issues.filter(x => !x || typeof x.locator !== 'string' || !x.locator.trim())
    if (missingLocator.length) {
      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(' | ')})`)
    }

    const fixR = await agent(fixPrompt(id, phase, r.issues), {label:`fix:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA})
    if (fixR.status === 'halt') throw new Error(`HALT fix ${phase}:${id} r${round}: ${fixR.reason || ''}`)

    lastVerify = await agent(
      verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验,上轮 must-fix: ${r.issues.length} 项)`, specPath, round),
      {label:`reverify:${phase}:${id}:r${round}`, phase: grp, schema: STAGE_RESULT_SCHEMA}
    )
    if (lastVerify.status === 'halt') throw new Error(`HALT reverify ${phase}:${id} r${round}: ${lastVerify.reason || ''}`)
  }
  throw new Error(`HALT review-unresolved ${phase}:${id}: 5 轮 review 仍未 approve(最后一次 reverify ${lastVerify?.status || '?'},最后一轮 must-fix ${lastIssuesCount} 项)`)
}

// flake 重试 1 次:attempt=2 写到独立证据文件 `<id>-test-gate-r2.md`,不覆盖 r1 的 red 证据(report § ⑤ 用得到)。
async function testGate(module, phase) {
  let g = await agent(gatePrompt(module, phase, 1), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  if (g.status === 'red') { // 自动重试 1 次(防 flaky)
    g = await agent(gatePrompt(module, phase, 2), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  }
  if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${(g.failures||[]).join('; ')}`)
  return g
}

phase('Router')
const routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA})

// Router 语义运行时断言:后端模块 feItems 必空、frontend-phase 聚合模块 reqs 必空。
// schema 用 additionalProperties:false 但不强制互斥;这里把契约违例在最早处暴露而不是让错配 phase 静默跑下去。
//
// 同时硬约束所有 id 形状为 /^[A-Za-z0-9_-]+$/:下游 micro step prompt 大量把 id / branch / phaseId
// 模板进 `git ... ${id}` shell 命令字符串,未单引号包裹也未做字符校验。LLM 返回畸形 id(含 ;、`、
// $()、空格等)会改变子代理执行的命令;这里在 Router 出口一次性把关,让 fail-fast 比 shell 注入早。
const ID_PATTERN = /^[A-Za-z0-9_-]+$/
function assertSafeId(kind, value) {
  if (typeof value !== 'string' || !ID_PATTERN.test(value)) {
    throw new Error(`HALT router-invalid-${kind}: ${JSON.stringify(value)}(必须匹配 /^[A-Za-z0-9_-]+$/,用于安全地拼入 git 命令)`)
  }
}
for (const m of routed.modules) {
  assertSafeId('module-id', m.id)
  for (const r of m.reqs || []) assertSafeId('req-id', r)
  for (const f of m.feItems || []) assertSafeId('fe-id', f)
  const isFE = m.id === 'frontend-phase'
  if (isFE && Array.isArray(m.reqs) && m.reqs.length) {
    throw new Error(`HALT router-violation: frontend-phase 聚合模块的 reqs 必须为空,实测含 ${m.reqs.length} 项 (${m.reqs.join(',')})`)
  }
  if (!isFE && Array.isArray(m.feItems) && m.feItems.length) {
    throw new Error(`HALT router-violation: 后端模块 ${m.id} 的 feItems 必须为空(前端只在 frontend-phase 聚合),实测含 ${m.feItems.length} 项 (${m.feItems.join(',')})`)
  }
}

const todo = routed.modules.filter(m => !m.done)
log(`coding: ${todo.length}/${routed.modules.length} modules to run`)

const results = []
let haltedAtIdx = -1
for (const [idx, module] of todo.entries()) {
  try {
    // C1:进入模块前建/切功能分支(milestone 的 merge 源)。runBranchSetup 把"探测默认分支 /
    // 校验工作树 / 切或新建分支 / 校验 HEAD"分解为 4-5 个微 agent,分支判定全在 JS 里。
    phase('Milestone')
    await runBranchSetup(module)
    if (module.reqs.length) {                          // 后端段(frontend-phase 模块 reqs 为空 → 跳过)
      phase('Backend')
      await featureLoop(module.reqs, 'backend')
      phase('Gate')
      await testGate(module, 'backend')
      phase('Milestone')
      await runCrossModule(module)                      // 替代被删 hook,JS 编排:diff → 分类 → 写日志
    }
    if (module.feItems.length) {                        // 前端段(仅末尾 frontend-phase 聚合模块)
      phase('Frontend')
      await featureLoop(module.feItems, 'frontend')
      phase('Gate')
      await testGate(module, 'frontend')
    }
    phase('Milestone')
    const rep = await agent(reportPrompt(module), {label:`report:${module.id}`, phase:'Milestone', schema: STAGE_RESULT_SCHEMA})
    if (rep.status === 'halt') throw new Error(`HALT report ${module.id}: ${rep.reason || ''}`)
    // runMilestone:原 6 步散文(worktree / 默认分支 / merge / docs/08 / tag / report)由 JS 编排,
    // 每个"已是目标态则跳过"的条件由 JS 在 read 微 agent 的结构化返回上判定,跨重入幂等。
    await runMilestone(module)
    results.push({ module: module.id, status:'done' })
  } catch (e) {
    results.push({ module: module.id, status:'halted', reason: String(e.message||e) })
    haltedAtIdx = idx
    break // 整阶段 fail-fast:halt 后停,等人工修复后重跑 coding-start
  }
}

// pending:halt 后被跳过的剩余模块(M5)。caller / coding-start 可据此告知用户"修好后还有哪些待跑",
// 而不是仅看到一个 halted 模块就误以为只剩一个。
const pending = haltedAtIdx >= 0
  ? todo.slice(haltedAtIdx + 1).map(m => ({ module: m.id, status: 'pending' }))
  : []

// Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表。
// 注:顶层 `return` 在 CommonJS 中合法,但在 ESM 中非法。本脚本被 Workflow 运行时以 ESM 方式
// (dynamic import)加载时,运行时会把脚本体包进 async function 再执行,于是顶层 `return` 实际成为
// Workflow 的结果通道(与 `export const meta` 并存)。**不要**改成 `export default {...}` —— 那
// 会破坏返回值契约,Workflow 拿不到 results / pending。
return { results, pending }