coding.mjs 312 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 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 3167 3168 3169 3170 3171 3172 3173 3174 3175 3176 3177 3178 3179 3180 3181 3182 3183 3184 3185 3186 3187 3188 3189 3190 3191 3192 3193 3194 3195 3196 3197 3198 3199 3200 3201 3202 3203 3204 3205 3206 3207 3208 3209 3210 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220 3221 3222 3223 3224 3225 3226 3227 3228 3229 3230 3231 3232 3233 3234 3235 3236 3237 3238 3239 3240 3241 3242 3243 3244 3245 3246 3247 3248 3249 3250 3251 3252 3253 3254 3255 3256 3257 3258 3259 3260 3261 3262 3263 3264 3265 3266 3267 3268 3269 3270 3271 3272 3273 3274 3275 3276 3277 3278 3279 3280 3281 3282 3283 3284 3285 3286 3287 3288 3289 3290 3291 3292 3293 3294 3295 3296 3297 3298 3299 3300 3301 3302 3303 3304 3305 3306 3307 3308 3309 3310 3311 3312 3313 3314 3315 3316 3317 3318 3319 3320 3321 3322 3323 3324 3325 3326 3327 3328 3329 3330 3331 3332 3333 3334 3335 3336 3337 3338 3339 3340 3341 3342 3343 3344 3345 3346 3347 3348 3349 3350 3351 3352 3353 3354 3355 3356 3357 3358 3359 3360 3361 3362 3363 3364 3365 3366 3367 3368 3369 3370 3371 3372 3373 3374 3375 3376 3377 3378 3379 3380 3381 3382 3383 3384 3385 3386 3387 3388 3389 3390 3391 3392 3393 3394 3395 3396 3397 3398 3399 3400 3401 3402 3403 3404 3405 3406 3407 3408 3409 3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 3472 3473 3474 3475 3476 3477 3478 3479 3480 3481 3482 3483 3484 3485 3486 3487 3488 3489 3490 3491 3492 3493 3494 3495 3496 3497 3498 3499 3500 3501 3502 3503 3504 3505 3506 3507 3508 3509 3510 3511 3512 3513 3514 3515 3516 3517 3518 3519 3520 3521 3522 3523 3524 3525 3526 3527 3528 3529 3530 3531 3532 3533 3534 3535 3536 3537 3538 3539 3540 3541 3542 3543 3544 3545 3546 3547 3548 3549 3550 3551 3552 3553 3554 3555 3556 3557 3558 3559 3560 3561 3562 3563 3564 3565 3566 3567 3568 3569 3570 3571 3572 3573 3574 3575 3576 3577 3578 3579 3580 3581 3582 3583 3584 3585 3586 3587 3588 3589 3590 3591 3592 3593 3594 3595 3596 3597 3598 3599 3600 3601 3602 3603 3604 3605 3606 3607 3608 3609 3610 3611 3612 3613 3614 3615 3616 3617 3618 3619 3620 3621 3622 3623 3624 3625 3626 3627 3628 3629 3630 3631 3632 3633 3634 3635 3636 3637 3638 3639 3640 3641 3642 3643 3644 3645 3646 3647 3648 3649 3650 3651 3652 3653 3654 3655 3656 3657 3658 3659 3660 3661 3662 3663 3664 3665 3666 3667 3668 3669 3670 3671 3672 3673 3674 3675 3676 3677 3678 3679 3680 3681 3682 3683 3684 3685 3686 3687 3688 3689 3690 3691 3692 3693 3694 3695 3696 3697 3698 3699 3700 3701 3702 3703 3704 3705 3706 3707 3708 3709 3710 3711 3712 3713 3714 3715 3716 3717 3718 3719 3720 3721 3722 3723 3724 3725 3726 3727 3728 3729 3730 3731 3732 3733 3734 3735 3736 3737 3738 3739 3740 3741 3742 3743 3744 3745 3746 3747 3748 3749 3750 3751 3752 3753 3754 3755 3756 3757 3758 3759 3760 3761 3762 3763 3764 3765 3766 3767 3768 3769 3770 3771
// 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: 'Schedule' }, { title: 'Backend' }, { title: 'Preview' }, { title: 'Frontend' },
    { title: 'Behavior' }, { title: 'Gate' }, { title: 'Seed' }, { title: 'Milestone' },
  ],
  // 注:'Behavior' = 阶段级行为验收门(v3)——整个前端阶段在 featureLoop 全部 FE 完成后只跑**一次**
  // 行为验收(含 fix 循环),不再在每个 FE 的 review 循环内做 approve 子门(v2 per-FE 形态已撤销)。
  // 时序:featureLoop(frontend) → Behavior(行为门+fix)→ Gate(testGate 全量回归,兜底行为 fix 引入的回归)。
  // 注:'Preview' = 静态原型渲染门——前端段开头一次性渲染 prototype/**/*.html 本身(Playwright headless
  // 截图归档 + 原型自带交互点击冒烟)。与 Behavior 正交:Preview 测**静态原型**、Behavior 测**实现**。
  // 硬门:原型渲染失败 / 脚本未捕获异常(jsErrors) → halt(原型属上游 plan 权威,coding 不改原型源码,
  // 应回 plan/add-req 修后重跑);console.error advisory;环境未就绪 retry→仲裁。时序:Preview → Frontend(骨架+featureLoop)。
}

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'}} } } } } }

