coding.mjs 147 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 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939
// 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' },
  ],
  // 注:'Behavior' phase 已删除——前端行为验收并入 per-FE reviewWithFixLoop 的 approve 子门,
  // 所有行为相关 agent()/adjudicate() 的 phase 入参统一用 'Frontend'(与 reviewWithFixLoop grp 一致)。
}

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 不再立即 fail-fast——先经 adjudicate() 仲裁(retry/continue/halt)才可能终止。
// decisions[]:stage 自主决策日志(缺值时不再停下,而是挑最有依据的默认/解读并登记于此),上层汇总进结果供人工事后审阅。
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'},
    decisions:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['question','choice','rationale'],
      properties:{
        question:{type:'string'},
        choice:{type:'string'},
        rationale:{type:'string'},
        confidence:{type:'string', enum:['high','medium','low']} } } } } }

// ADJUDICATE_SCHEMA:仲裁子代理在确定性 halt 之前的裁决——
//  retry    = 失败疑似一次性/可纠正,携 guidance 重跑上游;
//  continue = 缺陷不阻断正确性、可安全前进(降级为口头建议);
//  halt     = 确属不可恢复(结构性缺失无旁证 / git 树需人工 / 会污染源码或伪造业务语义)。
const ADJUDICATE_SCHEMA = { type:'object', additionalProperties:false,
  required:['action','rationale'], properties:{
    action:{type:'string', enum:['retry','continue','halt']},
    guidance:{type:'string'},
    rationale:{type:'string'} } }

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

