coding.mjs 73.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
// workflows/coding.mjs
//
// 整个 ERP Coding(B 阶段)= 一个静默、全自动的 Workflow 脚本。
// 设计原则见仓库根 README.md「阶段 B」与「设计原则」节;featureLoop 顺序 for-await 的取舍
// 详见 featureLoop 函数处的注释。运行时禁用日期 / 随机数 builtin,所有"今天"由子代理解析。

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 结构化对象(summary/locator/severity)驱动 fix。
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 统一返回,status=halt 时 JS 立即 throw 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 用)─────────
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 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 功能行勾选态;state 必填——只 require found 时 cb.state 缺失会静默走 checked 分支。
const CHECKBOX_STATE_SCHEMA = { type:'object', additionalProperties:false,
  required:['found','state'], properties:{
    found:{type:'boolean'},
    state:{type:'string', enum:['checked','unchecked']},
    lineNumber:{type:'integer'} } }

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 必须是绝对路径——相对 '.' 会绑定到子代理隐式 cwd,无保证。
if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) {
  throw new Error(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`)
}

// ── Feature-loop stage prompts(共享非交互契约见 featureStageContract)──

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 / 等待人类输入。**绝不要尝试问人**。',
    '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md` → `docs/05-API接口契约.md` → `prototype/`(前端布局/交互权威)→ `src/styles/tokens.css`(前端色值)→ `CLAUDE.md` → 现有代码。',
    '- 仍查不到 → **不要编造、不要留 `【人工填写:】` / `TBD` / `TODO` 占位**;把具体阻塞点(缺哪个值、应在哪个 Plan 闸门锁定、为何无法继续)写进产物。',
    '- 然后让本步骤以非零结果 / 显式 throw 结束,由上层 Workflow 转为带诊断的 halt(fail-fast)。',
    '- 全部输出文档**使用中文**。',
    `- **阶段 = ${fe ? '前端(frontend)' : '后端(backend)'}**。路径作用域:${fe
      ? '实现文件必须落在 `frontend/` 下;命中 `backend/` / `sql/` / `scripts/` 即越界,硬停。'
      : '产出范围限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约;**禁止**写 `frontend/` 路径下的实现(UI 推迟到前端阶段)。'}`,
    `- id 形态:${fe ? '前端为 `FE-NN`(业务功能粒度,可关联多个 prototype 区域与多个 REQ)。' : '后端为 `REQ-XXX-NNN`。'}`,
  ].join('\n')
}

// commitBlock:spec/plan/verify/review 共用的"写完 → add → commit → 失败 halt"四行块。
function commitBlock(addPath, msg, tail = '- commit 失败 → halt,把 stderr 摘要写进 reason。') {
  return [
    '## commit',
    `- 写完后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`,
    `  1. \`git -C ${ROOT} add ${addPath}\``,
    `  2. \`git -C ${ROOT} commit -m "${msg}"\``,
    tail,
  ].join('\n')
}