// SCHEDULE_SCHEMA:调度子代理只输出"依赖边 + 证据",绝不直接输出"并行组"——
// 并行性是全局属性由 JS 拓扑推导;LLM 只做局部、可引证的判断。kind=uncertain 也算边(宁串勿并)。
const SCHEDULE_SCHEMA = { type:'object', additionalProperties:false,
  required:['deps'], properties:{ deps:{ type:'array', items:{
    type:'object', additionalProperties:false,
    required:['id','dependsOn'],
    properties:{
      id:{type:'string'},
      dependsOn:{ type:'array', items:{ type:'object', additionalProperties:false,
        required:['id','kind','evidence'],
        properties:{
          id:{type:'string'},
          kind:{type:'string', enum:['fk','api','seed','order','doc','uncertain']},
          evidence:{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:前端行为门(阶段级,frontend-phase 末尾一次)返回。
// 不杂交 GATE×STAGE_RESULT——复用既有词汇但独立成型:交互层 / 文字层 / 样式层 / 覆盖率 / 环境错误分别结构化,
// JS 据 source/kind 分流(交互/样式硬边界转 must-fix,文字按 source 二分 allowContinue,envError 走 retry)。
// 设计:见 docs/design/2026-06-05-frontend-behavior-stage-gate.md(v3,取代 per-FE approve 子门形态)。
const BEHAVIOR_GATE_SCHEMA = { type:'object', additionalProperties:false,
  required:['status','routesPlanned','routesReached','controlsEnumerated'], properties:{
    status:{type:'string', enum:['green','red']},
    routesPlanned:{type:'integer'},        // 覆盖率分母 = 全部 FE spec「行为验收作用域」小节关联路由的并集(去重)
    routesReached:{type:'integer'},         // 实际带鉴权加载成功的路由数
    controlsEnumerated:{type:'integer'},    // live 枚举到的白名单控件数(全 FE 并集;空覆盖必须可见)
    authState:{type:'string'},              // 以何角色登录 / 覆盖角色 / 未覆盖角色集
    // interactionFailures.locator:行为硬问题的源码定位(组件文件 [+ DOM 描述])。行为门必须反查到
    //   组件文件路径才能转 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'} } } },
    // styleIssues:样式/布局客观断言(颜色 token 比对 + layout sanity)。全部客观、可 fix——
    // 有 locator → JS 并入 behaviorHard 转 must-fix;无 locator → 与交互硬问题同口径走 noLoc 仲裁。
    // 不确定项(半透明混合 / 无法归一化)按 prompt 约定不入此数组,记 decisions(宁漏勿误)。
    styleIssues:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['page','element','kind','expected','actual'],
      properties:{
        page:{type:'string'}, element:{type:'string'},
        kind:{type:'string', enum:[
          'non-token-color',      // 渲染色 ∉ tokens.css 色值集合(限项目自有样式作用域)
          'token-mismatch',       // 应取某 token 但渲染值 ≠ 该 token 解析值(被硬编码/级联覆盖)
          'horizontal-overflow',  // 路由页面出现横向滚动条(容差 1px)
          'overlap',              // 白名单控件 bounding box 相互重叠(双方可见可点)
          'zero-size',            // 预期可见的白名单控件渲染为 0 尺寸
          'offscreen']},          // scrollIntoView 后仍不在视口内
        expected:{type:'string'}, actual:{type:'string'},
        locator:{type:'string'} } } },
    // 覆盖率缺口:写证据 + recordDecisions,不单独 halt(空覆盖由 controlsEnumerated==0 兜底)
    // locator-not-resolvable:行为硬问题连组件文件都反查不出(B 类),计入未覆盖阻断 green,不静默放行
    // scope-missing:某 FE spec 缺「行为验收作用域」小节(该 FE 路由不在分母)——与 B 类同级阻断 green
    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','locator-not-resolvable','scope-missing']},
        detail:{type:'string'} } } },
    // 环境错误(与业务断言失败严格区分):none 表示无环境问题。
    //   build-failed:阶段末尾全部 FE 已实现,不再有「兄弟未实现」短路——根因落在 frontend/ 源码且可定位 →
    //   应归 interactionFailures[kind="js-error"](带 locator,可 fix);仅根因不可归到 frontend/ 源码
    //   (依赖/环境/无法定位)才用本 kind(确定性失败,跳过自动 attempt 重试直送仲裁)。rootCausePath 写报错根因文件路径。
    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'} } }

// PROTOTYPE_PREVIEW_SCHEMA:静态原型渲染门(phase('Preview'))返回。与 BEHAVIOR_GATE 正交——
// 后者测「实现」(冷起全栈 + Playwright 驱动真前端),本门只渲染 *静态* prototype/**/*.html 本身:
// 逐页 headless 渲染→截图归档(可视化基线)→跑原型自带交互点击冒烟→捕获 JS/console 错误。
// status:ok=渲染门正常跑完(含 advisory 问题);blocked=仅限环境未就绪(Playwright/浏览器缺失/超时)需仲裁。
// 原型属上游 plan 权威、coding 绝不改原型源码——故渲染/JS 问题是 advisory(记 decisions+证据,绝不硬 halt)。
const PROTOTYPE_PREVIEW_SCHEMA = { type:'object', additionalProperties:false,
  required:['status','pagesFound','pagesRendered'], properties:{
    status:{type:'string', enum:['ok','blocked']},
    pagesFound:{type:'integer'},      // Glob 到的 prototype/**/*.html 总数
    pagesRendered:{type:'integer'},   // 成功渲染并截图的页数
    pages:{ type:'array', items:{ type:'object', additionalProperties:false,
      required:['file','rendered'],
      properties:{
        file:{type:'string'},                                 // prototype 相对路径
        rendered:{type:'boolean'},
        screenshot:{type:'string'},                           // assets 相对路径(已归档,纳入版本管理)
        controlsExercised:{type:'integer'},                   // 实际驱动(点击/切换)过的控件数
        jsErrors:{type:'array', items:{type:'string'}},       // pageerror 文本(原型自身脚本质量信号)
        consoleErrors:{type:'array', items:{type:'string'}},  // console.error 文本
        notes:{type:'string'} } } },
    // envError:仅环境未就绪(与「原型自身有问题」严格区分——后者是 advisory,不进 envError)。
    envError:{ type:'object', additionalProperties:false,
      required:['kind'], properties:{
        kind:{type:'string', enum:['playwright-missing','browser-missing','timeout','none']},
        detail:{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']} } } },
    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'} } }

// lane DB 支持探测结果(附录补 6b:两点联查)。两布尔各自独立据实返回,"支持与否"的合取判定与
// 降级决策(任一 false → 本波次降级 maxWidth=1,不 halt)留给编排层(Phase D 波次执行器)——
// 子代理只做局部、可验证的事实陈述,与调度器"LLM 出边、JS 推全局"同一职责切分。
const LANE_DB_PROBE_SCHEMA = { type:'object', additionalProperties:false,
  required:['scriptsEnv','ymlPlaceholder'], properties:{
    scriptsEnv:{type:'boolean'},
    ymlPlaceholder:{type:'boolean'},
    detail:{type:'string'} } }

// 残留 lane 枚举(附录补 3a:波次前置占用感知)。只列 `<ROOT>-lanes/` 下已注册的 worktree——
// 它们持有的 module-* 分支会让主根 checkout 与新 worktree add 双向 fatal(git 实测),
// 必须在**每个波次**(含宽 1)开跑前感知并收口,否则"降级回串行"这条兜底路径本身会被残留 lane 毒化。
const LANE_LIST_SCHEMA = { type:'object', additionalProperties:false,
  required:['lanes'], properties:{ lanes:{ type:'array', items:{
    type:'object', additionalProperties:false,
    required:['path','branch'],
    properties:{ path:{type:'string'}, branch:{type:'string'} } } } } }

// PLAN_FACTS_SCHEMA(Phase F Task 12:feature 级并行准入的事实提取)。LLM 只陈述"plan 锁定了
// 哪些文件、是否声明 schema 改动"——局部、可对照 plan 原文复核的事实;"能否并行"由 JS 确定性
// 判定(批内两两 files 不相交 ∧ 全部 schemaChange=false ∧ 批宽限额),不信 LLM 单方判断。
const PLAN_FACTS_SCHEMA = { type:'object', additionalProperties:false,
  required:['schemaChange','files'], properties:{
    schemaChange:{type:'boolean'},
    files:{type:'array', items:{type:'string'}},
    detail:{type:'string'} } }

// 兼容 harness 把 args 以 JSON 字符串(而非对象)传入的情况。
const ARGS = typeof args === 'string' ? (() => { try { return JSON.parse(args) } catch { return undefined } })() : args
const ROOT = ARGS?.projectRoot || '.'
// ROOT 必须是绝对路径——相对 '.' 会绑定到子代理隐式 cwd,无保证。
if (ROOT === '.' || !(/^(?:\/|[A-Za-z]:[\\/])/.test(ROOT))) {
  throw haltError(`HALT invalid-projectRoot: must be absolute, got ${JSON.stringify(ROOT)}. coding-start 必须把绝对路径传入 args.projectRoot。`)
}
// 插件根(coding-start 透传):用于调用插件 lib/*.mjs(如 req-ledger 建需求台账基线)。
// 缺省(旧 coding-start 未传)则相关增强静默跳过——由 /add-req 首跑兜底建基线。
const PLUGIN = ARGS?.pluginRoot || ''

// 模块执行上下文:串行/主根模式 root=ROOT、lane=null;并行 lane 模式由 Phase C/D 填充。
// 所有"模块作用域"的 prompt builder 与 runner 一律经 C 取根,不再直接闭包 ROOT。
// ROOT 仍保留:仅供"主根专属"操作(milestone merge / docs/08 字段 / RESUME / ledger / preflight)使用。
// 字段:root=本模块工作树根(lane 模式 = lane worktree 路径);lane=lane 序号(null=串行/主根);
//       dbSchema=lane 测试库 schema 名(串行为空串);vBase=migration 版本段基址(0=不分段,沿用 max+1);
//       decisions=per-module 自主决策收集器(替代全局水位线,见 recordDecisions 的 sink 参数)。
function makeCtx(overrides = {}) {
  return { root: ROOT, lane: null, dbSchema: '', vBase: 0, decisions: [], ...overrides }
}

// ── 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]
}

// 所有子代理共享的"非交互静默"硬约束。
// c:模块执行上下文(末位参数,调用方必传)。lane 模式(c.dbSchema 非空)追加 lane 测试库硬约束——
// env 前缀(ERP_TEST_DB_SCHEMA)指定本 lane 的一次性副本库名,是 JS 拼好的**成品字符串**(附录补 6c):
// tdd 等主会话还会把命令转述给嵌套子会话(提示词链两跳),任何一跳自行拼接都可能丢/错前缀,导致 lane 间
// 用错副本库互相踩——成品照抄是唯一可靠形态(源库有 COPY≠SOURCE 硬守卫兜底、绝不会被误 drop,但 lane 隔离仍靠前缀正确)。
function featureStageContract(phase, c) {
  const fe = isFrontend(phase)
  return [
    '## 硬约束(非交互子代理)',
    '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
    '- **测试执行子会话的物理实现(授权)**:下文「派发 Agent 子会话跑测试 / 绝不在主会话直接跑测试」的护栏,其**真实意图**是「测试执行与你的主推理流隔离、只回收结构化结果(不把全文 stdout 灌进上下文)」。本运行环境若**没有** Agent/Task 子会话派发工具(用 ToolSearch `select:Task` 无匹配即属此情形),则以 **`Bash` 的 `run_in_background` 隔离后台子进程**作为该子会话的物理实现:派一个 detached 子进程跑测试命令、输出重定向到 `.tmp/` 日志文件,你只读回 `{command, exit_code, failing_assertion / passed / failed}` 等结构化摘要。这**已满足**护栏意图,故**绝不**因「缺 Agent 派发工具」而 halt——这不是硬事实缺失、不是缺值阻塞,retry 也不会改变工具面,halt 只会空耗一次重跑。**仍然禁止**:把测试命令在主推理流里内联同步执行并把全文 stdout 读进上下文。下文出现「Agent 子会话」一律按本条解释为「隔离后台子进程子会话」。',
    '- **后台等待纪律(硬约束,违反即前功尽弃)**:把测试/闸门派到后台子进程后,必须在**同一回合内**等到终态——用重复的 `Bash` 调用轮询(`sleep N` 后读日志尾/exit 标记文件,长跑闸门每 60~120s 一轮,多少轮都行)。**绝不**使用 Monitor / ScheduleWakeup / 任何「装个监听器然后结束回合等通知」的模式:你是 Workflow 派生子代理,**结束回合=宣告完成**,会被判「未调 StructuredOutput」使整个模块 halt,任何通知都不会把你唤醒(AGT test-gate 2026-07-11 实锤教训)。拿到终态先写证据再调 StructuredOutput,一个回合内闭环。',
    '- 缺值查找顺序:`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 的最简示例而漏登记。',
    c.dbSchema
      ? `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,测试跑在**一次性副本库**(从源库复制而来、跑完删除,源库绝不被 drop)。**所有**测试 / 建副本 / 种子 / 晋升命令(gradle test、pnpm test/e2e、\`node scripts/setup-test-db.mjs\`、\`node scripts/seed-demo-data.mjs\`、\`node scripts/drop-test-db.mjs\`、\`node scripts/promote-to-source.mjs\`、playwright 等,**含派发给子会话 / 嵌套子会话的命令**)必须前置环境变量 \`ERP_TEST_DB_SCHEMA=${c.dbSchema}\`——成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略前缀。`
      : '',
    c.dbSchema
      ? '- **lane 内禁全量测试入口(硬约束)**:lane 内 tdd/verify/fix 阶段**只允许目标化测试命令**(如 gradle 按类/按模块过滤、vitest 过滤模式);**禁止**运行含 e2e / 冷起全栈的全量入口(`node scripts/test.mjs`、`pnpm e2e:ci` 等)——它们按固定端口起栈,会击杀兄弟 lane 正在验证的进程;全量回归只发生在 test-gate(已由编排层起栈互斥保护)。'
      : '',
    '- 全部输出文档**使用中文**。',
    `- **阶段 = ${fe ? '前端(frontend)' : '后端(backend)'}**。路径作用域:${fe
      ? '实现文件必须落在 `frontend/` 下;命中 `backend/` / `sql/` / `scripts/` 即越界,硬停。'
      : '产出范围限定 controller / service / repository / DTO / 校验 / SQL migration / REST 契约;**禁止**写 `frontend/` 路径下的实现(UI 推迟到前端阶段)。'}`,
    `- id 形态:${fe ? '前端为 `FE-NN`(业务功能粒度,可关联多个 prototype 区域与多个 REQ)。' : '后端为 `<模块代码>-<子模块代码>-<功能名>`(3 段 req_id,模块代码大写短码 + 子模块代码/功能名大驼峰,如 `USR-UserInfo-Login`)。'}`,
  ].filter(Boolean).join('\n')
}

// commitBlock:spec/plan/verify/review 共用的"写完 → add → commit → 失败 halt"四行块。
// c 为末位参数(调用方必传);tail 用缺省时调用方传 undefined 占位(null 不触发参数默认值)。
function commitBlock(addPath, msg, tail = '- commit 失败 → halt,把 stderr 摘要写进 reason。', c) {
  return [
    '## commit',
    `- 写完后必须 commit(milestone 的 worktree-clean 前置依赖此 commit):`,
    `  1. \`git -C ${c.root} add ${addPath}\``,
    `  2. \`git -C ${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')
}

// Scheduler(Phase B):模块依赖边取证。只读、主根专属(调度发生在任何 lane 建立之前,
// root 传 ROOT,与 routerPrompt 同口径)。LLM 只输出局部、可引证的"依赖边 + 证据";
// 并行组/波次是全局属性,由 JS 拓扑(nextWave)确定性推导——职责切分是本调度器的设计核心。
function schedulerPrompt(todoBackendIds, allBackendIds, root) {
  return [
    '# Coding Scheduler — 模块依赖边取证',
    '',
    `项目根:\`${root}\``,
    '',
    '你是 Coding 阶段的调度子代理。**只读不写**(不改任何代码 / 文档)。逐对审视下列待跑后端模块,输出"谁必须先于谁"的**依赖边 + 证据**,供脚本做确定性拓扑波次调度。',
    '**绝不输出"并行组 / 波次"**——并行性是全局属性,由脚本侧拓扑推导;你只做局部、有证据可查的判断。',
    '',
    '## 保守偏置(最高优先级)',
    '对**真实依赖**(fk/api/seed/共享表/docs 显式约束)拿不准时就加边(kind=uncertain)——错误串行只损失时间,错误并行损失正确性。但"不确定"**仅指**怀疑存在真实依赖却证据不全;**绝不**把"在 docs/02 里靠后 / 排版顺序靠后"当作不确定理由——列序不是依赖(见 order 规则)。',
    '',
    '## 待跑后端模块(只对它们逐项输出 deps;`frontend-phase` 不在你的职责内——脚本硬规则强制它依赖全部后端模块)',
    todoBackendIds.map(id => `- \`${id}\``).join('\n'),
    '',
    '## dependsOn 合法取值域(全部后端模块 id;不在待跑清单内的为已完成模块,其依赖恒满足,但查到证据时仍应如实列出,保留可审计性)',
    allBackendIds.map(id => `- \`${id}\``).join('\n'),
    '',
    '## 边规则(按证据来源定 kind;方向:`dependsOn` 列出的是"必须先完成"的模块)',
    `1. **order**(仅 docs/06 显式顺序约束才加;docs/02 列序**不是**依赖):\`${root}/docs/02-开发计划.md § 二 开发顺序清单\` 的列出顺序**只是开发计划的排版次序,绝不构成依赖**——**绝不**因某模块"在 docs/02 里靠后 / 是另一模块的紧邻后继"就对其前驱输出 order 边。order 边**只在** \`${root}/docs/06-实现策略.md\`(或 \`${root}/docs/01-需求清单/\` REQ 卡)**显式声明**"模块 X 必须先于模块 Y 完成"这类顺序约束时才输出,evidence 写"文件 + 小节 + 原文约束"。**关键**:对**已完成模块**(不在待跑清单内)的 fk/api 证据**绝不构成**两个待跑模块之间的 order 理由——order 只看**待跑模块两两之间**显式声明的顺序约束。两个待跑模块间查不到 fk/api/seed/共享表/docs/06 显式顺序约束 → 对该对**不加边**(让它们落进同一波并行,这是默认而非例外)。`,
    `2. **fk**:\`${root}/docs/03-数据库设计文档.md\` 中本模块归属表的外键引用了另一模块归属的表 → 本模块依赖对方。evidence 写"docs/03 + 表名.外键列 → 被引表"。`,
    `3. **api**:\`${root}/docs/05-API接口契约.md\` 中本模块消费另一模块提供的端点,或 \`${root}/docs/01-需求清单/\` REQ 卡「依赖接口」字段指向对方模块端点 → api 边。evidence 写"文件 + 端点路径"。`,
    '4. **seed**:演示种子跨模块主键引用(本模块种子数据需引用对方模块表已存在的主键)→ seed 边。evidence 写引用的表与主键区间出处。',
    `5. **共享表**(必须串行):两个模块的演示种子会触达**同一张表**(按 docs/03 的表归属 / \`${root}/docs/08-模块任务管理.md\` 各模块 \`路径:\` 字段判定共同表)→ 必须加 fk 或 seed 边(宁串勿并),evidence 写共同表名。**方向固定单向**:按 \`docs/02 § 二\` 顺序**后者依赖前者**(只在后序模块的 dependsOn 列前序模块);**绝不**双向各输出一条——那会构造环,白白损失并行度(环虽有降级兜底)。`,
    '6. **doc**:docs/06 实现策略、REQ 卡等文档**显式声明**的顺序/依赖约束,而证据无法归入 fk/api/seed → doc 边。evidence 写"文件 + 小节"。',
    '7. **uncertain**:怀疑存在依赖但证据不完整 → 照样输出边(uncertain 也是边,同样阻断并行)。evidence 写怀疑依据。',
    '',
    '## evidence 纪律',
    '- 每条边 `evidence` 必须**可定位**:文件 + 小节 / 表名 / 端点,让人工能直接翻到出处复核。',
    '- 空泛的"业务上相关 / 感觉有关"不构成 fk/api/seed/doc 证据——拿不准时按保守偏置改出 uncertain 边,把怀疑点写进 evidence。',
    '',
    '## 输出(必须符合下发的 JSON schema)',
    '- `deps`: 数组,**待跑后端模块每个恰好一项**:`{ "id": "<module>", "dependsOn": [{ "id", "kind", "evidence" }] }`;无依赖则 `dependsOn: []`。',
    '- `dependsOn[].id` 只能取上面列出的后端模块 id;**绝不**含 `frontend-phase`,**绝不**自依赖,**绝不**构造互相依赖的环。',
    '- 不要返回任何额外字段(schema 为 `additionalProperties:false`)。',
  ].join('\n')
}

// ---- 功能内循环 stage 1:派生 spec(原 feature-brainstorm / fe-feature-brainstorm)----
// batch(Phase E Task 11):spec 批量预生成模式——spec 只读 docs/prototype/tokens、不读兄弟
// feature 代码,故可在同一工作树并行预生成;但并行 commit 会争 .git/index.lock,batch 下把
// commitBlock 替换为「只写盘不 commit」,统一提交由 featureLoop 的单一微步骤收口。
// 注:batch 接在 c 之后是计划 Task 11 的字面签名(`deriveSpecPrompt(id, phase, c, { batch: true })`),
// 是"c 为末位参数"约定的唯一特例——现有 3 参调用点无须改动。
function deriveSpecPrompt(id, phase, c, { batch = false } = {}) {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-brainstorm' : 'feature-brainstorm'} — 派生规格 ${id}`,
    '',
    featureStageContract(phase, c),
    '',
    '## 目标',
    `静默派生 \`${id}\` 的实现规格(无 Q&A)。需求歧义本应在 Plan 期的结构化 per-REQ 表单锁定,前端布局/交互以 \`${c.root}/prototype/\` 为权威;这里**只消费已锁定的事实**,不再澄清。`,
    '',
    '## 收集上下文',
    fe
      ? [
          `- 关联 REQ 卡片:\`${c.root}/docs/01-需求清单/<module>/<REQ>.md\`(提取业务校验规则、acceptance、UI 描述)。`,
          `- 关联 prototype:Read \`${c.root}/prototype/**/*.html\`(含 anchor 时聚焦相应区域),作为页面布局权威。`,
          `- API 契约:\`${c.root}/docs/05-API接口契约.md\`,按本 FE 关联的 REQ 过滤出消费的端点。`,
          `- Design Tokens:\`${c.root}/src/styles/tokens.css\`(色值 / 状态色单一来源;只用 var(--color-*),禁硬编码 hex)。**与 prototype 的色值冲突时以 tokens.css 为准**(prototype 管结构/布局/交互)。`,
          `- 前端组件库:\`${c.root}/docs/04-技术规范.md § 零\` 的 \`frontend.ui_lib\`,决定组件选型。`,
          `- 实现策略:\`${c.root}/docs/06-实现策略.md\`(A2 人工填写;关键技术决策 / 取舍、实现顺序与依赖、难点·风险、对默认流程的偏离——遵循其中与本 FE 相关的指引)。`,
        ].join('\n')
      : [
          `- REQ 卡片:\`${c.root}/docs/01-需求清单/<module>/${id}.md\`。**忽略 UI 描述**(控件类型 / 按钮位置 / 列表布局),但校验规则、业务规则仍要落到后端 DTO + service。`,
          `- 涉及的数据表定义:\`${c.root}/docs/03-数据库设计文档.md\`(必要时实时查 mysql 只读)。`,
          `- API 契约:\`${c.root}/docs/05-API接口契约.md\` 中本 REQ 相关端点。`,
          `- 实现策略:\`${c.root}/docs/06-实现策略.md\`(A2 人工填写;关键技术决策 / 取舍、实现顺序与依赖、难点·风险、对默认流程的偏离——遵循其中与本 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
      ? [
          '',
          '## 行为验收作用域结构化小节(阶段末尾行为门按全部 FE 聚合断言作用域的唯一来源,**强制写到 spec 头部**)',
          '- 在 spec 文件头部(紧随标题/关联 REQ 之后)写一个**结构化小节**,标题逐字为 `## 行为验收作用域`,内含两条机器可读清单:',
          '  ```',
          '  ## 行为验收作用域',
          '  - 关联路由: [/orders, /orders/:id]',
          '  - 负责控件白名单: [data-testid=order-submit, /orders 页 "提交" 按钮, ...]',
          '  ```',
          `- **关联路由**:从 \`${c.root}/frontend/\` router 配置(用 Grep 定位)取本 FE 真正负责渲染的路由 path(与 router 一致;带参动态路由保留 \`:id\` 占位)。**只列本 FE 路由**,不要列兄弟 FE / 共享路由。`,
          '- **负责控件白名单**:本 FE 页面上"点了必须有可观测效果 / 显示必须正确"的控件清单(优先 `data-testid` 约定;无 testid 时用 `<页面> + DOM 选择器/可见文案` 描述)。行为门只对白名单内控件判 must-fix;白名单外控件记证据不算缺陷。',
          '- 该小节是**确定性映射**(fe-feature-review 会校验其存在且与 router 一致,缺失/不一致 → request-changes;阶段末尾的行为门会聚合**全部 FE** 的该小节作为整体断言作用域,缺失的 FE 会被记 `scope-missing` 阻断 green);推不出路由(router 尚未声明本 FE 路由)→ 按硬约束登记 decisions 取最有依据的占位 path 或 halt(不要留空)。',
        ].join('\n')
      : '',
    '',
    batch
      ? [
          '## 落盘纪律(批量预生成模式:**只写盘,不 commit**)',
          '- 本 stage 正与兄弟 feature 的 spec 在**同一工作树并行**预生成:写完 spec 文件即止,**绝不** `git add` / `git commit` / 任何改写 .git 状态的命令(并行 commit 会争 .git/index.lock)。',
          '- 统一提交由编排层在全部 spec 落盘后以单个微步骤完成(`git add docs/superpowers/specs` + commit),无须你善后。',
        ].join('\n')
      : commitBlock('<spec artifactPath>', `docs(spec:${id}): 派生规格`, undefined, c),
    '',
    '## 自审(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, c) {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-plan' : 'feature-plan'} — 任务级计划 ${id}`,
    '',
    featureStageContract(phase, c),
    '',
    '## 输入',
    `- 上游 spec:\`${specPath}\`(已由 spec stage 落盘;不存在则 halt)。**plan 文件名日期前缀必须与 spec 一致**:取 spec 文件名首段 \`YYYY-MM-DD\`,写到 plan 路径,不要重新解析"今天"。`,
    fe
      ? `- \`${c.root}/docs/04-技术规范.md § 二 前端规范\`(§ 2.1 目录约定 = 落盘位置;状态管理 / 请求封装 / 错误处理);色值 / 样式见 \`${c.root}/src/styles/tokens.css\`;测试栈见 § 零。用 Grep 在 \`${c.root}/frontend/\` 定位现有文件。`
      : `- \`${c.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/\` 开头且**不得**是测试文件(\`*.test.*\` / \`*.spec.*\`,也不得落在 \`__tests__/\` / \`__mocks__/\` / \`__smoke__/\` 目录);\`test_file\` 必须以 \`frontend/tests/\`(jsdom 单测,目录镜像 \`frontend/src/\` 相对路径)或 \`frontend/e2e/\`(Playwright)开头,**绝不**把测试文件计划进 \`frontend/src/\`(交付源码与测试物理分离,见 docs/04 § 2.1);命中 \`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 计划`, undefined, c),
    '',
    '## 输出(必须符合下发的 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, c, feStage = 'all') {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-tdd' : 'feature-tdd'} — 逐任务 TDD ${id}`,
    '',
    featureStageContract(phase, c),
    '',
    '## 输入',
    `- 计划文件:\`${planPath}\`(不存在则 halt)。`,
    // 后端默认命令按 lane 分支:顶部硬约束已禁 lane 内全量入口,输入段若仍把 `node scripts/test.mjs`
    // 钉成默认,同一 prompt 就顶部禁止、底部指定——命令还要两跳转述给嵌套子会话,必须在源头给出
    // lane 内合法默认(目标化 gradle 过滤)。串行(c.dbSchema 空)与前端文案一字不动。
    `- 测试命令来源:\`${c.root}/docs/04-技术规范.md § 零\`${fe
      ? ' 的 `frontend.unit_test_runner` / `frontend.e2e_runner` / `frontend.test_command` / `frontend.e2e_command`(缺失则默认 `pnpm test:ci` / `pnpm e2e:ci`)。'
      : c.dbSchema
        ? ' 确认的后端测试命令,并**目标化**到本功能涉及的测试类/模块(如 Gradle `test --tests` 按类过滤)——lane 模式见上方硬约束,**禁全量入口**;docs/04 缺后端命令时默认 Gradle 按类过滤跑本功能测试,**绝不**回退 `node scripts/test.mjs` 等冷起全栈的全量命令。'
        : ' 确认的后端测试命令(如 Gradle task / `./scripts/test.mjs`);缺失则默认 `node scripts/test.mjs`(与 test-gate 一致)。'}`,
    '',
    '## 流程',
    // V<n> 段规则(Task 7 Step 3):c.vBase > 0(并行 lane)时取号改走专属百段,杜绝同波次模块从同一
    // 基点各算 max+1 撞号。依据:同波次以"无依赖边"为准入——独立模块的 migration 互不引用,版本相对
    // 顺序任意;lane 测试库每次从零重建、合并后全量按版本序 apply,段间空洞无影响。并行版本段下版本号
    // **只保唯一、不保连续、不保合并序**(附录补 5:高版本段可能先合入默认分支,骨架约定 Flyway
    // out-of-order: true,乱序 apply 安全)。串行(vBase=0)维持现行 max+1 文案,一字不差。
    fe ? '' : `- **Schema 改动前置**(仅当 plan 声明需要):第一个任务写 migration 文件 \`V<n>__<snake_case>.sql\`(${c.vBase > 0
      ? `\`<n>\` 取**你的专属版本段 [${c.vBase}, ${c.vBase + 99}]** 内现有最大版本号 +1(段内尚无文件则取 ${c.vBase});**绝不**使用段外版本号——并行版本段下版本号只保唯一、不保连续,段间空洞与乱序合并安全(骨架已约定 Flyway out-of-order),**不要**"补洞"或改用段外小号`
      : '\`<n>\` = 现有 \`sql/migrations/V*.sql\` 最大版本号 + 1'},只含 DDL),**同步**把新 CREATE / ALTER 反向更新到 \`docs/03-数据库设计文档.md\` 对应表小节(docs/03 是 schema 的 SSoT),migration + docs/03 改动同一 commit。`,
    // 数据源占位约定(附录补 6a):lane 测试库 env 能打穿 Spring 进程的前提——application*.yml 写死
    // schema 时 ERP_TEST_DB_SCHEMA 传进去无人消费(脚本支持 ≠ 测试进程支持)。**关键安全点**:占位缺省值
    // 必须是副本库 `<schema>__test`,绝不能是源库 `<schema>` 本身——否则 env 万一缺省,测试就直接跑在源库上。
    fe ? '' : '- **数据源 schema 占位约定**:生成 / 修改后端 `application*.yml`(含测试 profile)时,JDBC URL 的 schema 段必须写成 Spring 占位 `${ERP_TEST_DB_SCHEMA:<db.schema 字面量>__test}`(**缺省值是副本库 `<schema>__test`,绝不是源库**——测试永远连一次性副本、绝不连源库);**绝不**把 schema 名写死成纯字面量、**绝不**把缺省值写成源库名。',
    // feStage 任务子集(前端 jsdom-only 重叠;设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)。
    // 'all'(缺省,含全部后端)与现行一字不差;'jsdom'/'e2e' 仅前端 phase1/phase2 传入。
    fe && feStage === 'jsdom'
      ? '- **本阶段 = 仅 jsdom 任务(重叠 phase1,不依赖后端)**:只处理 plan 中 `测试先行类型 = jsdom` 的代码任务;`= e2e` 的任务**全部跳过**,并在 `summary` 里以 `deferredE2e: [<task...>]` 列出(留待 phase2)。测试命令**只用** vitest(`frontend.test_command` / `frontend.unit_test_runner`)。**硬护栏**:本阶段**绝不**运行 Playwright / `pnpm e2e:ci` / `frontend.e2e_command` / 任何冷起全栈或固定端口的 e2e 命令——命中即 `{status:"halt", reason:"jsdom 模式禁跑 e2e(重叠 phase1 后端尚未就绪)"}`。FeStub→真组件替换(见下)**在本阶段完成**。'
      : '',
    fe && feStage === 'e2e'
      ? '- **本阶段 = 仅 e2e 任务(phase2,后端已就绪;jsdom 已在 phase1 绿)**:只处理 plan 中 `测试先行类型 = e2e` 的任务;jsdom 任务**跳过**(phase1 已完成实现与单测)。e2e 基线约束见下;FeStub→真组件替换 phase1 已做,本阶段确认 router 仍指向真组件即可。'
      : '',
    '- 按顺序处理每个代码类任务:(a) 在 `test_file::test_name` 写**失败**测试;(b) **派发 Agent 子会话**跑测试确认失败,子会话只返回 `{command, exit_code, failing_assertion}` JSON;(c) 写**最小**实现使测试通过;(d) 再派子会话确认通过;(e) commit(含 `REQ_ID` / REQ 标签)。',
    fe
      ? '- **测试落位(硬约定,对齐 docs/04 § 2.1)**:jsdom 单测用 vitest/jest 一律写到 `frontend/tests/`,目录**镜像** `frontend/src/` 相对路径(如 `src/pages/home/HomePage.tsx` → `tests/pages/home/HomePage.test.tsx`);e2e 类型在 `frontend/e2e/` 写 Playwright(headless)。**绝不**把 `*.test.*` / `*.spec.*` / `__tests__/` / `__mocks__/` / `__smoke__/` 落在 `frontend/src/` 内(交付源码与测试物理分离,同后端 src/main↔src/test)。实现时:色值用 `var(--color-*)`(不硬编码 hex),业务校验按 spec 在 form-level 复刻。'
      : '',
    fe
      ? '- **e2e 基线约束**:e2e 跑在「源库副本 + 本轮新迁移 + 演示种子」基线上(骨架 globalSetup 已复制源库为副本并注入 `sql/seed`,无需测试自行建库/起栈)。e2e 断言**优先**定位**演示种子已知主键行**(1000–9999)或**测试自建数据**;**禁止**「全表恰好 N 行」式依赖全局计数的脆弱断言(副本含源库真实数据 + 演示种子行数会随后续模块种子增长,全局计数断言必然 flaky)。'
      : '',
    fe
      ? `- **占位替换(保证中途可构建 + 阶段末尾行为门可达本 FE 路由)**:前端骨架阶段已在 router 里为本 FE 路由声明 lazy import 但指向占位组件 \`FeStub\`。本 FE 实现完成后,**必须**把 router 中本 FE 路由的 import 从 \`FeStub\` 改为本 FE 真组件(用 Grep 在 \`${c.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, c, feStage = 'all') {
  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, c),
    '',
    '## 目标',
    `把 \`${id}\` 的功能测试**派发到 Agent 子会话**执行,按结构化结果渲染证据。**主会话从不直接跑测试,也不自由编写证据。**`,
    `- 上游 spec:\`${specPath}\`(日期前缀来源);本次产物文件名前缀必须 = spec 文件名首段 \`YYYY-MM-DD\`。`,
    implSummary ? `- 上游 TDD 摘要:${implSummary}` : '',
    '',
    '## 流程',
    fe && feStage === 'jsdom'
      ? [
          // 重叠 phase1:只验 jsdom 单测(不依赖后端),e2e 留 phase2。
          `- 测试目标:从 plan 取 \`测试先行类型 = jsdom\` 的 test_file → 拼 vitest/jest 过滤模式。命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.unit_test_runner\` 取(缺失默认 \`pnpm test:ci\`)。`,
          '- 派子会话**只跑 unit(vitest)**,返回 `{ unit:{command,exit_code,passed,failed,failed_list,stdout_excerpt} }`(`stdout_excerpt` ≤ 30 行)。**硬护栏**:本阶段绝不跑 e2e / Playwright / `pnpm e2e:ci`(后端尚未就绪)。',
          '- **unit `exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt。',
        ].join('\n')
    : fe
      ? [
          `- 测试目标:从 plan 取 \`测试先行类型 = jsdom\` 的 test_file → 拼 vitest/jest 过滤模式;\`= e2e\` 的 → 拼 Playwright spec 过滤模式。命令从 \`${c.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')
      : [
          // lane 模式追加过滤提示:泛指入口里混着全量命令形态,不点破会与顶部"禁全量入口"硬约束打架。
          `- 测试目标:从 plan 或项目标准命令确定(Gradle task / pnpm script / pytest path / \`${c.root}/docs/04-技术规范.md § 零\` 的后端命令)。${c.dbSchema ? 'lane 模式见上方硬约束:只取**目标化**命令(gradle 按类/按模块过滤),**禁全量入口**。' : ''}`,
          '- 派子会话执行,子会话只返回结构化 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(仍要返回已写入的证据路径)。', c),
    '',
    '## 输出(必须符合下发的 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, c) {
  const fe = isFrontend(phase)
  return [
    `# ${fe ? 'fe-feature-review' : 'feature-review'} — AI 自审 ${id}(第 ${round} 轮)`,
    '',
    featureStageContract(phase, c),
    '',
    '## 目标',
    `对 \`${id}\` 本轮引入的代码 diff 做 AI 自审,给出 \`approve\` 或 \`request-changes\` 裁决。`,
    '',
    '## 输入给 reviewer',
    `- 本 ${fe ? 'FE' : 'REQ'} 引入的代码 diff + 规格 \`${specPath}\`。`,
    fe ? `- 本 FE 关联的所有 prototype 文件(spec 顶部"关联原型"列表),供对照渲染结构。` : '',
    `- **phase = ${fe ? 'frontend → 附加前端 8 维 checklist(含 §8 测试文件隔离:本轮 diff 在 frontend/src/ 内引入任何 *.test.* / *.spec.* / __tests__ / __mocks__ / __smoke__ → must-fix,应移至 frontend/tests/ 镜像路径)。其中仅"颜色对比度"(§3 子项)与"响应式"(§4)为主观/best-effort,绝不单独触发 request-changes;a11y 的 label/键盘可达/危险操作确认等客观项仍可作 must-fix(与 agents/code-reviewer.md §3-4 对齐,避免非确定性循环耗尽 5 轮)。' : 'backend → 通用代码审查维度(正确性 / 边界 / 错误处理 / 一致性)。'}**`,
    fe ? `- **行为验收作用域小节校验(阶段级行为门的作用域真值来源,必查)**:spec \`${specPath}\` 头部**必须**含逐字标题为 \`## 行为验收作用域\` 的结构化小节,且其 \`关联路由:\` 清单与 \`${c.root}/frontend/\` router 配置一致(本 FE 路由都在 router 声明、无悬空/错配)。该小节缺失 或 与 router 不一致 → **必须 request-changes**,把"补齐/对齐 行为验收作用域小节"列入 issues(locator 指向 spec 文件路径)。这是 approve 前置——阶段末尾的行为门按全部 FE spec 的该小节聚合断言作用域,缺失/错配会让该 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 中夹带额外字段)。', c),
  ].filter(Boolean).join('\n')
}

// ---- stage 5b:按 review must-fix 修复并重新 commit(review 循环的 fix 步)----
// issues:结构化对象数组 {summary, locator, severity}(见 REVIEW_SCHEMA)。
function fixPrompt(id, phase, issues, c) {
  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, c),
    '',
    '## 待修复 must-fix(已结构化)',
    list,
    '',
    '## 流程',
    '- 逐项编辑 locator 指向的代码文件(遵守阶段路径作用域护栏)。',
    `- 编辑前必须先校验 locator 文件存在:跑 \`git -C ${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, c) {
  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, c),
    '',
    '## 目标',
    `打里程碑 tag 前的唯一硬测试门。**派发 Agent 子会话**跑测试,绿则通过,红则失败。**绝不**在主会话直接跑测试,红色时**绝不**跳过。`,
    attempt > 1 ? `- 本次 = 第 ${attempt} 次(上一次 red,本轮用于辨识 flaky);证据**写到独立文件**不要覆盖前一次。` : '',
    '',
    '## 命令',
    fe
      ? `- 前端:命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` / \`frontend.e2e_command\` 拼接(缺失则 \`pnpm test:ci && pnpm e2e:ci\`),跑 vitest + playwright(含全 FE 回归)。**收尾(无论红绿)**:测试跑完后执行 \`node ${c.root}/scripts/drop-test-db.mjs\` 删测试副本(前端阶段不走 test.mjs,副本清理落在本门;源库不动)。`
      : `- 后端:跑 \`${c.root}/scripts/test.mjs\`(跨平台 Node 测试入口;含本模块新增 + 已合并模块回归;其内部自带 复制副本→测试→绿后晋升→删副本 全时序)。`,
    '- 子会话只返回结构化 JSON:`{command, exit_code, passed, failed, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行含 FAIL 摘要)。',
    // 补 9:testGate 的 e2e(Playwright globalSetup 按固定端口冷起后端)与 Seed 同属起栈类 stage——
    // 整段由编排层包进全局起栈互斥(stackLock,Phase D 定义);lane 测试库硬约束已由 featureStageContract 注入。
    c.dbSchema
      ? '- **起栈互斥纪律(并行 lane)**:本 test-gate 整段在全局起栈互斥内执行——端口探测 / 残留 pid 回收**只允许**在持有该互斥的本 stage 内做;**绝不**触碰非本 stage 启动的进程(兄弟 lane 的栈不归你管)。'
      : '',
    '',
    '## 证据 + commit',
    `- 渲染证据写入 \`${c.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')
}

// ---- 演示种子生成 stage(Seed)----
// 设计:每个后端模块 testGate green 之后生成本模块演示假数据(demo seed)并冷起栈真跑验证。
// 与 behaviorGateContract 同属「跨栈 stage 不套 featureStageContract」的**第三类 stage**:本门要**运行**
//   scripts/setup-test-db.mjs / 起后端 / scripts/seed-demo-data.mjs(featureStageContract('backend') 的路径护栏
//   会把 scripts/ 命中越界硬停,与本门必须运行这些脚本自相矛盾),故另起 seedStageContract 自带契约。
// 锁定契约(与 A2/A4/test.mjs/行为门一致):种子文件 `sql/seed/<NN>__<module_id>.sql`(随 git 提交);头部
//   机器可读行 `-- demo-seed: <module_id>` + 每表一行 `-- expect: <table>=<rows>`;主键区间 1000–9999;
//   演示数据值绝不含 `_S<数字>` 样式(预留行为门 sentinel);注入脚本 scripts/seed-demo-data.mjs(A2 已生成)。

// seedStageContract:种子 stage 的硬约束。非交互;证据报告用中文但 SQL/标识符可英文(受控格式);
// 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端 / scripts/seed-demo-data.mjs / mysql 只读查询,
// 唯一**可写** = sql/seed/ + .tmp/seed-gen/<module_id>/(跑完即弃)+ docs/superpowers/module-reports/<module_id>-seed-verify.md;
// 改 backend//frontend//scripts/ 源码即越界硬停。
function seedStageContract(c) {
  return [
    '## 硬约束(非交互演示种子子代理)',
    '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹出 AskUserQuestion / 等待人类输入。**绝不要尝试问人**。',
    '- 你的职责 = **为本模块生成演示种子(demo seed)并冷起栈真跑验证**——**不是**实现功能、**不是**改源码、**不是**改 schema。',
    '- 缺值查找顺序:`config-vars.yaml` → `docs/03-数据库设计文档.md` → `docs/01-需求清单/` 各 REQ 卡(业务语义)→ 既有 `sql/seed/*`(跨模块语义引用前序模块种子的已知主键)→ 现有代码。仍查不到时**优先自主决策继续**,把决策写进证据报告显著位置并登记到返回 `decisions[]`(`{question,choice,rationale,confidence}`)。',
    `- **作用域例外(关键)**:本门为跨栈验证,明确允许**运行**(不修改)以下——\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等,Flyway 在副本上 apply 本轮新迁移)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子到副本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(收尾删副本)、mysql **只读** COUNT/查询;唯一允许**写入**的路径是 \`${c.root}/sql/seed/\`(种子文件,随 git 提交)+ \`${c.root}/.tmp/seed-gen/<module_id>/\`(一次性 runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/<module_id>-seed-verify.md\`。`,
    `- **越界硬停**:**绝不**编辑 \`backend/\` / \`frontend/\` / \`scripts/\` 下的任何源码文件(只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs,不许改它们)。区分「运行 backend 服务 / 运行脚本」(允许)与「写 backend 实现 / 改脚本」(越界)。命中越界即以 \`status:halt\` 写清阻塞点结束。`,
    '- **确定性红线(关键)**:种子值一律**显式主键**(1000–9999 区间)+ **固定历史日期**(写死字面量,如 `2024-03-15`),**绝不**依赖时间戳 / `NOW()` / 随机数 / 自增主键的隐式取值。',
    '- **区间隔离红线**:演示数据值**绝不含 `_S<数字>` 样式编码串**(如 `CUST_NAME_S001`)——该样式预留给行为门 sentinel;数值主键固定落 1000–9999(1–999=初始数据 / ≥100000=sentinel)。',
    // lane 注入(附录补 10):Seed 是不套 featureStageContract 的"第三类 stage",必须单独注入同一条
    // 硬约束——Seed 全链恰是 复制副本 / 起栈 / 灌种子 的大户,漏注入会让 lane 用错副本库互相踩(源库有
    // COPY≠SOURCE 守卫兜底、绝不被误 drop)。与 marker 兜底(补 7,lane 根 .tmp/erp-lane-db)自洽:lane 内一切脚本确定性指向本 lane 副本。
    ...(c.dbSchema ? [
      `- **并行 lane 测试库(硬约束)**:本模块运行在并行 lane,跑在一次性副本库上。Seed 全链——\`node scripts/setup-test-db.mjs\` / 起后端(gradle bootRun 等)/ \`node scripts/seed-demo-data.mjs\` / \`node scripts/drop-test-db.mjs\` / mysql COUNT 对账——的**全部**命令(含派发给子会话的)必须前置环境变量 \`ERP_TEST_DB_SCHEMA=${c.dbSchema}\`:成品字符串照抄使用,**绝不**自行拼接 / 改名 / 省略(bootRun 经 application*.yml 的 \`\${ERP_TEST_DB_SCHEMA:...}\` 占位吃到同一 lane 副本;COUNT 对账必须连 lane 副本查询)。`,
    ] : []),
    '- 红线:**绝不**伪造验证通过;**绝不**留 `TBD` / `TODO` / `【人工填写:】`;自主默认必须可被现有证据支撑且记入 `decisions[]`。',
    '- 证据报告 / 注释 / 提示**使用中文**;SQL / 标识符 / 表名可用英文(受控 `[A-Za-z0-9_]` 格式)。',
  ].join('\n')
}

// seedGenPrompt:单模块演示种子生成 + 冷起栈真跑验证的完整流水线提示。
// module:本后端模块(含 id);本 stage 在该模块 testGate green 之后跑(schema 含 tdd 新增 V<n> 已终态全绿)。
function seedGenPrompt(module, c) {
  const id = module?.id ?? '<module>'
  const tmpDir = `${c.root}/.tmp/seed-gen/${id}`
  const evidence = `docs/superpowers/module-reports/${id}-seed-verify.md`
  return [
    `# seed — 演示种子生成 + 冷起栈真跑验证(模块 ${id})`,
    '',
    seedStageContract(c),
    '',
    '## 目标',
    `为本模块 \`${id}\` 生成**演示假数据(demo seed)**并冷起栈真跑验证:生成 → \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 起后端(Flyway apply 本轮新迁移到副本)→ \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌进副本)→ mysql 只读 COUNT 对账 → \`node ${c.root}/scripts/drop-test-db.mjs\`(收尾删副本)。`,
    '种子产物随 git 提交(不保证「存活」,保证「随时可复现」——每次都从源库副本重注入,副本跑完即删;源库绝不被 drop)。',
    '',
    '## 输入',
    `- \`${c.root}/docs/03-数据库设计文档.md\`:本模块各表结构(列 / 类型 / enum 值域 / 语义引用关系 / NOT NULL / UNIQUE 约束)。`,
    `- \`${c.root}/docs/01-需求清单/<module>/\` 本模块 REQ 卡:业务语义(让假数据有真实感、符合业务取值)。`,
    `- 既有 \`${c.root}/sql/seed/*.sql\`:跨模块语义引用前序模块种子的**已知确定性主键**(你的语义引用列必须指向这些已存在的主键,不可悬空)。`,
    `- \`${c.root}/config-vars.yaml\`:database 段凭据(seed-demo-data.mjs / setup-test-db.mjs 自行读取,你只需确保起栈参数一致)。`,
    '',
    '## 幂等(resume 安全)',
    `- 用 Glob 查 \`${c.root}/sql/seed/*__${id}.sql\`。**已存在** → **Edit 复用该文件**(保留原 \`NN\` 序号,不另起新文件);按需补齐/修正内容。`,
    `- **不存在** → 新建 \`sql/seed/<NN>__${id}.sql\`,其中 \`NN\` = 既有 \`sql/seed/*.sql\` 文件名最大序号 + 1(两位补零,如既有最大为 \`03\` → 本文件用 \`04\`;无任何既有文件 → \`01\`)。`,
    // 并行 lane 附注(仅 lane 模式渲染,串行输出一字不变)。
    // 补 9:冷起栈资源(config-vars 固定端口 + 进程树)全局唯一——本 stage 整段由编排层包在全局起栈
    // 互斥(stackLock,Phase D 定义)内串行执行;"按既知端口回收残留 pid"若不在互斥内做,会把兄弟 lane
    // 正在验证的后端当残留 kill 掉(≥2 宽波次必然踩中的确定性互杀,非小概率 race)。
    ...(c.dbSchema ? [
      '',
      '## 并行 lane 附注',
      '- **起栈互斥纪律**:本 Seed stage 整段在全局起栈互斥内执行——端口探测 / 按既知端口回收残留 pid **只允许**在持有该互斥的本 stage 内做;**绝不**在 stage 外、或对非本 stage 启动的进程做回收。',
    ] : []),
    // 补 17:两个并行模块从同一基点各算 NN=max+1 必产同号文件——豁免必须显式落进 prompt,否则与上文
    // "NN = 最大序号 + 1"字面约定静默冲突,实现者会各自发挥。(NN, module_id) 才是唯一键:文件名含
    // module id(git 无冲突),_demo_seed_history 按完整文件名记账、注入按文件名字典序应用(现状已满足)。
    ...(c.vBase > 0 ? [
      '- **并行 lane 间允许同 NN**:NN 仅表观排序,`(NN, module_id)` 才是唯一键;同波次模块无 seed 依赖边、互不引用,注入按完整文件名字典序应用。发现兄弟分支存在同 NN 文件**不算冲突,不要改号**。',
    ] : []),
    '',
    '## 生成规则',
    '- **按语义引用有序(先被引用方后引用方)**:同一文件内 INSERT 先被引用方后引用方;跨模块语义引用列指向既有 `sql/seed/*` 中前序模块种子的已知主键。',
    '- **显式主键**:本模块种子行主键固定落 **1000–9999** 区间(避开 1–999 初始数据 / ≥100000 sentinel);同表内主键唯一、确定性。',
    '- **真实感中文业务数据**:依 REQ 卡业务语义取值(人名 / 机构 / 金额 / 状态等),不要 `测试1`/`aaa` 占位;但**绝不含 `_S<数字>` 样式编码**(预留 sentinel)。',
    '- **enum 取值域**:enum 列只从 `docs/03` 声明的值域取值(越界即数据类失败)。',
    '- **固定历史日期**:日期/时间列写死固定历史字面量(如 `2024-03-15 10:00:00`),绝不 `NOW()` / 时间戳。',
    '- **行数**:主业务列表表(页面会分页展示的)给 **15–30 行**(够触发分页 + 行级操作);字典/配置类小表按需少量(够语义引用 + 下拉非空)。',
    `- **头部注释(机器可读,验证对账依赖)**:文件头第一行 \`-- demo-seed: ${id}\`;随后**每张被本文件 INSERT 的表各一行** \`-- expect: <table>=<rows>\`(rows = 本文件向该表插入的行数)。`,
    `- **本模块无可种表**(纯计算/无表模块)→ **不建文件**,直接 \`status:ok\` + summary 说明「模块 ${id} 无可种表,跳过」(跳过下面的验证与 commit)。`,
    '',
    '## 运行验证(写一次性 runner,仿行为门冷起栈纪律的简化版)',
    `- **入口清目录**:先用确定性、跨平台方式重建 \`${tmpDir}/\`(\`fs.rmSync(tmpDir,{recursive:true,force:true})\` 后 \`fs.mkdirSync(tmpDir,{recursive:true})\`),仅限该受控路径,绝不删其它路径。`,
    `- 在 \`${tmpDir}/\` 写一次性 runner \`run.mjs\`,依序:`,
    `  1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。`,
    `  2) **起后端**:从 \`${c.root}/config-vars.yaml\` 取端口;起栈前先探测端口占用并按 \`${tmpDir}/*.pid\` / 既知端口回收上一次残留 pid;spawn 到后台进程树 + 轮询健康端点(\`/actuator/health\` 或登录端点 200)就绪(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过)。`,
    `  3) \`node ${c.root}/scripts/seed-demo-data.mjs\`(注入种子到副本;幂等账本 \`_demo_seed_history\` 自动跳过已应用文件)。`,
    '  4) **mysql 只读 COUNT 对账**:对本模块种子涉及的**每张表**,跑 `SELECT COUNT(*) ... WHERE <主键列> BETWEEN 1000 AND 9999`(**只数演示种子区间**——后端启动可能把 admin_init 等初始数据 bootstrap 进共表,其键落 1–999,不计入 expect),与「全部 `sql/seed/*.sql` 文件头 `-- expect: <table>=<rows>` 之和」逐表比对(同一张表可能被多个种子文件插入,必须求和后再比)。',
    `  - \`finally\` **硬要求 kill 本 stage 起的全部子进程**(绝不让 gradle bootRun 挂死会话)**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动)。`,
    '- **失败归类(reason 里必须分清)**:',
    '  - **环境类**(端口占用 / 起栈超时 / setup-test-db 失败 / 健康端点不就绪)→ reason 标 `env-error` + 端口/pid。',
    '  - **数据类**(撞主键/唯一键 / 引用错序或悬空 / enum 越界 / 类型截断 / COUNT 不符)→ reason 标 `data-error` + 具体表与根因(这是种子本身的 bug,必须修种子文件后重验)。',
    '',
    '## 证据落盘',
    `- 写 \`${evidence}\`(中文):逐表「期望行数 / 实际行数 / 结论(match/mismatch)」表格 + 本模块种子文件路径 + 起栈端口 + 关键决策。`,
    '- 若验证失败(环境类或数据类)→ 证据**头部用红字标注根因**(区分环境类 vs 数据类)。',
    '',
    commitBlock(`sql/seed ${evidence}`, `chore(seed:${id}): 演示种子数据`,
      '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的种子/证据路径)。', c),
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    `- 成功(含验证 COUNT 全部对账通过):\`{ "status": "ok", "artifactPath": "sql/seed/<NN>__${id}.sql", "summary": "<种子表数 / 总行数 / 验证结论 ≤ 200 字>" }\`。`,
    `- 本模块无可种表:\`{ "status": "ok", "summary": "模块 ${id} 无可种表,跳过" }\`(artifactPath 可省)。`,
    '- 验证失败 / 越界 / 缺值(无法自洽决策)→ `{ "status": "halt", "reason": "<env-error 或 data-error + 具体根因>", "artifactPath": "<已写入的种子路径(如有)>" }`。',
    '- `artifactPath`(如有)必须为项目根相对路径;做过自主默认 → `decisions[]` 逐条登记;schema 是 `additionalProperties:false`,不要返回额外字段。',
  ].filter(Boolean).join('\n')
}

// ---- 静态原型渲染门(Preview)prompt ----
// 与下方「前端行为验收」正交:行为门测**实现**(冷起全栈 + Playwright 驱动真前端),本门只渲染
// **静态** prototype/**/*.html 本身——逐页 headless 渲染→截图归档(可视化基线)→跑原型自带交互点击
// 冒烟→捕获 JS/console 错误。原型是 plan/add-req 的上游权威产物:本门**只渲染不修改**,原型自身问题
// 走 advisory(记证据,不硬 halt)。不起后端/前端 app、不跑 setup-test-db/seed。

// prototypePreviewContract:渲染门硬约束(只读、不起全栈、唯一可写区 = .tmp + assets + 证据报告)。
function prototypePreviewContract(c) {
  return [
    '## 硬约束(非交互原型渲染子代理)',
    '- 你是 Workflow 派生的**非交互子代理**,物理上无法弹 AskUserQuestion / 等人输入。**绝不要尝试问人**。',
    '- 你是**静态原型只读渲染门**:用 Playwright headless 渲染 `prototype/**/*.html` 本身、截图、跑原型自带交互——**不是**实现功能、**不是**起后端/前端 app、**不是**改任何源码。',
    `- **作用域(唯一可写区)**:\`${c.root}/.tmp/prototype-preview/\`(一次性 runner,跑完即弃)+ 截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/prototype/\` + 证据报告 \`${c.root}/docs/superpowers/module-reports/prototype-preview.md\`。`,
    '- **越界硬停**:**绝不**编辑 `prototype/` / `frontend/` / `backend/` / `sql/` / `scripts/` 下任何文件——原型是上游 plan 的权威产物,本门只渲染不修改。命中越界即以 `status:blocked` 或写清阻塞点结束。',
    '- **不起全栈**:本门只渲染静态 HTML(`file://` 或仅服务 `prototype/` 的临时静态服务器),**绝不**冷起后端 / 前端 dev server、**绝不**跑 setup-test-db / seed。',
    '- 证据报告**使用中文**;文件名 / 选择器标识符可用英文。',
    '- **运行时确定性**:视口尺寸 / 临时目录名 / 截图文件名 / 临时端口一律确定性派生(按页面相对路径 / attempt 序号),**绝不**依赖时间戳 / 随机数。',
  ].join('\n')
}

// prototypePreviewPrompt:静态原型渲染门子代理完整流水线(step0-3 + schema)。
// feItems:本前端阶段全部 FE-NN(仅作分组参考,不强制与原型页一一对应);attempt:环境 race 重试序号(1..)。
function prototypePreviewPrompt(feItems, attempt, c) {
  const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无 FE 清单)'
  const tmpDir = `${c.root}/.tmp/prototype-preview`
  const assets = 'docs/superpowers/module-reports/assets/prototype'
  const evidence = 'docs/superpowers/module-reports/prototype-preview.md'
  return [
    `# preview — 静态原型渲染门(headless,attempt=${attempt})`,
    '',
    prototypePreviewContract(c),
    '',
    '## 目标',
    '渲染**全部静态原型** `prototype/**/*.html` 本身,产出两类产物:① 每页**截图**(可视化基线,供人工 / 行为门比对);② 原型**自带交互**的点击冒烟(按钮 / 链接 / 表单切换是否触发 JS 错误)。这是对静态设计原型的「查看界面 + 模拟操作」,与实现无关。',
    `- 关联前端功能(仅作分组参考,不强制一一对应):${feList}。`,
    attempt > 1 ? `- 本次 = attempt ${attempt}(上次 \`status:blocked\` 环境未就绪后重试)。` : '',
    '',
    '## step0 发现原型 + 探测环境',
    `- Glob \`${c.root}/prototype/**/*.html\`,得页面清单 = \`pagesFound\`。**空**(项目无原型)→ 直接返回 \`{ "status":"ok", "pagesFound":0, "pagesRendered":0, "pages":[] }\`,不报错、不写任何文件。`,
    '- 探测 Playwright 是否可用(`frontend/node_modules` 内或全局)+ 浏览器内核是否已装。不可用 → `status:"blocked"` + `envError.kind="playwright-missing"|"browser-missing"`(**不要**尝试 `npm install` / `playwright install`——环境就绪由上层处理)。',
    '',
    '## step1 渲染 + 截图(确定性)',
    `- 入口清目录:删除并重建 \`${tmpDir}/\`(确定性、跨平台:\`fs.rmSync(p,{recursive:true,force:true})\` 后 \`fs.mkdirSync(p,{recursive:true})\`;**仅限**此路径)。`,
    `- 在 \`${tmpDir}/\` 写一次性 runner(如 \`run.mjs\`):用 Playwright headless 逐页加载。优先 \`file://\` 直开;若原型用了相对 fetch / 根绝对路径资源导致 file:// 加载不全 → 起一个**仅服务 \`${c.root}/prototype/\` 静态目录**的临时本地服务器(端口确定性派生,\`finally\` 中 teardown)。`,
    '- 视口确定性(默认 1440×900;某原型在 `<meta viewport>` / 注释里声明了特定尺寸则用之并记 `decisions[]`)。每页 `waitForLoadState` 稳定后整页截图。',
    `- 截图归档到**已纳入版本管理**的 \`${assets}/<安全文件名>.png\`(文件名按原型相对路径确定性派生,如 \`prototype/order/list.html\` → \`order__list.png\`;**不要**引用 \`.tmp\` 防断链)。\`pagesRendered\` = 成功截图页数。`,
    '',
    '## step2 原型自带交互冒烟(模拟操作)',
    '- 每页注册 `page.on("pageerror")`(收 JS 异常)与 `page.on("console")`(收 `error` 级);注册 `page.on("dialog")` 自动 dismiss,防原生 `confirm/alert` 阻塞。',
    '- 枚举页面可见可点控件(`button` / `a[href]` / `[role=button]` / `input` / `select` / 带 `onclick` 的元素),逐个**点击 / 切换一次**。`target=_blank` / 会 `window.open` 的只记不点(防新窗暴增)。`controlsExercised` 计实际驱动的控件数。',
    '- 收集每页 `jsErrors`(pageerror 文本)/ `consoleErrors`(console.error 文本)——**原型自身**的脚本质量信号,仅作记录(advisory)。',
    '- `finally` 中 kill 临时静态服务器(若起过)。',
    '',
    '## step3 证据落盘 + commit',
    `- 写 \`${evidence}\`:按页一小节(原型相对路径 / 截图链接 / controlsExercised / jsErrors / consoleErrors / 视口决策),头部写 pagesFound-Rendered 汇总。渲染失败 / JS 错用**红字**标注为**阻断项**(上层据此 halt——回 /erp-workflow:plan-start 或 /add-req 修原型后重跑,**不在 coding 改**);console.error 标注为 advisory。`,
    commitBlock(`${evidence} ${assets}`, 'docs(preview:prototype): 静态原型渲染基线 + 交互冒烟证据', undefined, c),
    '',
    '## 输出(必须符合下发的 PROTOTYPE_PREVIEW JSON schema)',
    '- `status`: `ok`(渲染门正常跑完——即使某页渲染失败/有 JS 错也填 ok,硬/软由上层 JS 据 `pages[]` 分流)| `blocked`(**仅限**环境未就绪:Playwright / 浏览器缺失 / 超时)。',
    '- `pages[]` **据实填**(上层据此判硬门):`rendered=false` 或 `jsErrors` 非空 = 硬缺陷(上层会 halt 等人工修原型);`consoleErrors` = 软信号(advisory)。`pagesFound` / `pagesRendered` 整数据实填;每页 file / rendered / screenshot / controlsExercised / jsErrors / consoleErrors / notes。',
    '- `envError`: 无环境问题填 `{ "kind":"none" }`;环境未就绪填对应 kind + detail。',
    '- 做过自主默认(视口 / 服务方式等)→ `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。',
    '- schema 是 `additionalProperties:false`,不要返回额外字段。**不要自动重试**——重试由上层 Workflow 控制。',
  ].filter(Boolean).join('\n')
}

// ---- 前端行为验收(阶段级行为门,v3)----
// 设计权威:docs/design/2026-06-05-frontend-behavior-stage-gate.md。
// 时机:featureLoop(frontend) 全部 FE 通过静态 review(req-done tag 已打)之后、testGate 之前,
// 整个前端阶段只跑**一次**行为验收:起全栈 + 演示/sentinel 种子,按全部 FE spec 聚合的作用域并集
// 枚举路由控件/文字,硬问题转可 fix must-fix→fix→复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮),green 才进 testGate。
// 门是**跨栈只读验证 + 临时产物**的第三类 stage:不套 featureStageContract('frontend')
// (其路径护栏命中 backend/sql/scripts 即越界硬停,与门必须运行 setup-test-db / 起后端 / 生成 SQL 种子自相矛盾)。

// behaviorGateContract:门的硬约束。非交互;证据报告用中文但 spec/sentinel/SQL 可英文标识符;
// 作用域例外——允许**运行**(不可写)scripts/setup-test-db.mjs / 起后端前端 / 跑 playwright,
// 唯一**可写** = .tmp/behavior-gate/frontend-phase/r<behaviorRound>/ + 证据报告及 assets;改 frontend//backend//sql/ 源码即越界硬停。
// lane 注入豁免(附录补 10 审计口径):行为门只在 frontend-phase 跑——其 deps 恒为全部后端模块,
// 终波恒宽 1、主根执行(不建 lane、c.dbSchema 恒空),prompt 含 setup-test-db/seed-demo-data 字样
// 但天然不需要 ERP_TEST_DB_SCHEMA 注入分支。
function behaviorGateContract(c) {
  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 ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)、起后端服务(gradle bootRun 等)、\`node ${c.root}/scripts/seed-demo-data.mjs\`(只运行注入演示种子,不修改脚本)、\`node ${c.root}/scripts/drop-test-db.mjs\`(teardown 删副本)、起前端 headless(vite / playwright)、跑 Playwright;唯一允许**写入**的路径是 \`${c.root}/.tmp/behavior-gate/frontend-phase/r<behaviorRound>/\`(种子 SQL/runner,跑完即弃)+ 证据报告 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r<behaviorRound>-a<attempt>.md\` + 其 assets(截图归档到 \`${c.root}/docs/superpowers/module-reports/assets/...\`)。`,
    `- **越界硬停**:**绝不**编辑 \`frontend/\` / \`backend/\` / \`sql/\` 下的任何源码文件,也**绝不**编辑 \`${c.root}/scripts/\` 下的脚本——只许**运行** scripts/setup-test-db.mjs / scripts/seed-demo-data.mjs / scripts/drop-test-db.mjs。区分「运行 backend 服务」(允许)与「写 backend 实现」(越界)。命中越界即以 \`status:red\` + \`envError\` 或写清阻塞点结束。`,
    '- **全量终态前提(关键)**:本门跑在**全部 FE 已实现并通过静态 review 之后**——`frontend/` 不应再有未实现路由 / FeStub 占位。某路由仍渲染 `data-fe-stub` 占位 → 这是硬缺陷(tdd 漏做占位替换),归 `interactionFailures[kind="no-observable-effect"]`,locator 指向 router 文件该路由 import 行,detail 写明「路由仍指向 FeStub 占位」。**断言作用域 = 全部 FE spec 的 `## 行为验收作用域` 小节并集**;白名单外控件记证据不入断言集。',
    '- 红线:**绝不**伪造断言通过;**绝不**留 `TBD` / `TODO`;自主默认必须可被现有证据支撑且记入 `decisions[]`。',
    '- 证据报告**使用中文**;spec / sentinel 标识符 / SQL 可用英文(`[A-Za-z0-9_]`,受控格式,不取任意文本)。',
    '- **运行时确定性**:sentinel 值 / 端口 / 临时目录名一律由你确定性派生(按列类型 / config-vars 端口 / behaviorRound / attempt 序号),**绝不**依赖时间戳 / 随机数。',
  ].join('\n')
}

// behaviorGatePrompt:阶段级行为验收子代理的完整流水线提示(step0-6 + schema)。
// feItems:本前端阶段全部 FE-NN(作用域聚合的清单真值,来自 Router frontend-phase 模块);
// behaviorRound:阶段门内的行为 fix 轮(1..BEHAVIOR_STAGE_MAX);attempt:本轮内环境 race 重试序号(1..)。
// 每 (behaviorRound × attempt) 独立 .tmp 子目录 + 独立证据文件,绝不互相覆盖(不丢 flake 信号)。
function behaviorGatePrompt(feItems, behaviorRound, attempt, c) {
  const feList = (feItems || []).map(x => `\`${x}\``).join(', ') || '(调用方未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)'
  const tmpDir = `${c.root}/.tmp/behavior-gate/frontend-phase/r${behaviorRound}`
  const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-r${behaviorRound}-a${attempt}.md`
  return [
    `# behavior — 前端阶段级行为验收(headless,frontend-phase, behaviorRound=${behaviorRound}, attempt=${attempt})`,
    '',
    behaviorGateContract(c),
    '',
    '## 目标',
    '用真实全栈运行证明**全部 FE** 的「每个按钮/点击都真的生效、每段文字都显示正确内容(right context)」。整个前端阶段只跑这一道行为门(featureLoop 全部 FE 已过静态 review)。',
    '单个子会话内**收敛完成**:冷起栈 → 逐路由枚举(全 FE 作用域并集)+ 两层断言 → teardown。期望即时推导(prototype/ + REQ + docs/05),**不**持久化为契约,但推导期望写进已提交证据报告。',
    `- 本阶段 FE 清单:${feList}。`,
    `- 断言作用域真值 = **每个 FE** 的 spec(\`${c.root}/docs/superpowers/specs/<date>-<FE-NN>.md\`,同一 FE 多份取最新日期)头部的 \`## 行为验收作用域\` 小节(\`关联路由:\` + \`负责控件白名单:\`)。先逐 FE Read 取出并**聚合为并集**(路由去重、逐路由标注归属 FE);某 FE 缺 spec 或缺该小节 → 记 \`coverageGaps[reason="scope-missing", page="<FE-NN>"]\`(该 FE 路由不计入分母,**绝不**静默跳过)。`,
    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
      ? `本次是本阶段首轮首次 → 先删除整个 \`${c.root}/.tmp/behavior-gate/frontend-phase/\` 目录(清掉历史残留 runner/种子),再新建本轮子目录 \`${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 本门起的全部子进程**并透传结构化结果。**绝不**让前台 gradle bootRun / vite 挂死会话——它们永不退出,必须 spawn 到后台进程树 + 轮询健康端点 + 跑完 teardown。`,
    `- **确定性端口/pid 回收前置**:起栈前先按既知端口 + \`${tmpDir}/*.pid\` 强制回收上一 attempt 残留(编排层 + runner 双保险);端口先探测占用,占用则回收或退到动态空闲端口 + 把 baseURL 注入下游。`,
    `- \`${c.root}/.tmp/behavior-gate/\`(含子目录)已被仓库 \`.gitignore\` 忽略,是唯一临时写区;跑完即弃,只提交证据报告 + assets。`,
    '',
    '## step0 探测 + build 归因',
    `- 读 \`${c.root}/docs/04-技术规范.md § 零\` + \`${c.root}/frontend/package.json\` + \`${c.root}/config-vars.yaml\`。`,
    '- runner 自负冷起后端 + 前端 headless(无既有 webServer 可复用)。**起 dev / source-map 模式**(注入定位辅助:`data-testid` 约定 / Vue `__file`),便于把 page+selector 映射回组件文件。',
    '- **build / 起 dev server 失败时先归因**:用 `git` / `Grep` 判断报错根因文件路径——全部 FE 已实现,**没有**「兄弟未实现」豁免:',
    '  - 根因落在 `frontend/` 源码且可定位到文件 → 真构建 bug → 归 `interactionFailures[kind="js-error"]`(locator=根因文件路径,可转 must-fix 喂 fix)。',
    '  - 根因不可归到 `frontend/` 源码(依赖 / 工具链 / 无法定位)→ `envError.kind="build-failed"`(如能定位仍填 `rootCausePath`)。',
    '  - 起栈本身就绪失败但非编译错(端口/超时)→ `envError.kind="stack-not-ready"|"timeout"`。',
    '',
    '## step1 路由真值发现(覆盖率分母 = 全部 FE 作用域路由并集)',
    '- 分母来源 = 全部 FE spec `## 行为验收作用域` 小节 `关联路由:` 清单的**并集(去重)**;`routesPlanned` = 并集路由数。逐路由标注归属 FE(证据分小节与硬问题归因用)。',
    `- 与 \`${c.root}/frontend/\` router 配置对账:FE 作用域声明但 router 缺失的路由 → \`coverageGaps[reason="unreachable-no-route"]\`;router 声明但不属任何 FE 作用域的路由记证据(不入分母、不断言)。`,
    '- 由 `prototype/` + 关联 REQ 卡片 + `docs/05` 推导**每路由的预期控件与文字来源**;每路由标注所需登录角色。',
    '- 带参动态路由用**种子已知主键**实例化(可用**演示种子已知主键**(1000–9999)或 **sentinel 主键**(≥100000));无法实例化 → 记 `coverageGaps[reason="dynamic-route-no-seed"]`,不静默判 green。',
    '',
    '## step2 起栈五段严格时序(副本从源库复制而来,Flyway 在后端启动时把本轮新迁移 apply 到副本)',
    `1) \`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本;源库绝不被 drop)。建副本前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程;脚本失败按普通 \`stack-not-ready\` 处理。`,
    '2) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此把本轮新迁移 apply 到副本,历史里已有的自动跳过);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
    `3) **注入演示种子**:\`node ${c.root}/scripts/seed-demo-data.mjs\`(幂等账本 \`_demo_seed_history\` 自动跳过源库已灌过的文件,把新 \`sql/seed/*.sql\` 演示数据灌进副本)。失败 → \`envError.kind="seed-error"\` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / 引用序错 / 类型截断 / schema 未初始化),**不**混进交互 RED。`,
    '4) **此时才跑 sentinel 种子**:按 `docs/03-数据库设计文档.md` 派生 **按语义引用有序的 INSERT** sentinel 种子(先被引用方后引用方;专司绑定断言——「保列表非空触发行级操作」已由本 step2 子项 3) 注入的演示种子承担)。失败 → `envError.kind="seed-error"` + 结构化根因,**不**混进交互 RED。',
    '   - **sentinel 规则**:按列类型派生类型合法且可辨识的值——数值主键**一律 ≥100000**(固定区间,不再动态扫描既有键:初始数据 1–999 / 演示种子 1000–9999 已由区间约定隔离,sentinel 落 ≥100000 天然不冲突);字符串列**仍逐字段唯一编码**(`_S<NNN>` 样式,如 `CUST_NAME_S001`,抓绑错字段——演示数据已被禁用该样式,故 sentinel 独占)+ 行序号保 UNIQUE;enum 列从 docs/03 值域取并标注。断言按 sentinel 行已知主键定位。所有 SQL 值参数化 / 白名单转义,sentinel 用受控 `[A-Za-z0-9_]` 格式。',
    '5) **起前端 headless**:spawn + 轮询 ready;端口同样探测 + 动态回退。',
    `- \`finally\` **硬要求 kill 本门起的全部子进程**,**并跑 \`node ${c.root}/scripts/drop-test-db.mjs\` 删副本**(无论成败;源库不动);端口 + 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 作用域并集)',
    '- **枚举/驱动 step1 聚合的全部路由 + 各 FE 控件白名单并集**。每路由带 `storageState` 加载,收集 DOM 真实控件与文字区域。分母 = step1 聚合清单,分子 = live 枚举。',
    '- **FeStub 残留检测**:每路由加载后检查 `data-fe-stub` 元素;仍渲染占位 → 该 FE 的 tdd 漏做占位替换(硬缺陷),归 `interactionFailures[kind="no-observable-effect"]`(locator=router 文件该路由 import 行,detail 写「路由仍指向 FeStub 占位」)。',
    '- 分母有但首帧无的控件:runner 尝试**驱动到出现态**(种子保列表非空触发行级操作 / 进多步流程下屏 / 展开 dropdown / 切 tab 后二次枚举);仍不可达 → `coverageGaps[reason="deep-control-not-driven"]`,不静默判 green。到不了的路由 → `coverageGaps[reason="unreachable-auth"|"unreachable-no-route"]`,与「到达了但控件死」严格区分。',
    '- **白名单外控件**(任何 FE 白名单都未列,如共享导航/布局区):不入「必须有效果」断言集,记证据即可;确属可疑死控件可记 `coverageGaps[reason="deep-control-not-driven"]`。',
    '- **inert 过滤**:`disabled` / `[aria-disabled]` / `fieldset[disabled]` / `pointer-events:none` 归 intentionally-inert,不入「必须有效果」断言集但记证据;disabled 的提交类按钮先填合法态观察是否解除 disabled。',
    '- `routesReached` / `controlsEnumerated` 据实填(空覆盖必须可见)。',
    '',
    '## 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"`(软文字,走仲裁,永不阻断 green)。',
    '- **样式层(客观断言:颜色 token 比对 + layout sanity)**。断言作用域 = **白名单控件及其直接容器 + spec/prototype 点名区域**;组件库深层内部元素**不查**,只查可见元素:',
    `  - **色值基准确定性派生**:读 \`${c.root}/src/styles/tokens.css\` 解析全部 \`--color-*\`,用探针元素 getComputedStyle 把任意色值格式归一化为 canonical rgb 集合;被检元素的渲染值(\`color\` / \`background-color\` / \`border-color\`)同法归一化后比对。`,
    '  - **颜色断言**:渲染色 ∉ token 集合 → `styleIssues[kind="non-token-color"]`;spec「Design Tokens 引用清单」点名了具体 token 的元素,渲染值 ≠ 该 token 解析值 → `styleIssues[kind="token-mismatch"]`。半透明混合 / 无法归一化的值 → **不入** styleIssues,记 `decisions[]`(宁漏勿误)。',
    '  - **几何断言(layout sanity)**:每路由 `scrollWidth > clientWidth + 1` → `styleIssues[kind="horizontal-overflow"]`(locator=该路由 view 组件文件);白名单控件两两 boundingBox 交叠 >4px² 且双方可见非 inert → `overlap`;预期可见控件 box 为零 → `zero-size`;`scrollIntoViewIfNeeded` 后仍不在视口 → `offscreen`。',
    '  - **视口**:默认用 Playwright 默认视口;prototype 明确声明 viewport 时用之并记 `decisions[]`。',
    '  - styleIssues 全部是客观硬问题:locator 要求与交互层逐字同口径(A 类反查组件文件;反查不出归 `coverageGaps[reason="locator-not-resolvable"]`),`expected`/`actual` 写比对双方的具体值(如 `expected="var(--color-primary)→rgb(22,119,255)" actual="rgb(255,0,0)"`)。',
    '- **行为硬问题必须带源码 locator(转 must-fix 喂 fix 的前置)**:',
    '  - **A 类(可反查到组件文件)**:经 route → router 配置 → view 组件文件反查到**组件级文件路径**。`interactionFailures[].locator` = `<组件文件路径>`(可附 DOM 选择器 / 绑定文本片段,写进 `detail`);`detail` 写「失败 kind + 归属 FE + 期望端点/期望 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 分小节**(每 FE:作用域 / 推导期望 / 逐控件判定 / 该 FE 的 styleIssues 与 coverageGaps),全局段写 routesPlanned-Reached-controlsEnumerated / authState(含未覆盖角色集)/ token 色值集合摘要 / 截图索引。`,
    `- 截图归档到**已纳入版本管理**的 \`docs/superpowers/module-reports/assets/...\`(**不要**引用 \`.tmp\` 防断链)。`,
    `- 若本次 \`status:red\` 或存在 envError,证据**头部用红字标注原因**。`,
    commitBlock(`${evidence} docs/superpowers/module-reports/assets`,
      `docs(behavior:frontend-phase:r${behaviorRound}-a${attempt}): 阶段级行为验收证据`, undefined, c),
    '',
    '## 输出(必须符合下发的 BEHAVIOR_GATE JSON schema)',
    '- `status`: `green`(交互层无失败 + 文字层无 sentinel 类失败 + **样式层无失败** + 无阻断性 envError + 覆盖非空)| `red`。',
    '- `routesPlanned` / `routesReached` / `controlsEnumerated`: 整数,据实填(**只数全部 FE 作用域并集**;空覆盖必须可见)。',
    '- `interactionFailures` / `textIssues` / `styleIssues` / `coverageGaps`: 见 schema 的 kind / source / reason 枚举;硬问题 A 类带 `locator`(含 `source="sentinel"` 的 textIssue 与全部 styleIssues)。',
    '- `envError`: 无环境问题填 `{ "kind": "none" }`;有则填对应 kind + detail + ports + pids;`build-failed` 时填 `rootCausePath`。',
    '- 做过任何自主默认 → `decisions[]` 逐条登记。`artifactPath` = 证据报告项目根相对路径。',
    '- 不要返回额外字段(schema 是 `additionalProperties:false`)。**不要在本步骤内自动重试**——重试由上层 Workflow 控制。',
  ].filter(Boolean).join('\n')
}

// behaviorReverifyPrompt:阶段级行为 fix 后的功能复验。fix 改的是 frontend/ UI 源码,可能引入功能回归——
// 在下一 behaviorRound 重起全栈之前,先派子会话跑**全量前端单测**(vitest,不跑 e2e——e2e/行为维度由下一轮
// 行为门重跑 + 阶段 testGate 全量回归兜底),红则当功能回归硬边界(调用方 allowContinue:false)。
function behaviorReverifyPrompt(behaviorRound, fixedCount, c) {
  const evidence = `docs/superpowers/module-reports/frontend-phase-behavior-reverify-r${behaviorRound}.md`
  return [
    `# behavior-reverify — 行为 fix 后前端单测复验(behaviorRound=${behaviorRound})`,
    '',
    featureStageContract('frontend', c),
    '',
    '## 目标',
    `阶段级行为门第 ${behaviorRound} 轮 fix(${fixedCount} 项 must-fix)改动了 \`frontend/\` 源码——**派发 Agent 子会话**跑全量前端单测确认无功能回归。**主会话从不直接跑测试,也不自由编写证据。**`,
    '',
    '## 流程',
    `- 命令从 \`${c.root}/docs/04-技术规范.md § 零 frontend.test_command\` 取(缺失默认 \`pnpm test:ci\`);**只跑单测(vitest),不跑 e2e**。`,
    '- 派子会话执行,子会话只返回结构化 JSON:`{command, exit_code, passed, failed, failed_list, stdout_excerpt}`(`stdout_excerpt` ≤ 30 行)。',
    '- **`exit_code != 0` 或 `failed > 0`** → 渲染证据后 halt(fix 引入功能回归,绝不带红进入下一轮行为门)。',
    `- 证据写入 \`${evidence}\`(每轮独立文件不覆盖前轮)。`,
    '',
    commitBlock(evidence, `docs(behavior:frontend-phase:r${behaviorRound}): 行为 fix 后单测复验`,
      '- commit 失败 → halt,把 stderr 摘要写进 reason(仍要返回已写入的证据路径)。', c),
    '',
    '## 输出(必须符合下发的 STAGE_RESULT JSON schema)',
    `- 全部通过:\`{ "status": "ok", "artifactPath": "${evidence}", "summary": "<exit_code / passed / failed 摘要>" }\`。`,
    '- 任一红色 / 缺值 → `{ "status": "halt", "reason": "<失败用例摘要>" }`。',
  ].join('\n')
}

// ---- 前端骨架占位 stage(runFrontendSkeleton 用)----
// 设计:docs/design/2026-06-02-frontend-behavior-in-review-loop.md § 2(前置依赖 A;v3 阶段级行为门下仍保留)。
// 在 featureLoop(frontend) 之前一次性建出 App 外壳 + router 全量 lazy 路由表(未实现 FE 路由指向 FeStub 占位)
// + 不指悬空 path 的共享导航——保证「前端只建了一部分」的任意时刻 app 仍可构建可起、每个 FE 路由可达。
// 由此逐 FE 的 verify(e2e) 与阶段末尾行为门的「可构建前提」成立、tddPrompt 的占位替换有真值起点。
// feItems:本前端阶段的全部 FE-NN(来自 Router 的 frontend-phase 聚合模块),即 router 全量路由表的清单。
// lane 注入豁免(附录补 10 审计口径):本 builder 仅 frontend-phase 调用(终波恒宽 1、主根执行),
// globalSetup 文案里的 seed-demo-data.mjs 字样不需要 ERP_TEST_DB_SCHEMA 注入分支。
function frontendSkeletonPrompt(feItems, c) {
  const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(Router 未给 FE 清单——不应出现,调用方仅在 feItems 非空时调用)'
  return [
    '# fe-skeleton — 前端骨架占位阶段(router 全量 lazy 路由表 + FeStub 占位)',
    '',
    featureStageContract('frontend', c),
    '',
    '## 目标',
    '在逐 FE 实现开始**之前**,一次性建出前端「可构建可起」的骨架:App 外壳 + router **全量** lazy 路由表(每个 FE 路由都声明,未实现的指向占位组件 `FeStub`)+ 不指悬空 path 的共享导航。',
    '保证后续「只建了一部分 FE」的任意时刻 `vite build` / dev server 都能起、每个 FE 路由都可达(加载到占位);逐 FE 实现时再把对应路由的 import 从 `FeStub` 换成真组件。',
    '',
    `## 本前端阶段 FE 清单(router 全量路由表必须覆盖的全部 FE)`,
    `- ${list}`,
    '',
    '## 收集上下文(确定技术栈 + 目录约定 + 路由)',
    `- \`${c.root}/docs/04-技术规范.md § 零\`(\`frontend.ui_lib\` / framework / 构建工具)+ \`§ 二 前端规范\`(§ 2.1 目录约定 = 落盘位置 / 路由库 / 入口文件名)。`,
    `- \`${c.root}/docs/08-模块任务管理.md § 三\`(前端阶段元数据 + \`功能:\` 下全部 \`FE-NN\` 行;与上面清单核对,以本提示给出的清单为准)。`,
    `- \`${c.root}/docs/01-需求清单/\` 各 FE 关联 REQ + \`${c.root}/prototype/\`(页面/路由结构权威)+ \`${c.root}/docs/05-API接口契约.md\`,据此推导每个 FE-NN 对应的**路由 path**(带参动态路由保留 \`:id\` 占位)。`,
    `- 用 Grep 在 \`${c.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),保证任意时刻无悬空链接。',
    '5. **e2e 基线脚手架(全部落 `frontend/` 内)**:',
    '   - **Playwright 配置**(按 docs/04 § 零 `frontend.e2e_runner` 约定,如 `frontend/playwright.config.*`):声明 `globalSetup` / `globalTeardown` 入口 + 共享 `storageState`。',
    `   - **globalSetup**(如 \`frontend/e2e/global-setup.*\`):\`node ${c.root}/scripts/setup-test-db.mjs\`(复制源库→一次性副本)→ 冷起后端 + 轮询健康端点就绪(Flyway 在此把本轮新迁移 apply 到副本)→ 执行 \`node ${c.root}/scripts/seed-demo-data.mjs\`(灌演示种子进副本)→ 用 \`config-vars.yaml\` 的 \`admin_init\` 凭据经 \`docs/05-API接口契约.md\` 登录端点取 JWT,写 \`storageState\`(admin 登录态供 e2e 复用)。`,
    // globalTeardown 只 kill 进程、**不删副本**:test.mjs 的晋升(promote-to-source)在 e2e 之后跑、
    // 靠读副本的 flyway_schema_history 算 delta——teardown 若删副本,promote 会静默判"无迁移可晋升",
    // 晋升链路断且无声。副本清理归各闸门的 finally(test.mjs / seed runner / 行为门 runner)在 promote 后做;
    // tdd 内循环单独跑 e2e 时副本留存到下次 globalSetup 重新复制,无害。
    '   - **globalTeardown**(如 `frontend/e2e/global-teardown.*`):kill globalSetup 起的后端进程树(**不**删副本——副本清理由外层闸门在晋升后负责)。',
    '   - **说明**:这是 **e2e 基线契约**(前端 e2e 基线 = 源库副本 + 本轮新迁移 + 演示种子 + admin storageState)的**唯一接线点**——per-FE tdd 的 e2e 与阶段级 testGate 跑的 e2e 共用此 globalSetup。**骨架期只需静态成立 + 不破坏 build,无需真跑 e2e。** 幂等:已存在则按需补齐。',
    '6. **单测基线(测试隔离接线点)**:vitest 配置(按 docs/04 § 零 `frontend.unit_test_runner` 约定,如 `frontend/vitest.config.*` 或 package.json 内配置段)`include` **限定** `tests/**/*.test.*`——单测一律落 `frontend/tests/**`(镜像 `src/` 结构,smoke 类归 `tests/__smoke__/` 且文件名同样以 `.test.*` 结尾,见 docs/04 § 2.1),`frontend/src/` 内的测试残留不被执行(约定漂移立即可见)。不存在才创建,已存在则只补齐 include 限定。**legacy 守卫**:若 `frontend/src/` 内已存在测试文件(旧约定 colocation 残留),**绝不**收窄 include(否则旧单测静默停跑、回归覆盖丢失)——保持现有 include 不动,登记 `decisions[]` 提示人工迁移(src/ 内测试迁至 tests/ 镜像路径后方可收窄)。',
    '- **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。', c),
    '',
    '## 输出(必须符合下发的 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, c) {
  const list = (feItems || []).map(x => `\`${x}\``).join(', ') || '(无)'
  return [
    '# 检测前端骨架是否已建(router 已声明全部 FE 路由 + 全 lazy)',
    microStepContract(c.root),
    '',
    `用 Grep / Read 检查 \`${c.root}/frontend/\`:是否已存在 router 配置文件,且其中**本阶段全部 FE 路由**(对应 FE:${list})都已声明、全部为 lazy import(\`() => import(...)\`),占位组件 \`FeStub\`(\`frontend/src/views/_stub/FeStub.*\`)存在,**且 e2e 基线脚手架存在**——Playwright 配置文件(\`frontend/playwright.config.*\`)+ globalSetup 文件(如 \`frontend/e2e/global-setup.*\`),**且单测基线存在**——vitest 配置 \`include\` 限定 \`tests/**/*.test.*\`。`,
    `- **legacy 豁免**:若 \`${c.root}/frontend/src/\` 内已存在测试文件(\`*.test.*\` / \`*.spec.*\`,旧约定 colocation 残留),vitest include 项**不作为缺失判据**(视为满足)——该状态须人工迁移决策,绝不由骨架重跑静默收窄 include 停跑旧测试。`,
    '- 全部满足(骨架已建齐,含 e2e 基线脚手架 + 单测基线)→ `{ "exists": true }`',
    '- 任一缺失(无 router / 缺某 FE 路由 / 存在 eager import / 无 FeStub / 缺 Playwright 配置 / 缺 globalSetup / vitest include 未限定 tests/**/*.test.*(且非 legacy 豁免))→ `{ "exists": false }`',
    '## 输出(EXISTS_SCHEMA)',
  ].join('\n')
}

// ---- 微步骤 prompt builders(runBranchSetup / runMilestone / runCrossModule 用)----
// 每个 prompt 单职责、短文本;返回严格 schema;执行(action)步统一返回 ACTION_RESULT_SCHEMA。
// root 显式入参(附录补 11):模块作用域微步骤传 c.root(lane 模式在 lane 树上取证/操作),
// 主根专属微步骤(milestone / RESUME / ledger / preflight / 默认分支探测)传 ROOT——两组名单见各调用方。
function microStepContract(root) {
  return [
    '## 硬约束(非交互子代理)',
    '- 你是 Workflow 派生的**非交互子代理**,绝不弹问。',
    '- 全部输出**使用中文**。',
    `- 项目根 = \`${root}\`。所有 git 命令必须用 \`git -C ${root} ...\`(除非步骤明示了其它 \`-C\` 目标——lane / feature worktree 路径,以步骤原文为准);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(防无限循环)
// 阶段级行为门预算(二维,钉死防证据覆盖):
//  - BEHAVIOR_STAGE_MAX = 阶段门内的行为 fix 轮硬上限(整个前端阶段共用,每轮 fix 可批量修当轮全部 must-fix);
//    超限 throw HALT。典型一次过(1 轮),最坏 3 轮。
//  - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。
const BEHAVIOR_STAGE_MAX = 3
const BEHAVIOR_ATTEMPT_MAX = 2
// 静态原型渲染门(Preview)的环境 race 重起上限(Playwright/浏览器缺失/超时时自动重起,沿用 behavior attempt 思路)。
const PROTOTYPE_PREVIEW_ATTEMPT_MAX = 2
const NULL_AGENT_PROBLEM = 'agent() 返回 null(子代理终端 API 错误 / 被用户跳过)——典型为断网或机器休眠;可重试'
const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : ''

// 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记后随结果回传供人工事后审阅。
// sink 参数:模块作用域调用传 c.decisions(per-module 收集器,并行交错下水位线语义失效的替代);
// 缺省落全局 autonomousDecisions(仅剩"漏传 dec 的兜底"语义,顶层 return 时作为残量并入)。
const autonomousDecisions = []
const decisionDigestMd = (list, emptyText) => list.length
  ? list.map(d => `  - [\`${d.site}\`] ${d.question || '?'} → ${d.choice || '?'}(${d.confidence || '?'})`).join('\n')
  : `  - ${emptyText}`
function recordDecisions(site, decisions, sink = autonomousDecisions) {
  if (!Array.isArray(decisions)) return
  for (const d of decisions) {
    if (!d) continue
    sink.push({ site, question:d.question, choice:d.choice, rationale:d.rationale, confidence:d.confidence })
    log(`decision ${site}: ${d.question || '?'} → ${d.choice || '?'} (${d.confidence || '?'})`)
  }
}

// halt 可见性:HALT 在抛出前先 log(出现在 /workflows 进度叙述行与 transcript),
// 保证终端能直接看到 halt 原因,而不是只埋在 Workflow 返回值 / RESUME.md 里。
// 顶层 function 声明会提升,:201 等早于本定义的调用点也可用。
function haltError(reason) {
  log(`⛔ ${reason}`)
  return new Error(reason)
}

// 直调 agent() 的非 runner 点位统一防御:null(断网/休眠/被跳过)→ 带诊断的 halt,而非下游 TypeError。
async function agentR(prompt, opts) {
  const r = await agent(prompt, opts)
  if (!r) throw haltError(`HALT agent-null ${opts?.label || '?'}: ${NULL_AGENT_PROBLEM}`)
  return r
}

// root(附录补 11):仲裁取证树根——lane stage 失败时传 c.root,让仲裁子代理看到 lane 分支上
// 刚 commit 的 spec/plan/源码(主根工作树可能停在默认分支,树面错误会让裁决与 guidance 失真)。
function adjudicatePromptM(site, context, root) {
  const ctx = typeof context === 'string' ? context : JSON.stringify(context, null, 2)
  return [
    `# 仲裁:\`${site}\` 触发潜在 halt,请裁决 retry / continue / halt`,
    microStepContract(root),
    '',
    '## 你的角色',
    '你是 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, root = ROOT) {
  const verdict = await agent(adjudicatePromptM(site, context, root),
    {label:`adjudicate:${site}:r${round}`, phase: grp, schema: ADJUDICATE_SCHEMA})
  if (!verdict) {                 // 仲裁子代理自身遭遇终端 API 错误 → 默认 retry(确定性,防 null 级联)
    log(`adjudicate ${site} r${round}: 仲裁子代理无返回(终端 API 错误/被跳过),默认 retry`)
    return { action: 'retry', guidance: '', rationale: 'adjudicate agent 无返回,默认 retry' }
  }
  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。
// dec:decisions 落点(模块作用域调用点传 c.decisions);root:仲裁取证树根(模块作用域传 c.root,补 11)。
async function runStage(makePrompt, { site, grp, label, validate, allowContinue = false, dec, root = ROOT }) {
  let guidance = ''
  for (let round = 1; round <= ADJUDICATE_MAX; round++) {
    const res = await agent(makePrompt(adjGuidance(guidance)), {label, phase: grp, schema: STAGE_RESULT_SCHEMA})
    if (!res) {
      const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root)
      if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`)
      guidance = verdict.guidance || ''
      continue
    }
    recordDecisions(site, res.decisions, dec)
    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, root)
    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 视为"接受失败并前进"(仅用于纯可视化等可安全跳过的副作用)。
// dec:与 runStage 调用点签名统一而收(ACTION_RESULT 无 decisions 字段,当前不消费);root:仲裁取证树根(补 11)。
async function runAction(makePrompt, { site, grp, label, allowContinue = false, dec, root = ROOT }) {
  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) {
      const verdict = await adjudicate(site, { problem: NULL_AGENT_PROBLEM, allowContinue: false }, grp, round, root)
      if (verdict.action !== 'retry') throw haltError(`HALT ${site}: ${NULL_AGENT_PROBLEM}`)
      guidance = verdict.guidance || ''
      continue
    }
    if (r.success) return r
    const verdict = await adjudicate(site,
      { problem:`action 失败:${r.error || ''}${r.detail ? '\n' + r.detail : ''}`, allowContinue }, grp, round, root)
    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} 轮仲裁仍未解决`)
}

// best-effort 微步骤统一包裹:失败/异常只 log 绝不阻断主流程。返回是否成功(供 RESUME flush 记账判断)。// ≠ runAction:本 helper 不经 adjudicate、绝不 halt。
async function bestEffortAction(prompt, { label, phase: ph, okMsg, failTag, errTag = failTag }) {
  try {
    const r = await agent(prompt, { label, phase: ph, schema: ACTION_RESULT_SCHEMA })
    if (r && r.success) { log(okMsg); return true }
    log(`${failTag}失败(不阻断):${(r && r.error) || ''}`)
  } catch (e) {
    log(`${errTag}异常(不阻断):${String(e?.message || e)}`)
  }
  return false
}

// ── 续跑 handoff(RESUME.md):halt / 全完成时追加一条跨运行记录 ─────────────
// 进度真值仍是 git tag(milestone/req-done)+ 已 commit 的 module-reports / specs 工件——
// 这些已让硬中断后 Router 正确续跑、per-feature 决策也落在工件里。RESUME.md 补的是
// 「上次为何 halt + 本次做过哪些自主默认假设 + 还剩哪些模块」这类**跨会话会丢失**的定向信息
// (GSD continue-here.md 类比),供下次 coding-start 重跑时向人工复盘。
// best-effort:它自身写失败**绝不**阻断 / 掩盖主流程(尤其在 halt 善后路径上)。
const RESUME_PATH = 'docs/superpowers/RESUME.md'
function resumeJournalPromptM(sectionMd) {
  return [
    '# 追加续跑日志(RESUME handoff)— 非交互静默',
    microStepContract(ROOT),
    '',
    `## 任务:把给定条目**追加到 \`${RESUME_PATH}\` 末尾**后 commit(不改其它任何文件)`,
    `1. 确保目录 \`${ROOT}/docs/superpowers/\` 存在(不存在则创建)。`,
    `2. 取时间戳:\`git -C ${ROOT} log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M'\`;取不到(无 commit)则用空串。`,
    `3. 若 \`${RESUME_PATH}\` 不存在 → 先写文件头:一行 \`# 续跑日志(RESUME handoff)\`,空行,再一行引用块说明「Coding 每次 halt/完成时追加,**最新条目在末尾**;中断后重跑 /erp-workflow:coding-start 前先读末尾条目」。`,
    '4. 把下面这段条目**追加到文件末尾**(前置一空行;把其中的 `<ts>` 替换为步骤 2 的时间戳):',
    '```markdown',
    sectionMd,
    '```',
    `5. commit:\`git -C ${ROOT} add ${RESUME_PATH}\` → \`git -C ${ROOT} commit -m "chore(resume): 续跑 handoff 追加"\`。`,
    '',
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 → `{ "success": true }`;任何步骤失败 → `{ "success": false, "error": "<原因>", "detail": "<stderr 摘要>" }`(**不要**抛错,照实返回即可)。',
  ].join('\n')
}
async function recordResume(sectionMd) {
  // best-effort:续跑日志写失败绝不阻断主流程(它为 resume 而存在,不该反过来制造 halt)。
  return bestEffortAction(resumeJournalPromptM(sectionMd),
    { label: 'resume-journal', phase: 'Milestone', okMsg: 'resume-journal 已追加 RESUME.md', failTag: 'resume-journal 写入', errTag: 'resume-journal ' })
}

// ── 需求台账基线(P1#3):coding 首跑时若 .req-ledger.json 缺失,自动建立并提交 ──────
// 语义 = 把「coding 开始时的需求快照」定为基线,使日后 /add-req 能正确 diff 出新增/变更,
// 免去现有项目首次 add-req 只为建基线的空跑。幂等(已存在则跳过)、best-effort(失败不阻断)。
function ledgerBaselinePromptM() {
  return [
    '# 需求台账基线(首跑)— 非交互静默',
    microStepContract(ROOT),
    '',
    `## 任务:若 \`${ROOT}/.req-ledger.json\` 不存在,则建立并 commit;已存在则跳过`,
    `1. 检查 \`${ROOT}/.req-ledger.json\` 是否存在。存在 → 直接返回 \`{ "success": true, "detail": "ledger 已存在,跳过" }\`。`,
    `2. 不存在 → 建立基线:\`node ${PLUGIN}/lib/req-ledger.mjs commit ${ROOT}\`(扫 docs/01 REQ 卡 + docs/08 §三 FE 行写哈希)。`,
    `3. commit 到当前分支:\`git -C ${ROOT} add .req-ledger.json\` → \`git -C ${ROOT} commit -m "chore(req-ledger): 建立需求台账基线(coding 首跑)"\`。`,
    '   (只 add .req-ledger.json 这一个文件;其余未提交的 Plan 产物由后续 runBranchSetup 的脏树恢复处理,勿在此一并提交。)',
    '',
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 / 已存在 → `{ "success": true }`;失败 → `{ "success": false, "error": "...", "detail": "..." }`(不要抛错)。',
  ].join('\n')
}
async function ensureLedgerBaseline() {
  if (!PLUGIN) { log('req-ledger 基线跳过:未透传 pluginRoot(由 /add-req 首跑兜底)'); return }
  return bestEffortAction(ledgerBaselinePromptM(),
    { label: 'req-ledger-baseline', phase: 'Router', okMsg: 'req-ledger 基线就绪', failTag: 'req-ledger 基线' })
}
function preflightPromptM() {
  return [
    '# Preflight:编码阶段环境探测(只读探测 + 少量无副作用命令,不改任何文件)',
    microStepContract(ROOT),
    '',
    `读 \`${ROOT}/docs/04-技术规范.md § 零\`(锁定技术栈与命令清单),逐项探测本机环境是否满足:`,
    '1. **node**:`node --version` 主版本满足 docs/04 要求。',
    '2. **JDK**(若后端为 Java):`java -version` 主版本与 docs/04 锁定版本一致;不一致时探测 `/usr/libexec/java_home -v <版本>`(macOS)或 `JAVA_HOME` 是否可解析到正确版本,并在 detail 给出导出 JAVA_HOME 的具体命令。',
    '3. **数据库客户端**:docs/04 指明的 DB(如 mysql)客户端命令存在;若 `.env.local` 存在则尝试一次只读连接探测(失败不算硬错,记入 detail 提示)。',
    '4. **测试入口**:docs/04 § 零命令清单里 test-gate 用到的脚本文件(如 `scripts/test.mjs`)存在。',
    `5. **测试库脚本必须是复制副本模型(fail-closed 硬门)**:若 \`${ROOT}/scripts/setup-test-db.mjs\` 存在,Grep 其内容是否含字符串 \`COPY_SCHEMA\`(新模型:复制源库→一次性副本,带 COPY≠SOURCE 守卫)。**不含即硬错**——旧模型脚本对 env 缺省时直接 DROP config-vars.yaml 的源库(真实数据全灭,已有事故)。error 写「scripts/setup-test-db.mjs 是旧 DROP-源库模型」,detail 写明:用插件 \`skills/plan/skeleton-gen/templates/\` 下的 scripts-setup-test-db / scripts-drop-test-db / scripts-promote-to-source / scripts-seed-demo-data / scripts-test 模板升级目标项目 \`scripts/\` 后重跑。`,
    '',
    '全部满足 → `{ "success": true }`;任一硬缺失 → `{ "success": false, "error": "<一句话>", "detail": "<逐项列出缺什么、装/配的具体命令>" }`。',
    '## 输出(ACTION_RESULT_SCHEMA)',
  ].join('\n')
}

// 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, c) {
  const list = (dirty || []).map(p => `- ${p}`).join('\n') || '(调用方未给清单,请自行 `git status --porcelain` 复核)'
  return [
    '# 工作树不干净——判定能否自主提交后继续',
    microStepContract(c.root),
    '',
    '## 背景',
    `分支切换 / 里程碑前要求工作树干净,当前存在未提交改动。${scopeHint || ''}`,
    '在**不丢失工作、不混入越界改动、不提交到错误分支**的前提下尽量让流程继续。',
    '',
    '## 脏文件清单',
    list,
    '',
    '## 流程',
    `0. **分支护栏(必须先做)**:跑 \`git -C ${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 ${c.root} status --porcelain\`,必要时 \`git -C ${c.root} diff\`)。`,
    `2. **全部都是本阶段合法产物**(spec/plan/verify/review/report/源码/migration,且落在当前阶段路径作用域内)→ \`git -C ${c.root} add -A\` 后 \`git -C ${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(ROOT),
    '',
    `用 \`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(c) {
  return [
    '# 检查工作树是否干净',
    microStepContract(c.root),
    '',
    `跑 \`git -C ${c.root} status --porcelain\`,按行解析 dirty 文件路径(第 4 字符起)。`,
    '## 输出(WT_SCHEMA)',
    '- 干净:`{ "clean": true }`',
    '- 不干净:`{ "clean": false, "dirty": ["<path>", ...] }`',
  ].join('\n')
}

function checkBranchExistsPromptM(branch, c) {
  return [
    `# 本地分支 \`${branch}\` 是否存在`,
    microStepContract(c.root),
    '',
    `跑 \`git -C ${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(c) {
  return [
    '# 当前所在分支',
    microStepContract(c.root),
    '',
    `跑 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\`。`,
    '## 输出(DEFAULT_BRANCH_SCHEMA)',
    '- `{ "branch": "<stdout 第一行去空白>" }`',
  ].join('\n')
}

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

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

// ── lane 物理隔离基础设施(Phase C:worktree 生命周期 + migration 版本段 + lane 测试库)──
// 启用时机:Phase D 波次执行器在 ≥2 宽波次的前置串行段调用(读 maxV / 读 schema 基底 / 探测支持 /
// 建 lane),本阶段只提供确定性原语;串行 / 主根路径(c.lane == null)不触达其中任何一个,行为不变。

// lane worktree 路径约定(Task 6 Step 1):主根同级的 `<ROOT>-lanes/<moduleId>`——由 module id
// 确定性导出(运行时禁时间/随机源),落在 ROOT 之外(不脏主树、不被主根 status / 脏树恢复误扫)。
function laneRoot(moduleId) { return `${ROOT}-lanes/${moduleId}` }

// migration 版本段基址(Task 7 Step 2):lane 0 得下一个整百段、lane 1 再下一段——确定性、零随机。
// maxV 必须取"主根文件 ∪ 全部 module-* 分支"的全局并集口径(maxMigrationVersionPromptM,附录补 4),
// 由此 halt-resume 后 laneIdx 漂移**无害**:每个新波次的段基址恒高于一切已知版本(含未合并分支已占
// 用的段),同段绝不二次发放,只产生无害的段空洞(独立模块 migration 互不引用 + lane 库从零重建 +
// 合并后全量按版本序 apply,空洞与乱序均安全——段规则文案见 tddPrompt)。
function vBase(maxV, laneIdx) { return (Math.floor(maxV / 100) + 1 + laneIdx) * 100 }

// lane 测试库命名(附录补 8,序号制统一口径):`<schema>_lane<N>`。固定短后缀避开 moduleId 进
// schema 名的三个坑:64 字符上限的截断规则、截断后长前缀撞名重新互相清库、`-` 字符的 MySQL 标识符
// 转义。N = 波次内 lane 序号(波次严格串行执行,不存在并发撞名)。波次执行器读 config-vars.yaml 的
// database.schema(readDbSchemaPromptM)后经本函数拼成**成品**放 c.dbSchema——prompt 只渲染成品
// 字符串,绝不让子代理自己拼(附录补 6c)。
function laneDbSchema(baseSchema, laneIdx) { return `${baseSchema}_lane${laneIdx}` }

// feature lane 路径约定(Phase F Task 13):`<模块工作树根>-feats/<featureId>`——由 feature id
// 确定性导出(id 已经 assertSafeId 校验,路径安全)。串行主根模式 = `<ROOT>-feats/<id>`;模块
// lane 模式 = `<ROOT>-lanes/<m>-feats/<id>`,后者带 `<ROOT>-lanes/` 前缀会被 listLaneWorktrees
// 枚举到,但其分支为 feat/*——占用感知只按 module-*/frontend-phase 分支匹配,互不干扰。
function featureLaneRoot(moduleRoot, id) { return `${moduleRoot}-feats/${id}` }

// createLaneWorktreePromptM(Task 6 Step 2):建/复用 lane worktree,幂等(resume 安全)。
// worktree add 在主仓库(ROOT)上执行;每个 worktree 独立 index,跨 lane 并行 commit 不争
// .git/index.lock。分支语义与主根路径完全一致(module-<id> / frontend-phase),defaultBranch 由
// 调用方传 detectDefaultBranchPromptM 的结果——不让子代理自行探测,保持单一真值来源。
function createLaneWorktreePromptM(branch, path, defaultBranch) {
  return [
    `# 建立 lane worktree \`${path}\`(分支 \`${branch}\`,幂等)`,
    microStepContract(ROOT),
    '',
    '按下列三分支判定后执行(全部幂等,resume 安全):',
    `1. **path 已注册**:跑 \`git -C ${ROOT} worktree list --porcelain\`,按行解析各条目的 \`worktree <path>\` 行——路径字段**全等于** \`${path}\` 才算已注册(**绝不**用子串包含判断:lane id 互为前缀时会误匹配兄弟树)→ 校验该树的 HEAD 分支:跑 \`git -C ${path} rev-parse --abbrev-ref HEAD\`,等于 \`${branch}\` → 直接返回成功(resume 复用);**不等** → 返回失败(error 写明实际分支;**绝不**自行强切 / 删树,留给上层处置)。`,
    `2. path 未注册、分支已存在(\`git -C ${ROOT} rev-parse --verify refs/heads/${branch}\` exit=0)→ 跑 \`git -C ${ROOT} worktree add ${path} ${branch}\`。`,
    `3. path 未注册、分支不存在 → 跑 \`git -C ${ROOT} worktree add -b ${branch} ${path} ${defaultBranch}\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功(含 resume 复用):`{ "success": true, "detail": "<reused|added|added-new-branch>" }`',
    '- 失败:`{ "success": false, "error": "<分支不符 / git stderr 摘要>" }`',
  ].join('\n')
}

// removeLaneWorktreePromptM(Task 6 Step 2/4):移除 lane worktree,幂等。脏树**禁用 --force**——
// 脏 lane 说明有未收口的工作(理应已被 runModule 的 lane 收口段提交干净),照实返回失败留给人工,
// 绝不静默丢工作。调用方一律 bestEffortAction:删失败只 log(分支/对象库全仓共享,留树不损正确性)。
function removeLaneWorktreePromptM(path) {
  return [
    `# 移除 lane worktree \`${path}\`(幂等;脏树禁 --force)`,
    microStepContract(ROOT),
    '',
    `1. 注册判定:跑 \`git -C ${ROOT} worktree list --porcelain\`,按行解析各条目的 \`worktree <path>\` 行——路径字段**全等于** \`${path}\` 才算已注册(**绝不**用子串包含判断:lane 路径是其 feature 树路径的严格前缀,feature 树残留会让已删的 lane 误判"仍注册",把幂等成功变成 spurious 失败)。未注册 → 本就不存在,直接返回成功(幂等)。`,
    `2. 否则跑 \`git -C ${ROOT} worktree remove ${path}\`。**绝不**加 \`--force\`:树脏 / 含未提交改动时该命令会失败——照实返回失败并把 stderr 摘要写进 error(lane 留给人工取证)。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 / 本就不存在:`{ "success": true }`',
    '- 失败(脏树等):`{ "success": false, "error": "<stderr 摘要>" }`',
  ].join('\n')
}

// maxMigrationVersionPromptM(Task 7 Step 1):读全仓已知最大 migration 版本号——**全局并集口径**
// (附录补 4)。只看主根文件不够:halt 波次收敛 + 保留 lane 意味着 halted 模块的 V 文件已 commit 在
// 未合并的 module-<id> 分支上、对主根 ls 不可见,漏看会把同一版本段二次发放给别的模块(两分支各写
// V<n>,文件名不同 git 合并静默通过,Flyway 在"同版本号多文件"上硬失败)。纯 git 只读、确定性。
// 主根专属(波次前置串行段每波读一次,microStepContract(ROOT));FIELD_VALUE_SCHEMA 复用。
function maxMigrationVersionPromptM() {
  return [
    '# 读全仓最大 migration 版本号(主根文件 ∪ 全部 module-* 分支,全局并集)',
    microStepContract(ROOT),
    '',
    `1. 主根文件:列出 \`${ROOT}/sql/migrations/V*.sql\`(Glob 即可)。`,
    `2. 分支并集:\`git -C ${ROOT} for-each-ref --format='%(refname:short)' refs/heads/module-*\` 列出全部模块分支;对**每个**分支跑 \`git -C ${ROOT} ls-tree -r --name-only <branch> -- sql/migrations\`。`,
    '3. 对以上全部文件名解析 `V<n>__*.sql` 的 `<n>`(十进制整数),取**全体最大值**。',
    '## 输出(FIELD_VALUE_SCHEMA)',
    '- 有任何 migration 文件:`{ "found": true, "value": "<最大版本号十进制字符串,如 "12">" }`',
    '- 全仓(含全部 module-* 分支)无任何 V*.sql:`{ "found": true, "value": "0" }`',
    '- 命令失败无法判定:`{ "found": false, "value": "" }`(调用方按失败降级处理,**不要**猜值)。',
  ].join('\n')
}

// readDbSchemaPromptM(Task 8 Step 2):读 config-vars.yaml 的 database.schema 字面量——lane 库名
// 基底。主根专属(波次前置串行段读一次);拼接由 JS 的 laneDbSchema 完成,子代理只取字面值。
function readDbSchemaPromptM() {
  return [
    '# 读 config-vars.yaml 的 database.schema(lane 测试库名基底)',
    microStepContract(ROOT),
    '',
    `Read \`${ROOT}/config-vars.yaml\`,定位 \`database\` 段的 \`schema\` 字段,取其字面值(去引号去空白)。`,
    '## 输出(FIELD_VALUE_SCHEMA)',
    '- 命中:`{ "found": true, "value": "<schema 字面值>", "lineNumber": <行号> }`',
    '- 文件或字段不存在:`{ "found": false, "value": "" }`(调用方据此降级单宽,**不要**猜值)。',
  ].join('\n')
}

// laneDbSupportPromptM(Task 8 Step 3 / 附录补 6b:两点联查替换单 grep):存量项目 lane 测试库
// 支持探测。只 grep scripts/ 不够——schema 引用若只活在 Node 脚本里,gradle test / bootRun 的
// datasource 仍写死库名,单点探测会假阳性放行(脚本建了副本,但 gradle test / bootRun 照样连那个写死的库——
// 两条 lane 互相踩数据、甚至直接跑在源库上污染它)。两点都满足才算支持;任一不满足 → Phase D 波次执行器把本波次
// 降级 maxWidth=1(log 写明原因 + 建议重跑 skeleton 升级脚本),**不 halt**——与其并行却让测试跑在同一个写死的库上互相踩,不如明示串行。主根专属、只读。
function laneDbSupportPromptM() {
  return [
    '# 探测目标项目是否支持 lane 测试库(ERP_TEST_DB_SCHEMA 两点联查,只读)',
    microStepContract(ROOT),
    '',
    `1. \`scriptsEnv\`:Grep \`${ROOT}/scripts/\` 是否含字符串 \`ERP_TEST_DB_SCHEMA\`(setup-test-db / test / seed-demo-data 脚本支持 env 覆盖 schema)。`,
    `2. \`ymlPlaceholder\`:Grep \`${ROOT}/backend/src/\` 下的 \`application*.yml\` / \`application*.properties\` 是否含 \`\${ERP_TEST_DB_SCHEMA\` 占位(Spring datasource 的 JDBC URL schema 段支持 env 打穿)。`,
    '## 输出(LANE_DB_PROBE_SCHEMA)',
    '- `{ "scriptsEnv": <boolean>, "ymlPlaceholder": <boolean>, "detail": "<命中文件路径摘要 / 未命中说明>" }`——两项独立据实填,**不要**互相推断或合并判断。',
  ].join('\n')
}

// listLaneWorktreesPromptM(附录补 3a:占用感知枚举,主根专属、只读)。
// 只看 `<ROOT>-lanes/` 前缀——主根自身与用户自建的其它 worktree 一概不碰、不上报。
function listLaneWorktreesPromptM() {
  return [
    '# 枚举残留 lane worktree(只读)',
    microStepContract(ROOT),
    '',
    `1. 跑 \`git -C ${ROOT} worktree list --porcelain\`,按空行分组解析每个条目的 \`worktree <path>\` 与 \`branch refs/heads/<name>\` 行。`,
    `2. 只保留 path 以 \`${ROOT}-lanes/\` 开头的条目;branch 取短名(去 \`refs/heads/\` 前缀);detached 条目 branch 填空串。`,
    '## 输出(LANE_LIST_SCHEMA)',
    '- `{ "lanes": [ { "path": "<绝对路径>", "branch": "<分支短名>" }, ... ] }`;无残留 lane → `{ "lanes": [] }`。',
  ].join('\n')
}

// pruneWorktreesPromptM(计划 D2):清理"目录已删"的 worktree 管理残留。注意(附录补 3 末注):
// 它对目录完好的 lane(恰是 halt 刻意保留的取证态)毫无作用——占用感知(listLaneWorktrees + 恢复/
// 移除)才是收口主力,绝不要依赖 prune 释放被残留 lane 持有的分支。
function pruneWorktreesPromptM() {
  return [
    '# git worktree prune(清理目录已删的管理残留)',
    microStepContract(ROOT),
    '',
    `跑 \`git -C ${ROOT} worktree prune\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- exit=0 → `{ "success": true }`;失败 → `{ "success": false, "error": "<stderr 摘要>" }`(不要抛错)。',
  ].join('\n')
}

// writeLaneDbMarkerPromptM(附录补 7:lane 副本库名的 fail-closed 兜底 marker)。
// lane 注入是纯 prompt-only,且 tdd 主会话会把命令转述给二级子会话(提示词链两跳)——任何一跳丢掉
// ERP_TEST_DB_SCHEMA 前缀,若无兜底则各脚本回退到串行缺省副本名 `<source>__test`(多条 lane 撞同一副本名、
// 互相 DROP 重建对方副本,随机红测/烧 testGate/仲裁预算甚至假 halt;源库有 COPY≠SOURCE 守卫、绝不被误 drop)。
// lane 隔离需要确定性兜底:setup / drop / promote / seed / test 脚本模板均按「env → 本仓库根 .tmp/erp-lane-db →
// `<db.schema>__test`」顺序取副本库名(缺省是副本 `<source>__test`,**绝非源库本身**),lane 副本天然 lane 作用域;
// 主根无此文件,串行缺省副本 `<source>__test`。`.tmp/` 已被 gitignore 忽略,绝不会被 recoverDirtyWorktree 判
// in-scope 自动 commit。明令**禁止**改 lane 副本 config-vars.yaml 的 database.schema 兜底(该文件随 git
// 提交,lane 树一脏会被自动 commit 并随 milestone 合回默认分支,把真 schema 名搞坏)。幂等(覆盖写)。
function writeLaneDbMarkerPromptM(lanePath, schema) {
  return [
    `# 写 lane 测试库 marker \`${lanePath}/.tmp/erp-lane-db\`(幂等覆盖写)`,
    microStepContract(ROOT),
    '',
    `1. 确保目录 \`${lanePath}/.tmp/\` 存在(不存在则创建)。`,
    `2. 把单行内容 \`${schema}\`(行尾换行,无其它任何内容)**覆盖写**入 \`${lanePath}/.tmp/erp-lane-db\`。`,
    '3. **不 commit**(该文件被 .gitignore 忽略,仅供 lane 内脚本兜底读取);**绝不**改 config-vars.yaml。',
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 → `{ "success": true }`;失败 → `{ "success": false, "error": "<原因>" }`(不要抛错)。',
  ].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, c) {
  return [
    `# tag \`req-done/${id}\` 是否存在(功能级 dedup 真值)`,
    microStepContract(c.root),
    '',
    `跑 \`git -C ${c.root} tag -l req-done/${id}\`。`,
    '## 输出(EXISTS_SCHEMA)',
    '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`',
  ].join('\n')
}

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

// ── 微步骤:前端 jsdom-only 重叠的中间完成态 tag `fe-code-done/<FE>`(设计见
// docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)──
// 语义:phase1 完成(组件代码 + jsdom 绿,e2e/review/req-done 全部延后)。与 req-done 正交、双 tag 续跑链:
// fe-code-done 存在 → phase1 跳过该 FE;req-done 存在 → phase2 跳过;两者皆缺 → phase1 重跑该 FE。
// 仅在 frontendOverlap 开启时产生;关闭时全程只用 req-done,与现行一字不差。
function checkFeCodeDoneTagPromptM(id, c) {
  return [
    `# tag \`fe-code-done/${id}\` 是否存在(前端 phase1 完成态 dedup 真值)`,
    microStepContract(c.root),
    '',
    `跑 \`git -C ${c.root} tag -l fe-code-done/${id}\`。`,
    '## 输出(EXISTS_SCHEMA)',
    '- stdout 含完整匹配 → `{ "exists": true }`;为空 → `{ "exists": false }`',
  ].join('\n')
}

function createFeCodeDoneTagPromptM(id, c) {
  return [
    `# 打 annotated tag \`fe-code-done/${id}\`(前端 phase1:组件代码 + jsdom 绿,e2e/review 延后)`,
    microStepContract(c.root),
    '',
    `跑 \`git -C ${c.root} tag -a fe-code-done/${id} -m "fe-code(${id}): jsdom green, e2e/review deferred (overlap phase1)"\`。`,
    `先用 \`git -C ${c.root} tag -l fe-code-done/${id}\` 检查;已存在则视为成功(幂等)直接返回 success。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 / 已存在:`{ "success": true }`;其它失败:`{ "success": false, "error": "<stderr>" }`',
  ].join('\n')
}

// ── 微步骤:批量预生成 spec 的统一提交(Phase E Task 11)──
// batch 模式 spec 只写盘不 commit(避免同一工作树并行 commit 争 .git/index.lock);全部预生成
// 落盘后由本微步骤一次性 add+commit 收口。幂等:resume 场景 spec 可能是"复用已提交文件、就地
// Edit 后无实际变更"——无 staged 变更直接视为成功,绝不空 commit 报错。
function commitPrefetchedSpecsPromptM(ids, phase, c) {
  return [
    `# 统一提交预生成 spec(${phase},共 ${ids.length} 份)`,
    microStepContract(c.root),
    '',
    `1. 跑 \`git -C ${c.root} add docs/superpowers/specs\`。`,
    `2. 跑 \`git -C ${c.root} diff --cached --quiet\`:exit=0(无 staged 变更)→ 直接返回成功,跳过第 3 步。`,
    `3. 跑 \`git -C ${c.root} commit -m "docs(spec:${phase}): 批量预生成派生规格" -m "ids: ${ids.join('、')}"\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 / 无变更:`{ "success": true }`;失败:`{ "success": false, "error": "<stderr 摘要>" }`(不要抛错)。',
  ].join('\n')
}

// ── 微步骤:feature 级受限并行(Phase F Task 12/13;仅后端 REQ,附录补 2)──

// planFactsPromptM(Task 12):从 plan 提取并行准入事实。保守偏置同调度器:拿不准 schemaChange
// → true、文件边界不清 → files 空数组——错误串行只损失时间,错误并行损失正确性。模块作用域
// (plan 在模块分支上,主根树面可能看不到,microStepContract(c.root))。
function planFactsPromptM(planPath, c) {
  return [
    `# 提取 plan 并行准入事实(只读):\`${planPath}\``,
    microStepContract(c.root),
    '',
    `Read \`${c.root}/${planPath}\`(任务级 TDD 计划),只提取两项事实:`,
    '1. `schemaChange`:plan 是否声明**任何** schema 改动——migration 文件(`sql/migrations/V*.sql`)/ DDL / docs/03 反向更新任务,或任何会写 `sql/` 路径的任务。**拿不准 → `true`**(宁串勿并)。',
    '2. `files`:全部任务的 `impl_file` 与 `test_file` 路径**全集**(项目根相对、去重、按 plan 原文字面取,不要归一化 / 猜补)。某任务文件边界模糊 / 给不出明确路径 → **`files` 返回空数组**(调用方会让该 feature 留在串行链)。',
    '## 输出(PLAN_FACTS_SCHEMA)',
    '- `{ "schemaChange": <boolean>, "files": ["backend/src/.../Foo.java", ...], "detail": "<一句提取依据>" }`;不要返回额外字段。',
  ].join('\n')
}

// createFeatureWorktreePromptM(Task 13):建/复用 feature worktree——复用 createLaneWorktreePromptM
// 的幂等三分支形态,差异仅两点:基分支是**模块分支**(feature 从模块分支 HEAD 分叉,不是默认分支)、
// git 操作经 c.root(worktree 命令对任意同仓工作树等效,模块作用域保持穿线纪律)。
function createFeatureWorktreePromptM(featBranch, path, baseBranch, c) {
  return [
    `# 建立 feature worktree \`${path}\`(分支 \`${featBranch}\` 自 \`${baseBranch}\`,幂等)`,
    microStepContract(c.root),
    '',
    '按下列三分支判定后执行(全部幂等,resume 安全):',
    `1. **path 已注册**:跑 \`git -C ${c.root} worktree list --porcelain\`,按行解析各条目的 \`worktree <path>\` 行——路径字段**全等于** \`${path}\` 才算已注册(**绝不**用子串包含判断:feature id 互为前缀时会误匹配兄弟树)→ 校验该树 HEAD:跑 \`git -C ${path} rev-parse --abbrev-ref HEAD\`,等于 \`${featBranch}\` → 直接返回成功(resume 复用);**不等** → 返回失败(error 写实际分支;**绝不**自行强切 / 删树)。`,
    `2. path 未注册、分支已存在(\`git -C ${c.root} rev-parse --verify refs/heads/${featBranch}\` exit=0)→ 跑 \`git -C ${c.root} worktree add ${path} ${featBranch}\`。`,
    `3. path 未注册、分支不存在 → 跑 \`git -C ${c.root} worktree add -b ${featBranch} ${path} ${baseBranch}\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功(含 resume 复用):`{ "success": true, "detail": "<reused|added|added-new-branch>" }`',
    '- 失败:`{ "success": false, "error": "<分支不符 / git stderr 摘要>" }`',
  ].join('\n')
}

// mergeFeatureBranchPromptM(Task 13):批后把 feature 分支串行合并回模块分支。冲突处置与
// milestone 合并的"留树给人工"**刻意相反**:准入已保证文件集不相交,冲突本身即 facts 提取失真
// 的信号,计划钉死了自动恢复路径(丢弃 feature 分支 → 回模块分支串行重跑全链,不 adjudicate)——
// abort 恢复干净模块分支正是该路径的前置,树面没有人工要救的东西。
function mergeFeatureBranchPromptM(featBranch, moduleBranch, id, c) {
  return [
    `# 把 feature 分支 \`${featBranch}\` 合并回模块分支 \`${moduleBranch}\``,
    microStepContract(c.root),
    '',
    `0. **前置校验**:跑 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\`,必须等于 \`${moduleBranch}\`;不等 → 直接返回失败(error 写实际分支,**不要**自行 checkout)。`,
    `1. 跑 \`git -C ${c.root} merge --no-ff ${featBranch} -m "merge(feat:${id}): integrate ${featBranch}"\`。`,
    `2. **合并冲突** → 先跑 \`git -C ${c.root} merge --abort\` 恢复干净模块分支(调用方要在它上面串行重跑),再返回 \`{ "success": false, "error": "merge-conflict", "detail": "<冲突文件清单,换行分隔>" }\`。`,
    '3. 其它失败照实返回 failure(不要重试、不要改文件)。',
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功:`{ "success": true }`;失败:`{ "success": false, "error": "<merge-conflict 或 stderr 摘要>", "detail": "<冲突文件 / stderr 前 30 行>" }`',
  ].join('\n')
}

// deleteFeatureBranchPromptM(Task 13):删除 feature 分支,幂等。-D 强删的依据:合并成功路径上
// 该分支已并入模块分支(无独有 commit);丢弃路径则是有意弃置(冲突 = facts 失真,串行重跑会在
// 模块分支重做)——两条调用路径都不需要 -d 的"未合并"安全网。
function deleteFeatureBranchPromptM(featBranch, c) {
  return [
    `# 删除 feature 分支 \`${featBranch}\`(幂等)`,
    microStepContract(c.root),
    '',
    `1. \`git -C ${c.root} rev-parse --verify refs/heads/${featBranch}\` 非 0(分支不存在)→ 直接返回成功(幂等)。`,
    `2. 否则跑 \`git -C ${c.root} branch -D ${featBranch}\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 / 本就不存在:`{ "success": true }`;失败:`{ "success": false, "error": "<stderr 摘要>" }`',
  ].join('\n')
}

// ── 微步骤:milestone 专用 ──
function checkAlreadyMergedPromptM(branch, defaultBranch) {
  return [
    `# \`${branch}\` 是否已合入 \`${defaultBranch}\``,
    microStepContract(ROOT),
    '',
    `先跑 \`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(ROOT),
    '',
    `跑 \`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')
}

// reconcile(前端 jsdom-only 重叠 phase1→phase2 边界,设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md):
// phase1 的 frontend-phase 分支自**重叠开始时**的 default 分叉,不含后端代码;phase2 的 e2e 要打真后端,
// 故先把已完成的 default(含全部后端 milestone)合并进 frontend-phase。路径互斥(frontend/ vs backend/)
// → 冲突概率极低;冲突按 milestone merge 同口径「留树 halt 给人工」,绝不 abort。幂等:default 已是
// frontend-phase 祖先(无重叠 / resume 已 merge)→ git "Already up to date" → success。主根专属。
function mergeDefaultIntoFrontendPromptM(branch, defaultBranch) {
  return [
    `# reconcile:把 \`${defaultBranch}\`(已完成后端)合并进 \`${branch}\``,
    microStepContract(ROOT),
    '',
    `1. 跑 \`git -C ${ROOT} checkout ${branch}\` 确保 HEAD 在 ${branch}(phase1 worktree 已移除,分支空闲;若仍被占用则整步失败留人工)。`,
    `2. 跑 \`git -C ${ROOT} merge --no-ff ${defaultBranch} -m "merge(reconcile): integrate backend into ${branch} for e2e"\`。`,
    '## 输出(ACTION_RESULT_SCHEMA)',
    '- 成功 / 已是最新(Already up to date)→ `{ "success": true }`',
    '- 合并冲突 / 其它失败 → `{ "success": false, "error": "<simplified>", "detail": "<冲突文件换行分隔 或 stderr 前 30 行>" }`;**不要**自动 abort / stash / 改文件。',
  ].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(ROOT),
    '',
    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(ROOT),
    '',
    `调用方已确认字段当前值 = \`—\`(你不必再读一遍)。`,
    `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, c) {
  const section = fe ? '§ 三' : '§ 二'
  const kind = fe ? '功能' : 'REQ'
  const locator = fe
    ? `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section}(前端阶段)下的 \`功能:\` 项,从中找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(注意 id 后必须紧跟空格,避免误中前缀同名)。`
    : `Read \`${c.root}/docs/08-模块任务管理.md\`,定位 ${section},找**去掉行首空白后**以 \`- [ ] ${id} \` 或 \`- [x] ${id} \` 开头的行(id 后必须紧跟空格)。该行可能位于任一模块 bullet 下。`
  return [
    `# 读 docs/08 ${section} ${kind} \`${id}\` 的勾选态(\`- [ ] ${id} ...\` / \`- [x] ${id} ...\`)`,
    microStepContract(c.root),
    '',
    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, c) {
  const scope = fe ? `§ 三 功能 ${id}` : `§ 二 REQ ${id}`
  const lineGuard = (typeof lineNumber === 'number' && Number.isFinite(lineNumber))
    ? `先 Read \`${c.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(c.root),
    '',
    `调用方已读到状态 = \`unchecked\`(你不必再读一遍)。`,
    `1. ${lineGuard}`,
    `2. 跑 \`git -C ${c.root} add docs/08-模块任务管理.md\`。`,
    `3. 跑 \`git -C ${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(ROOT),
    '',
    `跑 \`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(ROOT),
    '',
    `跑 \`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(c) {
  return [
    '# 打 annotated tag `fe-skeleton-done`(前端骨架占位已建)',
    microStepContract(c.root),
    '',
    `先用 \`git -C ${c.root} tag -l fe-skeleton-done\` 检查;已存在则视为成功(幂等)直接返回 success。`,
    `否则跑 \`git -C ${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(ROOT),
    '',
    `用 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(ROOT),
    '',
    `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, c) {
  return [
    `# 收集功能分支自 \`${defaultBranch}\` 分叉以来的全部改动文件`,
    microStepContract(c.root),
    '',
    `跑 \`git -C ${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, c) {
  const filesText = files.map(f => `- ${f.status} ${f.path}`).join('\n')
  return [
    `# 把改动文件分类:哪些落在**非本模块 \`${moduleId}\`** 的目录下`,
    microStepContract(c.root),
    '',
    `本模块目录归属以 \`${c.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(req_id,如 USR-UserInfo-Login)迫使改它,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, c) {
  const newRowsJson = JSON.stringify(items, null, 2)
  return [
    `# 把跨模块改动以 dedup-and-rewrite 方式写入 \`docs/superpowers/module-reports/${moduleId}-cross-module.md\``,
    microStepContract(c.root),
    '',
    `目标文件(项目根相对):\`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 ${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, c) {
  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', c),
    '',
    '## 目标',
    `test-gate 绿后渲染标准化 **12 节**完成报告,commit 到当前分支(供 milestone 标记)。**只读 git 摘要,不读 diff 正文进上下文。**`,
    '',
    '## 前置',
    `- 验证上游 test-gate 绿:Glob \`${c.root}/docs/superpowers/module-reports/${phaseId}-test-gate-r*.md\`,**按 attempt 数字升序**读取每一份。**最后一份必须 green**;只要最后一份 red 立即 halt。中间存在 red→green 切换 = flake,需在 § ⑤ 标注。`,
    fe
      ? `- **验证阶段级行为门绿**:Glob \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`,按 behaviorRound → attempt 数字升序读取。**最后一份必须非 RED(绿)**;最后一份 red 或一份证据都没有 → 立即 halt(绝不在行为红 / 无行为证据上打 milestone)。注意 per-FE 行为证据(\`reviews/<date>-<FE>-behavior-*\`)已不再产生,不要校验。`
      : '',
    '',
    '## 收集输入(取摘要而非正文)',
    fe
      ? [
          '- § ① `module_id = frontend-phase`,`module_name = 前端阶段(整体)`。',
          `- § ② "FE 完成清单":扫 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-FE-*.md\`,按 FE-NN 顺序列出。`,
          `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\`(三点 diff,区间 = 功能分支 \`frontend-phase\` 自默认分支分叉以来的全部改动)。`,
          '- § ④ 数据库使用表 / § ⑥ Migration / § ⑦ 跨模块:填 `N/A(前端阶段)`。',
          `- § ⑤:把 \`${c.root}/docs/superpowers/module-reports/frontend-phase-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。**另把阶段级行为证据 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`(按 behaviorRound → attempt 排序)与行为复验 \`frontend-phase-behavior-reverify-r*.md\` 的 flake / 环境 race(envError)/ 行为 fix 轮数 / 样式 styleIssues 修复轮次 / 文字 continue 记录一并纳入 § ⑤ 汇总**。`,
          `- § ⑧ 偏离清单:审查"实际渲染 DOM 与各 FE 关联原型主结构的差异",逐 FE 列出;**额外按阶段级行为证据 \`${c.root}/docs/superpowers/module-reports/frontend-phase-behavior-r*-a*.md\`(取最后一份的逐 FE 小节)汇总各 FE 的 \`coverageGaps\` + 样式 \`styleIssues\` + 文字 \`textIssues\` 的 continue 记录 + 逐控件判定摘要 + authState 未覆盖角色集**。`,
          '- § ⑪ 下一模块预览:填"上线 / 部署后续步骤"。',
        ].join('\n')
      : [
          `- § ③ 文件变更:\`git -C ${c.root} diff --stat <默认分支 main/master>...HEAD\` / \`--name-status\` / \`git log <默认分支>..HEAD --oneline\`(区间 = 功能分支 \`module-${id}\` 自默认分支分叉以来的全部改动)。`,
          `- § ② / § ⑨:读 \`${c.root}/docs/superpowers/{specs,plans,reviews}/<日期>-<本模块 REQ>.md\`。`,
          `- § ⑤:把 \`${c.root}/docs/superpowers/module-reports/${id}-test-gate-r*.md\` 全部(按 attempt 排序)摘要汇总。若 attempt 数 > 1 且首次 red 末次 green → 在 § ⑤ 顶部明确标注 \`flake-detected: r1 red, r${'<最后一次>'} green\`,并附首次失败用例与最终绿色记录链接。`,
          `- § ⑥ Migration:\`git -C ${c.root} diff --name-only --diff-filter=A -- 'sql/migrations/V*.sql'\` 列新增,每个读第一行作说明。`,
          `- § ⑦ 跨模块改动:读 \`${c.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 判定,子代理只负责执行单一动作。
// c:模块上下文——checkout/HEAD/脏树恢复全部作用于 c.root(串行 = 主根;lane 分叉见下)。
// 默认分支探测保持 ROOT(读 refs,全仓共享,主根专属)。
// lane 分叉(Task 6 Step 3):c.lane != null 时不做主根 checkout——`git worktree add` 原子完成
// "建树 + 上分支"(分支语义不变:module-<id> / frontend-phase)。波次前置串行段通常已建好 lane,
// 这里的 createLaneWorktree 是幂等兜底(resume / 单独重派模块时仍能自洽建树)。
async function runBranchSetup(module, c) {
  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 agentR(detectDefaultBranchPromptM(), {label: lbl('default'), phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})

  if (c.lane != null) {
    // lane 模式:先确保 lane worktree 存在且在目标分支(幂等三分支见 prompt),树才存在、脏树检查才有意义。
    await runAction(g => createLaneWorktreePromptM(branch, c.root, def.branch) + g,
      {site:`branchSetup-lane-worktree:${branch}`, grp:'Milestone', label: lbl('lane-add'), dec: c.decisions, root: c.root})
  }

  // 工作树脏:先自主恢复(in-scope 残留 → 自动 commit);含越界改动则恢复失败 → halt(留给人工)。
  // 作用树 = c.root:lane 模式查 lane 树(halt 保留的 lane 在 resume 复用时可能带残留),主根模式与现行一致。
  const wt = await agentR(worktreeCleanPromptM(c), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  if (!wt.clean) {
    const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `分支 setup 前置(目标分支 ${branch})。`, c),
      {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 || ''})`)
  }

  if (c.lane == null) {
    // 主根模式:现行 checkout 路径,一行不差(lane 模式跳过——worktree add 已把 lane 放上目标分支,
    // 且 checkout 语义在"分支被别的 worktree 持有"时会 fatal,绝不能在主根重复执行)。
    const exists = await agentR(checkBranchExistsPromptM(branch, c), {label: lbl('exists?'), phase: 'Milestone', schema: EXISTS_SCHEMA})
    if (exists.exists) {
      await runAction(g => checkoutExistingBranchPromptM(branch, c) + g, {site:`branchSetup-checkout:${branch}`, grp:'Milestone', label: lbl('checkout'), dec: c.decisions, root: c.root})
    } else {
      await runAction(g => createBranchFromPromptM(def.branch, branch, c) + g, {site:`branchSetup-create:${branch}`, grp:'Milestone', label: lbl('create'), dec: c.decisions, root: c.root})
    }
  }

  // HEAD 确认:不符则经仲裁重切(retry)或留人工(halt)。
  let head = await agentR(currentBranchPromptM(c), {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, c.root)
    if (verdict.action !== 'retry')
      throw new Error(`HALT branchSetup-branch-mismatch ${branch}: ${verdict.rationale || `HEAD on ${head.branch}`}`)
    await runAction(g => checkoutExistingBranchPromptM(branch, c) + g, {site:`branchSetup-recheckout:${branch}`, grp:'Milestone', label: lbl('recheckout'), dec: c.decisions, root: c.root})
    head = await agentR(currentBranchPromptM(c), {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 结果上判定,子代理只执行确定性动作。
// 主根专属(不穿线 c):merge / docs/08 字段 / tag 全部作用于主根 + 默认分支。step 1 的 wt-clean
// 只管 merge 的机械安全(恒查主根树,用 mc);lane 树的 milestone 前收口由 runModule 另行处理(补 15)。
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}`
  const mc = makeCtx()   // 主根上下文:worktreeClean / recoverDirtyWorktree 已穿线,本函数恒以主根调用

  // step 1: worktree clean precondition(脏树先自主恢复 in-scope 残留;含越界改动则 halt 留人工)
  const wt = await agentR(worktreeCleanPromptM(mc), {label: lbl('wt'), phase: 'Milestone', schema: WT_SCHEMA})
  if (!wt.clean) {
    const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `里程碑前置(阶段 ${phaseId},应在功能分支 ${branch})。`, mc),
      {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 agentR(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 agentR(checkAlreadyMergedPromptM(branch, def.branch), {label: lbl('merged?'), phase: 'Milestone', schema: ALREADY_MERGED_SCHEMA})
  if (!merged.alreadyMerged) {
    const r = await agentR(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 agentR(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 agentR(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 agentR(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 agentR(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 agentR(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 判断,独立成一步。
// c:diff/分类/写日志全部在 c.root 上做(lane 模式读 lane 树;diff 基准默认分支为只读,不需要锁)。
async function runCrossModule(module, c) {
  const id = module?.id ?? '<module>'
  const lbl = (k) => `xmod:${k}:${id}`

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

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

  const classified = await agentR(classifyCrossModulePromptM(id, changed.files, c), {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, c) + g,
    {site:`crossModule-write:${id}`, grp:'Milestone', label: lbl('write'), dec: c.decisions, root: c.root})

  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, c) {
  const lbl = (k) => `fe-skeleton:${k}`

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

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

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

  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,让 runModule 的 try/catch 捕获不到,残缺模块照样被推进到 milestone。
//    顺序 for-await 让 throw 自然冒泡到 runModule try → catch → 结构化 halted 返回,使 fail-fast 真正生效。
//
// 唯一并行豁免 = 入口 spec 预生成(Phase E Task 11):spec 只读 docs/prototype/tokens、不读兄弟
// feature 代码(plan 才要 Grep 现有代码定位文件,依赖前序 feature 产出,**不**预生成),且 batch 模式
// 只写盘不 commit(文件名含 id 不相撞、不碰 .git),上面两条顺序理由对它都不成立;失败项 thunk 内
// catch 落 null 回退主链串行重跑(现行路径含 commit),halt 语义不受 parallel() 吞 throw 影响。
//
// 派生 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。
// spec 预生成闸(Phase E Task 11):spec 预生成无物理冲突风险(batch 只写盘不 commit、文件名含 id
// 不相撞),与模块并行开关解耦——parallel.features 或 parallel.modules 任一开启即启用,前后端
// featureLoop 同享;缺省(parallel 缺省 / modules:false 逃生口且未开 features)全关,维持与 master
// 串行等价。注意它不是 Phase F 的 feature 级并行(tdd/fix 链仍严格串行,那才归 features 闸全权管辖)。
const useSpecPrefetch = ARGS?.parallel?.features === true || ARGS?.parallel?.modules === true
// feature 级受限并行闸(Phase F Task 12-14 全权归 features 闸管辖;附录补 1:coding-start 不传
// features——开启是 Task 14 收益评估通过后的显式一行改动)。范围仅后端 REQ(附录补 2);
// featureMaxWidth 缺省 2(计划 Task 12 字面默认)。与 useSpecPrefetch 解耦:spec 预生成无物理
// 冲突风险、两开关任一开启即用;feature 级 tdd/fix 链并行只看本闸。
const useFeatureParallel = ARGS?.parallel?.features === true
const featureMaxWidth = Math.max(1, ARGS?.parallel?.featureMaxWidth ?? 2)
// feStage(前端 jsdom-only 重叠;设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md):
//   'all'(缺省)= 现行完整链(spec→plan→tdd→verify→review→req-done),后端恒走此;前端 overlap 关时亦走此。
//   'jsdom'(overlap phase1)= spec→plan→tdd(仅 jsdom 任务)→verify(仅 vitest)→tag fe-code-done;**不** review、**不** req-done。
//   'e2e'(phase2,后端已就绪)= dedup 读 fe-code-done → spec/plan(复用)→tdd(仅 e2e 任务)→verify(unit+e2e)→review→req-done。
async function featureLoop(items, phase, c, feStage = 'all') {
  const grp = phase === 'backend' ? 'Backend' : 'Frontend'
  const isPhase1 = feStage === 'jsdom'   // 只推进到 fe-code-done(中间态)
  // spec 的 artifactPath 校验(主链与批量预生成共用同一份,确保两条路径产物口径一致)。
  const specValidate = r => {
    if (!r.artifactPath) return 'spec 返回 ok 但缺 artifactPath(流程靠它定位 spec 并派生下游日期前缀)'
    dateFromArtifactPath(r.artifactPath)   // 文件名日期前缀非法 → 抛,被 runStage 捕获转为仲裁
    return null
  }

  // ── 入口 spec 预生成(Phase E Task 11;准入与豁免理由见函数头注释)────────────
  // tag check 并行(只读)→ 未完成项 parallel() 跑 batch spec(thunk 内 catch,失败项落 null)→
  // 单一微步骤统一 add+commit → 成功项存 specById 供主链直接消费;null 项回退主链重跑(含 commit)。
  const specById = new Map()    // id → 预生成 spec 的 STAGE_RESULT(主链命中即跳过 spec stage)
  // id → 预生成期间的临时决策 sink:仅当该项成果被主链**实际消费**(ensureSpec 命中)时才并入
  // c.decisions——回退路径(thunk null / 统一提交失败清空 specById)连 sink 一起丢弃,主链重跑
  // spec 会重新登记,同一 stage 的决策绝不记两遍。
  const specDecsById = new Map()
  const doneById = new Map()    // id → tag check 布尔缓存(批量查失败的槽位缺席,主链回退逐项重查)
  if (useSpecPrefetch && items.length >= 2) {
    // 直用 agent() 而非 agentR:thunk 内 throw 会被 parallel() 折成 null 丢失诊断;null 槽位留给
    // 主链 agentR 重查,那里的 halt 语义与现行串行路径逐字一致。
    const checks = await parallel(items.map(id => () =>
      agent(checkReqDoneTagPromptM(id, c), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA})))
    checks.forEach((r, i) => { if (r) doneById.set(items[i], r.exists) })
    // 只对"确认未完成"的项预生成;tag check 失败(缺席)的项不预生成——其完成态未知,预生成可能
    // 白烧一次 spec 子代理,且主链重查后若已完成会整段 skip。
    const todo = items.filter(id => doneById.get(id) === false)
    if (todo.length >= 2) {     // 仅 1 项时预生成无并行收益,纯多一次统一 commit 微步骤,走主链即可
      const specs = await parallel(todo.map(id => () => {
        const sink = []          // 临时决策 sink(F11):与成果同生共死,回退时一起丢弃
        return runStage(g => deriveSpecPrompt(id, phase, c, { batch: true }) + g, {
          site:`spec:${phase}:${id}`, grp, label:`spec:${phase}:${id}`, dec: sink, root: c.root,
          validate: specValidate,
        }).then(r => ({ r, sink }))
          .catch(e => {          // thunk 内 catch:runStage 的 HALT throw 折成 null(先 log 保诊断),
            log(`spec 预生成失败 ${phase}:${id}(落 null,回退主链串行重跑):${String(e?.message || e)}`)
            return null          // 绝不让 throw 进 parallel()——那会静默吞掉 halt 原因
          })
      }))
      specs.forEach((x, i) => { if (x) { specById.set(todo[i], x.r); specDecsById.set(todo[i], x.sink) } })
      if (specById.size) {
        // 统一提交(bestEffort 不 halt):失败 → 丢弃全部预生成结果,主链按现行路径逐项重跑 spec
        // (含 commit;写盘内容已在树上,spec prompt 的"已存在同 id spec 复用/就地 Edit"规则会复用它,
        // commit 若仍失败由现行 commitBlock 的 halt 语义兜底——不为预生成机制新增 halt 点)。
        const ok = await bestEffortAction(commitPrefetchedSpecsPromptM([...specById.keys()], phase, c),
          { label:`spec-batch-commit:${phase}`, phase: grp,
            okMsg:`spec 预生成: 统一提交 ${specById.size} 份(${phase})`, failTag:`spec 预生成统一提交(${phase})` })
        if (!ok) { specById.clear(); specDecsById.clear() }  // 全量回退:决策随成果一起丢,主链重跑重新登记
      }
    }
  }

  // ── 链段共用闭包:现行串行链与 Phase F 并行批共用同一组调用点(site/label/allowContinue/
  // dec/root 口径一字不差,仅执行 ctx 可换成 feature lane 的 fc)──────────────────
  // 入口 dedup:req-done/<id> 已存在 → 已 approve,整段 skip。批量 tag check 命中缓存直接消费
  // (同一次运行刚查过;req-done/<id> 按 id 归属本模块,兄弟 lane 不会代打);缺席槽位回退逐项重查。
  const isDone = async (id) => {
    const cached = doneById.get(id)
    if (cached !== undefined) return cached
    const done = await agentR(checkReqDoneTagPromptM(id, c), {label:`donecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA})
    return done.exists
  }
  // 预生成命中 → 直接消费 artifactPath、跳过 spec stage(统一提交已落 commit),并把预生成期间的
  // 临时决策 sink 并入 c.decisions(F11:仅消费时并入,delete 防同 id 二次并入;条目在预生成时已
  // 经 recordDecisions 落过 log,此处只并收集器不重复打日志);未命中/落 null → 现行串行路径重跑
  // (含 commitBlock,决策直落 c.decisions)。spec/plan 恒在模块树(ctx = c)上串行产出——Phase F
  // 只把 tdd→verify→review→fix 段挪进 feature lane。
  const ensureSpec = async (id) => {
    const pre = specById.get(id)
    if (pre) {
      const sink = specDecsById.get(id)
      if (sink) { c.decisions.push(...sink); specDecsById.delete(id) }
      return pre
    }
    return runStage(g => deriveSpecPrompt(id, phase, c) + g, {
      site:`spec:${phase}:${id}`, grp, label:`spec:${phase}:${id}`, dec: c.decisions, root: c.root,
      validate: specValidate,
    })
  }
  const runPlanStage = async (id, spec) => {
    // spec 经仲裁 continue 时 artifactPath 仍可能不带合法日期前缀——防御取值,避免重算抛出把 continue 变成隐式 halt。
    let specDate = null
    try { specDate = dateFromArtifactPath(spec.artifactPath) } catch { specDate = null }
    return runStage(g => planPrompt(id, phase, spec.artifactPath, c) + g, {
      site:`plan:${phase}:${id}`, grp, label:`plan:${phase}:${id}`, dec: c.decisions, root: c.root,
      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
      },
    })
  }
  // fc:执行上下文(串行 = 模块 ctx c;Phase F 并行批 = feature lane ctx)。rwOpts 透传
  // reviewWithFixLoop(feature lane 内跳过 docs/08 勾选,理由见该函数头注释)。
  // stage(末位,缺省 'all'=现行):透传 tddPrompt/verifyPrompt 的 feStage;'jsdom' 额外跳过 review(phase1
  // 只到 fe-code-done,review→req-done 延到 phase2)。'e2e'/'all' 含 review,一字不差。
  const runImplChain = async (id, planPath, specPath, fc, rwOpts, stage = 'all') => {
    // tdd allowContinue:false:tddPrompt 的 halt = 路径作用域越界护栏 / 同测试卡死 10 次——硬边界,
    // 仲裁不得 continue 放行(越界把前端实现混进后端分支 / 卡死等于测试没真过)。
    const impl = await runStage(g => tddPrompt(id, phase, planPath, fc, stage) + g, {
      site:`tdd:${phase}:${id}`, grp, label:`tdd:${phase}:${id}`, allowContinue: false, dec: fc.decisions, root: fc.root,
    })
    // 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 || '', specPath, 0, fc, stage) + g, {
      site:`verify:${phase}:${id}`, grp, label:`verify:${phase}:${id}`, allowContinue: false, dec: fc.decisions, root: fc.root,
    })
    if (stage === 'jsdom') return   // overlap phase1:jsdom 绿即止,不 review(review→req-done 留 phase2)
    const reviewResult = await reviewWithFixLoop(id, phase, v0, specPath, fc, rwOpts)
    log(`review approved ${phase}:${id} after ${reviewResult.rounds} round(s)`)
  }
  // approve 后落地 dedup 真值:req-done/<id> tag(失败经仲裁重试,确不可恢复才 halt)。
  // tag 全仓共享,恒以模块 ctx c 打(Phase F 在合并回模块分支**之后**才打,见 featureBatchRun 批后收口)。
  const tagDone = async (id) => runAction(g => createReqDoneTagPromptM(id, phase, c) + g, {
    site:`req-done-tag:${phase}:${id}`, grp, label:`reqdone:${phase}:${id}`, dec: c.decisions, root: c.root,
  })
  // overlap phase1 中间完成态 tag:fe-code-done/<id>(组件代码 + jsdom 绿)。
  const tagFeCodeDone = async (id) => runAction(g => createFeCodeDoneTagPromptM(id, c) + g, {
    site:`fe-code-done-tag:${id}`, grp, label:`fecodedone:${id}`, dec: c.decisions, root: c.root,
  })
  // phase1 入口 dedup:fe-code-done/<id> 已存在 → 本 FE phase1 已完成,跳过(phase2 用 req-done dedup,走 isDone)。
  const isPhase1Done = async (id) => {
    const r = await agentR(checkFeCodeDoneTagPromptM(id, c), {label:`fecodecheck:${phase}:${id}`, phase: grp, schema: EXISTS_SCHEMA})
    return r.exists
  }

  // ── Phase F(Task 12/13):后端 REQ 的 feature 级受限并行(设计见 featureBatchRun 头注释)。
  // frontend phase 不进准入(附录补 2);闸关 / 单 feature 时走下方现行串行链,一字不差。
  if (useFeatureParallel && phase === 'backend' && items.length >= 2) {
    await featureBatchRun(items, phase, c, { grp, isDone, ensureSpec, runPlanStage, runImplChain, tagDone })
    return
  }

  for (const id of items) {
    // dedup:phase1 用 fe-code-done;'all'/'e2e'(phase2)用 req-done。phase2 的 fe-code-done 必已存在
    // (phase1 落过),spec/plan 经 ensureSpec/runPlanStage 复用既有产物,tdd(e2e) 只处理延后的 e2e 任务。
    if (isPhase1 ? await isPhase1Done(id) : await isDone(id)) {
      log(`featureLoop skip ${phase}:${id} — tag ${isPhase1 ? 'fe-code-done' : 'req-done'}/${id} 已存在`); continue
    }
    const spec = await ensureSpec(id)
    const plan = await runPlanStage(id, spec)
    await runImplChain(id, plan.artifactPath, spec.artifactPath, c, undefined, feStage)
    await (isPhase1 ? tagFeCodeDone(id) : tagDone(id))
  }
}

// laneEnvCache:lane 库 schema 基底 + lane DB 支持度是**运行内不变量**(同一运行环境不变),首个
// 探测点(featureBatchRun 的 lane 前置 / ≥2 宽波次的 prepareParallelWave,谁先到谁填)探测后缓存
// 复用,成功失败都缓存(不支持也是稳定事实,后续免重复子代理调用直接降级)。agent 无返回 / 抛错
// 是瞬时态不是环境事实,**不缓存**,留给下个探测点重探。形态:null=未探测;{ fail } / { baseSchema }。
let laneEnvCache = null

// ---- Phase F(Task 12/13):后端 REQ 的 feature 级受限并行批 ----
// 准入闸的灵魂:**LLM 提议、JS 验证**——plan 本就锁文件边界,文件集相交与否是确定性可算的,
// 不信 LLM 单方判断。准入 = 批内两两 files 不相交 ∧ 全部 schemaChange=false ∧ 批宽 ≤
// featureMaxWidth;改 schema 的 feature **永远串行**;frontend phase 不进准入(附录补 2:FE 的
// e2e 要打穿 vite/playwright/后端三层端口配置,复杂度/收益比不成立,FE 收益由 spec 预生成承担)。
// 失败路径一律 fail-open:准入不过 / facts 提取失败 / feature lane 建失败 → 该 feature 留串行链,
// 绝不因并行机制新增 halt 点(feature 链自身的 stage halt 是真实正确性边界,不在豁免之列)。
// 隔离三前提逐一替换(与模块级 lane 同构):feature worktree(独立 index 不争 .git/index.lock)、
// 准入排除 schema 改动(无 V<n> 取号即无撞号)、feature 专属测试库(`<schema>_lane<N>_f<i>`,
// 附录补 8 序号制;tdd/verify 不起栈不占固定端口,无需 stackLock——这正是收窄到后端 REQ 的原因)。
// h:featureLoop 的链段共用闭包(isDone/ensureSpec/runPlanStage/runImplChain/tagDone)——并行批
// 与串行链共用同一组调用点。
async function featureBatchRun(items, phase, c, h) {
  const { grp, isDone, ensureSpec, runPlanStage, runImplChain, tagDone } = h
  const serialChain = async (f) => { await runImplChain(f.id, f.planPath, f.specPath, c); await tagDone(f.id) }

  // 执行序第 1 段(Task 13 字面):模块内先**串行**跑完全部 spec+plan(轻;plan 要 Grep 现有代码
  // 定位文件,必须在模块树上串行产出)——与现行链的逐 feature 交错不同:plan 全集先行,才有
  // facts 可提取、可整体分批。
  const feats = []                         // { id, specPath, planPath, facts: Set<string>|null }
  for (const id of items) {
    if (await isDone(id)) { log(`featureLoop skip ${phase}:${id} — tag req-done/${id} 已存在`); continue }
    const spec = await ensureSpec(id)
    const plan = await runPlanStage(id, spec)
    feats.push({ id, specPath: spec.artifactPath, planPath: plan.artifactPath, facts: null })
  }

  // 执行序第 2 段:提取 PLAN_FACTS(只读微步骤,agent 直调——失败不仲裁不 halt)。null / 形状
  // 不符 / 文件集为空 / 改 schema → facts 留 null(该 feature 走串行链);待跑不足 2 个则无并行
  // 可言,全员 facts=null 自然全串行(省 facts 子代理)。
  if (feats.length >= 2) {
    for (const f of feats) {
      const r = await agent(planFactsPromptM(f.planPath, c), { label:`plan-facts:${f.id}`, phase: grp, schema: PLAN_FACTS_SCHEMA })
      if (!r) { log(`feature-parallel: ${f.id} facts 提取无返回(fail-open 留串行链)`); continue }
      if (r.schemaChange !== false) { log(`feature-parallel: ${f.id} plan 声明 schema 改动 → 永远串行`); continue }
      const files = Array.isArray(r.files) ? r.files.map(x => typeof x === 'string' ? x.trim() : '').filter(Boolean) : []
      if (!files.length) { log(`feature-parallel: ${f.id} plan 文件集为空/不可辨(fail-open 留串行链)`); continue }
      f.facts = new Set(files)
    }
  }

  // JS 准入分批(Task 12):保 docs/02 原序的贪心**连续**分批。批内并发放弃成员间顺序——两两
  // 不相交 + 无 schema 改动正是顺序无关的准入前提;但**跨批**顺序保持原序:无 facts 的 feature
  // 自成串行项并截断当前批,绝不跨越它重排(后续 feature 可能依赖它的 schema/代码产出)。
  const runs = []                          // 每项 = feats 子数组;length 1 即串行项
  {
    let cur = []
    const flush = () => { if (cur.length) { runs.push(cur); cur = [] } }
    const disjoint = (f, batch) => batch.every(b => { for (const x of f.facts) if (b.facts.has(x)) return false; return true })
    for (const f of feats) {
      if (!f.facts) { flush(); runs.push([f]); continue }
      if (cur.length >= featureMaxWidth || (cur.length && !disjoint(f, cur))) flush()
      cur.push(f)
    }
    flush()
  }

  // feature lane 前置(首个 ≥2 批前惰性准备一次;任何一步失败 → 本模块全部批降级串行,fail-open)。
  // lane 库命名(附录补 8 序号制):`<schema>_lane<N>_f<i>`——模块 lane 模式 c.dbSchema 已是
  // `<schema>_lane<N>`,直接缀 `_f<i>`;串行主根模式 N 取 0。后缀固定短(≤10 字符),不引入
  // moduleId 进库名的截断/撞名/转义问题。
  let featPrep                             // undefined=未试;null=失败(降级);{ base, moduleBranch }
  const prepareFeatureLanes = async () => {
    if (featPrep !== undefined) return featPrep
    featPrep = null
    try {
      // 模块分支名 = c.root 当前 HEAD(runBranchSetup 已确保在 module-<id> 上)——featureLoop
      // 不接收 module 形参,取 HEAD 是唯一不另起约定的真值来源。
      const head = await agent(currentBranchPromptM(c), { label:'feat:module-branch', phase: grp, schema: DEFAULT_BRANCH_SCHEMA })
      if (!head || !head.branch) { log('feature-parallel: 模块分支名读取失败 → 本模块 feature 批全部降级串行'); return featPrep }
      let base = c.dbSchema
      if (!base) {
        // 串行主根模式:lane 测试库支持与模块级同判据(附录补 6b 两点联查)——并行 feature 链的
        // tdd 内循环测试若同连共享库会互相清库,任一不满足 → 老老实实串行,不 halt。
        // (模块 lane 模式 c.dbSchema 非空 = 波次前置已探测通过,免重查;探测/读基底两个微步骤
        // 均主根专属,串行主根下 c.root === ROOT,口径一致。)
        // 经 laneEnvCache 缓存:串行主根 + features 闸模式下波次前置永不执行、缓存恒空,不在此
        // 读/填会逐模块白烧两个探测子代理。agent 无返回不缓存(瞬时态,仅本模块降级,留待重探)。
        if (!laneEnvCache) {
          const probe = await agent(laneDbSupportPromptM(), { label:'feat:lane-db?', phase: grp, schema: LANE_DB_PROBE_SCHEMA })
          if (!probe) { log('feature-parallel: lane 测试库支持度探测无返回 → 本模块 feature 批全部降级串行'); return featPrep }
          if (!probe.scriptsEnv || !probe.ymlPlaceholder) {
            laneEnvCache = { fail: `目标项目不支持 lane 测试库(scriptsEnv=${probe.scriptsEnv}, ymlPlaceholder=${probe.ymlPlaceholder})` }
          } else {
            const sb = await agent(readDbSchemaPromptM(), { label:'feat:db-base', phase: grp, schema: FIELD_VALUE_SCHEMA })
            if (!sb) { log('feature-parallel: database.schema 读取无返回 → 本模块 feature 批全部降级串行'); return featPrep }
            laneEnvCache = (!sb.found || !sb.value)
              ? { fail: 'config-vars.yaml 的 database.schema 读不到(lane 库名无基底)' }
              : { baseSchema: sb.value }
          }
        }
        if (laneEnvCache.fail) { log(`feature-parallel: ${laneEnvCache.fail} → 本模块 feature 批全部降级串行`); return featPrep }
        base = laneDbSchema(laneEnvCache.baseSchema, 0)   // serial 主根跑 feature 并行:N 取 0(附录补 8)
      }
      featPrep = { base, moduleBranch: head.branch }
    } catch (e) {
      log(`feature-parallel: lane 前置准备异常(${String(e?.message || e)})→ 本模块 feature 批全部降级串行`)
    }
    return featPrep
  }
  // lane 清理/丢弃共用:移除 worktree + 删分支(均 bestEffort——分支与对象库全仓共享,残留不损
  // 正确性,删失败只 log;残留 feature lane 持 feat/* 分支,不挡 module-* 占用感知)。
  const dropFeatureLane = async (l) => {
    await bestEffortAction(removeLaneWorktreePromptM(l.path),
      { label:`feat:lane-remove:${l.f.id}`, phase: grp, okMsg:`feature-parallel: 已移除 lane ${l.path}`, failTag:`feature-parallel: 移除 lane(${l.path})` })
    await bestEffortAction(deleteFeatureBranchPromptM(l.branch, c),
      { label:`feat:branch-del:${l.f.id}`, phase: grp, okMsg:`feature-parallel: 已删除分支 ${l.branch}`, failTag:`feature-parallel: 删除分支(${l.branch})` })
  }

  for (const run of runs) {
    const prep = run.length >= 2 ? await prepareFeatureLanes() : null
    if (run.length < 2 || !prep) {         // 串行项 / 前置失败降级 → 现行串行链(与非并行路径同源闭包)
      for (const f of run) await serialChain(f)
      continue
    }

    // 建 feature lanes(复用 createLaneWorktreePromptM 的幂等三分支形态;从模块分支 HEAD 建
    // feat/<id>)。fail-open:建 lane / 写 marker(附录补 7 fail-closed 兜底——setup-test-db 是
    // DROP+CREATE,不能只靠提示词注入)失败 → 该 feature 退串行;可并行的不足 2 条 → 整批退串行。
    const lanes = []                       // { f, branch, path, fc }
    const fallback = []
    for (const [i, f] of run.entries()) {
      const l = { f, branch: `feat/${f.id}`, path: featureLaneRoot(c.root, f.id) }
      const schema = `${prep.base}_f${i}`
      const add = await agent(createFeatureWorktreePromptM(l.branch, l.path, prep.moduleBranch, c),
        { label:`feat:lane-add:${f.id}`, phase: grp, schema: ACTION_RESULT_SCHEMA })
      if (!add || !add.success) {
        log(`feature-parallel: ${f.id} 建 feature lane 失败(${(add && add.error) || 'agent 无返回'})→ 留串行链`)
        fallback.push(f); continue
      }
      const mk = await agent(writeLaneDbMarkerPromptM(l.path, schema), { label:`feat:lane-marker:${f.id}`, phase: grp, schema: ACTION_RESULT_SCHEMA })
      if (!mk || !mk.success) {
        log(`feature-parallel: ${f.id} 写 lane DB marker 失败(${(mk && mk.error) || 'agent 无返回'})→ 留串行链`)
        await dropFeatureLane(l); fallback.push(f); continue
      }
      // feature 级 ctx:root=feature lane、dbSchema=feature 专属库;decisions **共享**模块收集器
      // (单线程事件循环下并发 push 原子,逐模块 RESUME flush 口径不变);vBase 透传仅保字段语义
      // 一致(准入已排除 schema 改动,tdd 不会取号)。
      l.fc = makeCtx({ lane: c.lane, root: l.path, dbSchema: schema, vBase: c.vBase, decisions: c.decisions })
      lanes.push(l)
    }
    if (lanes.length < 2) {
      // 并行不成立:孤 lane 弃用回收,全员按原序走串行链(在模块树上跑,与 lane 无关)。
      for (const l of lanes) await dropFeatureLane(l)
      for (const f of run) await serialChain(f)
      continue
    }

    // 并行跑 tdd→verify→review→fix(thunk 内 catch:HALT throw 先 log 保诊断再折成结构化失败,
    // 绝不让 throw 进 parallel()——那会静默折成 null 丢失原因)。lane 内跳过 docs/08 勾选
    // (flipDocs08:false):相邻 REQ 行的并行编辑会在批后合并时制造无谓 docs/08 冲突,批后统一勾。
    const rs = await parallel(lanes.map(l => () =>
      runImplChain(l.f.id, l.f.planPath, l.f.specPath, l.fc, { flipDocs08: false })
        .then(() => ({ ok: true }))
        .catch(e => {
          const msg = String(e?.message || e)
          log(`feature-parallel: ${l.f.id} 链失败(feature lane 保留取证 ${l.path}):${msg}`)
          return { ok: false, error: msg }
        })))

    // 批后串行收口(Task 13):成功链按批内序合并回模块分支 → 勾 docs/08 → 打 req-done → 清理
    // lane。模块内本就串行(同一时刻只有本批在收口),无需全局锁。
    const haltMsgs = []
    const rerun = []
    for (const [i, l] of lanes.entries()) {
      const r = rs[i]
      if (!r || r.ok !== true) {
        // 链失败 / runtime-null:feature lane 整树保留取证(reason 带路径),批边界收敛后统一
        // halt——这是真实 stage halt 的批级聚合(串行链同样会在此 halt),不属 fail-open 范畴。
        haltMsgs.push(`${l.f.id} [feature-lane 保留取证: ${l.path}]: ${r ? r.error : 'runtime-null(parallel() 槽位为 null)'}`)
        continue
      }
      // 合并经 agentR(null 走 NULL_AGENT halt——计划只豁免"冲突不 adjudicate",不豁免 runtime 故障)。
      const mg = await agentR(mergeFeatureBranchPromptM(l.branch, prep.moduleBranch, l.f.id, c),
        { label:`feat:merge:${l.f.id}`, phase: grp, schema: ACTION_RESULT_SCHEMA })
      if (mg.success) {
        await flipDocs08Checkbox(false, l.f.id, phase, grp, c)   // lane 内跳过的勾选在此补落(纯可视化,失败只 log)
        await tagDone(l.f.id)
        await dropFeatureLane(l)
      } else {
        // 合并冲突(文件集不相交下理论不可能,命中即 facts 提取失真)→ 不 adjudicate:丢弃
        // feature 分支,回模块分支串行重跑该 feature 全链(merge 微步骤已 abort 恢复干净模块分支)。
        log(`feature-parallel: ${l.f.id} 合并回 ${prep.moduleBranch} 失败(${mg.error || ''}${mg.detail ? ':' + mg.detail : ''})——文件集不相交下不应发生,按 facts 提取失真处置:丢弃 feat/${l.f.id},回模块分支串行重跑全链`)
        await dropFeatureLane(l)
        rerun.push(l.f)
      }
    }
    if (haltMsgs.length) {
      // 兄弟链已收口(成功者的工作已合并 + 打 tag 落地);冲突重跑搁置——模块即将 halt,不再起
      // 新链(与模块级"波次边界收敛后不再起新波"同口径),resume 时这些 feature 因缺 req-done tag 自动重跑。
      for (const f of rerun) log(`feature-parallel: ${f.id} 的串行重跑因同批链失败而搁置(resume 由 req-done tag 缺失兜底)`)
      throw new Error(`HALT feature-batch ${phase}: ${haltMsgs.join(';')}`)
    }
    for (const f of [...rerun, ...fallback]) await serialChain(f)
  }
}

// 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。
// 注意:docs/08 **checkbox** 发生在模块分支上(approve 后逐功能勾选)→ 穿线 c;
// docs/08 **里程碑字段**发生在 merge 后的默认分支上 → 主根专属(见 runMilestone),两组不要搞混。
async function flipDocs08Checkbox(fe, id, phase, grp, c) {
  const cb = await agent(readDocs08CheckboxPromptM(fe, id, c), {label:`cb?:${phase}:${id}`, phase: grp, schema: CHECKBOX_STATE_SCHEMA})
  if (!cb) { log(`docs08-checkbox ${id}: 读取子代理无返回(不阻断)`); return }
  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, c), {label:`cb:${phase}:${id}`, phase: grp, schema: ACTION_RESULT_SCHEMA})
  if (!wr || !wr.success) log(`docs08-checkbox ${phase}:${id}: 勾选写入失败(${(wr && wr.error) || ''}),跳过——cosmetic,不阻断`)
}

// rwOpts.flipDocs08(Phase F Task 13):false 时 approve 路径跳过 docs/08 checkbox 勾选——feature
// lane 内相邻 REQ 行的并行编辑会在批后合并时制造无谓 docs/08 冲突(冲突即整链串行重跑,cosmetic
// 副作用不配这个代价),勾选由批后收口在模块分支统一补落。opts 接在 c 之后是继 deriveSpecPrompt
// ({batch}) 之后"c 为末位参数"约定的第二个特例(同因:带默认值的 opts 不动既有调用点),后续勿仿。
async function reviewWithFixLoop(id, phase, verifyResult, specPath, c, { flipDocs08 = true } = {}) {
  const grp = phase === 'backend' ? 'Backend' : 'Frontend'
  const fe = isFrontend(phase)
  let lastVerify = verifyResult
  let lastIssuesCount = 0
  let reviewGuidance = ''                                   // 仲裁 retry 时注入下一轮 review 的纠正指令
  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 agentR(
      reviewPrompt(id, phase, round, lastVerifySummary, specPath, c) + adjGuidance(reviewGuidance),
      {label:`review:${phase}:${id}:r${round}`, phase: grp, schema: REVIEW_SCHEMA, agentType:'erp-workflow:code-reviewer'}
    )
    reviewGuidance = ''                                     // 已消费

    if (r.verdict === 'approve') {
      // approve = 静态 review 通过即放行(前后端同构)。行为验收已挪到阶段末尾的 phase('Behavior')
      // 一次性阶段级行为门(runBehaviorGate)——不再是 per-FE approve 子门。req-done/<FE> 语义 =「静态过」;
      // 行为维度由阶段门统一验收,行为 green 是 milestone 的前置(reportPrompt 校验行为证据非 RED)。
      if (flipDocs08) await flipDocs08Checkbox(fe, id, phase, grp, c)
      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, c.root)
      // continue 视为「无 must-fix → 静态 approve」(行为维度由阶段末尾的行为门统一验收,不在此处)。
      if (verdict.action === 'continue') {
        if (flipDocs08) await flipDocs08Checkbox(fe, id, phase, grp, c)
        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, c) + g, {
      site:`fix:${phase}:${id}:r${round}`, grp, label:`fix:${phase}:${id}:r${round}`, allowContinue: true, dec: c.decisions, root: c.root,
    })

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

    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, c.root)
      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, c) {
  let attempt = 1
  let g = await agentR(gatePrompt(module, phase, attempt, c), {label:`gate:${phase}:${module.id}`, phase:'Gate', schema: GATE_SCHEMA})
  if (g.status === 'red') {                                  // 自动重试 1 次(防 flaky)
    attempt = 2
    g = await agentR(gatePrompt(module, phase, attempt, c), {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, c.root)
    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 agentR(gatePrompt(module, phase, attempt, c), {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
}

// ---- 前端阶段级行为验收控制流(runBehaviorGateOnce + runBehaviorGate)----
// 设计:docs/design/2026-06-05-frontend-behavior-stage-gate.md(v3)。
// 时机:顶层 frontend 段 featureLoop 全部 FE 完成(req-done 已打)之后、testGate 之前,phase('Behavior') 下
// 整个前端阶段只跑**一次**行为验收(含 fix 循环)。失败分层:
//  - envError / 空覆盖 = 环境 race:runBehaviorGateOnce 内部 attempt 1→2 重试;仍异常 → adjudicate(allowContinue:false)。
//    build-failed(阶段末尾无「兄弟未实现」豁免)属确定性失败:跳过自动 attempt 重起(重跑不自愈),直送仲裁。
//  - 软文字(i18n/literal/semantic) → adjudicate(continue 记 decisions + 跨 behaviorRound softPassed;sentinel 并入 behaviorHard);永不阻断 green。
//  - behaviorHard = interactionFailures + sentinel textIssues + styleIssues(颜色 token / layout sanity):
//    有 locator → 降维喂 fixPrompt 跑 fix(fix 后全量前端单测复验 + 下一 behaviorRound 重跑门);
//    无 locator → adjudicate(allowContinue:false) retry/halt,绝不静默丢弃、绝不放行。
//  - BEHAVIOR_STAGE_MAX 轮仍未 green → throw HALT behavior-unresolved(冒泡到顶层 try/catch → fail-fast)。

// envBlocked / ifails:环境/空覆盖与交互失败判定。v3:build-failed 计入 envBlocked(不再有短路放行分支)。
function behaviorEnvBlocked(r) {
  const k = r.envError && r.envError.kind
  const ev = (k && k !== 'none') ? 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 : [] }
const isBuildFailed = (r) => !!(r.envError && r.envError.kind === 'build-failed')

// runBehaviorGateOnce:跑一次阶段级行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。
// 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 runBehaviorGate 推进)。
// behaviorRound:阶段门内的行为 fix 轮;内部 attempt 1..BEHAVIOR_ATTEMPT_MAX(环境 race 重起)+ 仲裁兜底。
// runPrototypePreview:静态原型渲染门主驱动(被前端段开头调用,phase('Preview') 下)。
// 与 runBehaviorGate 正交——本门只渲染静态 prototype/。两类边界严格分流:
//  - **硬门(halt)**:原型**渲染失败**(rendered=false)或**原型脚本未捕获异常**(jsErrors=pageerror)——
//    经仲裁 allowContinue:false(只许 retry/halt)。原型属上游 plan 权威、coding 不改原型源码——故 halt =
//    「停下等人工回 /erp-workflow:plan-start 或 /add-req 修原型后重跑」,不是在 coding 里改原型。
//  - **advisory**:console.error(弱信号,缺图标/CDN 警告等噪声多)——只记 decisions 不阻断。
//  - **环境未就绪**(Playwright/浏览器缺失/超时):retry→仲裁 allowContinue:true(不让「机器没装浏览器」卡死 coding)。
// 终波恒宽 1、主根执行(c.lane==null);静态渲染无 DB、无需 ERP_TEST_DB_SCHEMA 注入。
async function runPrototypePreview(feItems, c) {
  const lbl = (a) => `preview:proto:a${a}`
  let attempt = 1
  let pv = await agentR(prototypePreviewPrompt(feItems, attempt, c),
    {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA})
  recordDecisions('preview:prototype', pv.decisions, c.decisions)
  // 环境未就绪 → 自动重起 attempt 1→PROTOTYPE_PREVIEW_ATTEMPT_MAX;仍 blocked → 进仲裁循环。
  while (pv.status === 'blocked' && attempt < PROTOTYPE_PREVIEW_ATTEMPT_MAX) {
    attempt += 1
    pv = await agentR(prototypePreviewPrompt(feItems, attempt, c),
      {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA})
    recordDecisions('preview:prototype', pv.decisions, c.decisions)
  }
  for (let adj = 1; pv.status === 'blocked' && adj <= ADJUDICATE_MAX; adj++) {
    const ev = pv.envError || { kind: '?', detail: '' }
    // allowContinue:true——渲染门只产可视化基线(非硬正确性边界);环境长期缺 Playwright 时仲裁可降级跳过,
    // 不让「机器没装浏览器」卡死整个 coding(与行为门的 allowContinue:false 严格区分)。
    const verdict = await adjudicate('preview-env:prototype',
      { problem: `原型渲染门环境未就绪 envError=${ev.kind}: ${ev.detail || ''}`, envError: pv.envError || null, allowContinue: true },
      'Preview', adj, c.root)
    if (verdict.action === 'continue') {
      log(`原型渲染门:环境未就绪经仲裁降级为口头建议继续(${ev.kind})——本次跳过原型可视化基线`)
      recordDecisions('preview:prototype',
        [{ question: '原型渲染门环境未就绪', choice: '降级继续(跳过可视化基线)', rationale: ev.detail || ev.kind, confidence: 'low' }], c.decisions)
      return pv
    }
    if (verdict.action !== 'retry') throw new Error(`HALT preview-env prototype: ${verdict.rationale || ev.kind}`)
    attempt += 1
    pv = await agentR(prototypePreviewPrompt(feItems, attempt, c),
      {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA})
    recordDecisions('preview:prototype', pv.decisions, c.decisions)
  }
  if (pv.status === 'blocked') throw new Error(`HALT preview-env prototype: ${ADJUDICATE_MAX} 轮仲裁后原型渲染门仍环境未就绪`)
  // 至此 env 就绪(status:ok)。硬门评估(含仲裁 retry 重跑——疑似一次性渲染抖动/超时被误判时重跑一次门):
  //  硬缺陷 = 渲染失败(rendered=false) 或 原型脚本未捕获异常(jsErrors);console.error 仅 advisory(弱信号)。
  for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) {
    if (pv.pagesFound === 0) {
      log('原型渲染门:项目无 prototype/**/*.html,跳过(无可视化基线可产)')
      return pv
    }
    const pages = Array.isArray(pv.pages) ? pv.pages : []
    const hardBroken = pages.filter(p => p && (!p.rendered || (p.jsErrors || []).length))
    const consoleOnly = pages.filter(p => p && p.rendered && !(p.jsErrors || []).length && (p.consoleErrors || []).length)
    // advisory:渲染成功但有 console.error 的页只记 decisions,不阻断。
    if (consoleOnly.length) {
      recordDecisions('preview:prototype', consoleOnly.map(p => ({
        question: `原型 ${p.file} 有 console.error`,
        choice: '记录不阻断(弱信号,回 plan/add-req 酌情修)',
        rationale: `consoleErrors=${(p.consoleErrors || []).length}`,
        confidence: 'low' })), c.decisions)
    }
    if (!hardBroken.length) {
      log(`原型渲染门:${pv.pagesRendered}/${pv.pagesFound} 页全部渲染通过(无渲染失败/JS 异常)——可视化基线已归档${consoleOnly.length ? `;${consoleOnly.length} 页有 console.error(advisory)` : ''}`)
      return pv
    }
    const digest = hardBroken.map(p => `${p.file}(rendered=${p.rendered}, jsErrors=${(p.jsErrors || []).length})`).join('; ')
    // allowContinue:false——原型渲染失败/脚本异常是硬缺陷,仲裁只许 retry/halt,绝不放行带病原型进前端实现。
    const verdict = await adjudicate('preview-broken:prototype',
      { problem: `静态原型渲染门硬缺陷(渲染失败 / 原型脚本未捕获异常):${digest}。原型属上游 plan 权威,coding 不改原型源码——应回 /erp-workflow:plan-start 或 /add-req 修原型后重跑。`,
        allowContinue: false }, 'Preview', adj, c.root)
    if (verdict.action !== 'retry') throw new Error(`HALT preview-broken prototype: ${verdict.rationale || digest}`)
    // retry:重跑渲染门一次(仲裁判定疑似一次性,如渲染超时被误标 jsError)。
    attempt += 1
    pv = await agentR(prototypePreviewPrompt(feItems, attempt, c),
      {label: lbl(attempt), phase: 'Preview', schema: PROTOTYPE_PREVIEW_SCHEMA})
    recordDecisions('preview:prototype', pv.decisions, c.decisions)
    if (pv.status === 'blocked') throw new Error(`HALT preview-env prototype: 硬门 retry 后又遇环境未就绪 envError=${(pv.envError || {}).kind || '?'}`)
  }
  throw new Error(`HALT preview-broken prototype: ${ADJUDICATE_MAX} 轮仲裁后原型仍有硬缺陷(渲染失败/JS 异常)`)
}

async function runBehaviorGateOnce(feItems, behaviorRound, c) {
  const lbl = (a) => `behavior:frontend-phase:r${behaviorRound}:a${a}`
  let attempt = 1
  let bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c),
    {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
  recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions)

  // 内部 envError / 空覆盖重试:attempt 1→BEHAVIOR_ATTEMPT_MAX(沿用 testGate 思路);仍异常 → adjudicate(allowContinue:false)。
  // build-failed 是确定性失败(重起不自愈)→ 跳过自动重起,直接进下方仲裁循环。
  while (behaviorEnvBlocked(bg).blocked && !isBuildFailed(bg) && attempt < BEHAVIOR_ATTEMPT_MAX) {
    attempt += 1
    bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c),
      {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
    recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions)
  }
  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 || ''}${envState.ev.rootCausePath ? `(rootCausePath=${envState.ev.rootCausePath})` : ''}`
      : `behavior 空覆盖:routesReached=${bg.routesReached} controlsEnumerated=${bg.controlsEnumerated}(绝不带空覆盖判 green)`
    // riders:环境失败同轮搭车的硬问题(如 build-failed 时已归因到的 interactionFailures/styleIssues)——
    // 本轮不进 fix(环境未就绪,fix 无意义),但透传给仲裁者辅助 retry/halt 判断;若环境修复后仍真实,下一轮门会重新发现。
    const verdict = await adjudicate('behavior-env:frontend-phase',
      { problem: reason, envError: bg.envError || null, ports:(bg.envError||{}).ports, pids:(bg.envError||{}).pids,
        riders: { interactionFailures: behaviorIfails(bg).length, styleIssues: (bg.styleIssues || []).length,
                  sentinelTextIssues: (Array.isArray(bg.textIssues) ? bg.textIssues : []).filter(t => t && t.source === 'sentinel').length },
        allowContinue:false }, 'Behavior', adj, c.root)
    if (verdict.action !== 'retry') throw new Error(`HALT behavior-env frontend-phase: ${verdict.rationale || reason}`)
    attempt += 1
    bg = await agentR(behaviorGatePrompt(feItems, behaviorRound, attempt, c),
      {label: lbl(attempt), phase: 'Behavior', schema: BEHAVIOR_GATE_SCHEMA})
    recordDecisions('behavior:frontend-phase', bg.decisions, c.decisions)
    envState = behaviorEnvBlocked(bg)
  }
  if (envState.blocked) throw new Error(`HALT behavior-env frontend-phase: ${ADJUDICATE_MAX} 轮仲裁后仍环境异常 / 空覆盖`)
  return bg
}

// runBehaviorGate:阶段级行为门主循环(被顶层 frontend 段调用,phase('Behavior') 下)。green 才正常返回(放行进 testGate)。
// softPassed:本函数内声明,跨 behaviorRound 持久(软文字一旦放行不再追问,避免反复消耗仲裁预算)。
// green ≡ behaviorHard.length===0 ∧ envError===none ∧ 无 B 类/scope-missing 未覆盖 ∧ 覆盖非空 ∧ 无未解释漏达路由。
async function runBehaviorGate(feItems, c) {
  const regionKey = (x) => `${x.page || '?'}::${x.region || '?'}`
  const softPassed = new Set()
  for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_STAGE_MAX; behaviorRound++) {
    const bg = await runBehaviorGateOnce(feItems, behaviorRound, c)

    // 1) coverageGaps:写证据 + recordDecisions(不单独 halt;空覆盖已在 runBehaviorGateOnce 兜底)。
    //    locator-not-resolvable / scope-missing 在 §3.5 单独阻断 green。
    for (const cg of (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : [])) {
      if (!cg) continue
      recordDecisions('behavior-coverage:frontend-phase',
        [{ question:`覆盖缺口 ${cg.page}(${cg.reason})`, choice:'记录不阻断', rationale: cg.detail || '', confidence:'low' }], c.decisions)
    }

    // 2) 软文字(i18n/literal/semantic)→ 仲裁 continue 记 decisions + softPassed;sentinel 客观 bug 不在此处放行(下面并入 behaviorHard)。
    //    永不阻断 green;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:${ti.page || '?'}:${ti.region || '?'}`
      const verdict = await adjudicate(site,
        { problem:`文字不符(source=${ti.source},可 continue 降级;永不阻断 green):${ti.page}:${ti.region} 期望=${JSON.stringify(ti.expected)} 实际=${JSON.stringify(ti.actual)}`,
          textIssue: ti, allowContinue: true }, 'Behavior', behaviorRound, c.root)
      if (verdict.action === 'continue') {
        recordDecisions(site, [{ question:`文字不符 ${ti.page}:${ti.region}(source=${ti.source})`,
          choice:'continue(仲裁判可安全前进)', rationale: verdict.rationale || '', confidence:'low' }], c.decisions)
        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:连组件文件都反查不出)+ scope-missing(某 FE 作用域小节缺失,
    //      其路由整体漏验):不静默放行——计入未覆盖阻断 green,走 adjudicate(allowContinue:false) retry/halt。
    const bClass = (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : [])
      .filter(cg => cg && (cg.reason === 'locator-not-resolvable' || cg.reason === 'scope-missing'))
    if (bClass.length) {
      const summary = bClass.map(cg => `[${cg.reason}] ${cg.page} — ${cg.detail}`).join('; ')
      const verdict = await adjudicate('behavior-bclass:frontend-phase',
        { problem:`behavior 不可降级的未覆盖(B 类反查不出 / FE 作用域缺失,阻断 green):${summary}`,
          coverageGaps: bClass, allowContinue:false }, 'Behavior', behaviorRound, c.root)
      if (verdict.action !== 'retry') throw new Error(`HALT behavior-bclass frontend-phase: ${verdict.rationale || summary}`)
      continue                                                  // retry → 重跑行为验收(下一 behaviorRound)
    }

    // 3.6) 覆盖率对账(确定性兜底):空覆盖只兜 ==0;这里兜 0<routesReached<routesPlanned 的「部分覆盖假绿」。
    //      每条 planned-but-unreached 路由必须由「路由级 coverageGap」解释;未被解释的漏达路由 = 静默漏验,绝不判 green。
    //      只数路由级 reason(控件级 deep-control-not-driven / locator-not-resolvable / scope-missing 不抵漏达路由——
    //      scope-missing 的 FE 路由本就不在分母);过计只会抑制本门、绝不误 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'])
    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:frontend-phase',
        { problem:`路由覆盖不足:routesPlanned=${planned} routesReached=${reached},仅 ${routeGapCount} 条不同路由有路由级 coverageGap 解释,尚有 ${unaccounted} 条漏达路由无证据(绝不带静默漏达判 green)`,
          coverageGaps: bg.coverageGaps || [], allowContinue: false }, 'Behavior', behaviorRound, c.root)
      if (verdict.action !== 'retry') throw new Error(`HALT behavior-undercoverage frontend-phase: ${verdict.rationale || `${unaccounted} 条漏达路由无证据`}`)
      continue                                                  // retry → 下一 behaviorRound 重跑整门
    }

    // 4) behaviorHard = interactionFailures(含 binding-garbage)+ source=='sentinel' textIssues
    //    + styleIssues(颜色 token / layout sanity,全部客观可 fix)。
    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 styleHard = (Array.isArray(bg.styleIssues) ? bg.styleIssues : [])
      .filter(Boolean)
      .map(s => ({ page:s.page, control:s.element, kind:`style-${s.kind}`,
                   detail:`期望=${s.expected} 实际=${s.actual}`, locator:s.locator }))
    const behaviorHard = [...behaviorIfails(bg), ...sentinelHard, ...styleHard]

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

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

    // 6) 分流:无 locator 的硬问题 → adjudicate(allowContinue:false) retry/halt(绝不静默丢弃、绝不放行)。
    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:frontend-phase',
        { problem:`behavior 硬问题无源码 locator(无法转 must-fix 喂 fix,绝不 continue 放行):${summary}`,
          interactionFailures: noLoc, allowContinue:false }, 'Behavior', behaviorRound, c.root)
      if (verdict.action !== 'retry')
        throw new Error(`HALT behavior-noloc-hard frontend-phase: ${verdict.rationale || summary}`)
      continue                                                  // retry → 重跑行为验收(下一 behaviorRound)
    }

    // 7) 有 locator 的硬问题 → 降维成 {summary,locator,severity} 喂现有 fixPrompt 跑 fix(schema 不合并、fix 入参合并)。
    //    一轮 fix 批量修当轮全部 must-fix(跨 FE 也在同一 fix 子会话内逐项修,locator 已含组件文件路径)。
    const fixIssues = withLoc.map(f => ({
      summary: `[behavior:${f.kind}] ${f.page}:${f.control} — ${f.detail}`,
      locator: f.locator,
      severity: 'high',
    }))
    await runStage(g => fixPrompt('frontend-phase', 'frontend', fixIssues, c) + g, {
      site:`behavior-fix:frontend-phase:r${behaviorRound}`, grp:'Behavior', label:`behavior-fix:r${behaviorRound}`, allowContinue: true, dec: c.decisions, root: c.root,
    })

    // 8) fix 后功能复验(allowContinue:false):行为 fix 改的是 frontend/ UI 源码,可能引入功能回归——
    //    先跑全量前端单测(不起全栈、不跑 e2e,成本低),红则当功能回归硬边界;绿后下一 behaviorRound 重跑行为验收。
    //    (e2e 维度由下一轮行为门 + 后续阶段 testGate 全量回归兜底。)
    await runStage(
      g => behaviorReverifyPrompt(behaviorRound, fixIssues.length, c) + g,
      { site:`behavior-reverify:frontend-phase:r${behaviorRound}`, grp:'Behavior', label:`behavior-reverify:r${behaviorRound}`, allowContinue: false, dec: c.decisions, root: c.root },
    )
    // 进入下一 behaviorRound → 重跑行为验收
  }
  throw new Error(`HALT behavior-unresolved frontend-phase: ${BEHAVIOR_STAGE_MAX} 轮阶段级行为门仍未 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 haltError(`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 agentR(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 haltError(`HALT router-violation: ${verdict.rationale || violation}`)
  routed = await agentR(routerPrompt(ROOT) + adjGuidance(verdict.guidance || ''), {label:`router:r${adj + 1}`, phase:'Router', schema: ROUTER_SCHEMA})
}
const finalViolation = routerViolation(routed.modules)
if (finalViolation) throw haltError(`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`)

// ============================================================================
// 调度器(Phase B):LLM 出依赖边 + JS 拓扑波次。Phase D 的波次主循环消费——useParallel 开启时
// 起跑前调一次 runScheduler 标注 deps,逐波 nextWave 取 ready 集;关闭时直接 serialChainDeps。
// 降级原则(fail-open to serial):任何调度环节失败(scheduler agent null / schema 违例 /
// 仲裁耗尽 / 图卡死)一律 log 后降级 docs/02 全序(todo 顺序链式依赖),**绝不 halt**——
// 错误并行损失正确性,错误串行只损失时间,调度永远不该成为新的停机点。
// ============================================================================

// 波次宽度上限(确定性,仅由 args 导出)。Task 9 主循环直接复用本 const,勿重复定义;
// 是否启用并行由 Task 9 的 useParallel 闸决定,这里只约束"同波次最多取几个"。
const maxWidth = Math.max(1, ARGS?.parallel?.maxWidth ?? 3)

// 降级形态:docs/02 全序 = todo 顺序链式依赖(每项依赖紧邻前项)。
// nextWave 在此图上每轮恰出 1 个,与现行串行主循环逐模块推进完全一致。
function serialChainDeps(todo) {
  return todo.map((m, i) => ({ ...m, deps: i > 0 ? [todo[i - 1].id] : [] }))
}

// 仿 routerViolation 形态:返回 null=通过 / 违例描述串(喂 adjudicate guidance 重跑 scheduler)。
// 检查:非 todo 后端 id / 重复项 / 缺项(完整性)/ 依赖指向未知模块 / 自依赖 / 环(Kahn 消解)。
// 与 router 不同:违例最终不 halt,由 runScheduler 降级全序兜底(调度失败只损失并行收益)。
function scheduleViolation(deps, todoBackendIds, allBackendIds) {
  const todoSet = new Set(todoBackendIds)
  const knownSet = new Set(allBackendIds)
  const seen = new Set()
  for (const d of deps) {
    if (!todoSet.has(d.id)) return `deps 含非 todo 后端模块 id:${d.id}(只允许对待跑后端模块输出;frontend-phase 由 JS 硬规则处理)`
    if (seen.has(d.id)) return `deps 含重复模块项:${d.id}(重复项会静默丢边,必须合并为一项)`
    seen.add(d.id)
    for (const e of d.dependsOn) {
      if (e.id === d.id) return `模块 ${d.id} 自依赖`
      if (!knownSet.has(e.id)) return `模块 ${d.id} 的依赖指向未知模块 id:${e.id}`
    }
  }
  // 完整性:deps 必须**恰好**逐一覆盖全部 todo 后端模块。LLM 漏报的模块若静默放过,
  // annotateDeps 会把它当"无依赖"放进首波最大并行——与保守偏置(宁串勿并)正相反,缺项必须打回。
  const missing = todoBackendIds.filter(id => !seen.has(id))
  if (missing.length) return `deps 缺待跑后端模块项:${missing.join('、')}(每个待跑模块必须恰好一项,无依赖也要输出 dependsOn: [])`
  // 环检测(Kahn 消解):只看 todo 内部边——指向已完成模块的边恒满足,不参与成环。
  const blocking = new Map(deps.map(d => [d.id, d.dependsOn.map(e => e.id).filter(x => todoSet.has(x))]))
  let rest = [...todoBackendIds]
  const settled = new Set()
  while (rest.length) {
    const ready = rest.filter(id => (blocking.get(id) || []).every(x => settled.has(x)))
    if (!ready.length) return `依赖图成环(Kahn 无法消解):${rest.join('、')}`
    for (const id of ready) settled.add(id)
    rest = rest.filter(id => !settled.has(id))
  }
  return null
}

// 成功路径标注:把校验通过的依赖边落到 todo 模块项上(deps = 依赖模块 id 数组,nextWave 消费;
// deps 可含已完成模块 id——Task 9 的 doneSet 初值含 routed 里 done:true 的模块,恒满足)。
// frontend-phase 不经 LLM:JS 硬规则强制依赖**全部**后端模块(前端聚合消费所有后端契约/种子)。
function annotateDeps(todo, deps, allBackendIds) {
  const byId = new Map(deps.map(d => [d.id, d.dependsOn.map(e => e.id)]))
  return todo.map(m => m.id === 'frontend-phase'
    ? { ...m, deps: [...allBackendIds] }
    // `|| []` 纯防御:scheduleViolation 的完整性校验保证每个 todo 后端模块在 deps 恰有一项,
    // 校验通过后此 fallback 理论不可达——仅 0/1 模块免调度的空边集路径会走到(语义恰为"无依赖")。
    : { ...m, deps: byId.get(m.id) || [] })
}

// nextWave:Kahn 拓扑取下一波——remaining 中 deps ⊆ doneSet 的模块,按 docs/02 原序(remaining 保序)
// 取前 maxWidth 个。纯函数、不依赖 agent;coding.mjs 无法 node --test,以下内联 case 自证正确性:
// ① 菱形(b、c 依赖 a;d 依赖 b+c):done=∅ → [a];done={a} → [b,c];done={a,b,c} → [d]。
// ② 链式(serialChainDeps 降级形态即此,b 依赖 a、c 依赖 b):每轮恰出 1 个,与现行串行全序一致。
// ③ 全独立 a,b,c,d(maxWidth=3):第一轮 [a,b,c](原序取前 3),第二轮 [d]。
// ④ 环 a⇄b(理论上 scheduleViolation 已排除):ready 恒空 → log 后按原序硬出 remaining[0];
//    a 跑完进 doneSet 后 b 的边随之消解——行为收敛于 docs/02 全序,绝不 halt、绝不空转。
function nextWave(remaining, doneSet) {
  const ready = remaining.filter(m => (m.deps || []).every(d => doneSet.has(d)))
  if (!ready.length && remaining.length) {
    log(`schedule: 图卡死(ready 空而 remaining=${remaining.map(m => m.id).join('、')}),降级 docs/02 全序逐个推进`)
    return remaining.slice(0, 1)
  }
  return ready.slice(0, maxWidth)
}

// runScheduler:调度执行点(波次主循环起跑前、useParallel 开启时调用一次)。返回 todo 模块数组
// (保 docs/02 原序)每项追加 deps: string[],供 nextWave 消费。永不 throw:
// 一切失败路径走 serialChainDeps 降级(见上方设计注释),调度只能让流程变快、不能让它停下。
async function runScheduler(todo, routedModules) {
  const allBackendIds = routedModules.filter(m => m.id !== 'frontend-phase').map(m => m.id)
  const todoBackendIds = todo.filter(m => m.id !== 'frontend-phase').map(m => m.id)
  // 0/1 个 todo 后端模块:模块间不存在任何可判边(dependsOn 只能指向 done 模块,恒满足)——
  // 免去一次取证子代理,免校验直接按"无依赖"标注(不经 scheduleViolation:其完整性校验会把
  // 空边集判成缺项,但此处"无可判边"本身就是结论,无需取证也无需打回)。
  // phase('Schedule') 在早退**之后**才切:resume 全 done / 仅剩单模块的项目什么调度都没发生,
  // 不该让 UI 终态从 Router 漂移到 Schedule。
  if (todoBackendIds.length <= 1) return annotateDeps(todo, [], allBackendIds)
  phase('Schedule')
  const degrade = (why) => {
    log(`schedule: ${why} → 降级 docs/02 全序(todo 顺序链式依赖),不 halt`)
    return serialChainDeps(todo)
  }
  // 整体 try/catch:fail-open 不能只依赖 agent() 永不 reject 的契约——adjudicate / 校验等任何
  // runtime 抛错同样必须降级全序,调度绝不把整个 Workflow 顶层带崩(与"永不 throw"承诺名副其实)。
  try {
    let sched = await agent(schedulerPrompt(todoBackendIds, allBackendIds, ROOT),
      { label: 'scheduler', phase: 'Schedule', schema: SCHEDULE_SCHEMA })
    if (!sched) return degrade(`scheduler agent 无返回(${NULL_AGENT_PROBLEM})`)
    for (let adj = 1; adj <= ADJUDICATE_MAX; adj++) {
      const violation = scheduleViolation(sched.deps, todoBackendIds, allBackendIds)
      if (!violation) break
      const verdict = await adjudicate('schedule-violation', { problem: violation }, 'Schedule', adj)
      if (verdict.action !== 'retry') return degrade(`调度违例且仲裁未予 retry(${verdict.rationale || violation})`)
      sched = await agent(schedulerPrompt(todoBackendIds, allBackendIds, ROOT) + adjGuidance(verdict.guidance || ''),
        { label: `scheduler:r${adj + 1}`, phase: 'Schedule', schema: SCHEDULE_SCHEMA })
      if (!sched) return degrade(`scheduler agent 重跑无返回(${NULL_AGENT_PROBLEM})`)
    }
    const finalViolation = scheduleViolation(sched.deps, todoBackendIds, allBackendIds)
    if (finalViolation) return degrade(`${ADJUDICATE_MAX} 轮仲裁后仍违例:${finalViolation}`)
    // 审计可见性:依赖边落 log,人工可复盘"为什么这两个模块没并行"(evidence 详情在子代理 transcript)。
    for (const d of sched.deps) {
      if (d.dependsOn.length) log(`schedule: ${d.id} ← ${d.dependsOn.map(e => `${e.id}[${e.kind}]`).join(' ')}`)
    }
    return annotateDeps(todo, sched.deps, allBackendIds)
  } catch (e) {
    return degrade(`调度 runtime 异常(${String(e?.message || e)})`)
  }
}

// P1#3:首跑建需求台账基线(幂等、best-effort),使日后 /add-req 能正确识别增量、免空跑。
await ensureLedgerBaseline()

// Preflight:一次性环境探测。新机器上工具链缺失若拖到 test-gate / 行为门才爆,
// 要先白跑几十分钟编码;这里在起跑线带诊断 halt。
if (todo.length) {
  phase('Router')
  const pre = await agentR(preflightPromptM(), { label: 'preflight', phase: 'Router', schema: ACTION_RESULT_SCHEMA })
  if (!pre.success) throw haltError(`HALT preflight-env: ${pre.error || ''}${pre.detail ? '\n' + pre.detail : ''}`)
  log('preflight: 环境探测通过')
}

// ── 全局互斥锁(Task 9 Step 1;附录补 9 / 补 13)────────────────────────────
// promise 链实现:无原生锁可用,且运行时禁时间/随机源——链式 then 即"排队",确定性、零轮询。
// fn 抛错不破坏链:尾指针始终指向 catch(()=>{}) 后的节点,下一个获取者照常排队;调用方拿到的
// run 仍保留原始 rejection(halt 语义不被锁吞掉)。两把锁同构,统一由本工厂生成。
//
// withMainRootLock:milestone(merge / docs/08 字段 / tag)与 RESUME 追加都作用于主根 + 默认分支,
//   并行下必须全局串行化(merge 冲突保持硬 halt 的设计原则不变)。runCrossModule 的 diff 基准读
//   默认分支为**只读**,不需要锁。
// withStackLock(补 9):冷起栈资源(config-vars 固定端口 + 进程树)全局唯一——相关 stage 串行化
//   而非 lane 化:Seed 与 testGate(test.mjs 第 5 步 e2e 的 Playwright globalSetup 同按固定端口
//   冷起后端,e2e 段不可单拆则整个 testGate 包锁)整段互斥。"按既知端口回收残留 pid"只允许在持锁
//   的 stage 内做,否则后到者会把兄弟 lane 正在验证的后端当残留 kill 掉(≥2 宽波次必然踩中的确定
//   性互杀)。Seed/testGate 占模块总时长比例小,串行损失远低于打穿三层端口配置;并行收益主体
//   (tdd/review/fix,不起栈、Spring 测试随机端口)完整保留、不进锁。
//
// **非重入纪律(补 13,两把锁同适用)**:只允许 runModule 顶层 / loop-end 逐段获取
//   (`await withMainRootLock(() => runMilestone(m))`、`await withMainRootLock(() => recordResume(...))`
//   这种形态);被包函数及其调用链内**绝不**再调同一把锁,两把锁之间也**绝不**嵌套获取——
//   promise 链锁嵌套获取即互等死锁,且静默无诊断(Workflow 直接挂死)。
function makeChainLock() {
  let tail = Promise.resolve()
  return (fn) => {
    const run = tail.then(fn)
    tail = run.catch(() => {})
    return run
  }
}
const withMainRootLock = makeChainLock()
const withStackLock = makeChainLock()

// ---- runModule:单模块全链(branchSetup → 后端段 → 前端段 → report → milestone → RESUME flush)----
// **永不 throw**:内部 catch 全部 HALT 并结构化返回 { module, status, reason?, decisions }——
// 这是它能放进 parallel() 的前提(parallel() 把 thunk 异常静默折成 null、永不 reject,throw 会丢失 halt 原因)。
// c:模块执行上下文(makeCtx();串行 = 主根,lane 模式由 Phase C/D 填充)。
// phase(...) 全局 UI 分组只在主根模式调用(c.lane == null):lane 并行下兄弟模块会竞态改全局 phase 状态,
// 而 agent 级 opts.phase(grp)已全覆盖分组——注意不能写 `!c.lane`(lane 序号 0 是合法 lane,falsy 会误判主根)。
const resumeFlushed = new Set()   // per-module RESUME flush 成功记账(替代 flushedCount 水位线;loop-end 据此补记失败模块)

// ---- runFrontendPhase1:前端 jsdom-only 重叠的 phase1(设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)----
// 与后端波次循环**并发**跑,**只做不依赖后端**的事:branchSetup(建前端 worktree)→ skeleton → featureLoop(jsdom)。
// 跑在**独立前端 worktree**(c.lane 非 null、root=前端 lane 路径、dbSchema=''——jsdom 无 DB,featureStageContract
// 的 lane-DB 注入按 c.dbSchema 非空判定,此处不触发),与后端主根/lane 的 git index 互不干扰;**不**调全局
// phase()(并发下会与后端波次竞态改全局 UI 状态——agent 级 grp='Frontend' 已覆盖分组),**不** merge/milestone/
// RESUME(全部留 phase2)。**永不 throw**:catch 结构化返回,phase1 halt 不停后端(reason 带前端 worktree 取证前缀)。
// 幂等(resume):skeleton 凭 fe-skeleton-done、各 FE 凭 fe-code-done 去重,已完成项整段 skip。
async function runFrontendPhase1(feModule, c) {
  try {
    await runBranchSetup(feModule, c)
    await runFrontendSkeleton(feModule.feItems, c)
    await featureLoop(feModule.feItems, 'frontend', c, 'jsdom')
    log(`fe-overlap phase1 完成:${feModule.feItems.length} 个 FE 的组件代码 + jsdom 已绿(e2e/review 留 phase2)`)
    return { module: 'frontend-phase', status: 'done', decisions: c.decisions }
  } catch (e) {
    const reason = `[fe-overlap phase1 保留取证: ${c.root}] ${String(e?.message || e)}`
    log(`⛔ HALT — fe-overlap phase1:${reason}`)
    return { module: 'frontend-phase', status: 'halted', reason, decisions: c.decisions }
  }
}

async function runModule(module, c) {
  try {
    if (c.lane == null) phase('Milestone')
    await runBranchSetup(module, c)
    if (module.reqs.length) {                          // 后端段(frontend-phase 模块 reqs 为空 → 跳过)
      if (c.lane == null) phase('Backend')
      await featureLoop(module.reqs, 'backend', c)
      if (c.lane == null) phase('Gate')
      // 起栈互斥(补 9):testGate 整段包锁——其 e2e 段 Playwright globalSetup 按固定端口冷起后端,
      // 不可单拆。串行模式锁恒空闲,行为不变(统一纪律,免按模式分叉)。
      await withStackLock(() => testGate(module, 'backend', c))
      // 演示种子生成 stage(Seed):在 testGate 后跑——此时本模块 schema(含 tdd 新增的 V<n> migration)
      // 已终态且全绿,按它生成的种子才不会撞结构。allowContinue:false——e2e 基线(globalSetup 注入)与行为门
      // step2 子项③(演示种子注入)都依赖该种子,坏种子放行会让整个前端阶段(行为验收/e2e)在脏数据上全线误判。
      // 起栈互斥(补 9):Seed 冷起 gradle bootRun 占固定端口且会按既知端口回收"残留"pid——整段取锁。
      if (c.lane == null) phase('Seed')
      await withStackLock(() => runStage(g => seedGenPrompt(module, c) + g,
        { site:`seed:${module.id}`, grp:'Seed', label:`seed:${module.id}`, allowContinue: false, dec: c.decisions, root: c.root }))
      if (c.lane == null) phase('Milestone')
      await runCrossModule(module, c)                   // 替代被删 hook,JS 编排:diff → 分类 → 写日志
    }
    if (module.feItems.length) {                        // 前端段(仅末尾 frontend-phase 聚合模块)
      // 静态原型渲染门(Preview):前端段开头先渲染 prototype/**/*.html 本身——Playwright headless
      // 截图归档为可视化基线 + 跑原型自带交互点击冒烟。与行为门(测实现)正交;advisory(原型属上游
      // plan 权威,coding 不改原型源码,渲染/JS 问题只记证据不阻断)。静态渲染、不起全栈。终波主根执行。
      if (c.lane == null) phase('Preview')
      await runPrototypePreview(module.feItems, c)
      if (c.lane == null) phase('Frontend')
      // 前端骨架占位 stage(设计 § 2,前置依赖 A):featureLoop 之前一次性建 App 外壳 + router 全量 lazy
      // 路由表(FeStub 占位)+ 无悬空导航——保证逐 FE 实现中途任意时刻 app 可构建可起、每 FE 路由可达,
      // 使逐 FE verify(e2e) 与阶段末尾行为门的可构建前提成立、tddPrompt 的 FeStub→真组件占位替换有真值起点。幂等(fe-skeleton-done tag)。
      await runFrontendSkeleton(module.feItems, c)
      // featureLoop 的 review 循环只做静态验收(reviewer approve 即打 req-done)——行为验收不在内循环。
      // module.feStage(前端 jsdom-only 重叠):缺省 'all'=现行完整链;overlap phase2 传 'e2e'(phase1 已在
      // 前端 worktree 跑完 jsdom,本处只补 e2e 任务 + review + req-done)。skeleton 上方已幂等(fe-skeleton-done)。
      await featureLoop(module.feItems, 'frontend', c, module.feStage || 'all')
      // 阶段级行为门(v3):整个前端阶段只跑一次行为验收——起全栈 + 演示/sentinel 种子,按全部 FE spec 聚合
      // 作用域并集验「按钮真生效/文字对」;硬问题转 must-fix→fix→单测复验→重跑门(≤BEHAVIOR_STAGE_MAX 轮)。
      // 放在 testGate 之前:行为 fix 改动 frontend/ 源码,绿后由 testGate 全量回归兜底,不让回归证据过期。
      if (c.lane == null) phase('Behavior')
      // 行为门虽也冷起全栈,但只在 frontend-phase 终波(宽 1、主根)跑——无兄弟 lane 可互杀,
      // 不进起栈互斥(补 9 范围仅 Seed / testGate)。
      await runBehaviorGate(module.feItems, c)
      if (c.lane == null) phase('Gate')
      // 阶段级 testGate(全量回归 vitest+playwright),与行为门职责正交;起栈互斥(补 9)同后端段整段包锁。
      await withStackLock(() => testGate(module, 'frontend', c))
    }
    if (c.lane == null) phase('Milestone')
    // report allowContinue:false:reportPrompt 的前置硬验证含"最后一次 test-gate 必须 green,红则 halt"——
    // 绝不 continue 放行去打 milestone(否则可能在红色测试上 milestone)。
    await runStage(g => reportPrompt(module, c) + g, {
      site:`report:${module.id}`, grp:'Milestone', label:`report:${module.id}`, allowContinue: false, dec: c.decisions, root: c.root,
    })
    // lane 收口(附录补 15):lane 模式 milestone 前先在 lane 树上 wt-clean + 自主恢复——lane 里 tdd/fix
    // 的未提交残留参与了 testGate(test.mjs 跑在工作树上)却不进 merge,默认分支会拿到"没测过的子集"。
    // in-scope 残留 commit 进模块分支;越界 → halt 留人工。串行/主根模式不加这一步:runMilestone 自身的
    // step 1 wt-clean 已覆盖同一棵树(主根),重复检查徒增一次子代理调用。
    if (c.lane != null) {
      const branch = module.id === 'frontend-phase' ? 'frontend-phase' : `module-${module.id}`
      const wt = await agentR(worktreeCleanPromptM(c), {label:`lane-wt:${module.id}`, phase:'Milestone', schema: WT_SCHEMA})
      if (!wt.clean) {
        const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `lane 收口(模块 ${module.id},milestone 前;lane 树 ${c.root})。`, c),
          {label:`lane-wt-recover:${module.id}`, phase:'Milestone', schema: ACTION_RESULT_SCHEMA})
        if (!rec.success) throw new Error(`HALT lane-dirty-worktree ${module.id}: ${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`)
        log(`lane 收口: ${module.id} 自动提交 lane 残留(${rec.detail || ''})`)
      }
    }
    // 主根串行段互斥(Task 9 Step 1):milestone(merge/docs08/tag)与紧随的 RESUME 追加都作用于
    // 主根 + 默认分支——并行下逐段取锁串行化(补 13 非重入纪律:被包调用链内绝不再取锁,两段分别获取)。
    await withMainRootLock(() => runMilestone(module))
    // P1#1 增量 flush:每个模块里程碑落定即追加 RESUME.md,使**硬中断**(进程被杀,到不了主循环末尾)
    // 也能复盘已完成到哪、各模块做过哪些自主假设——不必等 loop-end 才写。best-effort,绝不阻断。
    // 决策改读 c.decisions(per-module 收集器,替代 decStart 水位线——并行交错下水位线语义失效)。
    {
      const ok = await withMainRootLock(() => recordResume([
        '## ✅ 模块完成 `' + module.id + '` → milestone/' + module.id + '(<ts>)',
        '',
        '- **本模块自主默认决策**(缺值时自动取的解读,可能含错误假设):',
        decisionDigestMd(c.decisions, '(本模块无自主默认记录)'),
      ].join('\n')))
      if (ok) resumeFlushed.add(module.id)   // 仅成功才记账:flush 失败时 loop-end 兜底补记该模块全部决策
    }
    // lane 清理(Task 6 Step 4):milestone 成功后即移除 lane worktree——bestEffort,删失败只 log
    // 绝不阻断(分支 / 对象库 / tag 全仓共享,留树不损正确性;残树由下个波次的前置占用感知收口,补 3)。
    if (c.lane != null) {
      await bestEffortAction(removeLaneWorktreePromptM(c.root),
        { label:`lane-remove:${module.id}`, phase:'Milestone', okMsg:`lane 清理: 已移除 ${c.root}`, failTag:`lane 清理(${c.root})` })
    }
    return { module: module.id, status:'done', decisions: c.decisions }
  } catch (e) {
    // halt:lane 整树**保留**供取证(绝不在此清理;resume 时由波次前置的占用感知统一恢复/移除,附录
    // 补 3)。reason 前缀 lane 路径——RESUME halt 条目与终端日志据此直接定位现场(Task 6 Step 4)。
    const reason = (c.lane != null ? `[lane 保留取证: ${c.root}] ` : '') + String(e.message || e)
    log(`⛔ HALT — 模块 ${module.id}:${reason}`)
    return { module: module.id, status:'halted', reason, decisions: c.decisions }
  }
}