// BEHAVIOR_GATE_SCHEMA:前端行为门(per-FE behavior 子门)返回。
// 不杂交 GATE×STAGE_RESULT——复用既有词汇但独立成型:交互层 / 文字层 / 覆盖率 / 环境错误分别结构化,
// JS 据 source/kind 分流(交互硬边界转 must-fix,文字按 source 二分 allowContinue,envError 走 retry,
// build-failed 确定性短路)。设计:见 docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 3/6/7。
const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false,
  required:['status','routesPlanned','routesReached','controlsEnumerated'], properties:{
    status:{type:'string', enum:['green','red']},
    routesPlanned:{type:'integer'},        // 本 FE 关联路由数(覆盖率分母来源;per-FE 只数 feScope.routes,不数 router 全部)
    routesReached:{type:'integer'},         // 实际带鉴权加载成功的本 FE 路由数
    controlsEnumerated:{type:'integer'},    // live 枚举到的本 FE 白名单控件数(空覆盖必须可见)
    authState:{type:'string'},              // 以何角色登录 / 覆盖角色 / 未覆盖角色集
    // interactionFailures.locator:行为硬问题的源码定位(组件文件 [+ DOM 描述])。per-FE 行为门必须反查到
    //   组件文件路径才能转 must-fix 喂 fix;反查不出(B 类)→ 不入 interactionFailures,归 coverageGap(不放行)。
    // 交互层硬边界:no-observable-effect / js-error / console-error / missing-docs05-call / binding-garbage
    interactionFailures:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['page','control','kind','detail'],
      properties:{
        page:{type:'string'}, control:{type:'string'},
        kind:{type:'string', enum:['no-observable-effect','js-error','console-error','missing-docs05-call','binding-garbage']},
        detail:{type:'string'},
        locator:{type:'string'} } } },     // 组件文件路径 [+ DOM 选择器/绑定片段描述];有则可转 must-fix 喂 fix
    // 文字层软边界:source 决定 allowContinue(sentinel 客观 bug 不可 continue;i18n/literal/semantic 可 adjudicate continue)
    textIssues:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['page','region','expected','actual','source'],
      properties:{
        page:{type:'string'}, region:{type:'string'},
        expected:{type:'string'}, actual:{type:'string'},
        source:{type:'string', enum:['sentinel','i18n','literal','semantic']},
        locator:{type:'string'} } } },
    // 覆盖率缺口:写证据 + recordDecisions,不单独 halt(空覆盖由 controlsEnumerated==0 兜底)
    // build-failed-sibling-unimpl:兄弟 FE 未实现导致本 FE 之外路由/组件编译缺件(预期中途态,不归本 FE 缺陷)
    // locator-not-resolvable:行为硬问题连组件文件都反查不出(B 类),计入未覆盖阻断 approve,不静默放行
    coverageGaps:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['page','reason','detail'],
      properties:{
        page:{type:'string'},
        reason:{type:'string', enum:['unreachable-auth','unreachable-no-route','deep-control-not-driven','dynamic-route-no-seed','build-failed-sibling-unimpl','locator-not-resolvable']},
        detail:{type:'string'} } } },
    // 环境错误(与业务断言失败严格区分):none 表示无环境问题;build-failed 是确定性短路(既不 retry 也不 halt)。
    //   build-failed 时 rootCausePath 写报错根因文件路径——落在非本 FE 路径=兄弟未实现(短路放行),落在本 FE=真构建 bug。
    envError:{ type:'object', additionalProperties:false,
      required:['kind'],
      properties:{
        kind:{type:'string', enum:['port-conflict','stack-not-ready','seed-error','auth-failed','timeout','build-failed','none']},
        detail:{type:'string'}, ports:{type:'string'}, pids:{type:'string'}, rootCausePath:{type:'string'} } },
    // decisions[]:复用 STAGE_RESULT 形状,缺值自主决策日志
    decisions:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['question','choice','rationale'],
      properties:{
        question:{type:'string'}, choice:{type:'string'}, rationale:{type:'string'},
        confidence:{type:'string', enum:['high','medium','low']} } } },
    artifactPath:{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` → 现有代码。',
    '- 仍查不到时——**优先自主决策继续,不要停下**:基于现有代码约定 / 技术规范 / 同类实现,挑选**最有依据的解读或合理默认值**,把该决策写进产物显著位置,并在返回的 `decisions[]` 中逐条登记 `{question, choice, rationale, confidence}`(这是默认动作,项目目标是全自动静默、尽可能少 halt)。',
    '- 红线:**绝不**留 `【人工填写:】` / `TBD` / `TODO` 占位;**绝不**编造与现有约定/技术规范冲突的"事实";自主默认必须可被现有证据支撑且记入 `decisions[]`。',
    '- 仅当缺失的是**无法自洽决策的硬事实**(如某表结构 / 业务主键语义完全缺失且无任何旁证,任何默认都可能污染源码或伪造业务语义)时,才以 `status:halt` 结束并把阻塞点写清;上层会再经仲裁评估能否继续,halt 是最后手段而非首选。',
    '- 输出纪律:本次若做过任何自主默认 / 解读,成功返回(status:ok)**必须**带 `decisions[]`(逐条 `{question,choice,rationale,confidence}`,与上面登记要求一致);完全没有自主决策时才可省略——别照抄"输出"段里不含 decisions 的最简示例而漏登记。',
    '- 全部输出文档**使用中文**。',
    `- **阶段 = ${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。',
    fe
      ? [
          '',
          '## 行为验收作用域结构化小节(per-FE 行为门唯一断言依据,**强制写到 spec 头部**)',
          '- 在 spec 文件头部(紧随标题/关联 REQ 之后)写一个**结构化小节**,标题逐字为 `## 行为验收作用域`,内含两条机器可读清单:',
          '  ```',
          '  ## 行为验收作用域',
          '  - 关联路由: [/orders, /orders/:id]',
          '  - 负责控件白名单: [data-testid=order-submit, /orders 页 "提交" 按钮, ...]',
          '  ```',
          `- **关联路由**:从 \`${ROOT}/frontend/\` router 配置(用 Grep 定位)取本 FE 真正负责渲染的路由 path(与 router 一致;带参动态路由保留 \`:id\` 占位)。**只列本 FE 路由**,不要列兄弟 FE / 共享路由。`,
          '- **负责控件白名单**:本 FE 页面上"点了必须有可观测效果 / 显示必须正确"的控件清单(优先 `data-testid` 约定;无 testid 时用 `<页面> + DOM 选择器/可见文案` 描述)。行为门只对白名单内控件判 must-fix;白名单外 / 共享控件归 coverageGap,绝不算本 FE 缺陷。',
          '- 该小节是**确定性映射**(fe-feature-review 会校验其存在且与 router 一致,缺失/不一致 → request-changes);推不出路由(router 尚未声明本 FE 路由)→ 按硬约束登记 decisions 取最有依据的占位 path 或 halt(不要留空)。',
        ].join('\n')
      : '',
    '',
    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 整个文件内容**(build.gradle / 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`)。'
      : ' 确认的后端测试命令(如 Gradle task / `./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 复刻。'
      : '',
    fe
      ? `- **占位替换(保证中途可构建 + per-FE 行为门可达本 FE 路由)**:前端骨架阶段已在 router 里为本 FE 路由声明 lazy import 但指向占位组件 \`FeStub\`。本 FE 实现完成后,**必须**把 router 中本 FE 路由的 import 从 \`FeStub\` 改为本 FE 真组件(用 Grep 在 \`${ROOT}/frontend/\` router 定位本 FE 路由 path 的 import 行;仍在 \`frontend/\` 路径内,不破坏护栏)。改完确保 router 该路由 lazy import 指向真组件、可构建可达。`
      : '',
    '',
    '## 护栏',
    '- **绝不**在主会话直接跑测试(gradle / 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 或项目标准命令确定(Gradle task / 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 → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`,
    fe ? `- **行为验收作用域小节校验(per-FE 行为门前置真值,必查)**:spec \`${specPath}\` 头部**必须**含逐字标题为 \`## 行为验收作用域\` 的结构化小节,且其 \`关联路由:\` 清单与 \`${ROOT}/frontend/\` router 配置一致(本 FE 路由都在 router 声明、无悬空/错配)。该小节缺失 或 与 router 不一致 → **必须 request-changes**,把"补齐/对齐 行为验收作用域小节"列入 issues(locator 指向 spec 文件路径)。这是 approve 前置——行为门只能据此确定本 FE 路由作用域。` : '',
    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')
}

// ---- 前端行为验收(per-FE behavior 子门)----
// 设计权威:docs/design/2026-06-02-frontend-behavior-in-review-loop.md。
// 不再是阶段级末尾独立门——并入 per-FE reviewWithFixLoop 的 approve 子门:某轮 reviewer 判 approve 时才触发,
// 起本 FE 全栈 + sentinel 种子,枚举本 FE 路由控件/文字,硬问题转可 fix must-fix→重验,行为 green 才放行 approve。
// 门是**跨栈只读验证 + 临时产物**的第三类 stage:不套 featureStageContract('frontend')
// (其路径护栏命中 backend/sql/scripts 即越界硬停,与门必须运行 setup-test-db / 起后端 / 生成 SQL 种子自相矛盾)。

// behaviorGateContract:门的硬约束。非交互;证据报告用中文但 spec/sentinel/SQL 可英文标识符;
// 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端前端 / 跑 playwright,
// 唯一**可写** = .tmp/behavior-gate/<FE>/r<behaviorRound>/ + 证据报告及 assets;改 frontend//backend//sql/ 源码即越界硬停。
function behaviorGateContract() {
  return [
    '## 硬约束(非交互行为验收子代理)',
    '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
    '- 你是**跨栈只读验证门**:用真实运行(起后端 + 起前端 headless + Playwright 枚举)证明「本 FE 每个按钮/点击真的生效、每段文字显示正确内容」,**不是**实现功能、**不是**改源码。',
    '- 缺值查找顺序:`config-vars.yaml` → `docs/04-技术规范.md § 零` → `docs/05-API接口契约.md` → `docs/03-数据库设计文档.md` → `prototype/`(前端布局/交互权威)→ `frontend/`(router 配置 / package.json)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。',
    `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下命令——\`node ${ROOT}/scripts/setup-test-db.mjs\`、起后端服务(spring-boot:run 等)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${ROOT}/.tmp/behavior-gate/<FE>/r<behaviorRound>/\`(spec/种子 SQL/runner,跑完即弃)+ 证据报告 \`${ROOT}/docs/superpowers/reviews/<date>-<FE>-behavior-r<behaviorRound>-a<attempt>.md\` + 其 assets(截图归档到 \`${ROOT}/docs/superpowers/reviews/assets/...\`)。`,
    `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${ROOT}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`,
    '- **per-FE 中途态豁免(关键)**:本门在 **per-FE 模式**下运行——`frontend/` 中**本 FE 之外**的路由/组件可能尚未实现,属预期中途态。遇到指向未建路由的链接 / 404 / 编译缺件(兄弟 FE 或骨架占位未覆盖),一律记 `coverageGaps[reason="build-failed-sibling-unimpl"]` 或 `envError.kind="build-failed"`(按根因路径归属,见 step0/step2),**绝不**归为本 FE 的 `interactionFailures`。**本 FE 路由清单(feScope.routes)是唯一断言作用域**;白名单外 / 共享控件归 coverageGap,不算本 FE 缺陷。',
    '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。',
    '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。',
    '- **运行时确定性**:sentinel 值 / 端口 / 临时目录名一律由你确定性派生(按列类型 / config-vars 端口 / FE id / behaviorRound / attempt 序号),**绝不**依赖时间戳 / 随机数。',
  ].join('\n')
}

// behaviorGatePrompt:per-FE 行为验收子代理的完整流水线提示(step0-6 + schema)。
// id:本 FE id(如 FE-07);specPath:本 FE spec(含 ## 行为验收作用域 小节,feScope 来源 + 日期前缀);
// behaviorRound:approve 子门内的行为 fix 轮(1..BEHAVIOR_FE_MAX);attempt:本轮内环境 race 重试序号(1..)。
// 每 (FE × behaviorRound × attempt) 独立 .tmp 子目录 + 独立证据文件,绝不互相覆盖(不丢 flake 信号)。
function behaviorGatePrompt(id, specPath, behaviorRound, attempt) {
  const safeId = id ?? 'FE'
  const tmpDir = `${ROOT}/.tmp/behavior-gate/${safeId}/r${behaviorRound}`
  const date = (() => { try { return dateFromArtifactPath(specPath) } catch { return '<date>' } })()
  const evidence = `docs/superpowers/reviews/${date}-${safeId}-behavior-r${behaviorRound}-a${attempt}.md`
  return [
    `# behavior — 前端 per-FE 行为验收(headless,FE=${safeId}, behaviorRound=${behaviorRound}, attempt=${attempt})`,
    '',
    behaviorGateContract(),
    '',
    '## 目标',
    `用真实全栈运行证明本 FE \`${safeId}\` 的「每个按钮/点击都真的生效、每段文字都显示正确内容(right context)」。`,
    `单个子会话内**收敛完成**:冷起栈 → 逐**本 FE 路由**枚举 + 两层断言 → teardown。期望即时推导(prototype/ + REQ + docs/05),**不**持久化为契约,但推导期望写进已提交证据报告。`,
    `- 本 FE 行为验收作用域唯一真值 = spec \`${specPath}\` 头部的 \`## 行为验收作用域\` 小节(\`关联路由:\` + \`负责控件白名单:\`)。先 Read 该 spec 取出 feScope;缺该小节 → \`envError.kind="stack-not-ready"\` 并在 detail 写明(不应出现:reviewer 已校验它存在)。`,
    behaviorRound > 1 || attempt > 1 ? `- 本次 = behaviorRound ${behaviorRound} / attempt ${attempt}(上一次 red / envError / fix 后重验);证据**写到独立文件 r${behaviorRound}-a${attempt}** 不要覆盖前一次。` : '',
    '',
    '## 运行机制(无常驻进程跨会话;冷起栈→跑→teardown 收敛进单 runner)',
    '- **冷起栈(运行时硬约束)**:本项目**无既有 e2e webServer / playwright.config 复用入口**——runner 必须**自负冷起后端 + 前端**,behaviorRound / attempt 之间**绝不复用运行栈、无 HMR**,每次从头 spawn 起栈→跑→teardown。',
    `- **入口清目录(跑前第一步,去串味)**:${behaviorRound === 1 && attempt === 1
      ? `本次是本 FE 首轮首次 → 先删除整个 \`${ROOT}/.tmp/behavior-gate/${safeId}/\` 目录(清掉本 FE 历史残留 runner/种子/spec),再新建本轮子目录 \`${tmpDir}/\`。`
      : `本次 behaviorRound=${behaviorRound} → 仅删除/清空本轮子目录 \`${tmpDir}/\`(幂等,不动其它 round 的临时残留),再新建。`}用确定性、跨平台方式删除(如 \`fs.rmSync(path, { recursive:true, force:true })\` 后 \`fs.mkdirSync(path, { recursive:true })\`),**仅限上述受控路径**,绝不删 \`.tmp/behavior-gate/\` 之外的任何路径。`,
    `- 你在 \`${tmpDir}/\` 写一个一次性 runner(如 \`run.mjs\`),用 spawn 起进程树、轮询就绪、\`finally\` 中 **kill 本 FE 起的全部子进程**并透传结构化结果。**绝不**让前台 spring-boot:run / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`,
    `- **确定性端口/pid 回收前置**:起栈前先按既知端口 + \`${tmpDir}/*.pid\` 强制回收上一 attempt 残留(编排层 + runner 双保险);端口先探测占用,占用则回收或退到动态空闲端口 + 把 baseURL 注入下游。`,
    `- \`${ROOT}/.tmp/behavior-gate/\`(含子目录)已被仓库 \`.gitignore\` 忽略,是唯一临时写区;跑完即弃,只提交证据报告 + assets。`,
    '',
    '## step0 探测 + build 归因(确定性短路前置,依赖 build-failed kind)',
    `- 读 \`${ROOT}/docs/04-技术规范.md § 零\` + \`${ROOT}/frontend/package.json\` + \`${ROOT}/config-vars.yaml\`。`,
    '- runner 自负冷起后端 + 前端 headless(无既有 webServer 可复用)。**起 dev / source-map 模式**(注入定位辅助:`data-testid` 约定 / Vue `__file`),便于把 page+selector 映射回组件文件。',
    '- **build / 起 dev server 失败时先归因**:用 `git` / `Grep` 判断报错根因文件路径——',
    `  - 落在**非本 FE 的 \`frontend/\` 路径**(兄弟 FE 组件缺失 / 骨架占位未覆盖 / 指向未建路由)→ \`envError.kind="build-failed"\` + \`rootCausePath=<非本FE路径>\`(**预期中途态**,不是本 FE bug)。`,
    '  - 落在**本 FE 路径**(feScope 关联组件)→ 才是本 FE 引入的真构建 bug → 归 `interactionFailures[kind="js-error"]`(带 locator=组件文件)。',
    '  - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。',
    '',
    '## step1 路由真值发现(覆盖率分母 = 本 FE 路由,不数 router 全部)',
    '- 分母来源 = spec `## 行为验收作用域` 小节的 `关联路由:` 清单(**只数本 FE 路由**);`routesPlanned` = 本 FE 关联路由数。**不要**把 router 全部路由计入分母(router 含兄弟 FE + 占位路由)。',
    '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**本 FE 每路由的预期控件与文字来源**;每路由标注所需登录角色。',
    '- 带参动态路由用**种子已知主键**实例化;无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。',
    '- **未建兄弟路由既不计入分母也不计 coverageGap**(属预期中途态,按 step0 归 build-failed 短路)。',
    '',
    '## step2 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)',
    `1) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`,
    '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
    '3) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。',
    '   - **sentinel 规则**:按列类型派生类型合法且可辨识的值——字符串列逐字段唯一编码(如 `CUST_NAME_S001`,抓绑错字段)+ 行序号保 UNIQUE;数值列用高位魔数;enum 列从 docs/03 值域取并标注。插入前扫 Flyway / config-vars 既有初始数据(admin_init 等)键,sentinel 主键偏移到不冲突区;断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。',
    '4) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。',
    '- `finally` **硬要求 kill 本 FE 起的全部子进程**;端口 + pid 写入 `envError.ports` / `envError.pids`(即便成功也回填,便于审计)。反复 port-conflict 设独立硬上限直接 halt 提示人工清理(不连环 retry 烧时间)。',
    '',
    '## step2.5 鉴权 bootstrap(确定性前置)',
    '- 用 config-vars `admin_init` 或种子已知凭据,经 `docs/05` 登录端点**真实登录**拿 JWT,注入 Playwright `storageState`;`authState` 记角色覆盖(覆盖 / 未覆盖角色集)。',
    '- 登录失败 = `envError.kind="auth-failed"`(环境 race,走 retry),**绝不**当成死控件。',
    '',
    '## step3 枚举(可达性驱动 + 分母对账,非首帧快照;只驱动本 FE feScope)',
    '- **只枚举/驱动 feScope.routes + feScope.controlWhitelist**(本 FE 白名单控件)。每路由带 `storageState` 加载,收集 DOM 真实控件与文字区域。分母 = step1 本 FE 推导清单,分子 = live 枚举。',
    '- 分母有但首帧无的控件:runner 尝试**驱动到出现态**(种子保列表非空触发行级操作 / 进多步流程下屏 / 展开 dropdown / 切 tab 后二次枚举);仍不可达 → `coverageGaps[reason="deep-control-not-driven"]`,不静默判 green。到不了的路由 → `coverageGaps[reason="unreachable-auth"|"unreachable-no-route"]`,与「到达了但控件死」严格区分。',
    '- **白名单外 / 共享控件**:若属其它未 approve FE 或共享区 → 归 `coverageGaps[reason="deep-control-not-driven"]`,**绝不**归本 FE 的 `interactionFailures`。',
    '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。',
    '- `routesReached` / `controlsEnumerated` 据实填(本 FE 子集空覆盖必须可见)。',
    '',
    '## step4 推导期望',
    '- 每控件预期可观测效果;每文字区域预期内容 + 来源(`literal` / `sentinel` / `i18n` / `semantic`)。',
    '',
    '## step5 断言(两层 + 可观测效果白名单 + 硬问题带源码 locator)',
    '- **交互层可观测效果白名单**:URL 变化 / docs05 网络调用(`page.on("request")` 比对端点)/ DOM 变更 / 校验信息 / 弹层 / toast / 原生对话框(枚举前注册 `page.on("dialog")`,confirm/alert/beforeunload 计合法效果,防 confirm 阻塞误判 missing-docs05-call)/ 下载(`page.on("download")`)/ 新标签(`page.on("popup")` / `target=_blank`)。',
    '  - 无任何效果 → `interactionFailures[kind="no-observable-effect"]`;JS 异常 → `js-error`;`console.error` → `console-error`;应发未发网络调用 → `missing-docs05-call`。断言用 auto-waiting / `expect.poll`,**不用**固定 sleep。',
    '- **文字层**:动态文字格对比该 region 字段的唯一 sentinel(抓绑错字段)。',
    '- **绑定垃圾分级**:`null` / `undefined` / `[object Object]` / `NaN` / `lorem` 出现在绑定位 → `interactionFailures[kind="binding-garbage"]`;双花括号未渲染 / 空占位 `—` / 疑似 i18n key → `textIssues`(走 adjudicate;i18n 类额外加载真实 locale 比对)。',
    '- **文字不符按来源分流到 source**:绑定 sentinel 不符 → `source="sentinel"`(客观 bug,转 must-fix,必须带 `locator`;反查不到组件文件则归 `coverageGaps[reason="locator-not-resolvable"]`);i18n key / 字面 / 语义类 → `source="i18n"|"literal"|"semantic"`(软文字,走仲裁,永不阻断 approve)。',
    '- **行为硬问题必须带源码 locator(转 must-fix 喂 fix 的前置)**:',
    '  - **A 类(可反查到组件文件)**:经 route → router 配置 → view 组件文件反查到**组件级文件路径**。`interactionFailures[].locator` = `<组件文件路径>`(可附 DOM 选择器 / 绑定文本片段,写进 `detail`);`detail` 写「失败 kind + 期望端点/期望 sentinel 值 + 实际渲染值 + DOM 路径 + 绑定片段」,供 fix 子代理在该组件内 Grep 定位 handler/绑定。binding-garbage / sentinel-mismatch 同样附 DOM 路径 + 绑定片段 + 期望 sentinel + 实际渲染值。',
    '  - **B 类(连组件文件都反查不出)**:**不静默降级放行**——归 `coverageGaps[reason="locator-not-resolvable"]`(计入未覆盖,使本轮不能判 green),或归 `envError.kind="stack-not-ready"` 走 retry。绝不把无 locator 的硬问题塞进 `interactionFailures` 不带 locator(上层会因无 locator 走 adjudicate(allowContinue:false),绝不放行)。',
    '',
    `## step6 证据落盘 + commit(运行时行为,沿用证据 commit 习惯)`,
    `- 写 \`${evidence}\`:本 FE feScope / 推导期望 / 逐控件判定 / routesPlanned-Reached-controlsEnumerated / authState(含未覆盖角色集)/ coverageGaps / 截图。`,
    `- 截图归档到**已纳入版本管理**的 \`docs/superpowers/reviews/assets/...\`(**不要**引用 \`.tmp\` 防断链)。`,
    `- 若本次 \`status:red\` 或存在 envError,证据**头部用红字标注原因**。`,
    commitBlock(`${evidence} docs/superpowers/reviews/assets`,
      `docs(behavior:${safeId}:r${behaviorRound}-a${attempt}): per-FE 行为验收证据`),
    '',
    '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)',
    '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + 无阻断性 envError + 本 FE 覆盖非空)| `red`。',
    '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数本 FE feScope**;空覆盖必须可见)。',
    '- `interactionFailures` / `textIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`(含 `source="sentinel"` 的 textIssue)。',
    '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。',
    '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。',
    '- 不要返回额外字段(schema 是 `additionalProperties:false`)。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。',
  ].filter(Boolean).join('\n')
}

// ---- 前端骨架占位 stage(runFrontendSkeleton 用)----
// 设计:docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 2(前置依赖 A,blocker)。
// 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(未实现 FE 路由指向 FeStub 占位)
// + 不指悬空 path 的共享导航——保证「前端只建了一部分」的任意时刻 app 仍可构建可起、每个 FE 路由可达。
// 由此 per-FE 行为门的「可构建前提」成立、tddPrompt 的占位替换有真值起点、build-failed 退化为罕见兜底。
// feItems:本前端阶段的全部 FE-NN(来自 Router 的 frontend-phase 聚合模块),即 router 全量路由表的清单。
function frontendSkeletonPrompt(feItems) {
  const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(Router 未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)'
  return [
    '# fe-skeleton — 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位)',
    '',
    featureStageContract('frontend'),
    '',
    '## 目标',
    '在逐 FE 实现开始**之前**,一次性建出前端「可构建可起」的骨架:App 外壳 + router **全量** lazy 路由表(每个 FE 路由都声明,未实现的指向占位组件 `FeStub`)+ 不指悬空 path 的共享导航。',
    '保证后续「只建了一部分 FE」的任意时刻 `vite build` / dev server 都能起、每个 FE 路由都可达(加载到占位);逐 FE 实现时再把对应路由的 import 从 `FeStub` 换成真组件。',
    '',
    `## 本前端阶段 FE 清单(router 全量路由表必须覆盖的全部 FE)`,
    `- ${list}`,
    '',
    '## 收集上下文(确定技术栈 + 目录约定 + 路由)',
    `- \`${ROOT}/docs/04-技术规范.md § 零\`(\`frontend.ui_lib\` / framework / 构建工具)+ \`§ 二 前端规范\`(§ 2.1 目录约定 = 落盘位置 / 路由库 / 入口文件名)。`,
    `- \`${ROOT}/docs/08-模块任务管理.md § 三\`(前端阶段元数据 + \`功能:\` 下全部 \`FE-NN\` 行;与上面清单核对,以本提示给出的清单为准)。`,
    `- \`${ROOT}/docs/01-需求清单/\` 各 FE 关联 REQ + \`${ROOT}/prototype/\`(页面/路由结构权威)+ \`${ROOT}/docs/05-API接口契约.md\`,据此推导每个 FE-NN 对应的**路由 path**(带参动态路由保留 \`:id\` 占位)。`,
    `- 用 Grep 在 \`${ROOT}/frontend/\` 探测现有 App 外壳 / 入口 / router 是否已存在(幂等:已存在则按需补齐,不重复创建/不覆盖已实现的真组件)。`,
    '',
    '## 产出(全部落在 `frontend/` 路径内——遵守前端阶段路径作用域护栏)',
    '1. **App 外壳 + 入口**:`frontend/src/App.*` 与入口 `frontend/src/main.*`(按 framework / docs/04 约定的扩展名;不存在才创建)。挂载共享布局 + `<router-view>`(或等价 outlet)。',
    '2. **router 全量路由表**(按 docs/04 § 2.1 约定的路由文件位置,如 `frontend/src/router/index.*`):',
    '   - **每个** FE-NN 对应路由都声明,**全部用 lazy import**(`component: () => import(...)` 或 framework 等价的动态 import;**绝不** eager `import X from ...` 顶部静态引入,否则未建组件会让整表编译失败)。',
    '   - **未实现的 FE 路由全部指向占位组件 `FeStub`**:`component: () => import("../views/_stub/FeStub.vue")`(或 framework 等价)。逐 FE 实现后由 tdd stage 把对应路由 import 换成真组件。',
    '   - 路由 path 取自上面推导的 FE→path 映射;带参路由用 `:id` 等占位。',
    '3. **占位组件 `FeStub`**:`frontend/src/views/_stub/FeStub.vue`(framework 非 Vue 时落对应等价文件,如 `FeStub.tsx`),最小渲染一个带 `data-fe-stub` 属性的元素(如 `<div data-fe-stub>占位</div>`;行为门据 `data-fe-stub` 识别占位态)。**不实现任何业务逻辑**。',
    '4. **共享布局/导航**:导航链接**全部指向已在 router 声明的路由 path**(不指向任何不存在的 path),保证任意时刻无悬空链接。',
    '- **lazy 硬护栏**:router 表里**任何** FE 路由都不得用顶部静态 `import`;必须 `() => import(...)`。自检:Grep 路由文件,确认每个 FE 路由的 `component` 都是动态 import 形态。',
    '- **路径硬护栏**:所有产出文件必须以 `frontend/` 开头;命中 `backend/` / `sql/` / `scripts/` → 越界硬停。',
    '',
    '## 自检(可构建)',
    '- 推断本项目前端 build / typecheck 命令(docs/04 § 零 / `frontend/package.json` scripts)。若可在子会话内安全跑(不挂死),**派 Agent 子会话**跑一次 build / dev-server 就绪探测确认骨架可构建可起;不可行则至少静态核对「全部 FE 路由已声明 + 全 lazy + 导航无悬空 path + FeStub 存在」。',
    '- 占位符扫描:`TBD` / `TODO` / `【人工填写:】` → 命中即修。',
    '',
    commitBlock('frontend/', 'feat(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位',
      '- commit 失败 → halt,把 stderr 摘要写进 reason。'),
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    '- 成功:`{ "status": "ok", "summary": "<已声明的 FE 路由数 / 入口与 router 文件路径摘要>" }`(artifactPath 可省)。',
    '- 任一护栏 / 缺值(如无法推导某 FE 的路由 path 且无任何旁证)→ `{ "status": "halt", "reason": "<具体阻塞点>" }`。',
    '- 做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。',
  ].filter(Boolean).join('\n')
}

// fe-skeleton 幂等判定:检测 router 是否已声明本阶段全部 FE 路由(全量 + 全 lazy)。
// router/state 是骨架真实完成态;fe-skeleton-done tag 只作补记,避免陈旧 tag 跳过缺失骨架。
function frontendSkeletonStatePromptM(feItems) {
  const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)'
  return [
    '# 检测前端骨架是否已建(router 已声明全部 FE 路由 + 全 lazy)',
    microStepContract(),
    '',
    `用 Grep / Read 检查 \`${ROOT}/frontend/\`:是否已存在 router 配置文件,且其中**本阶段全部 FE 路由**(对应 FE:${list})都已声明、全部为 lazy import(\`() => import(...)\`),且占位组件 \`FeStub\`(\`frontend/src/views/_stub/FeStub.*\`)存在。`,
    '- 全部满足(骨架已建齐)→ `{ "exists": true }`',
    '- 任一缺失(无 router / 缺某 FE 路由 / 存在 eager import / 无 FeStub)→ `{ "exists": false }`',
    '## 输出(EXISTS_SCHEMA)',
  ].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')
}

// ============================================================================
// 仲裁 / 自主决策基础设施(halt 收敛)
// 设计:原先每个"缺值 / 结构违约 / 重试耗尽"点都直接 throw HALT 让整阶段 fail-fast。
// 现在改为先经 adjudicate() 仲裁——retry(带 guidance 重跑)/ continue(降级前进)/ halt(确属不可恢复)。
// stage 自身也被要求优先自主决策继续(见 featureStageContract),其默认/解读记入 decisions[] 汇总。
// 仅 git 树冲突 / 配置错 / id 形状错(assertSafeId)保持硬 halt——这些不可由 LLM 代决。
// ============================================================================

const ADJUDICATE_MAX = 3                       // 单个 site 的仲裁轮上限;超出则确定性 halt(防无限循环)
// per-FE 行为子门预算(二维,钉死防证据覆盖;设计 §6.4):
//  - BEHAVIOR_FE_MAX = approve 子门内的行为 fix 轮硬上限(每 FE);超限 throw HALT。**不**复用 review 的 10 轮、
//    **不**让 REVIEW_HARD_ROUNDS × 行为重试隐式相乘——典型一次过(1 轮),最坏 3 轮。
//  - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。
const BEHAVIOR_FE_MAX = 3
const BEHAVIOR_ATTEMPT_MAX = 2
const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : ''

// 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。
const autonomousDecisions = []
function recordDecisions(site, decisions) {
  if (!Array.isArray(decisions)) return
  for (const d of decisions) {
    if (!d) continue
    autonomousDecisions.push({ site, question:d.question, choice:d.choice, rationale:d.rationale, confidence:d.confidence })
    log(`decision ${site}: ${d.question || '?'} → ${d.choice || '?'} (${d.confidence || '?'})`)
  }
}

function adjudicatePromptM(site, context) {
  const ctx = typeof context === 'string' ? context : JSON.stringify(context, null, 2)
  return [
    `# 仲裁:\`${site}\` 触发潜在 halt,请裁决 retry / continue / halt`,
    microStepContract(),
    '',
    '## 你的角色',
    '你是 ERP 编码 Workflow 的**仲裁子代理**。某上游步骤触发了一个原本会让整阶段 fail-fast 停下的护栏。',
    '项目目标是全自动静默、尽可能少停。请在**不损坏 git 工作树、不伪造业务事实、不污染源码**的前提下,尽量让流程继续。',
    '',
    '## 触发上下文',
    '```',
    ctx,
    '```',
    '',
    '## 裁决口径',
    '- `retry`:失败疑似一次性 / 可纠正(子代理输出不符 schema 约定、git 命令瞬时失败、上游漏给某字段)。**必须**在 `guidance` 写清"重跑时要修正什么",下游会把它原样注入重跑提示。',
    '- `continue`:缺陷不阻断正确性、可安全前进(reviewer 的非必须建议 / 可降级为口头建议的 issue / 纯可视化副作用缺失 / 已可由后续 verify / test-gate 兜底的疑虑)。在 `rationale` 说明为何安全。',
    '- `halt`:确属不可恢复——结构性缺失且无任何旁证、git 树冲突需人工、继续会污染源码 / 伪造业务语义。在 `rationale` 写清人工需要做什么。',
    '- 若上下文含 `"allowContinue": false`,**不得**选 continue(如红色测试不可跳过),只在 retry / halt 间选。',
    '## 输出(ADJUDICATE_SCHEMA)',
    '- `{ "action": "retry|continue|halt", "guidance": "<retry 时给下游的纠正指令,其余可空字符串>", "rationale": "<裁决理由>" }`',
  ].join('\n')
}

async function adjudicate(site, context, grp, round) {
  const verdict = await agent(adjudicatePromptM(site, context),
    {label:`adjudicate:${site}:r${round}`, phase: grp, schema: ADJUDICATE_SCHEMA})
  log(`adjudicate ${site} r${round}: ${verdict.action}${verdict.rationale ? ' — ' + verdict.rationale : ''}`)
  return verdict
}

// runStage:跑一个 STAGE_RESULT 派生 stage(spec/plan/tdd/verify/fix/report)。
//   ① 登记 decisions[];② status:halt 或 validate() 报结构问题 → 经 adjudicate 决定 retry/continue/halt。
// makePrompt(guidanceTail) 接收仲裁追加指令串(adjGuidance 已格式化);validate(res) 返回 null=通过 / 问题串。
// allowContinue=true 只用于后续 reviewer / behavior 会再次兜底的软 stage;流程前提默认不可 continue。
// allowContinue=false:本 stage 的 halt 代表**硬正确性边界**(功能测试红色 verify/reverify、路径越界/卡死 tdd、
//   test-gate 红 report),仲裁只许 retry/halt,**绝不 continue 放行**残缺/越界状态去 approve / milestone。
async function runStage(makePrompt, { site, grp, label, validate, allowContinue = false }) {
  let guidance = ''
  for (let round = 1; round <= ADJUDICATE_MAX; round++) {
    const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA})
    recordDecisions(site, res.decisions)
    let problem = null
    if (res.status === 'halt') problem = `stage 返回 status:halt;reason: ${res.reason || '(空)'}`
    else if (validate) { try { problem = validate(res) } catch (e) { problem = String(e?.message || e) } }
    if (!problem) return res
    const verdict = await adjudicate(site, { problem, stageResult: res, allowContinue }, grp, round)
    if (verdict.action === 'continue' && allowContinue) return res
    if (verdict.action !== 'retry') throw new Error(`HALT ${site}: ${verdict.rationale || problem}`)
    guidance = verdict.guidance || ''            // retry:带 guidance 重跑
  }
  throw new Error(`HALT ${site}-adjudication-exhausted: ${ADJUDICATE_MAX} 轮仲裁仍未解决`)
}

