coding.mjs
320 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
3772
3773
3774
3775
3776
3777
3778
3779
3780
3781
3782
3783
3784
3785
3786
3787
3788
3789
3790
3791
3792
3793
3794
3795
3796
3797
3798
3799
3800
3801
3802
3803
3804
3805
3806
3807
3808
3809
3810
3811
3812
3813
3814
3815
3816
3817
3818
3819
3820
3821
3822
3823
3824
3825
3826
3827
3828
3829
3830
3831
3832
3833
3834
3835
3836
3837
3838
3839
3840
3841
3842
3843
3844
3845
3846
3847
3848
3849
3850
3851
3852
3853
3854
3855
3856
3857
3858
3859
3860
3861
3862
3863
3864
3865
3866
3867
3868
3869
3870
3871
3872
3873
3874
3875
3876
3877
3878
3879
3880
3881
3882
3883
3884
3885
3886
3887
3888
3889
3890
// 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')
}
// syncBranchWithDefaultPromptM:把默认分支合进当前功能分支(幂等)。
//
// 为什么必须有这一步:runBranchSetup 对**新建**分支以默认分支为起点(createBranchFromPromptM),
// 但对**已存在**分支只做 checkout(主根)/ worktree add(lane 分支 2),二者都不带任何同步动作。
// 于是「上一轮跑完留下的功能分支」会停在它当时的位置,其后提交到默认分支的一切——新 REQ 卡片、
// 新 migration、docs 增量——对本轮**全部不可见**。子代理据残缺事实作业不会报错,只会自行发明
// 需求(实测:spec 阶段报「全仓 grep <req> 零命中」后照自己的理解另实现一套),到 milestone
// merge 才以内容冲突暴露,此时整轮返工。故此步须在功能分支上作业**之前**执行。
//
// 冲突处置:不 abort、不 stash——保留冲突现场返回失败,由 runAction 仲裁后 halt 交人工。
// 陈旧分支与默认分支真冲突意味着两边都动了同一处,只有人能判断取舍;自动 abort 会把这个
// 信号抹掉,让下一轮再次撞上同一个坑。
function syncBranchWithDefaultPromptM(branch, defaultBranch, c) {
return [
`# 把默认分支 \`${defaultBranch}\` 合进功能分支 \`${branch}\`(幂等)`,
microStepContract(c.root),
'',
`前置:确认 \`git -C ${c.root} rev-parse --abbrev-ref HEAD\` 等于 \`${branch}\`;不等则直接返回失败(error 写明实际分支),**绝不**自行切分支。`,
`跑 \`git -C ${c.root} merge --no-edit ${defaultBranch}\`。`,
'按结果分三种:',
`1. 输出含 \`Already up to date\` → 分支本就包含默认分支全部提交,返回 \`{ "success": true, "detail": "up-to-date" }\`。`,
`2. 合并成功(fast-forward 或产生合并提交)→ 返回 \`{ "success": true, "detail": "merged: <被合入的提交数,取 git -C ${c.root} rev-list --count ${branch}@{1}..${branch}>" }\`。`,
`3. 冲突或其它失败 → **保留冲突现场**(绝不跑 \`merge --abort\` / \`reset\` / \`stash\`),返回 \`{ "success": false, "error": "merge conflict", "detail": "<git -C ${c.root} diff --name-only --diff-filter=U 的冲突文件清单>" }\`。`,
'## 输出(ACTION_RESULT_SCHEMA)',
'- 见上三种分支;`detail` 必填,供上层日志与人工诊断。',
].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')
}
// ── 完成态 tag 全量预取(省会话优化 1a)───────────────────────────────────────
// 动机:req-done/<id> / fe-code-done/<FE> / milestone/<id> 的存在性查询原本**每次一个子代理**
// (featureLoop 入口 dedup 每 REQ 一次、phase1 dedup 每 FE 一次、runMilestone 每模块一次)。
// Workflow 脚本自身无 fs / 无 shell(沙箱限制),git 只能经子代理跑——省不掉"手",但可以省"次数":
// 开跑时**一次**拉回全表,之后全部变成内存查表。tag 是仓库级 ref(worktree 间共享),故一份全局
// 快照对主根与所有 lane 同时成立。
//
// 一致性:运行中新打的 tag 由 JS 在创建成功后 noteTag() 同步进快照——脚本是唯一写入方,
// 单线程 await 串行记账,不会漏。预取失败(子代理 null / 返回不合形)→ tagSnapshot 保持 null,
// 全部调用点自动回退到"每次一个子代理查"的现行路径,语义一字不差、不新增 halt 点。
const TAG_LIST_SCHEMA = { type:'object', additionalProperties:false,
required:['tags'], properties:{ tags:{ type:'array', items:{ type:'string' } } } }
function listAllTagsPromptM() {
return [
'# 列出全部完成态 tag(一次性预取,供上层内存查表)',
microStepContract(ROOT),
'',
`跑 \`git -C ${ROOT} tag -l "req-done/*" "fe-code-done/*" "milestone/*"\`(\`git tag -l\` 接受多个 pattern 作位置参数,一次列全三类)。`,
'把 stdout 按行切分,去掉空行与行首尾空白,原样返回**完整 tag 名**(含 `req-done/` 等前缀),不要截断前缀、不要排序、不要去重加工。',
'一个 tag 都没有(仓库尚无完成态)→ 返回空数组 `{ "tags": [] }`,这是合法结果,不是失败。',
'## 输出(TAG_LIST_SCHEMA)',
'- `{ "tags": ["req-done/REQ-001", "milestone/order_mgmt", ...] }`',
].join('\n')
}
let tagSnapshot = null // Set<string> | null(null = 未预取 / 预取失败 → 回退逐项查)
// 预取一次。失败只 log 不抛——回退路径完全等价于本优化之前的行为。
async function primeTagSnapshot() {
if (tagSnapshot) return
const r = await agent(listAllTagsPromptM(), {label:'tags:prefetch', phase:'Router', schema: TAG_LIST_SCHEMA})
if (!r || !Array.isArray(r.tags)) {
log('tag 预取失败(子代理无返回 / 结果不合形)——回退逐项 tag 查询,行为同优化前')
return
}
tagSnapshot = new Set(r.tags.map(t => String(t).trim()).filter(Boolean))
log(`tag 预取: ${tagSnapshot.size} 个完成态 tag 进入内存快照(后续 dedup 查询零子代理开销)`)
}
// 查表。返回 null = 快照不可用,调用方须回退到子代理查询。
function tagKnown(name) { return tagSnapshot ? tagSnapshot.has(name) : null }
// 记账:tag 创建成功后调用,保持快照与仓库一致(快照不可用时是 no-op)。
function noteTag(name) { if (tagSnapshot) tagSnapshot.add(name) }
// ── 默认分支 memo(省会话优化 1b)─────────────────────────────────────────────
// `refs/heads/main|master` 是整程不变量且全仓共享,但原本有 5 个调用点各起一次子代理探测
// (runBranchSetup / runMilestone / runCrossModule / prepareParallelWave / fe-overlap),
// 每个模块至少查 3 次。首次探测结果整程复用;探测失败**不缓存**,下次调用重试,
// 保持原有 agentR 的 halt 语义不变。
let defaultBranchMemo = null
async function getDefaultBranch(label) {
if (defaultBranchMemo) return defaultBranchMemo
const def = await agentR(detectDefaultBranchPromptM(), {label, phase: 'Milestone', schema: DEFAULT_BRANCH_SCHEMA})
if (def?.branch) defaultBranchMemo = def
return def
}
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 getDefaultBranch(lbl('default'))
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}`)
// 默认分支 → 功能分支同步(幂等)。新建分支走到这里恒为 up-to-date(起点就是默认分支);
// 真正吃这一步的是**复用已存在分支**的两条路径——主根 checkout 与 lane worktree add——
// 它们此前不带任何同步,会让上一轮遗留的功能分支看不到其后进入默认分支的 REQ 卡片 /
// migration / docs 增量,子代理据残缺事实自行发明实现,直到 milestone merge 才以冲突暴露。
// 冲突时保留现场 halt(见 prompt 说明),由人工判断取舍。
const sync = await runAction(g => syncBranchWithDefaultPromptM(branch, def.branch, c) + g,
{site:`branchSetup-sync-default:${branch}`, grp:'Milestone', label: lbl('sync'), dec: c.decisions, root: c.root})
log(`branch-setup: ${id} → ${branch}(同步默认分支 ${def.branch}:${sync.detail || 'ok'})`)
}
// ---- 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(memo,整程只探测一次——见 getDefaultBranch)
const def = await getDefaultBranch(lbl('default'))
// 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 snapTag = tagKnown(targetTag) // 1a:快照可用则零子代理
const tagExists = snapTag !== null ? snapTag
: (await agentR(checkTagExistsPromptM(targetTag), {label: lbl('tag?'), phase: 'Milestone', schema: EXISTS_SCHEMA})).exists
if (!tagExists) {
const tr = await runAction(g => createTagPromptM(phaseId, fe) + g, {site:`milestone-tag:${phaseId}`, grp:'Milestone', label: lbl('tag')})
if (tr?.success !== false) noteTag(targetTag) // 1a 记账(同上,显式判 success)
}
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 getDefaultBranch(lbl('default'))
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 语义与现行串行路径逐字一致。
// 1a:快照可用 → 全批 dedup 零子代理(原本每项一次,items 多时这里就是一整排会话)。
if (tagSnapshot) {
items.forEach(id => doneById.set(id, tagKnown(`req-done/${id}`)))
} else {
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 snap = tagKnown(`req-done/${id}`) // 1a:快照可用则零子代理
if (snap !== null) return snap
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) => {
const r = await runAction(g => createReqDoneTagPromptM(id, phase, c) + g, {
site:`req-done-tag:${phase}:${id}`, grp, label:`reqdone:${phase}:${id}`, dec: c.decisions, root: c.root,
})
// 1a 记账:保持内存快照与仓库一致。显式判 success——当前 runAction 未开 allowContinue,
// 走到这里恒为真;若日后给本调用点加了 allowContinue,失败放行时**绝不能**误记为已打 tag。
if (r?.success !== false) noteTag(`req-done/${id}`)
return r
}
// overlap phase1 中间完成态 tag:fe-code-done/<id>(组件代码 + jsdom 绿)。
const tagFeCodeDone = async (id) => {
const r = await runAction(g => createFeCodeDoneTagPromptM(id, c) + g, {
site:`fe-code-done-tag:${id}`, grp, label:`fecodedone:${id}`, dec: c.decisions, root: c.root,
})
if (r?.success !== false) noteTag(`fe-code-done/${id}`) // 1a 记账(同上,显式判 success)
return r
}
// phase1 入口 dedup:fe-code-done/<id> 已存在 → 本 FE phase1 已完成,跳过(phase2 用 req-done dedup,走 isDone)。
const isPhase1Done = async (id) => {
const snap = tagKnown(`fe-code-done/${id}`) // 1a:快照可用则零子代理
if (snap !== null) return snap
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')
// 完成态 tag 全量预取(1a):放在 Router 之前——其后 featureLoop 的每 REQ / 每 FE dedup、
// runMilestone 的 tag 存在性查询全部走内存快照。失败静默回退逐项查(见 primeTagSnapshot)。
await primeTagSnapshot()
// 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 getDefaultBranch('wave:default')
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 getDefaultBranch('fe-overlap:default')
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] }