// ── 波次前置·占用感知(附录补 3a/3b)─────────────────────────────────────────
// 残留 lane(上次 halt 保留取证 / removeLaneWorktree bestEffort 删失败)持有 module-* 分支时,
// 主根 checkout 与新 worktree add 双向 fatal(git 实测)——本波要跑的模块若其分支被占用,必须先
// 收口:树脏 → recoverDirtyWorktree(in-scope 残留 commit 进模块分支,工作不丢)→ 移除;恢复/移除
// 失败(越界残留等)→ 该模块按 branchSetup-dirty-worktree 同类语义**结构化 halt**(等价前移,非
// 新增 halt 类——串行路径走到 runBranchSetup 也会在同一种脏树上 halt),波次其余模块照常调度。
// 每个波次(**含宽 1**)开跑前都要跑:宽 1 回退路径正是被残留 lane 卡死的高发场景。
// 枚举自身失败 → fail-open 跳过感知(看不见就当没有,由 branchSetup 现有 fatal→仲裁→halt 兜底)。
async function laneOccupancySweep(wave) {
  const halted = []
  let lanes
  try {
    // 枚举直调 agent() 判空(不走 agentR):agentR 的 null 路径会先打 ⛔ HALT 再被下面 catch 吞掉
    // 继续跑,稀释「⛔=真 halt」约定——这里 null 与抛错同义,都安静 log 后 fail-open 跳过本波感知。
    const r = await agent(listLaneWorktreesPromptM(), { label: 'wave:lanes?', phase: 'Milestone', schema: LANE_LIST_SCHEMA })
    if (!r) {
      log(`wave: 残留 lane 枚举无返回(${NULL_AGENT_PROBLEM}),本波跳过占用感知(由 branchSetup 现有护栏兜底)`)
      return { wave, halted }
    }
    lanes = r.lanes || []
  } catch (e) {
    log(`wave: 残留 lane 枚举失败(${String(e?.message || e)}),本波跳过占用感知(由 branchSetup 现有护栏兜底)`)
    return { wave, halted }
  }
  if (!lanes.length) return { wave, halted }
  const byBranch = new Map(lanes.filter(l => l && l.branch).map(l => [l.branch, l.path]))
  const survivors = []
  for (const m of wave) {
    const branch = m.id === 'frontend-phase' ? 'frontend-phase' : `module-${m.id}`
    const lanePath = byBranch.get(branch)
    if (!lanePath) { survivors.push(m); continue }
    try {
      const lc = makeCtx({ root: lanePath })   // 只为复用穿线 prompt 的取根;不是 lane 执行上下文
      const wt = await agentR(worktreeCleanPromptM(lc), { label: `wave:lane-wt:${m.id}`, phase: 'Milestone', schema: WT_SCHEMA })
      if (!wt.clean) {
        const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, branch, `残留 lane 占用感知(波次前置;lane 树 ${lanePath} 持有分支 ${branch})。`, lc),
          { label: `wave:lane-recover:${m.id}`, 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(`wave: 残留 lane ${lanePath} in-scope 残留已 commit 进 ${branch}(${rec.detail || ''})`)
      }
      const rm = await agentR(removeLaneWorktreePromptM(lanePath), { label: `wave:lane-remove:${m.id}`, phase: 'Milestone', schema: ACTION_RESULT_SCHEMA })
      if (!rm.success) throw new Error(`HALT branchSetup-dirty-worktree ${branch}: 残留 lane 移除失败——${rm.error || ''}`)
      log(`wave: 残留 lane ${lanePath} 已收口移除(释放分支 ${branch})`)
      survivors.push(m)
    } catch (e) {
      const reason = `[lane 保留取证: ${lanePath}] ${String(e?.message || e)}`
      log(`⛔ HALT — 模块 ${m.id}:${reason}`)
      halted.push({ module: m.id, status: 'halted', reason, decisions: [] })
    }
  }
  return { wave: survivors, halted }
}