// runAction:跑一个 ACTION_RESULT 微步骤(git / 文件写),失败时经 adjudicate 决定 retry/continue/halt。
// allowContinue=true 时 continue 视为"接受失败并前进"(仅用于纯可视化等可安全跳过的副作用)。
async function runAction(makePrompt, { site, grp, label, allowContinue = false }) {
  let guidance = ''
  for (let round = 1; round <= ADJUDICATE_MAX; round++) {
    const r = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: ACTION_RESULT_SCHEMA})
    if (r.success) return r
    const verdict = await adjudicate(site,
      { problem:`action 失败:${r.error || ''}${r.detail ? '\n' + r.detail : ''}`, allowContinue }, grp, round)
    if (verdict.action === 'continue' && allowContinue) return r
    if (verdict.action === 'halt' || verdict.action === 'continue')
      throw new Error(`HALT ${site}: ${verdict.rationale || r.error || ''}`)
    guidance = verdict.guidance || ''            // retry:带 guidance 重跑
  }
  throw new Error(`HALT ${site}-adjudication-exhausted: ${ADJUDICATE_MAX} 轮仲裁仍未解决`)
}

// recoverDirtyWorktreePromptM:branchSetup / milestone 前置的"工作树干净"被打破时的自主恢复(class D 部分)。
// 子代理检查脏文件——全是本阶段合法产物 → 自动 commit 后继续;含越界/不明改动 → 不提交、返回失败让上层 halt。
// **分支护栏(branch)**:自动 commit 只允许发生在目标功能分支上。若当前 HEAD 不在 branch(如里程碑后 HEAD
//   停在默认分支、resume 时残留落在默认分支),绝不 add -A/commit——否则会把绕过 review/test-gate 的改动
//   直接提交进默认分支,且该改动对模块 `<default>...HEAD` 三点 diff 不可见(污染 cross-module / 完成报告)。
function recoverDirtyWorktreePromptM(dirty, branch, scopeHint) {
  const list = (dirty || []).map(p => `- ${p}`).join('\n') || '(调用方未给清单,请自行 `git status --porcelain` 复核)'
  return [
    '# 工作树不干净——判定能否自主提交后继续',
    microStepContract(),
    '',
    '## 背景',
    `分支切换 / 里程碑前要求工作树干净,当前存在未提交改动。${scopeHint || ''}`,
    '在**不丢失工作、不混入越界改动、不提交到错误分支**的前提下尽量让流程继续。',
    '',
    '## 脏文件清单',
    list,
    '',
    '## 流程',
    `0. **分支护栏(必须先做)**:跑 \`git -C ${ROOT} rev-parse --abbrev-ref HEAD\`。若当前分支 **!= \`${branch}\`**(目标功能分支),**绝不提交**——直接返回 \`{ "success": false, "error": "dirty-on-wrong-branch", "detail": "HEAD=<当前分支>, expected ${branch};拒绝把残留提交到非功能分支,留给人工" }\`。只有当前已在 \`${branch}\` 才继续 step 1。`,
    `1. 逐一检查改动(\`git -C ${ROOT} status --porcelain\`,必要时 \`git -C ${ROOT} diff\`)。`,
    `2. **全部都是本阶段合法产物**(spec/plan/verify/review/report/源码/migration,且落在当前阶段路径作用域内)→ \`git -C ${ROOT} add -A\` 后 \`git -C ${ROOT} commit -m "chore: 自动提交上一步残留改动"\`,返回 \`{ "success": true, "detail": "committed-in-scope" }\`。`,
    '3. 含**越界 / 不明 / 与本阶段无关**的改动(手工临时文件、其它模块代码、构建产物等)→ **不要提交**,返回 `{ "success": false, "error": "dirty-out-of-scope", "detail": "<可疑文件 + 原因>" }`。',
    '## 输出(ACTION_RESULT_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')
}