// Router:读 docs/08 §二/§三 + git tag,重算进度,返回 ROUTER_SCHEMA。
function routerPrompt(root) {
  return [
    '# Coding Router — 从账本重算进度',
    '',
    `项目根:\`${root}\``,
    '',
    '你是 Coding 阶段的路由子代理。**只读不写**(不改任何代码 / 文档),仅从状态账本重算"哪些模块还要跑",返回结构化结果。',
    '',
    '## 读取来源(账本 = docs/08 + git tag,里程碑和功能级完成都以 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。',
    '4. `git -C <root> tag -l "req-done/*"`:列出已通过 review 并落地的功能级完成 tag。`docs/08` checkbox 只作可视化,不作为跳过功能的真值。',
    '',
    '## 完成判定(每个模块独立)',
    '- 后端模块 `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"` 存在。',
    '- 后端 REQ / 前端 FE 的功能级完成判定:仅当 `git tag -l "req-done/<id>"` 能查到该 tag 才视为已 approve。不要因为存在 review markdown 或 docs/08 checkbox 已勾就跳过;若 tag 缺失,必须把该 id 放回待跑列表。',
    '',
    '## 输出(必须符合下发的 JSON schema)',
    '- `modules`: 数组。**先**按 `docs/02 § 二` 的模块顺序列出全部后端模块,**再在末尾追加唯一一个前端聚合模块**(仅当存在前端 FE 时)。每项:',
    '  - `id`: 模块标识(后端为英文蛇形 module id;前端聚合模块固定用 `frontend-phase`)。',
    '  - `done`: 该模块/前端阶段是否已完成(按上面的 milestone 判定)。',
    '  - `reqs`: **仅后端模块**填本模块**缺少 `req-done/<REQ>` tag** 的后端 REQ 有序列表;模块已 done → 空数组。**前端聚合模块 `reqs` 恒为空数组**。',
    '  - `feItems`: **仅前端聚合模块**填——把**全部模块**缺少 `req-done/<FE-NN>` tag 的前端 FE-NN 汇总为一个有序列表放进 `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 表单锁定,前端布局/交互以 \`${ROOT}/prototype/\` 为权威;这里**只消费已锁定的事实**,不再澄清。`,
    '',
    '## 收集上下文',
    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}/src/styles/tokens.css\`(色值 / 状态色单一来源;只用 var(--color-*),禁硬编码 hex)。**与 prototype 的色值冲突时以 tokens.css 为准**(prototype 管结构/布局/交互)。`,
          `- 前端组件库:\`${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。',
    '',
    commitBlock('<spec artifactPath>', `docs(spec:${id}): 派生规格`),
    '',
    '## 自审(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 § 二 前端规范\`(§ 2.1 目录约定 = 落盘位置;状态管理 / 请求封装 / 错误处理);色值 / 样式见 \`${ROOT}/src/styles/tokens.css\`;测试栈见 § 零。用 Grep 在 \`${ROOT}/frontend/\` 定位现有文件。`
      : `- \`${ROOT}/docs/04-技术规范.md\`(编码规范 + § 1.2 分层结构 = 后端落盘)。用 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/\` 开头;命中 \`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 一致)。',
    '',
    commitBlock('<plan artifactPath>', `docs(plan:${id}): 任务级 TDD 计划`),
    '',
    '## 输出(必须符合下发的 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/` 路径的 `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 引用,**不要**自行另起目录或自由命名文件。`,
    '',
    commitBlock('<证据 artifactPath>', `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`)。',
    '',
    commitBlock(`docs/superpowers/reviews/<同 spec 的 YYYY-MM-DD>-${id}.md`,
      `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,不要"修一个不存在的文件"。`,
    `- 修复后 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 refs/heads/<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 refs/heads/${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\`。`,
    '## 输出(BRANCH_NAME_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) {
  const section = fe ? '§ 三' : '§ 二'
  const title = fe
    ? '# 读 docs/08 § 三 `整体里程碑:` 字段当前值'
    : `# 读 docs/08 § 二 模块 \`${id}\` 的 \`里程碑:\` 字段当前值`
  const locator = fe
    ? `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`- 整体里程碑: <value>\` 行。`
    : `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section} 中 module id == \`${id}\` 的 bullet 段,取其 \`  - 里程碑: <value>\` 子项。`
  const missing = fe
    ? '- § 三 或该行不存在:`{ "found": false, "value": "" }`'
    : `- 模块 \`${id}\` 或该字段不存在:\`{ "found": false, "value": "" }\``
  return [
    title,
    microStepContract(),
    '',
    locator,
    '## 输出(FIELD_VALUE_SCHEMA)',
    '- 命中:`{ "found": true, "value": "<冒号后去空白的当前值>", "lineNumber": <行号> }`',
    missing,
  ].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;read-then-write)──
function readDocs08CheckboxPromptM(fe, id) {
  const section = fe ? '§ 三' : '§ 二'
  const kind = fe ? '功能' : 'REQ'
  const locator = fe
    ? `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`
    : `Read \`${ROOT}/docs/08-模块任务管理.md\`,定位 ${section},找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`
  return [
    `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`,
    microStepContract(),
    '',
    locator,
    '## 输出(CHECKBOX_STATE_SCHEMA)',
    `- 命中 \`- [x] ${id} ...\`:\`{ "found": true, "state": "checked", "lineNumber": <行号> }\``,
    `- 命中 \`- [ ] ${id} ...\`:\`{ "found": true, "state": "unchecked", "lineNumber": <行号> }\``,
    '- 找不到:`{ "found": false, "state": "unchecked" }`(state 仍必填,避免 schema 失败掩盖真实缺口)。',
  ].join('\n')
}

function writeDocs08CheckboxPromptM(fe, id, phase, lineNumber) {
  const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}`
  const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber))
    ? `先 Read \`${ROOT}/docs/08-模块任务管理.md\` 第 ${lineNumber} 行(1-based),确认该行去掉行首空白后以 \`- [ ] ${id} \` 开头;不满足则返回 \`{success:false, error:"line-${lineNumber}-mismatch: actual=<actual>"}\`。然后只替换第 ${lineNumber} 行的第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。`
    : `定位 docs/08 ${scope} 中去掉行首空白后以 \`- [ ] ${id} \` 开头的唯一一行,只替换该行第一个 \`[ ]\` 为 \`[x]\`,保留缩进与 id 之后的全部文本。`
  return [
    `# 把 docs/08 ${scope} 的 \`[ ]\` 勾选为 \`[x]\` 并 commit`,
    microStepContract(),
    '',
    `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`,
    `1. ${lineGuard}`,
    `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')
}

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/08-模块任务管理.md § 二\` 中本模块 bullet 的 \`路径:\` 字段为准。Read 它以建立"路径 → 模块"映射(粒度/分层约定见 docs/04 § 1.2/2.1)。`,
    '',
    '## 改动文件清单',
    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: DEFAULT_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 — tag exists 时静默跳过)
  const tag = await agent(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
  if (!tag.exists) {
    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/08 § 二 路径归属判定哪些是跨模块"需要 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(终止态,非对话框)。approve 后独立 micro step flip docs/08 checkbox。
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')是 harness UI 分组;domain phase 见 agents/code-reviewer.md。
    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') {
      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} ...\` 行`)
      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, cb.lineNumber), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
        if (!wr.success) throw new Error(`HALT docs08-checkbox-write ${phase}:${id}: ${wr.error || ''}`)
      }
      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/reqs 互斥)+ id 形状硬约束(防 shell 注入:id 直接拼入 `git ... ${id}`)。
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 {
    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 || ''}`)
    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` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行,
// 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。
// 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`,
// 否则 Workflow 拿不到 results / pending。
return { results, pending }