// ── 波次前置·并行准备(Task 9 Step 3;计划 D2 + 附录补 3c / 补 4 / 补 6b / 补 7)──
// 仅 ≥2 宽波次调用(主根,串行)。成功返回 { defBranch, maxV, baseSchema };任何一步失败 → 先清
// 掉本次已建的 lane(其持有的分支会毒化降级后的宽 1 legacy checkout)再返回 null,调用方把本波次
// 降级 maxWidth=1(fail-open to serial,**绝不 halt**——隔离失败只损失并行收益,不该成为新停机点)。
// laneEnvCache(声明在 featureBatchRun 旁,与其 lane 前置共享,语义见声明处注释):首个探测点
// 填充后跨波次/跨模块复用。maxV 绝不入缓存——它随波次合并增长,必须每波重读。
async function prepareParallelWave(wave) {
  const created = []
  const degrade = async (why) => {
    log(`wave: ${why} → 本波次降级 maxWidth=1(串行主根路径),不 halt`)
    for (const path of created) {
      await bestEffortAction(removeLaneWorktreePromptM(path),
        { label: 'wave:lane-rollback', phase: 'Milestone', okMsg: `wave: 降级回收 lane ${path}`, failTag: `wave: 降级回收 lane(${path})` })
    }
    return null
  }
  try {
    const def = await agentR(detectDefaultBranchPromptM(), { label: 'wave:default', phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA })
    const mc = makeCtx()
    // (1) 主根复位(D2 + 补 3c):module-* 分支必须未被主根持有(worktree add 才不 fatal),且兄弟
    // milestone 的 merge 要落在默认分支主根上。脏树恢复的目标分支 = **当前 HEAD 分支**(传默认分支
    // 会被 recoverDirtyWorktree 的分支护栏按 dirty-on-wrong-branch 拒绝,造成本可避免的降级);
    // HEAD 已在默认分支且脏 → 不在此处理(绝不为并行机制新增"对默认分支自动 commit"),降级交给
    // legacy 路径现有恢复/halt 语义。
    const head = await agentR(currentBranchPromptM(mc), { label: 'wave:head', phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA })
    const wt = await agentR(worktreeCleanPromptM(mc), { label: 'wave:wt', phase: 'Milestone', schema: WT_SCHEMA })
    if (!wt.clean) {
      if (head.branch === def.branch) return degrade(`主根在默认分支 ${def.branch} 上有未提交改动(绝不自动 commit 默认分支)`)
      const rec = await agentR(recoverDirtyWorktreePromptM(wt.dirty, head.branch, `并行波次前置主根复位(HEAD=${head.branch},就地收口后回默认分支)。`, mc),
        { label: 'wave:wt-recover', phase: 'Milestone', schema: ACTION_RESULT_SCHEMA })
      if (!rec.success) return degrade(`主根脏树恢复失败(${rec.error || ''})`)
      log(`wave: 主根 in-scope 残留已 commit 进 ${head.branch}(${rec.detail || ''})`)
    }
    if (head.branch !== def.branch) {
      const co = await agentR(checkoutExistingBranchPromptM(def.branch, mc), { label: 'wave:checkout-default', phase: 'Milestone', schema: ACTION_RESULT_SCHEMA })
      if (!co.success) return degrade(`主根切回默认分支失败(${co.error || ''})`)
    }
    // (2) prune:只清"目录已删"的管理残留(完好取证 lane 由占用感知收口,勿依赖 prune)。
    const pr = await agentR(pruneWorktreesPromptM(), { label: 'wave:prune', phase: 'Milestone', schema: ACTION_RESULT_SCHEMA })
    if (!pr.success) return degrade(`git worktree prune 失败(${pr.error || ''})`)
    // (3) 全仓最大 migration 版本(全局并集口径,补 4)→ 执行段据此给每条 lane 发 vBase 版本段。
    const mv = await agentR(maxMigrationVersionPromptM(), { label: 'wave:maxV', phase: 'Milestone', schema: FIELD_VALUE_SCHEMA })
    const maxV = mv.found ? parseInt(mv.value, 10) : NaN
    if (!Number.isInteger(maxV) || maxV < 0) return degrade(`migration 版本并集读取失败(found=${mv.found}, value=${JSON.stringify(mv.value)})`)
    // (4)(5) lane 测试库 schema 基底(config-vars.yaml database.schema)+ 支持度两点联查(补 6b)
    // ——经 laneEnvCache 缓存(运行内不变量,见函数头注释)。agentR 抛错(runtime 故障)不缓存:
    // 那是瞬时态不是环境事实,留给下个 ≥2 宽波次重探。
    if (!laneEnvCache) {
      const sb = await agentR(readDbSchemaPromptM(), { label: 'wave:schema', phase: 'Milestone', schema: FIELD_VALUE_SCHEMA })
      if (!sb.found || !sb.value) {
        laneEnvCache = { fail: 'config-vars.yaml 的 database.schema 读不到(lane 库名无基底)' }
      } else {
        // 任一不满足 → 降级(与其并行却在共享库上互相清库,不如明示串行)。
        const probe = await agentR(laneDbSupportPromptM(), { label: 'wave:lane-db?', phase: 'Milestone', schema: LANE_DB_PROBE_SCHEMA })
        laneEnvCache = (!probe.scriptsEnv || !probe.ymlPlaceholder)
          ? { fail: `目标项目不支持 lane 测试库(scriptsEnv=${probe.scriptsEnv}, ymlPlaceholder=${probe.ymlPlaceholder};${probe.detail || ''}——建议重跑 skeleton 升级 scripts/ 与 application*.yml 占位)` }
          : { baseSchema: sb.value }
      }
    }
    if (laneEnvCache.fail) return degrade(laneEnvCache.fail)
    const baseSchema = laneEnvCache.baseSchema
    // (6) 建 lane worktrees + 写 fail-closed marker(补 7)。lane 序号 = 本波 wave 下标,与执行段
    // ctxs 的 laneDbSchema 取号严格同序(marker 内容必须 == c.dbSchema)。
    for (const [i, m] of wave.entries()) {
      const branch = m.id === 'frontend-phase' ? 'frontend-phase' : `module-${m.id}`
      const path = laneRoot(m.id)
      const add = await agentR(createLaneWorktreePromptM(branch, path, def.branch), { label: `wave:lane-add:${m.id}`, phase: 'Milestone', schema: ACTION_RESULT_SCHEMA })
      if (!add.success) return degrade(`建 lane worktree 失败(${m.id}:${add.error || ''})`)
      created.push(path)
      const mk = await agentR(writeLaneDbMarkerPromptM(path, laneDbSchema(baseSchema, i)), { label: `wave:lane-marker:${m.id}`, phase: 'Milestone', schema: ACTION_RESULT_SCHEMA })
      if (!mk.success) return degrade(`写 lane DB marker 失败(${m.id}:${mk.error || ''})`)
    }
    log(`wave: 并行准备就绪——宽 ${wave.length}(${wave.map(m => m.id).join('、')}),maxV=${maxV},lane 库基底 ${baseSchema}`)
    return { defBranch: def.branch, maxV, baseSchema }
  } catch (e) {
    return degrade(`波次前置串行段异常(${String(e?.message || e)})`)
  }
}