// fe-skeleton-done:前端骨架占位 stage 的补记 tag;真实完成态以 router/state 检测为准。
function createFeSkeletonTagPromptM() {
  return [
    '# 打 annotated tag `fe-skeleton-done`(前端骨架占位已建)',
    microStepContract(),
    '',
    `先用 \`git -C ${ROOT} tag -l fe-skeleton-done\` 检查;已存在则视为成功(幂等)直接返回 success。`,
    `否则跑 \`git -C ${ROOT} tag -a fe-skeleton-done -m "chore(fe-skeleton): App 外壳 + router 全量 lazy 路由表 + FeStub 占位已建"\`。`,
    '## 输出(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
      ? `- **前端行为验收已并入 per-FE review 循环**(reviewer approve 子门,行为 green 是 \`req-done/<FE>\` 的前置真值)——report **不再**校验阶段级 behavior-gate 文件(已不再产生)。**对每个 \`req-done/<FE>\` tag 即视为该 FE 行为已过**(避免双真值)。可选轻量校验:每个 FE 存在对应 per-FE 行为证据 \`${ROOT}/docs/superpowers/reviews/<date>-<FE>-behavior-r*-a*.md\` 且最后一份非 RED;缺证据不 halt(仅在 § ⑤/⑧ 标注)。`
      : '',
    '',
    '## 收集输入(取摘要而非正文)',
    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\`,并附首次失败用例与最终绿色记录链接。**另把 per-FE 行为证据 \`${ROOT}/docs/superpowers/reviews/<date>-FE-*-behavior-r*-a*.md\`(按 FE → behaviorRound → attempt 排序)的 flake / 环境 race(envError,含 build-failed 短路)/ 文字 continue 记录一并纳入 § ⑤ 汇总**。`,
          `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外按 per-FE 行为证据 \`${ROOT}/docs/superpowers/reviews/<date>-FE-*-behavior-r*-a*.md\` 汇总各 FE 的 \`coverageGaps\` + 文字 \`textIssues\` 的 continue 记录 + 逐控件判定摘要 + authState 未覆盖角色集**。`,
          '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。',
        ].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})

  // 工作树脏:先自主恢复(in-scope 残留 → 自动 commit);含越界改动则恢复失败 → halt(留给人工)。
  const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  if (!wt.clean) {
    const rec = await agent(recoverDirtyWorktreePromptM(wt.dirty, branch, `分支 setup 前置(目标分支 ${branch})。`),
      {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!rec.success) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`)
    log(`branch-setup: ${id} 自动提交脏工作树残留(${rec.detail || ''})`)
  }

  const exists = await agent(checkBranchExistsPromptM(branch), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
  if (exists.exists) {
    await runAction(g => checkoutExistingBranchPromptM(branch) + g, {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('checkout')})
  } else {
    await runAction(g => createBranchFromPromptM(def.branch, branch) + g, {site:`branchSetup-create:${branch}`, grp:'Milestone', label: lbl('create')})
  }

  // HEAD 确认:不符则经仲裁重切(retry)或留人工(halt)。
  let head = await agent(currentBranchPromptM(), {label: lbl('head'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
  for (let adj = 1; head.branch !== branch && adj <= ADJUDICATE_MAX; adj++) {
    const verdict = await adjudicate(`branchSetup-branch-mismatch:${branch}`,
      { problem:`分支 setup 后 HEAD 在 ${head.branch},期望 ${branch}` }, 'Milestone', adj)
    if (verdict.action !== 'retry')
      throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${verdict.rationale || `HEAD on ${head.branch}`}`)
    await runAction(g => checkoutExistingBranchPromptM(branch) + g, {site:`branchSetup-recheckout:${branch}`, grp:'Milestone', label: lbl('recheckout')})
    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}: ${ADJUDICATE_MAX} 轮后 HEAD 仍在 ${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(脏树先自主恢复 in-scope 残留;含越界改动则 halt 留人工)
  const wt = await agent(worktreeCleanPromptM(), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  if (!wt.clean) {
    const rec = await agent(recoverDirtyWorktreePromptM(wt.dirty, branch, `里程碑前置(阶段 ${phaseId},应在功能分支 ${branch})。`),
      {label: lbl('wt-recover'), phase: 'Milestone', schema: ACTION_RESULT_SCHEMA})
    if (!rec.success) throw new Error(`HALT milestone-dirty-worktree ${phaseId}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`)
    log(`milestone: ${phaseId} 自动提交脏工作树残留(${rec.detail || ''})`)
  }

  // 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)
  // merge 冲突保持**硬 halt**:自动 abort/stash/改文件均不安全,把树留给人工(设计原则不变)。
  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 '—')
  let field = await agent(readDocs08FieldPromptM(fe, id), {label: lbl('field?'), phase: 'Milestone', schema: FIELD_VALUE_SCHEMA})
  for (let adj = 1; !field.found && adj <= ADJUDICATE_MAX; adj++) {
    const verdict = await adjudicate(`milestone-docs08-missing:${phaseId}`,
      { problem:`docs/08 ${fe ? '§ 三' : `§ 二 模块 ${id}`} 里程碑字段未找到` }, 'Milestone', adj)
    if (verdict.action !== 'retry') throw new Error(`HALT milestone-docs08-missing ${phaseId}: ${verdict.rationale || '字段不存在'}`)
    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}: ${ADJUDICATE_MAX} 轮仲裁后仍未找到字段`)
  if (field.value === '—') {
    await runAction(g => writeDocs08FieldPromptM(fe, id, targetTag, phaseId, field.lineNumber) + g,
      {site:`milestone-docs08-write:${phaseId}`, grp:'Milestone', label: lbl('field-write')})
  } else if (field.value !== targetTag) {
    const verdict = await adjudicate(`milestone-docs08-unexpected:${phaseId}`,
      { problem:`docs/08 里程碑字段当前 = ${JSON.stringify(field.value)}(行 ${field.lineNumber || '?'}),期望 '—' 或 '${targetTag}'`, allowContinue:true }, 'Milestone', 1)
    if (verdict.action === 'halt') throw new Error(`HALT milestone-docs08-unexpected ${phaseId}: ${verdict.rationale || JSON.stringify(field.value)}`)
    log(`milestone ${phaseId}: docs/08 字段非预期值(${JSON.stringify(field.value)}),仲裁判放行`)
  }
  // else: 已是 targetTag → 静默跳过(续跑场景)

  // step 5: report § ⑫ FIRST(关键顺序:tag 必须指向"§ ⑫ 已落地"的 commit,否则
  // `git checkout milestone/<id>` 看到的报告 § ⑫ 仍是 placeholder。原版顺序 tag → § ⑫ 是已知 bug,
  // 此处显式倒过来;下面 step 6 的 tag 才会指向新鲜 commit。)
  let rpt = await agent(findReportPromptM(phaseId), {label: lbl('report?'), phase: 'Milestone', schema: REPORT_PATH_SCHEMA})
  for (let adj = 1; !rpt.found && adj <= ADJUDICATE_MAX; adj++) {
    const verdict = await adjudicate(`milestone-report-missing:${phaseId}`,
      { problem:`未找到匹配 docs/superpowers/module-reports/*-${phaseId}.md 的报告文件` }, 'Milestone', adj)
    if (verdict.action !== 'retry') throw new Error(`HALT milestone-report-missing ${phaseId}: ${verdict.rationale || '报告文件缺失'}`)
    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}: ${ADJUDICATE_MAX} 轮仲裁后仍无报告文件`)
  if (rpt.currentTagValue === '{{milestone_tag}}') {
    await runAction(g => updateReportPromptM(rpt.path, targetTag, phaseId) + g,
      {site:`milestone-report-update:${phaseId}`, grp:'Milestone', label: lbl('report')})
  } else if (rpt.currentTagValue !== targetTag) {
    const verdict = await adjudicate(`milestone-report-unexpected:${phaseId}`,
      { problem:`${rpt.path} § ⑫ 当前 = ${JSON.stringify(rpt.currentTagValue)},期望占位符 {{milestone_tag}} 或 ${targetTag}`, allowContinue:true }, 'Milestone', 1)
    if (verdict.action === 'halt') throw new Error(`HALT milestone-report-unexpected ${phaseId}: ${verdict.rationale || JSON.stringify(rpt.currentTagValue)}`)
    log(`milestone ${phaseId}: 报告 § ⑫ 非预期值(${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) {
    await runAction(g => createTagPromptM(phaseId, fe) + g, {site:`milestone-tag:${phaseId}`, grp:'Milestone', label: lbl('tag')})
  }

  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
  }

  await runAction(g => writeCrossModuleLogPromptM(id, classified.crossModule) + g,
    {site:`crossModule-write:${id}`, grp:'Milestone', label: lbl('write')})

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

// ---- runFrontendSkeleton:前端骨架占位 stage 的 JS 编排(设计 § 2,前置依赖 A)----
// 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(FeStub 占位)+ 无悬空导航。
// 幂等(resume 安全):router/state 是唯一真实完成态;fe-skeleton-done 只作补记,避免陈旧 tag 跳过缺失骨架。
async function runFrontendSkeleton(feItems) {
  const lbl = (k) => `fe-skeleton:${k}`

  // step 1: 检查 router 是否已声明全 FE 路由;已建则只确保补记 tag 存在。
  const state = await agent(frontendSkeletonStatePromptM(feItems),
    {label: lbl('state?'), phase: 'Frontend', schema: EXISTS_SCHEMA})
  if (state.exists) {
    log('fe-skeleton: router 已声明全部 FE 路由,确保 fe-skeleton-done tag 存在')
    await runAction(g => createFeSkeletonTagPromptM() + g,
      {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag')})
    return
  }

  // step 2: 派子代理生成骨架(成功后子代理自行 commit;此处仅经 runStage 仲裁 halt 收敛)。
  await runStage(g => frontendSkeletonPrompt(feItems) + g,
    {site:'fe-skeleton', grp:'Frontend', label: lbl('gen')})

  // step 3: 打 fe-skeleton-done 补记 tag。
  await runAction(g => createFeSkeletonTagPromptM() + g,
    {site:'fe-skeleton-tag', grp:'Frontend', label: lbl('tag')})

  log(`fe-skeleton: 已生成前端骨架(覆盖 ${(feItems || []).length} 个 FE 路由),打 fe-skeleton-done tag`)
}

// ============================================================================
// 编排逻辑(结构按 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,统一经 runStage 跑:
// stage 优先自主决策继续(缺值挑默认/解读并记入 decisions[]);返回 status:halt 或结构校验失败时不再立即
// fail-fast,而是经 adjudicate 仲裁 retry/continue/halt(最多 ADJUDICATE_MAX 轮),把"无法继续"收敛为最后手段。
// 功能级 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 runStage(g => deriveSpecPrompt(id, phase) + g, {
      site:`spec:${phase}:${id}`, grp, label:`spec:${phase}:${id}`,
      validate: r => {
        if (!r.artifactPath) return 'spec 返回 ok 但缺 artifactPath(流程靠它定位 spec 并派生下游日期前缀)'
        dateFromArtifactPath(r.artifactPath)   // 文件名日期前缀非法 → 抛,被 runStage 捕获转为仲裁
        return null
      },
    })
    // spec 经仲裁 continue 时 artifactPath 仍可能不带合法日期前缀——防御取值,避免重算抛出把 continue 变成隐式 halt。
    let specDate = null
    try { specDate = dateFromArtifactPath(spec.artifactPath) } catch { specDate = null }

    const plan = await runStage(g => planPrompt(id, phase, spec.artifactPath) + g, {
      site:`plan:${phase}:${id}`, grp, label:`plan:${phase}:${id}`,
      validate: r => {
        if (!r.artifactPath) return 'plan 返回 ok 但缺 artifactPath'
        if (specDate && dateFromArtifactPath(r.artifactPath) !== specDate)
          return `plan 日期前缀与 spec 不一致:plan=${r.artifactPath} / spec=${spec.artifactPath}`
        return null
      },
    })

    // tdd allowContinue:false:tddPrompt 的 halt = 路径作用域越界护栏 / 同测试卡死 10 次——硬边界,
    // 仲裁不得 continue 放行(越界把前端实现混进后端分支 / 卡死等于测试没真过)。
    const impl = await runStage(g => tddPrompt(id, phase, plan.artifactPath) + g, {
      site:`tdd:${phase}:${id}`, grp, label:`tdd:${phase}:${id}`, allowContinue: false,
    })

    // verify allowContinue:false:verifyPrompt 的 halt = 功能测试红色(exit!=0 / failed>0)——与 test-gate 红同级硬边界,
    // 绝不 continue 放行红色实现进 review→approve→打 req-done tag(否则红色功能被永久标记完成、resume 跳过)。
    const v0 = await runStage(g => verifyPrompt(id, phase, impl.summary || '', spec.artifactPath, 0) + g, {
      site:`verify:${phase}:${id}`, grp, label:`verify:${phase}:${id}`, allowContinue: false,
    })

    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(失败经仲裁重试,确不可恢复才 halt)。
    await runAction(g => createReqDoneTagPromptM(id, phase) + g, {
      site:`req-done-tag:${phase}:${id}`, grp, label:`reqdone:${phase}:${id}`,
    })
  }
}

// review→fix 循环。halt 收敛点:
//  - 软上限 REVIEW_SOFT_ROUNDS 轮起每轮经仲裁决定**再延一轮(retry) 或 收尾(halt)**——禁止 continue(approve 只能来自
//    reviewer,仲裁不得在仍有未修 must-fix 时凌驾它放行);绝对硬上限 REVIEW_HARD_ROUNDS 防无限循环。
//  - reviewer 契约小瑕疵不再直接 halt:缺 locator 的 issue 降级为口头建议丢弃;若一条可定位 issue 都不剩(无可执行
//    must-fix),经仲裁决定 continue(视为无 must-fix → approve)/ retry(带 guidance 重判)/ halt。
//  - fix 经 runStage(显式 allowContinue,可 continue 跳过——未修的 must-fix 由后续 reviewer 重新 flag 兜底);
//    reverify 经 runStage 但 allowContinue:false(复验红色 = 修复没生效,绝不放行)。
//  - approve 后的 docs/08 checkbox 是纯可视化副作用(req-done tag 才是完成真值),缺失/写失败一律 log 跳过不 halt。
const REVIEW_SOFT_ROUNDS = 5
const REVIEW_HARD_ROUNDS = 10

// flipDocs08Checkbox:approve 后把功能行 [ ]→[x]。纯可视化;任何缺失/异常/写失败都降级为日志,绝不 halt。
async function flipDocs08Checkbox(fe, id, phase, grp) {
  const cb = await agent(readDocs08CheckboxPromptM(fe, id), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA})
  if (!cb.found) { log(`docs08-checkbox ${phase}:${id}: 未找到功能行,跳过可视化勾选(req-done tag 仍是完成真值)`); return }
  if (cb.state === 'checked') return
  if (cb.state !== 'unchecked') { log(`docs08-checkbox ${phase}:${id}: state 异常 (${JSON.stringify(cb.state)}),跳过勾选`); return }
  const wr = await agent(writeDocs08CheckboxPromptM(fe, id, phase, cb.lineNumber), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
  if (!wr.success) log(`docs08-checkbox ${phase}:${id}: 勾选写入失败(${wr.error || ''}),跳过——cosmetic,不阻断`)
}

async function reviewWithFixLoop(id, phase, verifyResult, specPath) {
  const grp = phase === 'backend' ? 'Backend' : 'Frontend'
  const fe = isFrontend(phase)
  let lastVerify = verifyResult
  let lastIssuesCount = 0
  let reviewGuidance = ''                                   // 仲裁 retry 时注入下一轮 review 的纠正指令
  // softPassed 提升到 reviewWithFixLoop 顶层作用域(与本 FE review 同寿命,跨 behaviorRound 持久)——
  // 行为软文字一旦被仲裁 continue 放行(降级),重跑后即便仍在 textIssues 也不再追问,避免反复消耗仲裁预算。
  const behaviorSoftPassed = new Set()
  for (let round = 1; round <= REVIEW_HARD_ROUNDS; 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) + adjGuidance(reviewGuidance),
      {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'erp-workflow:code-reviewer'}
    )
    reviewGuidance = ''                                     // 已消费

    if (r.verdict === 'approve') {
      // approve 闸显式 AND(设计 §6.2):reviewer.verdict==='approve' ∧ behaviorSubGate green(仅前端)。
      //   后端逐字不变(无行为维度);前端:静态 approve 后**不立即 return**,先进 per-FE 行为 approve 子门——
      //   起本 FE 全栈验「按钮真生效/文字对」,硬问题转可 fix must-fix→重验,行为 green 才放行;
      //   行为 green ⇒ 才 flipDocs08Checkbox + return(req-done tag 落点 featureLoop 不动,语义自动升级为「静态过+行为过」)。
      if (fe) {
        await behaviorSubGate(id, specPath, grp, behaviorSoftPassed)
      }
      await flipDocs08Checkbox(fe, id, phase, grp)
      return { id, phase, approved:true, rounds:round }
    }

    // request-changes:保留带 locator 的 must-fix;缺 locator 的降级丢弃(fix 步无从定位)。
    const issues = Array.isArray(r.issues) ? r.issues.filter(x => x && typeof x.locator === 'string' && x.locator.trim()) : []
    const dropped = (Array.isArray(r.issues) ? r.issues.length : 0) - issues.length
    if (dropped > 0) log(`review ${phase}:${id} r${round}: 丢弃 ${dropped} 个缺 locator 的 issue(降级为口头建议)`)
    if (issues.length === 0) {
      // 无任何可执行 must-fix(空 issues 或全缺 locator)→ 仲裁,而非直接 halt。
      const verdict = await adjudicate(`review-no-actionable:${phase}:${id}:r${round}`,
        { problem:'reviewer 判 request-changes 但无任何带 locator 的可执行 must-fix(无法驱动 fix 步)',
          reviewerIssues: r.issues || [] }, grp, round)
      // continue 视为「无 must-fix → 静态 approve」——前端仍须先过行为 approve 子门(行为 green 是任何 approve return 的前置)。
      if (verdict.action === 'continue') {
        if (fe) await behaviorSubGate(id, specPath, grp, behaviorSoftPassed)
        await flipDocs08Checkbox(fe, id, phase, grp); return { id, phase, approved:true, rounds:round }
      }
      if (verdict.action === 'halt') throw new Error(`HALT review-no-actionable ${phase}:${id} r${round}: ${verdict.rationale || ''}`)
      reviewGuidance = verdict.guidance || ''              // retry:带 guidance 重判(进入下一轮)
      continue
    }
    lastIssuesCount = issues.length

    await runStage(g => fixPrompt(id, phase, issues) + g, {
      site:`fix:${phase}:${id}:r${round}`, grp, label:`fix:${phase}:${id}:r${round}`, allowContinue: true,
    })

    // reverify allowContinue:false:fix 后复验红色 = 修复没真正生效,绝不 continue 放行去 approve。
    lastVerify = await runStage(
      g => verifyPrompt(id, phase, `(第 ${round} 轮 fix 后复验,上轮 must-fix: ${issues.length} 项)`, specPath, round) + g,
      { site:`reverify:${phase}:${id}:r${round}`, grp, label:`reverify:${phase}:${id}:r${round}`, allowContinue: false },
    )

    if (round >= REVIEW_SOFT_ROUNDS) {
      // 软上限到顶:仲裁只在"再延一轮(retry) / 收尾(halt)"间选。**禁止 continue 放行 approve**——
      // 此处仍有 reviewer 认定的可定位 must-fix 未清,仲裁不得凌驾专用 code-reviewer 直接判 approve(approve 只能来自 reviewer 本身)。
      const verdict = await adjudicate(`review-extend:${phase}:${id}`,
        { problem:`已 ${round} 轮 review 仍未 approve(上轮 ${lastIssuesCount} 项可定位 must-fix 未清)`,
          lastVerify: lastVerify.summary || lastVerify.reason || '', allowContinue:false }, grp, round)
      if (verdict.action !== 'retry') throw new Error(`HALT review-unresolved ${phase}:${id}: ${verdict.rationale || `${round} 轮仍有未修 must-fix`}`)
      // retry → 继续跑到硬上限(approve 仍由后续轮的 reviewer 决定)
    }
  }
  throw new Error(`HALT review-unresolved ${phase}:${id}: ${REVIEW_HARD_ROUNDS} 轮 review 仍未 approve(最后一次 reverify ${lastVerify?.status || '?'},最后一轮 must-fix ${lastIssuesCount} 项)`)
}

// flake 重试:每个 attempt 写独立证据文件 `<id>-test-gate-r<attempt>.md`,不覆盖前一次 red 证据(report § ⑤ 用得到)。
// red 是硬正确性边界——**绝不** continue 跳过;只让仲裁在"再跑一次辨 flake"与"确属真失败 → halt"间裁决。
async function testGate(module, phase) {
  let attempt = 1
  let g = await agent(gatePrompt(module, phase, attempt), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  if (g.status === 'red') {                                  // 自动重试 1 次(防 flaky)
    attempt = 2
    g = await agent(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  }
  // 仍 red:经仲裁辨识 flake。allowContinue:false → 红色不可跳过,仲裁只在 retry / halt 间选。
  for (let adj = 1; g.status === 'red' && adj <= ADJUDICATE_MAX; adj++) {
    const verdict = await adjudicate(`test-gate-red:${phase}:${module.id}`,
      { problem:`test-gate 第 ${attempt} 次仍 red`, failures: g.failures || [], allowContinue:false }, 'Gate', adj)
    if (verdict.action !== 'retry')
      throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${verdict.rationale || (g.failures||[]).join('; ')}`)
    attempt += 1                                             // retry:再跑一个独立 attempt 证据文件
    g = await agent(gatePrompt(module, phase, attempt), {label:`gate-retry:${phase}:${module.id}:a${attempt}`, phase:'Gate', schema: GATE_SCHEMA})
  }
  if (g.status === 'red') throw new Error(`HALT test-gate-red ${phase}:${module.id}: ${ADJUDICATE_MAX} 轮仲裁后仍 red:${(g.failures||[]).join('; ')}`)
  return g
}

// ---- 前端 per-FE 行为验收控制流(runBehaviorGateOnce + behaviorSubGate)----
// 设计:docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 6.3 / 7。
// 行为验收并入 per-FE reviewWithFixLoop 的 approve 子门——reviewer 即将 approve 时才触发,绝不每 review round 起栈。
// behaviorSubGate 失败分层(per-FE 缩 scope,保留原 runBehaviorGate 的分层语义):
//  - build-failed(兄弟 FE 未实现 / 占位未覆盖,根因落非本 FE 路径)= 确定性短路:记 coverageGap + decisions,
//    本轮行为门视为「本 FE 非缺陷」直接放行 approve(预期中途态,不 retry 不 halt)。
//  - envError(其它) / 空覆盖 = 环境 race:runBehaviorGateOnce 内部 attempt 1→2 重试;仍异常 → adjudicate(allowContinue:false)。
//  - 软文字(i18n/literal/semantic) → adjudicate(continue 记 decisions + 跨 behaviorRound softPassed;sentinel 并入 behaviorHard);永不阻断 approve。
//  - behaviorHard = interactionFailures + sentinel textIssues:有 locator → 降维喂 fixPrompt 跑 fix(fix 后功能 reverify + 下一轮重跑行为);
//    无 locator → adjudicate(allowContinue:false) retry/halt,绝不静默丢弃、绝不 approve。
//  - BEHAVIOR_FE_MAX 轮仍未 green → throw HALT behavior-unresolved(冒泡到顶层 try/catch → fail-fast)。

// envBlocked / ifails:per-FE bg 的环境/空覆盖与交互失败判定(build-failed 不计 envBlocked——它走确定性短路分支)。
function behaviorEnvBlocked(r) {
  const k = r.envError && r.envError.kind
  const ev = (k && k !== 'none' && k !== 'build-failed') ? r.envError : null
  const emptyCov = (Number(r.controlsEnumerated) === 0) || (Number(r.routesReached) === 0)
  return { ev, emptyCov, blocked: !!ev || emptyCov }
}
function behaviorIfails(r) { return Array.isArray(r.interactionFailures) ? r.interactionFailures : [] }

// runBehaviorGateOnce:跑一次本 FE 行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。
// 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 behaviorSubGate 推进)。
// behaviorRound:approve 子门内的行为 fix 轮;内部 attempt 1..BEHAVIOR_ATTEMPT_MAX(环境 race 重起)+ 仲裁兜底。
async function runBehaviorGateOnce(id, specPath, grp, behaviorRound) {
  const lbl = (a) => `behavior:${id}:r${behaviorRound}:a${a}`
  let attempt = 1
  let bg = await agent(behaviorGatePrompt(id, specPath, behaviorRound, attempt),
    {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA})
  recordDecisions(`behavior:${id}`, bg.decisions)

  // build-failed 短路:根因落非本 FE 路径(兄弟未实现)→ 直接返回(外层据此放行 approve),不重试不仲裁。
  const isBuildFailedShortCircuit = (r) => r.envError && r.envError.kind === 'build-failed'
  if (isBuildFailedShortCircuit(bg)) return bg

  // 内部 envError / 空覆盖重试:attempt 1→BEHAVIOR_ATTEMPT_MAX(沿用 testGate 思路);仍异常 → adjudicate(allowContinue:false)。
  while (behaviorEnvBlocked(bg).blocked && attempt < BEHAVIOR_ATTEMPT_MAX) {
    attempt += 1
    bg = await agent(behaviorGatePrompt(id, specPath, behaviorRound, attempt),
      {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA})
    recordDecisions(`behavior:${id}`, bg.decisions)
    if (isBuildFailedShortCircuit(bg)) return bg
  }
  let envState = behaviorEnvBlocked(bg)
  for (let adj = 1; envState.blocked && adj <= ADJUDICATE_MAX; adj++) {
    const reason = envState.ev
      ? `behavior envError=${envState.ev.kind}: ${envState.ev.detail || ''}`
      : `behavior 空覆盖:routesReached=${bg.routesReached} controlsEnumerated=${bg.controlsEnumerated}(绝不带空覆盖判 green)`
    const verdict = await adjudicate(`behavior-env:${id}`,
      { problem: reason, envError: bg.envError || null, ports:(bg.envError||{}).ports, pids:(bg.envError||{}).pids, allowContinue:false }, grp, adj)
    if (verdict.action !== 'retry') throw new Error(`HALT behavior-env ${id}: ${verdict.rationale || reason}`)
    attempt += 1
    bg = await agent(behaviorGatePrompt(id, specPath, behaviorRound, attempt),
      {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA})
    recordDecisions(`behavior:${id}`, bg.decisions)
    if (isBuildFailedShortCircuit(bg)) return bg
    envState = behaviorEnvBlocked(bg)
  }
  if (envState.blocked) throw new Error(`HALT behavior-env ${id}: ${ADJUDICATE_MAX} 轮仲裁后仍环境异常 / 空覆盖`)
  return bg
}

// behaviorSubGate:reviewer approve 的「行为 approve 子门」。green 才允许 reviewWithFixLoop return approve。
// softPassed:由 reviewWithFixLoop 顶层注入,跨 behaviorRound 持久(软文字一旦放行不再追问)。
// green ≡ behaviorHard.length===0 ∧ envError∈{none,build-failed} ∧ 本 FE 覆盖非空(或 build-failed 短路)。
async function behaviorSubGate(id, specPath, grp, softPassed) {
  const regionKey = (x) => `${x.page || '?'}::${x.region || '?'}`
  for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_FE_MAX; behaviorRound++) {
    const bg = await runBehaviorGateOnce(id, specPath, grp, behaviorRound)

    // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → green-by-skip 放行。但骨架(lazy router + FeStub)
    //    令「合法的兄弟未实现 build-failed」极罕见,故一个 build-failed 更可能是本 FE 引入的真共享代码回归;
    //    绝不凭未校验的 LLM 归因静默放行——先过轻量前置校验(comment §107-108 声称 load-bearing 的边界,此前无 JS 兜底):
    //      a) 必须有 rootCausePath(否则无从判定根因落点);
    //      b) 不得同时携带交互硬问题(interactionFailures / source=sentinel 文字)——那是真缺陷搭车。
    //    任一不满足 = 「脏」build-failed → 不短路,过 adjudicate(allowContinue:false) retry/halt,绝不 green-by-skip。
    if (bg.envError && bg.envError.kind === 'build-failed') {
      const rootCausePath = (bg.envError.rootCausePath || '').trim()
      const hardRiders = behaviorIfails(bg).length
        + (Array.isArray(bg.textIssues) ? bg.textIssues : []).filter(t => t && t.source === 'sentinel').length
      const dirty = !rootCausePath
        ? 'build-failed 未给 rootCausePath(无法判定根因是否落在本 FE 之外)'
        : hardRiders
          ? `build-failed 同时携带 ${hardRiders} 项交互/sentinel 硬问题(疑似本 FE 真构建 bug 搭车)`
          : null
      if (dirty) {
        const verdict = await adjudicate(`behavior-buildfailed-dirty:${id}`,
          { problem:`build-failed 归因不可信,绝不短路放行:${dirty}(rootCausePath=${rootCausePath || '∅'})`,
            envError: bg.envError, allowContinue:false }, grp, behaviorRound)
        if (verdict.action !== 'retry') throw new Error(`HALT behavior-buildfailed ${id}: ${verdict.rationale || dirty}`)
        continue                                                  // retry → 下一 behaviorRound 重跑整门
      }
      // 干净的 build-failed(有 rootCausePath 且无硬问题搭车)→ green-by-skip 放行,记低置信证据。
      recordDecisions(`behavior-build-failed:${id}`, [{
        question:`本 FE ${id} 行为验收遇 build-failed(根因 ${rootCausePath})`,
        choice:'green-by-skip(兄弟 FE 未实现属预期中途态,本 FE 非缺陷,放行 approve)',
        rationale: bg.envError.detail || '', confidence:'low' }])
      log(`behavior ${id}: build-failed 短路放行(根因非本 FE:${rootCausePath}),记证据不阻断`)
      return
    }

    // 2) coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 runBehaviorGateOnce 兜底)。
    //    locator-not-resolvable(B 类硬问题反查不出)计入未覆盖——下面会因 behaviorHard 仍非空或覆盖不足而不 green。
    for (const cg of (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : [])) {
      if (!cg) continue
      recordDecisions(`behavior-coverage:${id}`,
        [{ question:`覆盖缺口 ${cg.page}(${cg.reason})`, choice:'记录不阻断', rationale: cg.detail || '', confidence:'low' }])
    }

    // 3) 软文字(i18n/literal/semantic)→ 仲裁 continue 记 decisions + softPassed;sentinel 客观 bug 不在此处放行(下面并入 behaviorHard)。
    //    永不阻断 approve;retry/halt 同现。一旦有软文字 retry → 重跑本 behaviorRound(continue 进下一轮迭代)。
    let softRetry = false
    for (const ti of (Array.isArray(bg.textIssues) ? bg.textIssues : [])) {
      if (!ti || ti.source === 'sentinel') continue              // sentinel 归 behaviorHard,不在软文字处理
      if (softPassed.has(regionKey(ti))) continue
      const site = `behavior-text:${id}:${ti.page || '?'}:${ti.region || '?'}`
      const verdict = await adjudicate(site,
        { problem:`文字不符(source=${ti.source},可 continue 降级;永不阻断 approve):${ti.page}:${ti.region} 期望=${JSON.stringify(ti.expected)} 实际=${JSON.stringify(ti.actual)}`,
          textIssue: ti, allowContinue: true }, grp, behaviorRound)
      if (verdict.action === 'continue') {
        recordDecisions(site, [{ question:`文字不符 ${ti.page}:${ti.region}(source=${ti.source})`,
          choice:'continue(仲裁判可安全前进)', rationale: verdict.rationale || '', confidence:'low' }])
        softPassed.add(regionKey(ti)); continue
      }
      if (verdict.action !== 'retry') throw new Error(`HALT ${site}: ${verdict.rationale || `文字不符 source=${ti.source}`}`)
      softRetry = true; break                                    // retry → 重跑本 behaviorRound(跳到下一轮迭代重起整门)
    }
    if (softRetry) continue

    // 3.5) B 类硬问题(locator-not-resolvable coverageGap):连组件文件都反查不出,不静默放行——
    //      计入未覆盖阻断 approve,走 adjudicate(allowContinue:false) retry/halt(绝不当 green 放行,降级≠放行)。
    const bClass = (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : []).filter(cg => cg && cg.reason === 'locator-not-resolvable')
    if (bClass.length) {
      const summary = bClass.map(cg => `${cg.page} — ${cg.detail}`).join('; ')
      const verdict = await adjudicate(`behavior-bclass:${id}`,
        { problem:`behavior 硬问题连组件文件都反查不出(B 类,不可降级放行,计入未覆盖阻断 approve):${summary}`,
          coverageGaps: bClass, allowContinue:false }, grp, behaviorRound)
      if (verdict.action !== 'retry') throw new Error(`HALT behavior-bclass ${id}: ${verdict.rationale || summary}`)
      continue                                                  // retry → 重跑本 FE 行为验收(下一 behaviorRound)
    }

    // 3.6) 覆盖率对账(确定性兜底):空覆盖只兜 ==0;这里兜 0<routesReached<routesPlanned 的「部分覆盖假绿」。
    //      每条 planned-but-unreached 路由必须由「路由级 coverageGap」解释;未被解释的漏达路由 = 静默漏验,绝不判 green。
    //      只数路由级 reason(控件级 deep-control-not-driven / locator-not-resolvable 不抵漏达路由);过计只会抑制本门、绝不误 halt。
    const planned = Number(bg.routesPlanned) || 0
    const reached = Number(bg.routesReached) || 0
    const ROUTE_GAP = new Set(['unreachable-auth', 'unreachable-no-route', 'dynamic-route-no-seed', 'build-failed-sibling-unimpl'])
    const routeGapPages = new Set((Array.isArray(bg.coverageGaps) ? bg.coverageGaps : [])
      .filter(cg => cg && ROUTE_GAP.has(cg.reason) && typeof cg.page === 'string' && cg.page.trim())
      .map(cg => cg.page.trim()))
    const routeGapCount = routeGapPages.size
    const missedRoutes = Math.max(0, planned - reached)
    const unaccounted = Math.max(0, missedRoutes - routeGapCount)
    if (planned > 0 && unaccounted > 0) {
      const verdict = await adjudicate(`behavior-undercoverage:${id}`,
        { problem:`本 FE 路由覆盖不足:routesPlanned=${planned} routesReached=${reached},仅 ${routeGapCount} 条不同路由有路由级 coverageGap 解释,尚有 ${unaccounted} 条漏达路由无证据(绝不带静默漏达判 green)`,
          coverageGaps: bg.coverageGaps || [], allowContinue: false }, grp, behaviorRound)
      if (verdict.action !== 'retry') throw new Error(`HALT behavior-undercoverage ${id}: ${verdict.rationale || `${unaccounted} 条漏达路由无证据`}`)
      continue                                                  // retry → 下一 behaviorRound 重跑整门
    }

    // 4) behaviorHard = interactionFailures(含 binding-garbage)+ source=='sentinel' textIssues。
    const sentinelHard = (Array.isArray(bg.textIssues) ? bg.textIssues : [])
      .filter(t => t && t.source === 'sentinel')
      .map(t => ({ page:t.page, control:t.region, kind:'binding-garbage', detail:`sentinel 不符 期望=${t.expected} 实际=${t.actual}`, locator:t.locator }))
    const behaviorHard = [...behaviorIfails(bg), ...sentinelHard]

    const hasEnvSignal = !!(bg.envError && bg.envError.kind && bg.envError.kind !== 'none')
    const hasAnyClassifiedSignal = hasEnvSignal
      || behaviorHard.length > 0
      || (Array.isArray(bg.textIssues) && bg.textIssues.length > 0)
      || (Array.isArray(bg.coverageGaps) && bg.coverageGaps.length > 0)
    if (bg.status === 'red' && !hasAnyClassifiedSignal) {
      const verdict = await adjudicate(`behavior-red-unclassified:${id}`,
        { problem:'behavior 返回 status:red,但没有 envError / interactionFailures / textIssues / coverageGaps 可解释该 red;拒绝把未分类红灯判 green',
          behaviorResult: bg, allowContinue:false }, grp, behaviorRound)
      if (verdict.action !== 'retry') throw new Error(`HALT behavior-red-unclassified ${id}: ${verdict.rationale || 'status:red 无分类原因'}`)
      continue
    }

    // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 子门 green 放行。
    if (behaviorHard.length === 0) {
      log(`behavior ${id} green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`)
      return
    }

    // 6) 分流:无 locator 的硬问题 → adjudicate(allowContinue:false) retry/halt(绝不静默丢弃、绝不 approve)。
    const withLoc = behaviorHard.filter(x => typeof x.locator === 'string' && x.locator.trim())
    const noLoc = behaviorHard.filter(x => !(typeof x.locator === 'string' && x.locator.trim()))
    if (noLoc.length) {
      const summary = noLoc.map(f => `[${f.kind}] ${f.page}:${f.control} — ${f.detail}`).join('; ')
      const verdict = await adjudicate(`behavior-noloc-hard:${id}`,
        { problem:`behavior 硬问题无源码 locator(无法转 must-fix 喂 fix,绝不 continue/approve):${summary}`,
          interactionFailures: noLoc, allowContinue:false }, grp, behaviorRound)
      if (verdict.action !== 'retry')
        throw new Error(`HALT behavior-noloc-hard ${id}: ${verdict.rationale || summary}`)
      continue                                                  // retry → 重跑本 FE 行为验收(下一 behaviorRound)
    }

    // 7) 有 locator 的硬问题 → 降维成 {summary,locator,severity} 喂现有 fixPrompt 跑 fix(schema 不合并、fix 入参合并)。
    const fixIssues = withLoc.map(f => ({
      summary: `[behavior:${f.kind}] ${f.page}:${f.control} — ${f.detail}`,
      locator: f.locator,
      severity: 'high',
    }))
    await runStage(g => fixPrompt(id, 'frontend', fixIssues) + g, {
      site:`behavior-fix:${id}:r${behaviorRound}`, grp, label:`behavior-fix:${id}:r${behaviorRound}`, allowContinue: true,
    })

    // 8) fix 后功能复验(allowContinue:false):behaviorSubGate 的 fix 改的是 frontend/ UI 源码,可能引入功能回归——
    //    先跑 scoped 组件测试 reverify(不起全栈,成本低),红则当功能回归硬边界;绿后下一 behaviorRound 重跑行为验收。
    await runStage(
      g => verifyPrompt(id, 'frontend', `(behaviorRound ${behaviorRound} 行为 fix 后功能复验,本轮 must-fix: ${fixIssues.length} 项)`, specPath, REVIEW_HARD_ROUNDS + behaviorRound) + g,
      { site:`behavior-reverify:${id}:r${behaviorRound}`, grp, label:`behavior-reverify:${id}:r${behaviorRound}`, allowContinue: false },
    )
    // 进入下一 behaviorRound → 重跑本 FE 行为验收
  }
  throw new Error(`HALT behavior-unresolved ${id}: ${BEHAVIOR_FE_MAX} 轮 per-FE 行为子门仍未 green(硬问题未清)`)
}

phase('Router')
// Router 语义断言(feItems/reqs 互斥)+ id 形状硬约束(防 shell 注入:id 直接拼入 `git ... ${id}`)。
// id 形状(assertSafeId)是**安全护栏**——失败立即硬 halt,绝不重试绕过。
// reqs/feItems 互斥违例可由仲裁带 guidance 重跑 router 纠正(绝对上限 ADJUDICATE_MAX)。
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 命令)`)
  }
}
function routerViolation(modules) {
  for (const m of modules) {
    const isFE = m.id === 'frontend-phase'
    if (isFE && Array.isArray(m.reqs) && m.reqs.length)
      return `frontend-phase 聚合模块的 reqs 必须为空,实测含 ${m.reqs.length} 项 (${m.reqs.join(',')})`
    if (!isFE && Array.isArray(m.feItems) && m.feItems.length)
      return `后端模块 ${m.id} 的 feItems 必须为空(前端只在 frontend-phase 聚合),实测含 ${m.feItems.length} 项 (${m.feItems.join(',')})`
  }
  return null
}

let routed = await agent(routerPrompt(ROOT), {label:'router', phase:'Router', schema: ROUTER_SCHEMA})
for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) {
  const violation = routerViolation(routed.modules)
  if (!violation) break
  const verdict = await adjudicate('router-violation', { problem: violation }, 'Router', adj)
  if (verdict.action !== 'retry') throw new Error(`HALT router-violation: ${verdict.rationale || violation}`)
  routed = await agent(routerPrompt(ROOT) + adjGuidance(verdict.guidance || ''), {label:`router:r${adj + 1}`, phase:'Router', schema: ROUTER_SCHEMA})
}
const finalViolation = routerViolation(routed.modules)
if (finalViolation) throw new Error(`HALT router-violation: ${ADJUDICATE_MAX} 轮仲裁后仍违例:${finalViolation}`)
// id 安全护栏:最终选定的 routed 必须全部通过(assertSafeId 硬 halt)。
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 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')
      // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy
      // 路由表(FeStub 占位)+ 无悬空导航——保证逐 FE 实现中途任意时刻 app 可构建可起、每 FE 路由可达,
      // 使 per-FE 行为门的可构建前提成立、tddPrompt 的 FeStub→真组件占位替换有真值起点。幂等(fe-skeleton-done tag)。
      await runFrontendSkeleton(module.feItems)
      // 前端行为验收已并入 featureLoop→reviewWithFixLoop 的 per-FE approve 子门(reviewer approve 时起本 FE 全栈验
      // 「按钮真生效/文字对」,硬问题转可 fix must-fix→重验,行为 green 才打 req-done)——不再有阶段级末尾独立行为门。
      await featureLoop(module.feItems, 'frontend')
      phase('Gate')
      await testGate(module, 'frontend')                // 阶段级 testGate(全量回归 vitest+playwright)保留,与 per-FE 行为验收职责正交
    }
    phase('Milestone')
    // report allowContinue:false:reportPrompt 的前置硬验证含"最后一次 test-gate 必须 green,红则 halt"——
    // 绝不 continue 放行去打 milestone(否则可能在红色测试上 milestone)。
    await runStage(g => reportPrompt(module) + g, {
      site:`report:${module.id}`, grp:'Milestone', label:`report:${module.id}`, allowContinue: false,
    })
    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 模块列表 + 全流程自主决策日志
// (decisions:stage 缺值时未停而自主取的默认/解读,供 coding-start / 人工事后审阅,可能含错误假设)。
// 注:decisions 仅覆盖**本次运行实际新跑**的 stage;resume 时被 req-done/milestone tag 跳过的已完成功能,
// 其决策不会重新登记于此——需到对应 docs/superpowers/specs|plans/<date>-<id>.md 产物显著位置查阅。
// 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行,
// 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。
// 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`,
// 否则 Workflow 拿不到 results / pending。
return { results, pending, decisions: autonomousDecisions }