// ── 主循环:依赖波次执行(Phase D Task 9 Step 2;伪代码修正见附录补 14)────────
// useParallel 总开关(计划 D4 / 附录补 1):ARGS.parallel 缺省或 modules !== true → 全串行——
// 不调 scheduler(免 Schedule 阶段噪声)、不建 lane;serialChainDeps 链式图上 nextWave 每轮恰出
// 1 个,主根 legacy 路径与 Phase A 重构后行为一致。逃生口:coding-start 传 parallel:{modules:false}。
const useParallel = ARGS?.parallel?.modules === true
const results = []
// doneSet:nextWave 的依赖消解集——必须以 routed 中 done:true 的模块初始化(runScheduler 标注的
// deps 允许指向已完成模块 id,frontend-phase 的"依赖全部后端"硬规则尤其依赖此初值)。
const doneSet = new Set(routed.modules.filter(m => m.done).map(m => m.id))
let remaining = useParallel ? await runScheduler(todo, routed.modules) : serialChainDeps(todo)

// ── 前端 jsdom-only 重叠(设计 docs/superpowers/plans/2026-06-15-frontend-overlap-jsdom.md)──
// frontendOverlap 开 + 存在待跑 frontend-phase(有 feItems)时:把 frontend-phase 从波次图剔除,起 phase1
// (前端 worktree,只跑 skeleton + jsdom,不依赖后端)与后端波次循环**并发**;屏障(后端全部 done)后再
// reconcile(合 default→frontend-phase)+ 标准 runModule(feStage='e2e')(主根全栈,保住补 8/9/10)。缺省关 /
// phase1 失败 / 后端未全 done → 完全回退现行终波路径,phase1 产物(fe-code-done)落 tag、resume 续跑,绝不新增 halt 点。
const useFrontendOverlap = ARGS?.parallel?.frontendOverlap === true
const feModule = routed.modules.find(m => m.id === 'frontend-phase' && !m.done && (m.feItems || []).length)
const overlapOn = useFrontendOverlap && !!feModule
const feDecisions = []          // phase1/phase2 共享决策收集器(RESUME flush 在 phase2 milestone 覆盖两段)
const fePending = []            // 后端未全 done 时,frontend-phase 的 pending 记账(并入下方 pending)
let fe1Promise = null
if (overlapOn) {
  remaining = remaining.filter(m => m.id !== 'frontend-phase')   // 从波次图剔除,后端波次独立推进
  const feCtx = makeCtx({ lane: 'fe', root: laneRoot('frontend-phase'), dbSchema: '', vBase: 0, decisions: feDecisions })
  fe1Promise = runFrontendPhase1(feModule, feCtx)               // 不 await:与下方后端波次循环并发
  log(`fe-overlap: 前端 phase1 已与后端波次并发启动(worktree ${laneRoot('frontend-phase')})`)
}

while (remaining.length) {
  let wave = nextWave(remaining, doneSet)
  if (!useParallel) wave = wave.slice(0, 1)   // 防御:串行闸下恒单宽(链式图本就每轮恰出 1 个)
  // 本轮消耗集(补 14):按波次成员记账、**绝不**以 rs 反查——runtime-null 槽位没有 r.module,反查
  // 会把故障模块留在 remaining,下轮 deps 不满足触发"图卡死降级全序"误重跑。降级被裁掉的成员**不**
  // 入此集(本轮未跑,留在 remaining 等下波)。
  const consumed = new Set()

  // 占用感知(每波,含宽 1):恢复失败的模块已结构化 halt(其余照常调度),波次边界收敛会停在本波末。
  const swept = await laneOccupancySweep(wave)
  wave = swept.wave
  for (const h of swept.halted) { results.push(h); consumed.add(h.module) }

  // ≥2 宽波次的并行准备:任一失败 → 本波次降级 maxWidth=1(被裁掉的成员留在 remaining 等下波)。
  let prep = null
  if (useParallel && wave.length >= 2) {
    prep = await prepareParallelWave(wave)
    if (!prep) wave = wave.slice(0, 1)
  }

  if (wave.length) {
    for (const m of wave) consumed.add(m.id)
    if (wave.length === 1 || !useParallel) {
      // 单宽波次(含 frontend-phase 终波)/ 并行关闭 → 主根 legacy 路径,不建 lane——makeCtx() 缺省
      // 即串行语义,与 Phase A 重构后行为一致。
      const r = await runModule(wave[0], makeCtx())
      results.push(r)
      if (r.status === 'done') doneSet.add(r.module)
    } else {
      // lane ctx 预先构造(thunk 外):runtime-null 槽位仍能从 ctxs[i] 抢救出已登记的 decisions
      // 与 lane 路径(补 12 的 loop-end 增量口径需要前者)。
      const ctxs = wave.map((m, i) => makeCtx({
        lane: i, root: laneRoot(m.id),
        dbSchema: laneDbSchema(prep.baseSchema, i), vBase: vBase(prep.maxV, i),
      }))
      // parallel():结果数组与 thunk 数组按位次对齐(runtime 保证);thunk 异常被静默折成 null、
      // 调用本身永不 reject——runModule 永不 throw,null 只剩 runtime 级故障(补 14)。
      const rs = await parallel(wave.map((m, i) => () => runModule(m, ctxs[i])))
      for (const [i, r] of rs.entries()) {
        if (r) {
          results.push(r)
          if (r.status === 'done') doneSet.add(r.module)
          continue
        }
        // runtime-null 槽位 → 记 halted;lane 整树保留取证(与 runModule catch 同口径的 reason 前缀)。
        const reason = `[lane 保留取证: ${ctxs[i].root}] runtime-null`
        log(`⛔ HALT — 模块 ${wave[i].id}:${reason}(parallel() 槽位为 null,runtime 级故障)`)
        results.push({ module: wave[i].id, status: 'halted', reason, decisions: ctxs[i].decisions })
      }
    }
  }

  remaining = remaining.filter(m => !consumed.has(m.id))
  // halt 波次边界收敛:无取消原语,兄弟模块跑完本波(接受算力浪费)后在波次边界停下——比串行
  // "单模块即断"粗一档;RESUME 记账把同波次全部 halt 原因逐条列出(见 loop-end)。
  if (results.some(r => r.status === 'halted')) break
}

// ── 前端 jsdom-only 重叠:屏障 + phase2 ──(overlap 关时整段跳过,现行终波路径不变)
if (overlapOn) {
  const fe1 = await fe1Promise                                   // 屏障:等前端 phase1(与后端波次并发)收束
  // 后端是否全部 done:无 halted(含 sweep/runtime-null)且 remaining 已空(无 pending 后端)。
  const backendAllDone = !results.some(r => r.status === 'halted') && remaining.length === 0
  if (fe1.status === 'halted') {
    results.push(fe1)                                            // phase1 halt:phase2 不跑;已完成 FE 的 fe-code-done 已保留,resume 续跑
  } else if (!backendAllDone) {
    // 后端未全 done(halt / 还有 pending):phase1 产物(fe-code-done)已落 tag,phase2 留待 resume。
    fePending.push({ module: 'frontend-phase', status: 'pending',
      blockedBy: ['<全部后端 done>'], decisions: fe1.decisions })
    log('fe-overlap: 后端未全部完成 → 前端 phase2 延后;phase1 产物已保留,resume 续跑')
  } else {
    // 后端全部 done + phase1 done → phase2:reconcile(合 default→frontend-phase + 移除 phase1 worktree)→
    // 标准 runModule(feStage='e2e')(主根全栈,skeleton 幂等跳过,behaviorGate/testGate/milestone 原样)。
    phase('Frontend')
    const def = await agentR(detectDefaultBranchPromptM(), { label:'fe-overlap:default', phase:'Milestone', schema: DEFAULT_BRANCH_SCHEMA })
    await bestEffortAction(removeLaneWorktreePromptM(laneRoot('frontend-phase')),
      { label:'fe-overlap:rm-worktree', phase:'Milestone', okMsg:'fe-overlap: 已移除 phase1 worktree', failTag:'fe-overlap phase1 worktree 移除' })
    const rec = await agentR(mergeDefaultIntoFrontendPromptM('frontend-phase', def.branch),
      { label:'fe-overlap:reconcile', phase:'Milestone', schema: ACTION_RESULT_SCHEMA })
    if (!rec.success) {
      // reconcile merge 冲突/失败:与 milestone merge 同口径,结构化 halt 留人工(phase1 产物已在 frontend-phase 分支)。
      const reason = `fe-overlap reconcile(merge ${def.branch}→frontend-phase)失败:${rec.error || ''}${rec.detail ? '\n' + rec.detail : ''}`
      log(`⛔ HALT — frontend-phase:${reason}`)
      results.push({ module: 'frontend-phase', status: 'halted', reason, decisions: fe1.decisions })
    } else {
      feModule.feStage = 'e2e'                                   // 让 runModule 前端段走 e2e(jsdom 已在 phase1 绿)
      const fe2 = await runModule(feModule, makeCtx({ decisions: feDecisions }))
      results.push(fe2)
      if (fe2.status === 'done') doneSet.add(fe2.module)
    }
  }
}

// pending:图余量(Task 10)——halt 收敛后 remaining 中每项标注 blockedBy(尚未满足的依赖),
// caller / coding-start 据此告知"修好后还有哪些待跑、各自被谁挡着",而非线性 slice 误导
// (波次图下 halt 模块之后的 docs/02 顺位可能与 halt 毫无依赖关系)。
// fePending:前端 jsdom-only 重叠下「phase1 done 但后端未全 done」的 frontend-phase pending(并入图余量)。
const pending = remaining.map(m => ({
  module: m.id, status: 'pending',
  blockedBy: (m.deps || []).filter(d => !doneSet.has(d)),
})).concat(fePending)

// Workflow 结果:跑完 / halt 的逐模块摘要 + halt 后未跑的 pending 模块列表 + 全流程自主决策日志
// (decisions:stage 缺值时未停而自主取的默认/解读,供 coding-start / 人工事后审阅,可能含错误假设)。
// 注:decisions 仅覆盖**本次运行实际新跑**的 stage;resume 时被 req-done/milestone tag 跳过的已完成功能,
// 其决策不会重新登记于此——需到对应 docs/superpowers/specs|plans/<date>-<id>.md 产物显著位置查阅。
// 续跑 handoff(best-effort,静默):把本次运行结果落进 docs/superpowers/RESUME.md,
// 使下次 coding-start 重跑能复盘「上次为何 halt / 做过哪些自主假设 / 还剩哪些模块」。
// 硬中断(进程被杀,到不了这里)时不写——那种情况无 halt 原因可记,且 tag+工件已够 Router 续跑。
// halts:同波次可能多个模块同时 halt(波次边界收敛跑完本波才停),一律逐条记账,绝不只取第一个。
const halts = results.filter(r => r.status === 'halted')
// loop-end 汇总口径(附录补 12):= 「flush 失败/未 flush 模块(含 halted)的 r.decisions ∪ 全局 sink 残量」。
// flush 成功的模块已被逐模块 RESUME 条目覆盖,不再重复列出——同一条决策绝不既丢失又重复。
// 全局残量 = autonomousDecisions:模块作用域决策都进 c.decisions,这里只剩"漏传 dec"的兜底记录。
const unflushedDecisions = [
  ...results.filter(r => !resumeFlushed.has(r.module)).flatMap(r => r.decisions || []),
  ...autonomousDecisions,
]
const decDigest = decisionDigestMd(unflushedDecisions, '(无未记录的自主默认;已完成模块的决策见上方各模块条目)')
// loop-end 的 recordResume 统一过 withMainRootLock(补 13):波次循环已结束本无竞态,但保持
// "RESUME 追加必持锁"的单一纪律避免遗忘(recordResume 自身及调用链内绝不再取锁)。
if (halts.length) {
  // RESUME halt 条目(Task 10):逐条列出同波次全部 halt 原因——lane 模式 reason 自带
  // 「[lane 保留取证: <path>]」前缀(即保留的 lane 路径);待跑模块逐个标注 blockedBy。
  const haltList = halts.map(h => `  - \`${h.module}\`:${h.reason || '(空)'}`).join('\n')
  const pendList = pending.length
    ? pending.map(p => `  - \`${p.module}\`${(p.blockedBy || []).length
        ? `(blockedBy: ${p.blockedBy.map(b => `\`${b}\``).join('、')})`
        : '(无未满足依赖,修复阻塞点后即可跑)'}`).join('\n')
    : '  - 无'
  await withMainRootLock(() => recordResume([
    '## ⛔ HALT — 模块 ' + halts.map(h => '`' + h.module + '`').join('、') + '(<ts>)',
    '',
    '- **halt 模块与原因**(同波次全部列出;lane 模式 reason 自带保留 lane 路径前缀,取证后人工移除):',
    haltList,
    '- **本次自主默认决策**(仅未被逐模块条目覆盖的增量;已完成模块的决策见上方各模块条目,重跑前请复核):',
    decDigest,
    '- **halt 后未跑的待办模块**(blockedBy = 尚未满足的依赖,重跑时由调度图自动消解):',
    pendList,
    '- **下一步**:人工修复阻塞点后重跑 `/erp-workflow:coding-start`;Router 按 git tag 续跑,已完成模块自动跳过。',
  ].join('\n')))
} else if (results.length) {
  const doneList = results.filter(r => r.status === 'done').map(r => `\`${r.module}\``).join('、') || '无'
  await withMainRootLock(() => recordResume([
    '## ✅ 全部完成(<ts>)',
    '',
    `- **本次完成模块**:${doneList}`,
    '- **本次自主默认决策**(仅未被逐模块条目覆盖的增量):',
    decDigest,
  ].join('\n')))
}

if (halts.length) log(`⛔ 本次运行 halt — 模块 ${halts.map(h => h.module).join('、')}(原因逐条见上方 ⛔ 日志与 RESUME.md);待跑:${pending.map(p => p.module).join('、') || '无'}`)
else if (results.length) log(`✅ 本次运行完成 ${results.length} 个模块,无 halt`)

// 注:顶层 `return` 不是普通 Node ESM 语法;本文件由 Claude Workflow 运行时执行,
// 运行时会把脚本体包进 async function,顶层 `return` 是 Workflow 的结果通道。
// 不要把本文件作为 `node workflows/coding.mjs` 直接运行,也不要改成 `export default {...}`,
// 否则 Workflow 拿不到 results / pending。语法检查用 `node lib/check-workflow-syntax.mjs`(附录补 18)。
// decisions 口径(附录补 12):results 各 r.decisions 拼接 ∪ 全局 sink 残量(漏传 dec 的兜底)。
return { results, pending, decisions: [...results.flatMap(r => r.decisions || []), ...autonomousDecisions] }