devflow-core 2.5.0

Opinionated AI-driven development workflow state machine
Documentation
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
3891
3892
3893
3894
3895
3896
3897
3898
3899
3900
3901
3902
3903
3904
3905
3906
3907
3908
3909
3910
3911
3912
3913
3914
3915
3916
3917
3918
3919
3920
3921
3922
3923
3924
3925
3926
3927
3928
3929
3930
3931
3932
3933
3934
3935
3936
3937
3938
3939
3940
3941
3942
3943
3944
3945
3946
3947
3948
3949
3950
3951
3952
3953
3954
3955
3956
3957
3958
3959
3960
3961
3962
3963
3964
3965
3966
3967
3968
3969
3970
3971
3972
3973
3974
3975
3976
3977
3978
3979
3980
3981
3982
3983
3984
3985
3986
3987
3988
3989
3990
3991
3992
3993
3994
3995
3996
3997
3998
3999
4000
4001
4002
4003
4004
4005
4006
4007
4008
4009
4010
4011
4012
4013
4014
4015
4016
4017
4018
4019
4020
4021
4022
4023
4024
4025
4026
4027
4028
4029
4030
4031
4032
4033
4034
4035
4036
4037
4038
4039
4040
4041
4042
4043
4044
4045
4046
4047
4048
4049
4050
4051
4052
4053
4054
4055
4056
4057
4058
4059
4060
4061
4062
4063
4064
4065
4066
4067
4068
4069
4070
4071
4072
4073
4074
4075
4076
4077
4078
4079
4080
4081
4082
4083
4084
4085
4086
4087
4088
4089
4090
4091
4092
4093
4094
4095
4096
4097
4098
4099
4100
4101
4102
4103
4104
4105
4106
4107
4108
4109
4110
4111
4112
4113
4114
4115
4116
4117
4118
4119
4120
4121
4122
4123
4124
4125
4126
4127
4128
4129
4130
4131
4132
4133
4134
4135
4136
4137
4138
4139
4140
4141
4142
4143
4144
4145
4146
4147
4148
4149
4150
4151
4152
4153
4154
4155
4156
4157
4158
4159
4160
4161
4162
4163
4164
4165
4166
4167
4168
4169
4170
4171
4172
4173
4174
4175
4176
4177
4178
4179
4180
4181
4182
4183
4184
4185
4186
4187
4188
4189
4190
4191
4192
4193
4194
4195
4196
4197
4198
4199
4200
4201
4202
4203
4204
4205
4206
4207
4208
4209
4210
4211
4212
4213
4214
4215
4216
4217
4218
4219
4220
4221
4222
4223
4224
4225
4226
4227
4228
4229
4230
4231
4232
4233
4234
4235
4236
4237
4238
4239
4240
4241
4242
4243
4244
4245
4246
4247
4248
4249
4250
4251
4252
4253
4254
4255
4256
4257
4258
4259
4260
4261
4262
4263
4264
4265
4266
4267
4268
4269
4270
4271
4272
4273
4274
4275
4276
4277
4278
4279
4280
4281
4282
4283
4284
4285
4286
4287
4288
4289
4290
4291
4292
4293
4294
4295
4296
4297
4298
4299
4300
4301
4302
4303
4304
4305
4306
4307
4308
4309
4310
4311
4312
4313
4314
4315
4316
4317
4318
4319
4320
4321
4322
4323
4324
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
4576
4577
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
4650
4651
4652
4653
4654
4655
4656
4657
4658
4659
4660
4661
4662
4663
4664
4665
4666
4667
4668
4669
4670
4671
4672
4673
4674
4675
4676
4677
4678
4679
4680
4681
4682
4683
4684
4685
4686
4687
4688
4689
4690
4691
4692
4693
4694
4695
4696
4697
4698
4699
4700
4701
4702
4703
4704
4705
4706
4707
4708
4709
4710
4711
4712
4713
4714
4715
4716
4717
4718
4719
4720
4721
4722
4723
4724
4725
4726
4727
4728
4729
4730
4731
4732
4733
4734
4735
4736
4737
4738
4739
4740
4741
4742
4743
4744
4745
4746
4747
4748
4749
4750
4751
4752
4753
4754
4755
4756
4757
4758
4759
4760
4761
4762
4763
4764
4765
4766
4767
4768
4769
4770
4771
4772
4773
4774
4775
4776
4777
4778
4779
4780
4781
4782
4783
4784
4785
4786
4787
4788
4789
4790
4791
4792
4793
4794
4795
4796
4797
4798
4799
4800
4801
4802
4803
4804
4805
4806
4807
4808
4809
4810
4811
4812
4813
4814
4815
4816
4817
4818
4819
4820
4821
4822
4823
4824
4825
4826
4827
4828
4829
4830
4831
4832
4833
4834
4835
4836
4837
4838
4839
4840
4841
4842
4843
4844
4845
4846
4847
4848
4849
4850
4851
4852
4853
4854
4855
4856
4857
4858
4859
4860
4861
4862
4863
4864
4865
4866
4867
4868
4869
4870
4871
4872
4873
4874
4875
4876
4877
4878
4879
4880
4881
4882
4883
4884
4885
4886
4887
4888
4889
4890
4891
4892
4893
4894
4895
4896
4897
4898
4899
4900
4901
4902
4903
4904
4905
4906
4907
4908
4909
4910
4911
4912
4913
4914
4915
4916
4917
4918
4919
4920
4921
4922
4923
4924
4925
4926
4927
4928
4929
4930
4931
4932
4933
4934
4935
4936
4937
4938
4939
4940
4941
4942
4943
4944
4945
4946
4947
4948
4949
4950
4951
4952
4953
4954
4955
4956
4957
4958
4959
4960
4961
4962
4963
4964
4965
4966
4967
4968
4969
4970
4971
4972
4973
4974
4975
4976
4977
4978
4979
4980
4981
4982
4983
4984
4985
4986
4987
4988
4989
4990
4991
4992
4993
4994
4995
4996
4997
4998
4999
5000
5001
5002
5003
5004
5005
5006
5007
5008
5009
5010
5011
5012
5013
5014
5015
5016
5017
5018
5019
5020
5021
5022
5023
5024
5025
5026
5027
5028
5029
5030
5031
5032
5033
5034
5035
5036
5037
5038
5039
5040
5041
5042
5043
5044
5045
5046
5047
5048
5049
5050
5051
5052
5053
5054
5055
5056
5057
5058
5059
5060
5061
5062
5063
5064
5065
5066
5067
5068
5069
5070
5071
5072
5073
5074
5075
5076
5077
5078
5079
5080
5081
5082
5083
5084
5085
5086
5087
5088
5089
5090
5091
5092
5093
5094
5095
5096
5097
5098
5099
5100
5101
5102
5103
5104
5105
5106
5107
5108
5109
5110
5111
5112
5113
5114
5115
5116
5117
5118
5119
5120
5121
5122
5123
5124
5125
5126
5127
5128
5129
5130
5131
5132
5133
5134
5135
5136
5137
5138
5139
5140
5141
5142
5143
5144
5145
5146
5147
5148
5149
5150
5151
5152
5153
5154
5155
5156
5157
5158
5159
5160
5161
5162
5163
5164
5165
5166
5167
5168
5169
5170
5171
5172
5173
5174
5175
5176
5177
5178
5179
5180
5181
5182
5183
5184
5185
5186
5187
5188
5189
5190
5191
5192
5193
5194
5195
5196
5197
5198
5199
5200
5201
5202
5203
5204
5205
5206
5207
5208
5209
5210
5211
5212
5213
5214
5215
5216
5217
5218
5219
5220
5221
5222
5223
5224
5225
5226
5227
5228
5229
5230
5231
5232
5233
5234
5235
5236
5237
5238
5239
5240
5241
5242
5243
5244
5245
5246
5247
5248
5249
5250
5251
5252
5253
5254
5255
5256
5257
5258
5259
5260
5261
5262
5263
5264
5265
5266
5267
5268
5269
5270
5271
5272
5273
5274
5275
5276
5277
5278
5279
5280
5281
5282
5283
5284
5285
5286
5287
5288
5289
5290
5291
5292
5293
5294
5295
5296
5297
5298
5299
5300
5301
5302
5303
5304
5305
5306
5307
5308
5309
5310
5311
5312
5313
5314
5315
5316
5317
5318
5319
5320
5321
5322
5323
5324
5325
5326
5327
5328
5329
5330
5331
5332
5333
5334
5335
5336
5337
5338
5339
5340
5341
5342
5343
5344
5345
5346
5347
5348
5349
5350
5351
5352
5353
5354
5355
5356
5357
5358
5359
5360
5361
5362
5363
5364
5365
5366
5367
5368
5369
5370
5371
5372
5373
5374
5375
5376
5377
5378
5379
5380
5381
5382
5383
5384
5385
5386
5387
5388
5389
5390
5391
5392
5393
5394
5395
5396
5397
5398
5399
5400
5401
5402
5403
5404
5405
5406
5407
5408
5409
5410
5411
5412
5413
5414
5415
5416
5417
5418
5419
5420
5421
5422
5423
5424
5425
5426
5427
5428
5429
5430
5431
5432
5433
5434
5435
5436
5437
5438
5439
5440
5441
5442
5443
5444
5445
5446
5447
5448
5449
5450
5451
5452
5453
5454
5455
5456
5457
5458
5459
5460
5461
5462
5463
5464
5465
5466
5467
5468
5469
5470
5471
5472
5473
5474
5475
5476
5477
5478
5479
5480
5481
5482
5483
5484
5485
5486
5487
5488
5489
5490
5491
5492
5493
5494
5495
5496
5497
5498
5499
5500
5501
5502
5503
5504
5505
5506
5507
5508
5509
5510
5511
5512
5513
5514
5515
5516
5517
5518
5519
5520
5521
5522
5523
5524
5525
5526
5527
5528
5529
5530
5531
5532
5533
5534
5535
5536
5537
5538
5539
5540
5541
5542
5543
5544
5545
5546
5547
5548
5549
5550
5551
5552
5553
5554
5555
5556
5557
5558
5559
5560
5561
5562
5563
5564
5565
5566
5567
5568
5569
5570
5571
5572
5573
5574
5575
5576
5577
5578
5579
5580
5581
5582
5583
5584
5585
5586
5587
5588
5589
5590
5591
5592
5593
5594
5595
5596
5597
5598
5599
5600
5601
5602
5603
5604
5605
5606
5607
5608
5609
5610
5611
5612
5613
5614
5615
5616
5617
5618
5619
5620
5621
5622
5623
5624
5625
5626
5627
5628
5629
5630
5631
5632
5633
5634
5635
5636
5637
5638
5639
5640
5641
5642
5643
5644
5645
5646
5647
5648
5649
5650
5651
5652
5653
5654
5655
5656
5657
5658
5659
5660
5661
5662
5663
5664
5665
5666
5667
5668
5669
5670
5671
5672
5673
5674
5675
5676
5677
5678
5679
5680
5681
5682
5683
5684
5685
5686
5687
5688
5689
5690
5691
5692
5693
5694
5695
5696
5697
5698
5699
5700
5701
5702
5703
5704
5705
5706
5707
5708
5709
5710
5711
5712
5713
5714
5715
5716
5717
5718
5719
5720
5721
5722
5723
5724
5725
5726
5727
5728
5729
5730
5731
5732
5733
5734
5735
5736
5737
5738
5739
5740
5741
5742
5743
5744
5745
5746
5747
5748
5749
5750
5751
5752
5753
5754
5755
5756
5757
5758
5759
5760
5761
5762
5763
5764
5765
5766
5767
5768
5769
5770
5771
5772
5773
5774
5775
5776
5777
5778
5779
5780
5781
5782
5783
5784
5785
5786
5787
5788
5789
5790
5791
5792
5793
5794
5795
5796
5797
5798
5799
5800
5801
5802
5803
5804
5805
5806
5807
5808
5809
5810
5811
5812
5813
5814
5815
5816
5817
5818
5819
5820
5821
5822
5823
5824
5825
5826
5827
5828
5829
5830
5831
5832
5833
5834
5835
5836
5837
5838
5839
5840
5841
5842
5843
5844
5845
5846
5847
5848
5849
5850
5851
5852
5853
5854
5855
5856
5857
5858
5859
5860
5861
5862
5863
5864
5865
5866
5867
5868
5869
5870
5871
5872
5873
5874
5875
5876
5877
5878
5879
5880
5881
5882
5883
5884
5885
5886
5887
5888
5889
5890
5891
5892
5893
5894
5895
5896
5897
5898
5899
5900
5901
5902
5903
5904
5905
5906
5907
5908
5909
5910
5911
5912
5913
5914
5915
5916
5917
5918
5919
5920
5921
5922
5923
5924
5925
5926
5927
5928
5929
5930
5931
5932
5933
5934
5935
5936
5937
5938
5939
5940
5941
5942
5943
5944
5945
5946
5947
5948
5949
5950
5951
5952
5953
5954
5955
5956
5957
5958
5959
5960
5961
5962
5963
5964
5965
5966
5967
5968
5969
5970
5971
5972
5973
5974
5975
5976
5977
5978
5979
5980
5981
5982
5983
5984
5985
5986
5987
5988
5989
5990
5991
5992
5993
5994
5995
5996
5997
5998
5999
6000
6001
6002
6003
6004
6005
6006
6007
6008
6009
6010
6011
6012
6013
6014
6015
6016
6017
6018
6019
6020
6021
6022
6023
6024
6025
6026
6027
6028
6029
6030
6031
6032
6033
6034
6035
6036
6037
6038
6039
6040
6041
6042
6043
6044
6045
6046
6047
6048
6049
6050
6051
6052
6053
6054
6055
6056
6057
6058
6059
6060
6061
6062
6063
6064
6065
6066
6067
6068
6069
6070
6071
6072
6073
6074
6075
6076
6077
6078
6079
6080
6081
6082
6083
6084
6085
6086
6087
6088
6089
6090
6091
6092
6093
6094
6095
6096
6097
6098
6099
6100
6101
6102
6103
6104
6105
6106
6107
6108
6109
6110
6111
6112
6113
6114
6115
6116
6117
6118
6119
6120
6121
6122
6123
6124
6125
6126
6127
6128
6129
6130
6131
6132
6133
6134
6135
6136
6137
6138
6139
6140
6141
6142
6143
6144
6145
6146
6147
6148
6149
6150
6151
6152
6153
6154
6155
6156
6157
6158
6159
6160
6161
6162
6163
6164
6165
6166
6167
6168
6169
6170
6171
6172
6173
6174
6175
6176
6177
6178
6179
6180
6181
6182
6183
6184
6185
6186
6187
6188
6189
6190
6191
6192
6193
6194
6195
6196
6197
6198
6199
6200
6201
6202
6203
6204
6205
6206
6207
6208
6209
6210
6211
6212
6213
6214
6215
6216
6217
6218
6219
6220
6221
6222
6223
6224
6225
6226
6227
6228
6229
6230
6231
6232
6233
6234
6235
6236
6237
6238
6239
6240
6241
6242
6243
6244
6245
6246
6247
6248
6249
6250
6251
6252
6253
6254
6255
6256
6257
6258
6259
6260
6261
6262
6263
6264
6265
6266
6267
6268
6269
6270
6271
6272
6273
6274
6275
6276
6277
6278
6279
6280
6281
6282
6283
6284
6285
6286
6287
6288
6289
6290
6291
6292
6293
6294
6295
6296
6297
6298
6299
6300
6301
6302
6303
6304
6305
6306
6307
6308
6309
6310
6311
6312
6313
6314
6315
6316
6317
6318
6319
6320
6321
6322
6323
6324
6325
6326
6327
6328
6329
6330
6331
6332
6333
6334
6335
6336
6337
6338
6339
6340
6341
6342
6343
6344
6345
6346
6347
6348
6349
6350
6351
6352
6353
6354
6355
6356
6357
6358
6359
6360
6361
6362
6363
6364
6365
6366
6367
6368
6369
6370
6371
6372
6373
6374
6375
6376
6377
6378
6379
6380
6381
6382
6383
6384
6385
6386
6387
6388
6389
6390
6391
6392
6393
6394
6395
6396
6397
6398
6399
6400
6401
6402
6403
6404
6405
6406
6407
6408
6409
6410
6411
6412
6413
6414
6415
6416
6417
6418
6419
6420
6421
6422
6423
6424
6425
6426
6427
6428
6429
6430
6431
6432
6433
6434
6435
6436
6437
6438
6439
6440
6441
6442
6443
6444
6445
6446
6447
6448
6449
6450
6451
6452
6453
6454
6455
6456
6457
6458
6459
6460
6461
6462
6463
6464
6465
6466
6467
6468
6469
6470
6471
6472
6473
6474
6475
6476
6477
6478
6479
6480
6481
6482
6483
6484
6485
6486
6487
6488
6489
6490
6491
6492
6493
6494
6495
6496
6497
6498
6499
6500
6501
6502
6503
6504
6505
6506
6507
6508
6509
6510
6511
6512
6513
6514
6515
6516
6517
6518
6519
6520
6521
6522
6523
6524
6525
6526
6527
6528
6529
6530
6531
6532
6533
6534
6535
6536
6537
6538
6539
6540
6541
6542
6543
6544
6545
6546
6547
6548
6549
6550
6551
6552
6553
6554
6555
6556
6557
6558
6559
6560
6561
6562
6563
6564
6565
6566
6567
6568
6569
6570
6571
6572
6573
6574
6575
6576
6577
6578
6579
6580
6581
6582
6583
6584
6585
6586
6587
6588
6589
6590
6591
6592
6593
6594
6595
6596
6597
6598
6599
6600
6601
6602
6603
6604
6605
6606
6607
6608
6609
6610
6611
6612
6613
6614
6615
6616
6617
6618
6619
6620
6621
6622
6623
6624
6625
6626
6627
6628
6629
6630
6631
6632
6633
6634
6635
6636
6637
6638
6639
6640
6641
6642
6643
6644
6645
6646
6647
6648
6649
6650
6651
6652
6653
6654
6655
6656
6657
6658
6659
6660
6661
6662
6663
6664
6665
6666
6667
6668
6669
6670
6671
6672
6673
6674
6675
6676
6677
6678
6679
6680
6681
6682
6683
6684
6685
6686
6687
6688
6689
6690
6691
6692
6693
6694
6695
6696
6697
6698
6699
6700
6701
6702
6703
6704
6705
6706
6707
6708
6709
6710
6711
6712
6713
6714
6715
6716
6717
6718
6719
6720
6721
6722
6723
6724
6725
6726
6727
6728
6729
6730
6731
6732
6733
6734
6735
6736
6737
6738
6739
6740
6741
6742
6743
6744
6745
6746
6747
6748
6749
6750
6751
6752
6753
6754
6755
6756
6757
6758
6759
6760
6761
6762
6763
6764
6765
6766
6767
6768
6769
6770
6771
6772
6773
6774
6775
6776
6777
6778
6779
6780
6781
6782
6783
6784
6785
6786
6787
6788
6789
6790
6791
6792
6793
6794
6795
6796
6797
6798
6799
6800
6801
6802
6803
6804
6805
6806
6807
6808
6809
6810
6811
6812
6813
6814
6815
6816
6817
6818
6819
6820
6821
6822
6823
6824
6825
6826
6827
6828
6829
6830
6831
6832
6833
6834
6835
6836
6837
6838
6839
6840
6841
6842
6843
6844
6845
6846
6847
6848
6849
6850
6851
6852
6853
6854
6855
6856
6857
6858
6859
6860
6861
6862
6863
6864
6865
6866
6867
6868
6869
6870
6871
6872
6873
6874
6875
6876
6877
6878
6879
6880
6881
6882
6883
6884
6885
6886
6887
6888
6889
6890
6891
6892
6893
6894
6895
6896
6897
6898
6899
6900
6901
6902
6903
6904
6905
6906
6907
6908
6909
6910
6911
6912
6913
6914
6915
6916
6917
6918
6919
6920
6921
6922
6923
6924
6925
6926
6927
6928
6929
6930
6931
6932
6933
6934
6935
6936
6937
6938
6939
6940
6941
6942
6943
6944
6945
6946
6947
6948
6949
6950
6951
6952
6953
6954
6955
6956
6957
6958
6959
6960
6961
6962
6963
6964
6965
6966
6967
6968
6969
6970
6971
6972
6973
6974
6975
6976
6977
6978
6979
6980
6981
6982
6983
6984
6985
6986
6987
6988
6989
6990
6991
6992
6993
6994
6995
6996
6997
6998
6999
7000
7001
7002
7003
7004
7005
7006
7007
7008
7009
7010
7011
7012
7013
7014
7015
7016
7017
7018
7019
7020
7021
7022
7023
7024
7025
7026
7027
7028
7029
7030
7031
7032
7033
7034
7035
7036
7037
7038
7039
7040
7041
7042
7043
7044
7045
7046
7047
7048
7049
7050
7051
7052
7053
7054
7055
7056
7057
7058
7059
7060
7061
7062
7063
7064
7065
7066
7067
7068
7069
7070
7071
7072
7073
7074
7075
7076
7077
7078
7079
7080
7081
7082
7083
7084
7085
7086
7087
7088
7089
7090
7091
7092
7093
7094
7095
7096
7097
7098
7099
7100
7101
7102
7103
7104
7105
7106
7107
7108
7109
7110
7111
7112
7113
7114
7115
7116
7117
7118
7119
7120
7121
7122
7123
7124
7125
7126
7127
7128
7129
7130
7131
7132
7133
7134
7135
7136
7137
7138
7139
7140
7141
7142
7143
7144
7145
7146
7147
7148
7149
7150
7151
7152
7153
7154
7155
7156
7157
7158
7159
7160
7161
7162
7163
7164
7165
7166
7167
7168
7169
7170
7171
7172
7173
7174
7175
7176
7177
7178
7179
7180
7181
7182
7183
7184
7185
7186
7187
7188
7189
7190
7191
7192
7193
7194
7195
7196
7197
7198
7199
7200
7201
7202
7203
7204
7205
7206
7207
7208
7209
7210
7211
7212
7213
7214
7215
7216
7217
7218
7219
7220
7221
7222
7223
7224
7225
7226
7227
7228
7229
7230
7231
7232
7233
7234
7235
7236
7237
7238
7239
7240
7241
7242
7243
7244
7245
7246
7247
7248
7249
7250
7251
7252
7253
7254
7255
7256
7257
7258
7259
7260
7261
7262
7263
7264
7265
7266
7267
7268
7269
7270
7271
7272
7273
7274
7275
7276
7277
7278
7279
7280
7281
7282
7283
7284
7285
7286
7287
7288
7289
7290
7291
7292
7293
7294
7295
7296
7297
7298
7299
7300
7301
7302
7303
7304
7305
7306
7307
7308
7309
7310
7311
7312
7313
7314
7315
7316
7317
7318
7319
7320
7321
7322
7323
7324
7325
7326
7327
7328
7329
7330
7331
7332
7333
7334
7335
7336
7337
7338
7339
7340
7341
7342
7343
7344
7345
7346
7347
7348
7349
7350
7351
7352
7353
7354
7355
7356
7357
7358
7359
7360
7361
7362
7363
7364
7365
7366
7367
7368
7369
7370
7371
7372
7373
7374
7375
7376
7377
7378
7379
7380
7381
7382
7383
7384
7385
7386
7387
7388
7389
7390
7391
7392
7393
7394
7395
7396
7397
7398
7399
7400
7401
7402
7403
7404
7405
7406
7407
7408
7409
7410
7411
7412
7413
7414
7415
7416
7417
7418
7419
7420
7421
7422
7423
7424
7425
7426
7427
7428
7429
7430
7431
7432
7433
7434
7435
7436
7437
7438
7439
7440
7441
7442
7443
7444
7445
7446
7447
7448
7449
7450
7451
7452
7453
7454
7455
7456
7457
7458
7459
7460
7461
7462
7463
7464
7465
7466
7467
7468
7469
7470
7471
7472
7473
7474
7475
7476
7477
7478
7479
7480
7481
7482
7483
7484
7485
7486
7487
7488
7489
7490
7491
7492
7493
7494
7495
7496
7497
7498
7499
7500
7501
7502
7503
7504
7505
7506
7507
7508
7509
7510
7511
7512
7513
7514
7515
7516
7517
7518
7519
7520
7521
7522
7523
7524
7525
7526
7527
7528
7529
7530
7531
7532
7533
7534
7535
7536
7537
7538
7539
7540
7541
7542
7543
7544
7545
7546
7547
7548
7549
7550
7551
7552
7553
7554
7555
7556
7557
7558
7559
7560
7561
7562
7563
7564
7565
7566
7567
7568
7569
7570
7571
7572
7573
7574
7575
7576
7577
7578
7579
7580
7581
7582
7583
7584
7585
7586
7587
7588
7589
7590
7591
7592
7593
7594
7595
7596
7597
7598
7599
7600
7601
7602
7603
7604
7605
7606
7607
7608
7609
7610
7611
7612
7613
7614
7615
7616
7617
7618
7619
7620
7621
7622
7623
7624
7625
7626
7627
7628
7629
7630
7631
7632
7633
7634
7635
7636
7637
7638
7639
7640
7641
7642
7643
7644
7645
7646
7647
7648
7649
7650
7651
7652
7653
7654
7655
7656
7657
7658
7659
7660
7661
7662
7663
7664
7665
7666
7667
7668
7669
7670
7671
7672
7673
7674
7675
7676
7677
7678
7679
7680
7681
7682
7683
7684
7685
7686
7687
7688
7689
7690
7691
7692
7693
7694
7695
7696
7697
7698
7699
7700
7701
7702
7703
7704
7705
7706
7707
7708
7709
7710
7711
7712
7713
7714
7715
7716
7717
7718
7719
7720
7721
7722
7723
7724
7725
7726
7727
7728
7729
7730
7731
7732
7733
7734
7735
7736
7737
7738
7739
7740
7741
7742
7743
7744
7745
7746
7747
7748
7749
7750
7751
7752
7753
7754
7755
7756
7757
7758
7759
7760
7761
7762
7763
7764
7765
7766
7767
7768
7769
7770
7771
7772
7773
7774
7775
7776
7777
7778
7779
7780
7781
7782
7783
7784
7785
7786
7787
7788
7789
7790
7791
7792
7793
7794
7795
7796
7797
7798
7799
7800
7801
7802
7803
7804
7805
7806
7807
7808
7809
7810
7811
7812
7813
7814
7815
7816
7817
7818
7819
7820
7821
7822
7823
7824
7825
7826
7827
7828
7829
7830
7831
7832
7833
7834
7835
7836
7837
7838
7839
7840
7841
7842
7843
7844
7845
7846
7847
7848
7849
7850
7851
7852
7853
7854
7855
7856
7857
7858
7859
7860
7861
7862
7863
7864
7865
7866
7867
7868
7869
7870
7871
7872
7873
7874
7875
7876
7877
7878
7879
//! Agent completion detection — parses DEVFLOW_RESULT markers and evaluates
//! exit codes to determine whether a coding agent succeeded or failed.
//!
//! Four-layer decision engine:
//! 0. Run operator-authored external post-condition probes (authoritative failure)
//! 1. Parse DEVFLOW_RESULT from agent stdout (authoritative for ordinary plans)
//! 2. Exit code + commit count gate (reliable fallback)
//! 3. Process gone + commits exist (last resort warning)

use crate::config::GitFlowConfig;
use crate::git::git_command;
use crate::phase_id::PhaseId;
use crate::stage::Stage;
use crate::state::State;
use std::path::{Path, PathBuf};

/// Parsed agent completion result.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentResult {
    pub status: AgentStatus,
    pub exit_code: Option<i32>,
    pub reason: Option<String>,
    pub commits: Option<u32>,
    pub summary: Option<String>,
    /// The Validate stage's self-reported verdict — distinct from `status`.
    /// `status` reports whether the stage's task (running `/gsd-validate-phase`)
    /// completed; `verdict` reports whether validation ITSELF passed. Only
    /// `Some(Verdict::Pass)` should advance Validate to Ship; `Some(Verdict::Gaps)`
    /// and `None` both gate/loop back to Code (see `advance()`'s Validate arm).
    /// Ignored entirely for non-Validate stages.
    ///
    /// Deserialized leniently via [`deserialize_verdict_lenient`]: an absent,
    /// unknown, or mis-cased value becomes `None` rather than failing the
    /// whole `AgentResult` parse (T-13-14) — a malformed verdict must never
    /// silently drop a valid `status` to Layer 2.
    #[serde(default, deserialize_with = "deserialize_verdict_lenient")]
    pub verdict: Option<Verdict>,
    /// Which evaluation layer (0-3) produced this result (D-10, 17-01). Set by
    /// every constructor in this module; `None` is reserved for test-only
    /// fixture literals that don't route through the real cascade.
    #[serde(default)]
    pub decided_by_layer: Option<u8>,
}

/// Agent completion status determined by DevFlow.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentStatus {
    /// Agent self-reported success via DEVFLOW_RESULT.
    Success,
    /// Agent self-reported failure, or exit code + commit gate indicated failure.
    Failed,
    /// Agent stopped because an upstream API or usage quota rate-limited it.
    RateLimited,
    /// No signal received — fallback to exit code / commit heuristic.
    Unknown,
    /// Layer 2 classified the process as killed for resource exhaustion
    /// (exit code 137, typically SIGKILL from an OOM killer) (D-07, 17b).
    #[serde(rename = "resource_killed")]
    ResourceKilled,
    /// Layer 2 classified the process as unable to start (exit code 127,
    /// typically "command not found") (D-07, 17b).
    #[serde(rename = "agent_unavailable")]
    AgentUnavailable,
    /// The pipe-owning monitor gave up waiting: the child's stream went silent
    /// for longer than the idle window and DevFlow terminated it (D-06, 31-02).
    ///
    /// Deliberately distinct from BOTH neighbours it would otherwise collapse
    /// into. Against `Failed`: nothing reported a failure — the agent simply
    /// stopped talking, and a graceful close would fall through to Layer 2,
    /// which scores partial commits as `Success` (999.64 reborn inside its own
    /// fix). Against `ResourceKilled`: the box did not run out of memory;
    /// DevFlow itself did the killing. Only a third variant lets the completion
    /// oracle tell "we gave up waiting" from either.
    ///
    /// The explicit `#[serde(rename)]` is required, not stylistic: the
    /// enum-level `rename_all = "lowercase"` would collapse the two words into
    /// `idletimeout`. The two existing two-word variants above carry the same
    /// rename for the same reason.
    #[serde(rename = "idle_timeout")]
    IdleTimeout,
}

impl AgentStatus {
    /// The wire-format name for this variant, pinned equal to
    /// `serde_json::to_string(&self)` with the surrounding quotes stripped
    /// (see the `as_wire_str_matches_serde_form` test). Exhaustive match with
    /// NO wildcard arm — adding a variant without updating this is a compile
    /// error. This is the sanctioned replacement for
    /// `format!("{:?}", status).to_ascii_lowercase()`, which collapses word
    /// boundaries on multi-word variants (review consensus #1).
    pub fn as_wire_str(&self) -> &'static str {
        match self {
            AgentStatus::Success => "success",
            AgentStatus::Failed => "failed",
            AgentStatus::RateLimited => "ratelimited",
            AgentStatus::Unknown => "unknown",
            AgentStatus::ResourceKilled => "resource_killed",
            AgentStatus::AgentUnavailable => "agent_unavailable",
            AgentStatus::IdleTimeout => "idle_timeout",
        }
    }
}

/// The Validate stage's self-reported verdict (13b verdict-vs-ran split).
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
    /// Validation found no gaps — ready to advance to Ship.
    Pass,
    /// Validation found gaps that still need fixing — must loop back to Code
    /// (or gate, depending on the consecutive-failure threshold).
    Gaps,
}

/// Deserialize `verdict` leniently: an absent, unknown, or mis-cased value
/// (e.g. `"wat"`, `"Pass"`) becomes `Ok(None)` rather than an error, so a
/// malformed verdict never fails the whole `from_str::<AgentResult>` parse
/// and silently drops a valid `status` to Layer 2 (T-13-14, consensus #5).
///
/// Matching is intentionally exact-case (only the wire-format lowercase
/// strings `"pass"`/`"gaps"` are accepted) — a mis-cased value like `"Pass"`
/// is NOT case-folded into a match; it is treated the same as an unknown
/// value and maps to `None`, so a subtly wrong-case verdict fails safe
/// (gate/loop) instead of silently passing.
///
/// WR-09 (13-REVIEW.md): decodes as `serde_json::Value` first, then only
/// pattern-matches the string case — a non-string JSON type (`true`, `123`,
/// an object) is a wrong *type*, not a malformed string value, and must
/// still fall through to `None` rather than erroring out the entire
/// `AgentResult` parse (the same guarantee this deserializer already gives
/// mis-cased/unknown string values).
fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
    Ok(raw.and_then(|v| {
        v.as_str().and_then(|s| match s {
            "pass" => Some(Verdict::Pass),
            "gaps" => Some(Verdict::Gaps),
            _ => None,
        })
    }))
}

/// Errors produced by agent result evaluation.
#[derive(Debug, thiserror::Error)]
pub enum ResultError {
    #[error("I/O error reading agent output: {0}")]
    Io(#[from] std::io::Error),
    #[error("phase directory not found")]
    NoPhaseDir,
}

/// Search stdout for a DEVFLOW_RESULT marker.
///
/// The marker is a single line starting with `DEVFLOW_RESULT:` followed by
/// a JSON object with at minimum a `status` field. Matching is case-insensitive.
///
/// When an agent is run with `--output-format json` (e.g. Claude), its final
/// message is wrapped in a JSON result envelope with the text — and its
/// embedded newlines — escaped inside a `result` field. In that case the
/// marker never appears at the start of a line, so we first unwrap the
/// envelope and search the inner text.
pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
    // normalise_stream_marker_provenance on BOTH arms: parse_marker_lines
    // deserializes the agent's own JSON, so without the overwrite an agent
    // writing `"decided_by_layer":0` into its marker forges Layer-0
    // external-verification provenance, which `classify_validate_outcome`
    // (pipeline_outcomes.rs) trusts when classifying a Validate stage. The
    // stream path has normalised since 30-01; this generic path — the one
    // production hits today — did not (fourth adversarial pass, Medium 1;
    // the class 999.67 tracks).
    if let Some(inner) = extract_json_result_text(stdout)
        && let Some(result) = parse_marker_lines(&inner)
    {
        return Some(normalise_stream_marker_provenance(result));
    }
    parse_marker_lines(stdout).map(normalise_stream_marker_provenance)
}

/// Detect agent-specific rate-limit output and return the retry description.
///
/// Claude can emit a JSON result envelope when run with `--output-format json`;
/// Codex commonly emits plain text such as "Try again at ...". This function is
/// intentionally conservative so ordinary progress text does not become a
/// false positive.
pub fn detect_rate_limit(stdout: &str) -> Option<String> {
    detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
}

fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
    // strip_corruption_padding, not trim(): this detector OUTRANKS the generic
    // envelope-failure detector, and rate-limit envelopes carry `is_error:
    // true`. When only the lower-precedence detector stripped edge corruption,
    // one stray byte inverted the precedence — a RateLimited envelope (routes
    // to auto-resume) decayed into a generic Failed (routes to review/gating).
    // Fifth adversarial pass, Medium 1.
    let value: serde_json::Value = serde_json::from_str(strip_corruption_padding(stdout)).ok()?;
    let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
        || json_has_i64(&value, "api_error_status", 429)
        || json_has_i64(&value, "status", 429)
        || json_has_i64(&value, "status_code", 429);
    if !rate_limited {
        return None;
    }
    json_find_key(&value, "retry_after")
        .and_then(json_scalar_to_string)
        .or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
        .or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
        .or_else(|| Some("usage limit".to_string()))
}

fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
    // This heuristic exists for Codex's PLAIN-TEXT output. JSONL event lines
    // are authoritative and handled by parse_codex_event_result — scanning
    // them here false-positives on document content echoed into events
    // (13-06 dogfood finding: GSD reference tables mentioning "rate limiting"
    // were read by the agent, echoed into an `item.completed` payload, and
    // this scan returned that entire multi-KB line as the "retry time").
    // The JSON-line exclusion applies the SAME edge-strip policy as
    // ParsedCapture::parse (sixth-pass Medium 4): an event line whose leading
    // byte was corrupted to U+FFFD failed the bare parse here and was treated
    // as prose — re-admitting the exact multi-KB echoed-document false
    // positive this filter exists to exclude, after ParsedCapture had already
    // correctly recovered the line as an event.
    let stdout: String = stdout
        .lines()
        .filter(|line| {
            serde_json::from_str::<serde_json::Value>(strip_corruption_padding(line))
                .map(|v| !v.is_object())
                .unwrap_or(true)
        })
        .collect::<Vec<_>>()
        .join("\n");
    let stdout = stdout.as_str();
    let lower = stdout.to_ascii_lowercase();
    if let Some(idx) = lower.find("try again at ") {
        let start = idx + "try again at ".len();
        let retry = stdout[start..]
            .lines()
            .next()
            .unwrap_or_default()
            .trim()
            .trim_end_matches(['.', ',', ';'])
            .trim();
        if !retry.is_empty() {
            return Some(retry.to_string());
        }
    }

    // "429" counts as rate-limit evidence only as a STANDALONE token
    // (sixth-pass Medium 5): a bare substring check fired on "processed issue
    // #429 successfully" and any number containing 429, routing a healthy run
    // into auto-resume. A neighbor that is alphanumeric or '#' means the
    // digits belong to something else.
    fn standalone_429(line: &str) -> bool {
        let bytes = line.as_bytes();
        line.match_indices("429").any(|(i, _)| {
            let before_ok = i == 0 || {
                let b = bytes[i - 1];
                !b.is_ascii_alphanumeric() && b != b'#'
            };
            let after_ok = i + 3 >= bytes.len() || !bytes[i + 3].is_ascii_alphanumeric();
            before_ok && after_ok
        })
    }

    if lower.contains("usage limit") || lower.contains("rate limit") || standalone_429(&lower) {
        stdout
            .lines()
            .find(|line| {
                let line = line.to_ascii_lowercase();
                line.contains("usage limit") || line.contains("rate limit") || standalone_429(&line)
            })
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(str::to_string)
            .or_else(|| Some("usage limit".to_string()))
    } else {
        None
    }
}

/// If `stdout` is a JSON result envelope, return the decoded `result` text
/// field (with escapes such as `\n` resolved). Returns `None` for plain text.
fn extract_json_result_text(stdout: &str) -> Option<String> {
    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
    // (third-pass High). Interior corruption still fails the parse, by design.
    let trimmed = strip_corruption_padding(stdout);
    if !trimmed.starts_with('{') {
        return None;
    }
    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
    value.get("result")?.as_str().map(str::to_string)
}

/// Read the top-level `session_id` string from a Claude JSON result envelope
/// (`--output-format json`). Returns `None` for plain-text stdout, a
/// non-JSON-object envelope, an envelope with no `session_id` key, or a
/// `session_id` of a non-string JSON type — never panics.
///
/// D-04 / T-28-04 (this plan's `<threat_model>`): deliberately reads ONLY the
/// envelope's TOP-LEVEL `session_id` key via a direct [`serde_json::Value::get`],
/// never the module's [`json_find_key`]/[`json_scan`] traversal helpers. Those
/// helpers descend into nested objects, and the agent-authored `DEVFLOW_RESULT`
/// marker payload — embedded inside this same envelope's `result` text and
/// deserialized by [`parse_marker_lines`] directly into [`AgentResult`] — is
/// reachable that way. A top-level `get` makes it true BY CONSTRUCTION that an
/// agent cannot redirect the session DevFlow later resumes into by planting a
/// different `session_id` key inside its own self-authored marker JSON.
/// Regression test: `session_id_in_devflow_result_marker_is_not_returned`.
///
/// Deliberate deviation from RESEARCH.md § "Discretion Resolutions" item 5,
/// which suggested adding a `session_id` field directly to [`AgentResult`].
/// NOT done: `parse_marker_lines` deserializes the agent's own
/// `DEVFLOW_RESULT` JSON straight into `AgentResult` via `serde_json::from_str`,
/// so a `#[serde(default)]` field there would be agent-settable — the agent
/// could name the session DevFlow resumes into (T-28-04). A standalone reader
/// over the top-level envelope key carries no such surface and is equally
/// available to every caller; D-04's persistence target (`State::session_id`)
/// is unchanged, only the carrier differs.
pub fn claude_session_id(stdout: &str) -> Option<String> {
    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
    // (third-pass High). Interior corruption still fails the parse, by design.
    let trimmed = strip_corruption_padding(stdout);
    if !trimmed.starts_with('{') {
        return None;
    }
    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
    value.get("session_id")?.as_str().map(str::to_string)
}

/// Read the CLI-emitted `session_id` from a Claude `--output-format
/// stream-json` JSONL capture: the top-level `session_id` of the LAST
/// `system`/`init` event. `None` for any other capture shape.
///
/// The stream sibling of [`claude_session_id`], and it carries that function's
/// D-04 / T-28-04 discipline **for the same reason** — read its doc comment
/// before changing anything here. Only the event's TOP-LEVEL `session_id` is
/// read, via a direct [`serde_json::Value::get`]; the
/// [`json_find_key`]/[`json_scan`] traversal helpers are NOT to be used. They
/// descend into nested objects, and a stream carries agent-authored text in
/// every `result` event — including the `DEVFLOW_RESULT` marker JSON that
/// [`parse_marker_lines`] deserializes. A traversal would make a `session_id`
/// the agent planted in its own marker reachable, handing it the ability to
/// name the session DevFlow later resumes into (T-30-11). Regression test:
/// `claude_stream_session_id_ignores_agent_planted_value`.
///
/// The LAST `init` event wins, consistent with the last-`result`-wins
/// convention. Verified against the archived capture: its three `init` events
/// (lines 5, 32 and 47) all carry the same `session_id`, so last-wins and
/// first-wins agree on today's evidence — but only last-wins stays correct if a
/// future capture rotates the value mid-stream. Three `init` events do NOT mean
/// three sessions: session continuity must never be keyed off "have I seen an
/// `init` event".
///
/// No `session_id` field is added to [`AgentResult`] — see
/// [`claude_session_id`]'s doc comment for why that design stays rejected.
pub fn claude_stream_session_id(stdout: &str) -> Option<String> {
    let capture = ParsedCapture::parse(stdout);
    if classify(&capture) != CaptureKind::ClaudeStream {
        return None;
    }

    // A session can rotate mid-capture: each turn opens with its own `init`, and
    // the LAST one carries the id a resume must target. A torn later `init` is
    // invisible to the scan below, which would silently return an EARLIER
    // session's id — resuming the wrong session with a token that looks
    // perfectly valid. Fail closed on any TORN JSON line: it could have been a
    // newer `init`. `None` costs a resume; the wrong id corrupts one. (Third
    // adversarial pass, 2026-08-02.)
    //
    // Prose noise lines do NOT block recovery — an `init` is a JSON line, so a
    // non-`{` line can never be a torn one. The first version of this guard
    // failed closed on ANY unparsed line and rejected captures with benign
    // interleaved progress output (fourth adversarial pass, Medium 3).
    if capture.torn_json_line_present() {
        return None;
    }

    capture
        .events
        .iter()
        .rev()
        .find(|v| {
            v.get("type").and_then(serde_json::Value::as_str) == Some("system")
                && v.get("subtype").and_then(serde_json::Value::as_str) == Some("init")
        })?
        .get("session_id")?
        .as_str()
        .map(str::to_string)
}

/// Thin file-reading wrapper over the two session-id readers: reads the phase's
/// captured stdout file (via [`stdout_path`]) and delegates. `None` for a
/// missing capture file, never an `Err` — mirrors [`evaluate_layer1`]'s
/// lossy-read convention (CR-01: one invalid UTF-8 byte from raw `sh`
/// redirection must not silently disable this reader).
///
/// [`claude_stream_session_id`] is tried FIRST, then [`claude_session_id`].
/// Stream-first is safe and behavior-preserving: the stream gate
/// ([`is_claude_event_stream`]) declines a single-document envelope, so every
/// capture shape that ships today still resolves through `claude_session_id`
/// bit-for-bit. Without this chain the Phase 28 checkpoint-resume path — whose
/// whole delivery is reconstructing a session via `claude --resume` — returns
/// `None` for every `stream-json` capture.
pub fn session_id_from_capture(project_root: &Path, phase: PhaseId) -> Option<String> {
    let stdout = read_capture(&stdout_path(project_root, phase))?;
    claude_stream_session_id(&stdout).or_else(|| claude_session_id(&stdout))
}

/// The ONE decode policy for capture files: read the bytes and replace invalid
/// UTF-8 with U+FFFD. Every capture-file consumer (`evaluate_layer1`,
/// `checkpoint_reported_in_capture`, `session_id_from_capture`) reads through
/// here, so the policy cannot silently diverge per call site again.
///
/// REPLACE, never drop. A drop-based decode was tried (third adversarial pass
/// remediation) and refuted by the fourth pass: deleting invalid bytes JOINS
/// the tokens on either side, and `DEVFLOW_RESULT: {"status":"suc<FF>cess"}`
/// decoded to a fabricated, VALID success marker that short-circuited a
/// nonzero exit code. Replacement keeps corruption visible: the marker parser
/// sees `suc\u{FFFD}cess`, which is not a recognized status, and correctly
/// refuses to trust it. Consumers that need to tolerate corruption at the
/// EDGES of a single-document capture strip it explicitly via
/// [`strip_corruption_padding`] — bounded, and incapable of joining tokens.
fn read_capture(path: &Path) -> Option<String> {
    let bytes = std::fs::read(path).ok()?;
    Some(String::from_utf8_lossy(&bytes).into_owned())
}

/// Trim whitespace and U+FFFD replacement characters from both ends of a
/// single-document capture.
///
/// U+FFFD is what [`read_capture`] substitutes for invalid bytes, and it is a
/// printing, non-whitespace character — so a stray byte written before or after
/// the JSON envelope survives `trim()` and defeats every `starts_with('{')`
/// guard. That was the third pass's High: Layer 1 abstained on an authoritative
/// `is_error: true` and the exit-code fallback turned a reported failure into a
/// Ship-gate success. Stripping only the EDGES is deliberate: corruption inside
/// the envelope must stay visible and fail the parse, because "repairing" it is
/// how the fourth pass's marker-fabrication High happened.
fn strip_corruption_padding(s: &str) -> &str {
    s.trim_matches(|c: char| c.is_whitespace() || c == '\u{FFFD}')
}

// WR-12 (13-REVIEW.md), revised: these traversal helpers run on the coding
// agent's raw stdout (via detect_claude_rate_limit, which every `devflow
// advance` invocation runs through evaluate_layer1), so deeply nested JSON —
// accidental or adversarial — must not stack-overflow the process. The
// traversal is iterative (an explicit worklist), so nesting depth never
// consumes call stack and no depth cap is needed. The first WR-12 fix capped
// recursion at 64, which silently missed keys at depths 64–128 — nesting
// serde_json's default 128-level parse recursion limit (the only producer of
// these `Value`s) accepts just fine.

/// Depth-first pre-order scan over every JSON object in `value`, returning
/// the first `Some` produced by `visit` on an object's map.
fn json_scan<'a, T>(
    value: &'a serde_json::Value,
    visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
) -> Option<T> {
    let mut stack = vec![value];
    while let Some(current) = stack.pop() {
        match current {
            serde_json::Value::Object(map) => {
                if let Some(found) = visit(map) {
                    return Some(found);
                }
                // Push in reverse so pop order preserves document order.
                for child in map.values().rev() {
                    stack.push(child);
                }
            }
            serde_json::Value::Array(values) => {
                for child in values.iter().rev() {
                    stack.push(child);
                }
            }
            _ => {}
        }
    }
    None
}

fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
    json_scan(value, |map| {
        (map.get(key)?.as_str()? == expected).then_some(())
    })
    .is_some()
}

fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
    json_scan(value, |map| {
        (map.get(key)?.as_i64()? == expected).then_some(())
    })
    .is_some()
}

fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
    json_scan(value, |map| map.get(key))
}

fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
    match value {
        serde_json::Value::String(s) => Some(s.clone()),
        serde_json::Value::Number(n) => Some(n.to_string()),
        _ => None,
    }
}

/// Read the top-level `is_error` boolean (and, if present, `num_turns`) from
/// a Claude JSON result envelope (`--output-format json`) and treat
/// `is_error: true` as an authoritative Layer-1 failure.
///
/// This is checked BEFORE the `DEVFLOW_RESULT` marker path in
/// [`evaluate_layer1`], so `is_error: true` OVERRIDES a stale/echoed success
/// marker embedded in the same envelope's `result` text — the envelope is
/// authoritative for errors. `is_error` absent or `false` returns `None`,
/// deferring to the marker path and, ultimately, Layer 2. It runs AFTER
/// `detect_claude_rate_limit`, though: rate-limit envelopes also carry
/// `is_error: true`, and the specific `RateLimited` classification (which
/// drives the primary rate-limit resume cron) must win over this
/// generic `Failed`.
///
/// Per RESEARCH Pitfall 5, `is_error` (not specific `subtype` strings) is
/// the documented, stable signal — this does not special-case non-success
/// subtype values beyond what already exists in `detect_claude_rate_limit`.
fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
    // strip_corruption_padding, not trim(): a stray invalid byte decoded to
    // U+FFFD at either EDGE of the envelope must not defeat the `{` guard
    // (third-pass High). Interior corruption still fails the parse, by design.
    let trimmed = strip_corruption_padding(stdout);
    if !trimmed.starts_with('{') {
        return None;
    }
    let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
    let is_error = value.get("is_error")?.as_bool()?;
    if !is_error {
        return None;
    }

    let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
    let base_reason = value
        .get("result")
        .and_then(serde_json::Value::as_str)
        .map(str::to_string)
        .or_else(|| {
            value
                .get("subtype")
                .and_then(serde_json::Value::as_str)
                .map(str::to_string)
        })
        .unwrap_or_else(|| "agent reported is_error".to_string());
    let reason = match num_turns {
        Some(n) => format!("{base_reason} (num_turns: {n})"),
        None => base_reason,
    };

    Some(AgentResult {
        status: AgentStatus::Failed,
        exit_code: None,
        reason: Some(reason),
        commits: None,
        summary: None,
        verdict: None,
        decided_by_layer: Some(1),
    })
}

/// The rendered VALUE of a human-blocking checkpoint's `**Gate:**` line.
///
/// **CONFIRMED against a live end-to-end run (2026-07-31).** Assumption A1 is
/// closed. A real `devflow start` run drove a synthetic phase declaring a
/// `gate="blocking-human"` task through DevFlow's own monitor process (not a
/// Claude Code agent session, which is what blocked `28-PROBE.md`'s original
/// attempt at the Bash-tool permission classifier). The checkpoint fired and
/// `.devflow/phase-NN-stdout` captured it inside the JSON envelope's `result`
/// text as:
///
/// ```text
/// **Gate:** `blocking-human`
/// ```
///
/// The VALUE is what this constant holds. The surrounding markdown — bold
/// label, and a **code span around the value** — is handled by
/// [`text_reports_human_gate`]'s trim set, not by this constant.
///
/// The code span is the part RESEARCH.md did not predict. Its § "Architecture
/// Patterns / Pattern 2" derived the literal by reading the *emitting* source
/// (`gsd-executor.md:356`, `execute-phase.md:1053`) and predicted a bare
/// `**Gate:** blocking-human`. The real relay renders the value as a code
/// span, which defeated the original matcher entirely — see
/// [`text_reports_human_gate`] for that failure and its fix. Lesson worth
/// keeping: the emitting source told us the value, not the rendering.
const HUMAN_GATE_VALUE: &str = "blocking-human";

/// Confirm whether captured stdout reports a human-blocking checkpoint, by
/// searching for a `**Gate:**`-labeled line whose VALUE is exactly
/// [`HUMAN_GATE_VALUE`] — see that constant's doc comment for the live
/// observation (2026-07-31) the matched rendering is built from.
///
/// This is the CONFIRMATION half of D-01: it is only ever consulted AFTER
/// [`crate::verify::phase_has_blocking_human_checkpoint`] has already
/// returned `true` for the stage's plan(s) (D-01's static half, plan 28-01).
/// A false negative here is the SAFE direction — it falls back to today's
/// never-silent generic gate, losing nothing. A false positive is bounded by
/// the resume ceiling (`mode::MAX_CHECKPOINT_RESUMES`, plan 28-03) and
/// unconditionally recorded by the `checkpoint_auto_decided` audit event
/// (plan 28-03) — it can never silently authorize anything.
///
/// Searches BOTH the raw stdout text and — when the stdout is a Claude JSON
/// result envelope — the unescaped inner `result` text obtained via
/// [`extract_json_result_text`], because the `Gate:` line typically crosses
/// into the capture escaped inside that envelope (RESEARCH § "Common
/// Pitfalls / Pitfall 2": two indirections, subagent emission → orchestrator
/// relay → DevFlow's captured top-level stdout). Matching is
/// case-insensitive on the `Gate` LABEL and tolerates surrounding markdown
/// emphasis (`*`) and whitespace, but the VALUE comparison is exact — this
/// deliberately does NOT widen into a general "does this look like a
/// checkpoint" heuristic (D-02 rejected that class of predicate); the scope
/// is one declared field label with one enumerated value.
///
/// **A Claude `stream-json` capture takes a separate branch** and is answered
/// by [`claude_stream_reports_human_gate`] ALONE — it never consults raw stdout.
/// That is not an oversight to be "completed" later: under a stream capture the
/// raw stdout contains the operator's prompt echoed back as a `user` event, so
/// also scanning it would reinstate the exact false positive the branch exists
/// to remove (review constraint 3 — the unbounded raw scan is the reader that
/// "survives by accident" once the single-document invariant is gone). See that
/// function for which events are eligible and why.
///
/// The branch is taken when [`classify`] says [`CaptureKind::ClaudeStream`], so
/// a single-document envelope, plain text and a Codex stream all fall through to
/// the two-target logic below, unchanged (T-30-25). Classification is
/// deliberately weaker than [`is_claude_event_stream`]: requiring a parsed
/// `system`/`init` here made a single torn line fail OPEN back to the raw scan,
/// reinstating the echoed-prompt false positive this branch exists to remove.
/// See [`classify`] for the full rule set and the defects each rule encodes;
/// see [`is_claude_event_stream`] for why the verdict path keeps its stricter
/// init-only gate.
pub fn blocking_human_checkpoint_reported(stdout: &str) -> bool {
    let capture = ParsedCapture::parse(stdout);
    if classify(&capture) == CaptureKind::ClaudeStream {
        return claude_stream_reports_human_gate(&capture.events);
    }
    if text_reports_human_gate(stdout) {
        return true;
    }
    extract_json_result_text(stdout)
        .as_deref()
        .is_some_and(text_reports_human_gate)
}

/// Core matcher shared by both search targets (raw stdout and the unescaped
/// inner envelope text) in [`blocking_human_checkpoint_reported`]. Scans for
/// a case-insensitive `gate` label, tolerating surrounding markdown emphasis
/// (`*`), code-span backticks (`` ` ``), and whitespace up to the following
/// `:`, then compares the VALUE token immediately after the colon exactly
/// against [`HUMAN_GATE_VALUE`].
///
/// The backtick tolerance is not speculative — it is the single reason this
/// matcher failed against the first real checkpoint ever observed. The live
/// A1 run (2026-07-31) captured the value as a markdown code span,
/// ``**Gate:** `blocking-human` ``, and the original trim set (`*` and space
/// only) left the leading backtick in place, so the `take_while` below
/// terminated immediately and produced an EMPTY value token. The reader
/// returned `false` and a genuine checkpoint fell through to the generic
/// gate. Trimming the backtick is what makes the observed rendering match;
/// do not narrow this set back without re-running that live probe.
///
/// Note the closing backtick needs no handling: `take_while` already stops
/// at it, since a backtick is neither alphanumeric nor `-`.
fn text_reports_human_gate(text: &str) -> bool {
    let lower = text.to_ascii_lowercase();
    let mut search_from = 0;
    while let Some(rel_idx) = lower[search_from..].find("gate") {
        let idx = search_from + rel_idx;
        let after_label = &lower[idx + "gate".len()..];
        let after_label = after_label.trim_start_matches(['*', ' ', '`']);
        if let Some(rest) = after_label.strip_prefix(':') {
            let value_region = rest.trim_start_matches(['*', ' ', '`']);
            let value_token: String = value_region
                .chars()
                .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
                .collect();
            if value_token == HUMAN_GATE_VALUE {
                return true;
            }
        }
        search_from = idx + "gate".len();
    }
    false
}

/// Thin file-reading wrapper over [`blocking_human_checkpoint_reported`]:
/// reads the phase's captured stdout file (via [`stdout_path`]) and
/// delegates. `false` for a missing capture file, never an error.
pub fn checkpoint_reported_in_capture(project_root: &Path, phase: PhaseId) -> bool {
    let Some(stdout) = read_capture(&stdout_path(project_root, phase)) else {
        return false;
    };
    blocking_human_checkpoint_reported(&stdout)
}

/// Determine whether a set of parsed JSONL lines look like a Codex `--json`
/// event stream (as opposed to a single-document Claude envelope or plain
/// text) — i.e. at least one line is a `thread.started` or `turn.*` event.
fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
    events.iter().any(|v| {
        v.get("type")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
    })
}

/// Parse a Codex `--json` JSONL event stream (one JSON object per line) and
/// look at the LAST terminal event (`turn.completed` / `turn.failed`).
///
/// Only decisive when the captured stdout is actually a Codex event stream
/// (per [`is_codex_event_stream`]) — a single-document Claude envelope
/// (`type: "result"`, no `turn.*` lines) is not consumed here and returns
/// `None`, so the Claude envelope/marker paths handle it instead.
///
/// `turn.failed` is decisive: returns `AgentStatus::Failed` with `reason`
/// from `error.message`. A final `turn.completed` with no `DEVFLOW_RESULT`
/// marker returns `None` (defers to Layer 2) rather than an unconditional
/// Success — a marker-less turn must not silently advance a stage (this is
/// the composition fix that keeps a marker-less Validate run from
/// false-passing to Ship).
///
/// NOTE: written against the documented `--json` event schema (thread.started
/// / turn.started / item.* / turn.completed with usage / turn.failed with
/// error.message) but not yet verified against the installed Codex CLI
/// version — the 13-06 dogfood run captures real output and reconciles any
/// delta, the same empirical practice 12-12-SUMMARY.md used for Claude.
fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
    let capture = ParsedCapture::parse(stdout);
    let events = &capture.events;

    if !is_codex_event_stream(events) {
        return None;
    }

    // Same trailing-torn rule as the Claude stream parser, same R1 root cause:
    // a torn JSON line after the last parsed event means the capture's tail —
    // where `turn.failed` would be — may be among the casualties. An earlier
    // `agent_message` success marker must not decide the stage over a tail we
    // provably failed to read. The Codex adapter is live in production, so
    // this is not a Phase-31 deferral.
    if capture.torn_json_after_last_matching(|_| true) {
        return Some(indeterminate_capture_failure());
    }

    // Codex delivers the agent's DEVFLOW_RESULT self-report inside an
    // `agent_message` item's `text` — never as a raw stdout line — so the
    // top-level marker scan cannot see it (13-06 dogfood finding: a Codex
    // `DEVFLOW_RESULT: failed` was invisible and the run fell through to
    // heuristics). The decoded `text` is a plain marker line; reuse the
    // marker parser on it. Last marker wins, matching parse_marker_lines.
    let marker = events.iter().rev().find_map(|v| {
        if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
            return None;
        }
        let item = v.get("item")?;
        if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
            return None;
        }
        let text = item.get("text").and_then(serde_json::Value::as_str)?;
        parse_marker_lines(text)
    });
    if let Some(result) = marker {
        // Same provenance overwrite as parse_devflow_result and the Claude
        // stream path (T-30-26): this AgentResult was deserialized from the
        // agent's own marker JSON, so a planted `"decided_by_layer":0` would
        // otherwise forge Layer-0 external-verification provenance. Found by
        // reading, while closing the identical hole one function over.
        return Some(normalise_stream_marker_provenance(result));
    }

    let terminal = events.iter().rev().find(|v| {
        matches!(
            v.get("type").and_then(serde_json::Value::as_str),
            Some("turn.completed") | Some("turn.failed")
        )
    })?;

    if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
        // turn.completed (or any other terminal we don't recognize) defers
        // to Layer 2 rather than an unconditional Success.
        return None;
    }

    let reason = terminal
        .get("error")
        .and_then(|e| e.get("message"))
        .and_then(serde_json::Value::as_str)
        .map(str::to_string)
        .unwrap_or_else(|| "codex turn failed".to_string());

    Some(AgentResult {
        status: AgentStatus::Failed,
        exit_code: None,
        reason: Some(reason),
        commits: None,
        summary: None,
        verdict: None,
        decided_by_layer: Some(1),
    })
}

/// Parse a captured stdout as JSONL: one `serde_json::Value` per non-blank,
/// parseable line. Lines that are not valid JSON are dropped, so a stream
/// interleaved with plain-text progress noise still yields its events.
///
/// Shared by [`is_claude_event_stream`] and [`last_top_level_result`], which
/// both need the same parsed vector. Deliberately NOT retrofitted into
/// [`parse_codex_event_result`], which open-codes the identical idiom: that
/// parser is correct and shipping, and rewriting it would put an unrelated
/// adapter's behavior at risk for a cosmetic dedupe.
/// Determine whether parsed JSONL lines are a Claude `--output-format
/// stream-json` event stream, as opposed to a single-document Claude envelope,
/// a Codex `--json` stream, or plain text.
///
/// **Gates on `type: "system"` + `subtype: "init"` and NOTHING ELSE.**
/// 30-RESEARCH.md offered an alternative — also gate on `type: "result"`
/// carrying a `session_id` — and that alternative is WRONG; do not "restore"
/// it. The single-document envelope that ships today is literally
/// `{"type":"result",...,"session_id":"abc"}`, so a `result`-keyed gate would
/// swallow every production capture in use and silently displace
/// [`parse_devflow_result`] in the [`evaluate_layer1`] cascade — a change to
/// the shipped Layer-1 verdict path, disguised as adding stream support
/// (T-30-02). The `init` event is both stronger and earlier: it opens the
/// stream and is present in all three archived captures
/// (`30a-evidence/raw_output_v3.jsonl` lines 5, 32 and 47).
///
/// `single_doc_envelope_not_consumed_by_claude_stream_parser` is the test that
/// fails if this gate is widened.
fn is_claude_event_stream(events: &[serde_json::Value]) -> bool {
    events.iter().any(|v| {
        v.get("type").and_then(serde_json::Value::as_str) == Some("system")
            && v.get("subtype").and_then(serde_json::Value::as_str) == Some("init")
    })
}

/// The shape of one non-empty capture line after a parse attempt.
///
/// `TornJson` vs `Noise` is the load-bearing distinction everywhere below: a
/// line that failed to parse but still opens with `{` could be a torn event —
/// a truncated write, or a read of a capture still being appended to — while a
/// prose line cannot be (every stream event line opens with `{`). Conflating
/// the two produced both prior misclassification defects: requiring ALL lines
/// to parse sent torn streams back to the raw scan (second-pass fail-open),
/// and counting any malformed line as suspicious rejected benign interleaved
/// progress noise (fourth-pass Medium 3).
#[derive(Clone, Copy, PartialEq, Eq)]
enum LineShape {
    /// Parsed as JSON; the value lives at the same index in
    /// [`ParsedCapture::events`]' insertion order.
    Event,
    /// Failed to parse but opens with `{` — potentially a torn event.
    TornJson,
    /// Failed to parse and does not open with `{` — cannot be a torn event.
    Noise,
}

/// A capture parsed ONCE, keeping both the surviving events and the shape of
/// every non-empty line — including the ones that did not parse.
///
/// This is the R1 root-cause fix from the phase-30 adversarial series: the old
/// `claude_stream_events` returned a bare `Vec<Value>`, so "I dropped
/// something" was unrepresentable and every consumer silently assumed the
/// survivors were complete. Four separate defects came from that assumption
/// (torn-init gate fail-open, stale-success verdict resurrection, stale
/// session-id resurrection, torn-user gate reopening). Consumers now see the
/// full line record and must decide explicitly what a torn line means for them.
struct ParsedCapture {
    events: Vec<serde_json::Value>,
    line_shapes: Vec<LineShape>,
}

impl ParsedCapture {
    fn parse(stdout: &str) -> Self {
        let mut events = Vec::new();
        let mut line_shapes = Vec::new();
        for line in stdout.lines() {
            let trimmed = line.trim();
            if trimmed.is_empty() {
                continue;
            }
            match serde_json::from_str::<serde_json::Value>(trimmed) {
                Ok(v) => {
                    events.push(v);
                    line_shapes.push(LineShape::Event);
                }
                Err(_) => {
                    // Apply the SAME edge-corruption policy per line that
                    // strip_corruption_padding applies per capture. Without
                    // this, `read_capture`'s U+FFFD replacement in front of an
                    // otherwise-intact line made it classify as Noise — not
                    // `{`-prefixed — so the torn-tail guard could not see a
                    // corrupt superseding event and an earlier success marker
                    // decided the stage (fifth adversarial pass, High 1).
                    //
                    // Retry the parse on the stripped line first: edge
                    // corruption around an intact event RECOVERS the event and
                    // its true verdict. Stripping edges cannot join tokens —
                    // the fabrication hazard was DROPPING bytes inside content
                    // (fourth pass) — and interior corruption still fails the
                    // parse. A line that strips to empty was pure corruption:
                    // torn, fail closed.
                    let stripped = strip_corruption_padding(trimmed);
                    if stripped != trimmed
                        && let Ok(v) = serde_json::from_str::<serde_json::Value>(stripped)
                    {
                        events.push(v);
                        line_shapes.push(LineShape::Event);
                    } else {
                        line_shapes.push(if stripped.starts_with('{') || stripped.is_empty() {
                            LineShape::TornJson
                        } else {
                            LineShape::Noise
                        });
                    }
                }
            }
        }
        Self {
            events,
            line_shapes,
        }
    }

    fn torn_json_line_present(&self) -> bool {
        self.line_shapes.contains(&LineShape::TornJson)
    }

    /// Whether a torn JSON line sits AFTER the last parsed event matching
    /// `pred` — or anywhere at all, when no event matches.
    ///
    /// This is the question behind constraint 9 item 1: the capture's REAL
    /// final verdict may be among the casualties, so nothing that survives
    /// before the tear is allowed to stand in for it. Prose noise lines are
    /// not counted — they cannot be a torn event (events open with `{`).
    fn torn_json_after_last_matching(&self, pred: impl Fn(&serde_json::Value) -> bool) -> bool {
        let mut last_match_line = None;
        let mut event_idx = 0usize;
        for (line_idx, shape) in self.line_shapes.iter().enumerate() {
            if *shape == LineShape::Event {
                if pred(&self.events[event_idx]) {
                    last_match_line = Some(line_idx);
                }
                event_idx += 1;
            }
        }
        self.line_shapes
            .iter()
            .enumerate()
            .any(|(line_idx, shape)| {
                *shape == LineShape::TornJson && last_match_line.is_none_or(|last| line_idx > last)
            })
    }
}

/// What kind of capture this is — decided ONCE, here, instead of re-derived by
/// per-call-site heuristics.
///
/// This is the R2 root-cause fix from the phase-30 adversarial series. Four
/// generations of ad-hoc shape checks (`starts_with('{')` guards, "any event of
/// type X", all-lines-JSON, line counts) each got one case wrong: a torn `init`
/// un-recognised a stream (fail-open), one stray JSON line hijacked plain text
/// (V-01, fail-closed), a torn gate-bearing `user` event un-recognised a stream
/// again, and an interleaved prose line was treated as tearing. One classifier
/// carries all of those lessons in one place.
#[derive(Clone, Copy, PartialEq, Eq)]
enum CaptureKind {
    /// Not JSONL-shaped in the majority — the raw-scan paths own it.
    PlainText,
    /// Exactly one parsed `{"type":"result",…}` line: the envelope the shipped
    /// `--output-format json` adapter emits (T-30-25). Raw-scan paths own it.
    SingleDocEnvelope,
    /// A Claude `stream-json` capture — possibly torn, possibly noisy.
    ClaudeStream,
    /// A Codex `--json` capture: dotted top-level types (`thread.started`,
    /// `item.completed`, `turn.*`). Raw-scan paths own it, as before.
    CodexStream,
}

/// Classification rules, in order — each carries the defect that forced it:
///
/// 1. **Majority of non-empty lines must be JSON-shaped** (parsed OR torn-`{`),
///    else `PlainText`. Counting only PARSED lines fails: truncating a real
///    stream drops its parsed count below any threshold while every surviving
///    line is still `{`-shaped (the truncation sweep caught exactly that). One
///    stray JSON line in prose stays under the majority (V-01).
/// 2. **Any parsed `system`/`user`/`assistant` event → `ClaudeStream`.** Claude
///    types win over dotted deterministically — the old event loop returned
///    whichever it happened to iterate first. Real Codex captures never carry
///    these types, and on a corrupt mixed capture the scoped path is the
///    fail-closed direction for the gate.
/// 3. **Any parsed dotted type → `CodexStream`.**
/// 4. **A single parsed `result` line → `SingleDocEnvelope`** — today's shipped
///    format, which must keep the raw-scan path (T-30-02 / T-30-25).
/// 5. **Multi-line with a `result` event or a torn JSON line → `ClaudeStream`.**
///    A stream whose gate-bearing `user` event tore, leaving only a later
///    `result`, is still a stream (fourth-pass Low / third-pass Medium shape).
/// 6. Everything else → `PlainText`.
///
/// A LONE torn JSON line is deliberately `PlainText`, not `ClaudeStream`: under
/// today's format that shape is a torn single-document envelope, and raw-scanning
/// it preserves detection of a REAL gate declaration inside (dropping one is the
/// T-30-24 harm — worse than the echo false positive). The residual — a stream
/// that died with only its echoed-prompt line, torn, and nothing else — requires
/// the `init` line to have never flushed while the echo line partially did.
/// Accepted and recorded rather than silently traded away.
fn classify(capture: &ParsedCapture) -> CaptureKind {
    let total = capture.line_shapes.len();
    if total == 0 {
        return CaptureKind::PlainText;
    }
    let noise = capture
        .line_shapes
        .iter()
        .filter(|s| **s == LineShape::Noise)
        .count();
    if (total - noise) * 2 <= total {
        return CaptureKind::PlainText;
    }

    if capture.events.iter().any(|v| {
        matches!(
            v.get("type").and_then(serde_json::Value::as_str),
            Some("system" | "user" | "assistant")
        )
    }) {
        return CaptureKind::ClaudeStream;
    }
    if capture.events.iter().any(|v| {
        v.get("type")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|t| t.contains('.'))
    }) {
        return CaptureKind::CodexStream;
    }

    let result_events = capture
        .events
        .iter()
        .filter(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
        .count();
    if total == 1 {
        return if result_events == 1 {
            CaptureKind::SingleDocEnvelope
        } else {
            CaptureKind::PlainText
        };
    }
    if result_events > 0 || capture.torn_json_line_present() {
        CaptureKind::ClaudeStream
    } else {
        CaptureKind::PlainText
    }
}

/// Test-only accessor: does [`classify`] call this capture text a Claude
/// `stream-json` capture?
///
/// Exists so `monitor.rs`'s end-to-end tracer test can assert on the REAL
/// classifier rather than re-deriving "looks like a stream" with its own
/// heuristic — which is precisely the per-call-site divergence [`classify`]
/// was introduced to end. `classify`/`CaptureKind`/`ParsedCapture` stay
/// private; only this yes/no question crosses the module boundary, and only
/// under `cfg(test)`.
#[cfg(test)]
pub(crate) fn capture_is_claude_stream(capture: &str) -> bool {
    classify(&ParsedCapture::parse(capture)) == CaptureKind::ClaudeStream
}

/// Whether an event is TOP-LEVEL — authored by the orchestrator session, not
/// forwarded from a subagent. `parent_tool_use_id` JSON-null or absent.
///
/// The ONE provenance predicate, shared by gate scanning and verdict selection
/// (constraint 9 item 2 / code-review M2: the two paths previously held
/// different notions — gate scanning enforced provenance while
/// [`last_top_level_result`] silently did not, despite its name and doc).
///
/// The absent case must stay top-level: `result` events carry no such key at
/// all in any archived capture. Treating absence as positive provenance remains
/// NECESSARY for today's captures and UNPROVEN safe — no archived capture
/// contains a subagent-origin `result`, so if one can omit the key it would be
/// admitted. Recorded, not solved; the type filter is the second, independent
/// guard on the gate path.
fn is_top_level(event: &serde_json::Value) -> bool {
    matches!(
        event.get("parent_tool_use_id"),
        None | Some(serde_json::Value::Null)
    )
}

/// The LAST top-level `type: "result"` event in a Claude stream capture.
///
/// One capture can hold several: a session kept alive across turns emits one
/// terminal `result` per turn (the archived v3 stream carries three, at lines
/// 19, 37 and 54, produced across task-notification wake-ups). The last is the
/// session's final verdict, so an earlier turn must never decide the stage.
///
/// T-30-01: selection runs over TOP-LEVEL objects only — each value here is one
/// whole JSONL line. A `result`-shaped structure the agent writes inside its own
/// message text is inert string content and structurally unreachable from this
/// scan. Never route this through [`json_scan`]/[`json_find_key`], which descend
/// into nested objects; that is the same protection class as D-04/T-28-04's
/// top-level-only `session_id` read.
///
/// Provenance is ENFORCED via [`is_top_level`], not merely documented — the
/// first version of this function selected on `type == "result"` alone, so a
/// subagent-origin `result` event would have decided the stage (code-review
/// M2, constraint 9 item 2).
fn last_top_level_result(events: &[serde_json::Value]) -> Option<&serde_json::Value> {
    events.iter().rev().find(|v| {
        v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
    })
}

/// Whether a declared canary `token` came back inside a TOP-LEVEL `result`
/// event of this capture (D-13).
///
/// **Why this takes capture TEXT rather than a project root and phase**, unlike
/// its siblings [`checkpoint_reported_in_capture`] and
/// [`session_id_from_capture`]: the delivery canary runs against its own
/// throwaway capture file, not the phase capture. A canary that read (and
/// therefore implied writing) `stdout_path(project_root, phase)` would clobber
/// the stage's own capture — the one artifact the entire Layer 1 cascade
/// decides on.
///
/// **D-13 trap 1 — this may not be a NEW trust path.** The CLI echoes the
/// operator's prompt back into the same stdout as a `user` event, so the
/// planted token *will* appear in the stream regardless of whether anything was
/// delivered. That echo is exactly what produced the checkpoint false positive
/// 30-05 fixed. Matching is therefore confined to events that are both
/// `type: "result"` and [`is_top_level`] — the same provenance predicate
/// [`last_top_level_result`] enforces, reused rather than reinvented.
///
/// **D-13 trap 2 — a match proves DELIVERY, never WORK.** The agent can see the
/// token in its own prompt and emit it without doing anything (999.67's shape).
/// A hit means "the task-notification path is alive"; it never means the
/// dispatched work happened. Summaries and merges remain the evidence of work
/// (D-16/D-18).
///
/// Scans EVERY top-level `result`, not just the last one, which is the one
/// place this deliberately differs from [`last_top_level_result`]. That
/// function selects the session's final *verdict*, so later turns must
/// supersede earlier ones. The canary asks a different question — "did the
/// token ever come back?" — and a token returned on an earlier
/// task-notification turn is a complete answer to it.
pub fn token_reported_in_capture(capture: &str, token: &str) -> bool {
    ParsedCapture::parse(capture)
        .events
        .iter()
        .filter(|v| {
            v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
        })
        .any(|v| {
            v.get("result")
                .and_then(serde_json::Value::as_str)
                .is_some_and(|text| text.contains(token))
        })
}

/// Whether ONE parsed stream event is a top-level `result` carrying a
/// `DEVFLOW_RESULT` marker in its `result` text.
///
/// Exposed for the pipe-owning monitor's close rule (Phase 31, constraint 4),
/// which must decide line-by-line and in real time whether the marker arm is
/// satisfied — it cannot wait for a whole capture and re-parse it.
///
/// This is a COMPOSITION of the two existing predicates, deliberately not a
/// second implementation of either. T-31-01: the CLI echoes the operator's
/// prompt back into the same stdout as a `user` event — that echo is what
/// produced the checkpoint false positive 30-05 fixed — so a marker seen
/// anywhere but inside an event that is BOTH `type: "result"` AND
/// [`is_top_level`] must not close the stream. Reusing [`parse_marker_lines`]
/// keeps the marker grammar (case-insensitive prefix, edge-corruption
/// stripping, JSON body) in one place rather than letting the monitor grow a
/// looser `contains("DEVFLOW_RESULT")` of its own.
pub(crate) fn event_is_top_level_result_marker(event: &serde_json::Value) -> bool {
    event.get("type").and_then(serde_json::Value::as_str) == Some("result")
        && is_top_level(event)
        && event
            .get("result")
            .and_then(serde_json::Value::as_str)
            .and_then(parse_marker_lines)
            .is_some()
}

/// Whether any AGENT-AUTHORED text in a Claude stream capture declares a
/// human-blocking gate. The stream-capture half of
/// [`blocking_human_checkpoint_reported`]; the pure matcher it delegates to,
/// [`text_reports_human_gate`], is unchanged.
///
/// **Why this exists (review constraint 3).** Scanning raw stdout is safe under
/// the single-document envelope, because the only place gate text can appear
/// there is the one `result` field the agent authored. A stream capture breaks
/// that invariant: the operator's prompt is echoed back into the same stdout as
/// a `user` event, so a prompt that merely DOCUMENTS a checkpoint gate
/// rendering becomes textually indistinguishable from a live declaration. The
/// failure is silent — a checkpoint auto-decide fires, or the resume ceiling is
/// consumed, on a stage whose prompt only discussed checkpoints. DevFlow's own
/// planning documents are exactly that kind of prompt content.
///
/// Two independent filters, both required, neither a substitute for the other:
///
/// 1. **Type — keep ONLY `result` events.** `user` events are always either the
///    echoed prompt or a `task_notification` summary re-injected as user-role
///    content; neither is the agent declaring anything. `system` events carry
///    the `init` tool and agent inventory, inert text with no business in a gate
///    scan. `assistant` events are excluded too, and that exclusion is
///    deliberate — do NOT "restore" it for completeness. Turn-FINAL assistant
///    text is duplicated verbatim into the `result` event that follows it
///    (`30a-evidence/raw_output_v3.jsonl` lines 17→19, 36→37, 53→54), so
///    admitting the class buys no detection the `result` events do not already
///    give. What it buys is a new false-positive surface: v3 line 6's top-level
///    assistant narration ("I'll spawn both subagents in the background now.")
///    reaches no `result` event at all, so an agent narrating "next I'll handle
///    the task whose gate the plan declares" would recreate the prompt-echo
///    false positive one layer inward.
/// 2. **Provenance — keep only top-level events.** An event is top-level when
///    `parent_tool_use_id` is JSON null OR the key is absent entirely. The
///    absent case is load-bearing: `result` events carry no such key at all
///    (confirmed across all three archived captures), so a naive presence check
///    would drop exactly the events that matter most. Mistaking
///    subagent-forwarded narration for orchestrator output is the error that
///    invalidated the v1 experiment outright. Kept even though filter 1 already
///    makes it redundant for today's captures — the two guards are meant to
///    fail independently, so a future widening of the type filter cannot
///    silently inherit subagent content.
///
/// **ALL eligible `result` events are scanned, not only the last.** This
/// deliberately diverges from [`last_top_level_result`]'s last-result-wins
/// verdict semantics, and the two conventions must not be "harmonised": a
/// verdict is a single final answer, whereas this asks whether a gate was
/// reported ANYWHERE in the stage's output. A gate declared in turn N followed
/// by task-notification wake-up turns N+1/N+2 — the exact turn shape the v3
/// capture archives — would be silently dropped by last-result-only, losing a
/// human authorization request to the generic gate. That is the
/// opposite-direction harm, and the worse of the two.
///
/// Text is read with a direct [`serde_json::Value::get`] chain. Never route
/// this through [`json_scan`]/[`json_find_key`]: a recursive traversal descends
/// straight back into the nested message content both filters just excluded,
/// silently undoing the fix while the tests on the outer shape still pass
/// (T-30-23).
///
/// Returns `bool` and short-circuits on the first match rather than collecting
/// the eligible text: this runs on every `devflow advance` over a capture that
/// grows for the whole stage, and there is no reason to allocate a copy of it.
fn claude_stream_reports_human_gate(events: &[serde_json::Value]) -> bool {
    events
        .iter()
        .filter(|event| event.get("type").and_then(serde_json::Value::as_str) == Some("result"))
        .filter(|event| is_top_level(event))
        .filter_map(|event| event.get("result").and_then(serde_json::Value::as_str))
        .any(text_reports_human_gate)
}

/// The `rate_limit_info.status` values that mean the CLI DENIED the request.
///
/// Provenance, per entry — required reading before adding one:
///
/// - `rejected` — drawn from the observed vocabulary of this schema: it is the
///   value the CLI writes for `overageStatus` in the only archived
///   `rate_limit_event`
///   (`.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/raw_output_v3.jsonl`
///   line 15), so it is the denial token this schema actually speaks. It has
///   NOT been observed as a `status` value — no archived capture is of a
///   blocked stream, and every capture DevFlow has taken carries
///   `status: "allowed"`.
///
/// Nothing else is listed, deliberately. Speculatively adding tokens is how the
/// false positive this list exists to prevent comes back: an unrecognised
/// status must DEFER (see [`detect_claude_stream_rate_limit`]), never classify.
/// Correct this list the first time a real blocked capture is archived — that
/// is the only evidence that settles the vocabulary.
const CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES: &[&str] = &["rejected"];

/// Detect an explicit quota DENIAL in a Claude `stream-json` capture and return
/// the retry description, mirroring what [`detect_claude_rate_limit`] returns
/// for the single-document envelope.
///
/// **A `rate_limit_event` is not a rate limit.** The CLI emits these routinely
/// as quota telemetry on healthy streams: the only archived one
/// (`raw_output_v3.jsonl` line 15) says `rate_limit_info.status: "allowed"` and
/// sits in a stream that then completed three turns successfully. Classifying
/// on the event's PRESENCE would mark every healthy Claude stream stage
/// `RateLimited`, and `outcome_policy.rs` maps that to `Action::AutoResume` —
/// so every stage would be auto-resumed against a fabricated retry time
/// instead of advancing (T-30-26). Note the second trap in the same object:
/// `overageStatus` is `rejected` one level below `status: "allowed"`, so any
/// nested search for the token also false-positives. Hence every field here is
/// read with a direct [`serde_json::Value::get`] on the top-level event and its
/// `rate_limit_info` child — never [`json_find_key`]/[`json_scan`], which
/// descend into nested (and, elsewhere in the stream, agent-authored) content
/// and would let the agent supply the retry hint that drives the resume cron's
/// scheduling (T-30-12).
///
/// Two independent guards, both required, neither a substitute for the other:
///
/// 1. **Positional** — only events after the SECOND-TO-LAST `result` event are
///    eligible, i.e. the final turn. A session kept alive across turns emits one
///    `result` per turn, and rate-limit chatter from an earlier turn must never
///    outrank the outcome of a turn that finished later. (In the archived
///    capture the rate event is at line 15 and the results at 19/37/54, so it is
///    excluded on position alone.) With fewer than two `result` events the whole
///    stream IS the final turn.
/// 2. **Semantic** — only a `status` in
///    [`CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES`] classifies. A missing
///    `rate_limit_info`, a missing or non-string `status`, or any unrecognised
///    value returns `None`.
///
/// **Deferring is the deliberately safe direction, not an oversight.**
/// Under-classifying means an unknown denial status falls through to the
/// envelope-failure path and is reported `Failed` — a real degradation (the
/// operator loses automatic resume) but a never-silent one that still gates.
/// Over-classifying means a healthy stream is auto-resumed against a retry time
/// the parser invented. The asymmetry is the whole reason this function reads
/// one field instead of matching a shape.
fn detect_claude_stream_rate_limit(events: &[serde_json::Value]) -> Option<String> {
    // Index of the second-to-last `result` event: everything at or before it is
    // previous-turn history. `None` (fewer than two results) means the whole
    // stream is the final turn.
    let boundary = events
        .iter()
        .enumerate()
        .filter(|(_, v)| v.get("type").and_then(serde_json::Value::as_str) == Some("result"))
        .map(|(idx, _)| idx)
        .rev()
        .nth(1);
    let eligible = match boundary {
        Some(idx) => &events[idx + 1..],
        None => events,
    };

    // Last eligible event wins, matching the last-`result`-wins convention.
    let event = eligible
        .iter()
        .rev()
        .find(|v| v.get("type").and_then(serde_json::Value::as_str) == Some("rate_limit_event"))?;

    let info = event.get("rate_limit_info")?;
    let status = info.get("status")?.as_str()?;
    if !CLAUDE_STREAM_RATE_LIMIT_DENIAL_STATUSES.contains(&status) {
        return None;
    }

    // `resetsAt` is epoch seconds, rendered from the JSON number as-is: nothing
    // parses this string. `outcome_policy.rs` routes on the
    // `AgentStatus::RateLimited` variant alone and the `reason` text is
    // operator-facing. Mirrors `detect_claude_rate_limit`'s `retry_after` →
    // `message` → `error` chain; its final `"usage limit"` default has no
    // counterpart here because a matched `status` is by construction one of the
    // non-empty enumerated strings above, so a third rung would be unreachable.
    Some(
        info.get("resetsAt")
            .and_then(json_scalar_to_string)
            .unwrap_or_else(|| status.to_string()),
    )
}

/// The stream-path counterpart of [`detect_claude_envelope_failure`]: treat
/// `is_error: true` on a stream's last `result` event as an authoritative
/// Layer-1 failure.
///
/// The `reason` shape is reproduced deliberately rather than shared — `result`
/// text, else `subtype`, else `agent reported is_error`, with a
/// ` (num_turns: {n})` suffix when present. This phase's scope fence keeps the
/// four shipped single-document parsers unmodified, so factoring the common
/// body out of `detect_claude_envelope_failure` is out of bounds here; the two
/// must be kept in step by hand. `is_error` absent, non-bool, or `false`
/// returns `None`, deferring exactly as the single-document path does.
fn claude_stream_envelope_failure(result_event: &serde_json::Value) -> Option<AgentResult> {
    if !result_event.get("is_error")?.as_bool()? {
        return None;
    }

    let num_turns = result_event
        .get("num_turns")
        .and_then(serde_json::Value::as_u64);
    let base_reason = result_event
        .get("result")
        .and_then(serde_json::Value::as_str)
        .map(str::to_string)
        .or_else(|| {
            result_event
                .get("subtype")
                .and_then(serde_json::Value::as_str)
                .map(str::to_string)
        })
        .unwrap_or_else(|| "agent reported is_error".to_string());
    let reason = match num_turns {
        Some(n) => format!("{base_reason} (num_turns: {n})"),
        None => base_reason,
    };

    Some(AgentResult {
        status: AgentStatus::Failed,
        exit_code: None,
        reason: Some(reason),
        commits: None,
        summary: None,
        verdict: None,
        decided_by_layer: Some(1),
    })
}

/// Parse a Claude `--output-format stream-json` JSONL capture and read the
/// `DEVFLOW_RESULT` marker out of its LAST `result` event.
///
/// The new sibling of [`parse_codex_event_result`], mirroring its shape. Only
/// decisive when the capture is actually a Claude event stream (per
/// [`is_claude_event_stream`]); every other shape returns `None` and falls
/// through to the parser that owns it. Before this existed, a JSONL capture
/// returned `None` from all four single-document parsers —
/// `serde_json::from_str` on the whole multi-line document is a hard "trailing
/// characters" error — so every Claude-driven stage fell through to Layer 2's
/// coarse exit-code+commit heuristic.
///
/// **Precedence, mirroring [`evaluate_layer1`]'s single-document ordering
/// rather than inventing a new one** — do not reshuffle without reading the
/// reasons:
///
/// 1. Format gate ([`is_claude_event_stream`]); every other shape declines here.
/// 2. [`detect_claude_stream_rate_limit`] — a final-turn explicit quota denial
///    wins over EVERYTHING below it, for the same reason `evaluate_layer1`
///    already puts `detect_claude_rate_limit` ahead of the generic failure
///    check: a rate-limited run classified as plain `Failed` kills the primary
///    rate-limit resume cron, the one automated path that exists to recover
///    from it (T-30-13). The precedence is narrow, not broad — the detector
///    only fires on an explicit denial inside the final turn, so it cannot
///    shadow the outcome of a stream that completed.
/// 3. The `DEVFLOW_RESULT` marker in the last `result` event. A non-success
///    marker is decisive and returns immediately; a success marker is HELD, not
///    returned, because step 4 may override it.
/// 4. [`claude_stream_envelope_failure`] — `is_error: true` on that same event
///    overrides a held success marker, matching the single-document rule that
///    the envelope is authoritative for errors and a stale or echoed success
///    marker must not win (T-30-15).
/// 5. The held success marker, else `None`.
///
/// A last `result` event with no marker and no `is_error` returns `None`
/// (defer to Layer 2) rather than an unconditional Success, matching the
/// `turn.completed` convention: a marker-less turn must never silently advance
/// a stage.
///
/// Passing the isolated `result` text to [`parse_marker_lines`] is the correct
/// scoping, not a workaround. The marker is JSON-escaped inside a
/// `"result":"..."` string value, so it can never appear as a line starting
/// with `DEVFLOW_RESULT:` in the raw capture, and that parser's 4000-character
/// tail window is smaller than a single stream `result` line. Once serde
/// decodes the field the escaped newlines become real newlines and the existing
/// tail scan works on it as designed.
fn parse_claude_event_result(stdout: &str) -> Option<AgentResult> {
    let capture = ParsedCapture::parse(stdout);
    if !is_claude_event_stream(&capture.events) {
        return None;
    }

    // Constraint 9 item 1 (code-review H1): a torn JSON line at or after the
    // last surviving top-level result means the session's REAL final verdict
    // may be among the casualties — a capture read while the CLI was still
    // appending, or a truncated write. Nothing that survives before the tear
    // is allowed to stand in for it; in particular an earlier turn's SUCCESS
    // must never advance the stage. Returning a Failed verdict rather than
    // None is deliberate: None would fall through to `parse_devflow_result`'s
    // raw tail scan, which can find the stale marker TEXT inside the surviving
    // JSON lines and resurrect it through the back door. The cost is a false
    // failure when the torn trailing line was a quiet task-notification turn;
    // that reads as loop-back noise, not a silent wrong advance.
    if capture.torn_json_after_last_matching(|v| {
        v.get("type").and_then(serde_json::Value::as_str) == Some("result") && is_top_level(v)
    }) {
        return Some(indeterminate_capture_failure());
    }

    if let Some(retry) = detect_claude_stream_rate_limit(&capture.events) {
        return Some(rate_limited_result(retry));
    }

    let last_result = last_top_level_result(&capture.events)?;

    let marker = last_result
        .get("result")
        .and_then(serde_json::Value::as_str)
        .and_then(parse_marker_lines)
        .map(normalise_stream_marker_provenance);

    let held_success = match marker {
        // A non-success marker is the agent's own final word and nothing below
        // can improve on it.
        //
        // 31-02 audit (non-exhaustive equality site 1 of 3). This `!= Success`
        // is CORRECT AS-IS for `AgentStatus::IdleTimeout` and is deliberately
        // left unchanged. The compiler cannot flag this site — an equality test
        // compiles fine against a new variant — so it is audited by hand here
        // rather than left to the wildcard-free-match mechanism, which does not
        // reach it.
        //
        // The only way `IdleTimeout` arrives here is an agent writing
        // `DEVFLOW_RESULT: {"status":"idle_timeout"}` into its own output,
        // claiming a verdict only DevFlow's monitor is supposed to produce.
        // The predicate handles that in the fail-safe direction: it is not
        // `Success`, so it returns immediately as decisive non-success and
        // `decide_action` gates it for review. A forged idle timeout can
        // therefore only make a run gate, never advance. The REAL
        // monitor-produced verdict does not travel this path at all — it is
        // read from its own side-channel file at the top of `evaluate_layer1`,
        // before this parser ever runs.
        Some(result) if result.status != AgentStatus::Success => return Some(result),
        other => other,
    };

    if let Some(failure) = claude_stream_envelope_failure(last_result) {
        return Some(failure);
    }

    held_success
}

/// The Layer-1 verdict for a stream capture whose TAIL is provably unreadable:
/// a torn JSON line after the last surviving result (constraint 9 item 1).
///
/// Failed, not `None`, and not the pre-tear result. `None` hands the same
/// stdout to `parse_devflow_result`'s raw tail scan, which can resurrect the
/// stale marker text out of the surviving JSON lines; the pre-tear result is
/// exactly the stale-success defect this exists to close. A false failure on a
/// torn-but-benign tail surfaces as a retried stage, never as a silent wrong
/// advance — the asymmetry this whole module is built around.
fn indeterminate_capture_failure() -> AgentResult {
    AgentResult {
        status: AgentStatus::Failed,
        exit_code: None,
        reason: Some(
            "stream capture ends in an unparseable line; the final verdict is indeterminate"
                .to_string(),
        ),
        commits: None,
        summary: None,
        verdict: None,
        decided_by_layer: Some(1),
    }
}

/// T-30-26: overwrite the agent-supplied `decided_by_layer` unconditionally.
///
/// [`parse_marker_lines`] deserializes the agent's own marker JSON straight
/// into [`AgentResult`], and the field is `#[serde(default)]`, so an ordinary
/// `{"status":"success"}` marker leaves it `None` while a hostile
/// `{"status":"success","decided_by_layer":0}` leaves it `Some(0)`. Neither is
/// acceptable: every other Layer-1 constructor in this module sets `Some(1)`
/// explicitly, and `Some(0)` is a Layer-0 external-probe provenance that
/// `classify_validate_outcome` (devflow-cli's `pipeline_outcomes.rs`) reads as
/// `external` when classifying a Validate stage. An agent must not be able to
/// claim a probe verdict it did not earn, so the value is derived here rather
/// than trusted.
fn normalise_stream_marker_provenance(mut result: AgentResult) -> AgentResult {
    result.decided_by_layer = Some(1);
    result
}

/// Scan a bounded tail of `stdout` in reverse line order for the last
/// `DEVFLOW_RESULT` marker.
///
/// `DEVFLOW_RESULT` markers are ASCII. Searching the bounded tail and returning
/// the last valid marker ensures the agent's final status wins over an earlier
/// prompt echo without requiring the surrounding output to be ASCII.
///
/// Three sixth-pass corrections, each with a paired regression:
/// - The tail budget counts WHOLE LINES, never bisecting one (High 2): the old
///   fixed 4000-char window could cut through the final marker line itself
///   when it carried a long `reason`, silently dropping the authoritative
///   failure and handing the verdict to the exit code.
/// - Each line is edge-stripped before prefix matching (High 1): the capture
///   is read lossily, so one stray byte became U+FFFD glued to the prefix or
///   the JSON and the marker vanished. Same policy as every other reader:
///   edges stripped, interior corruption stays visible and untrusted.
/// - The prefix match is genuinely case-insensitive (High 3), as this
///   parser's contract has promised all along — the old strip_prefix chain
///   accepted only ALL-upper or ALL-lower.
fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
    const TAIL_BUDGET_CHARS: usize = 4000;
    const PREFIX: &str = "DEVFLOW_RESULT:";

    let mut budget_used = 0usize;
    for line in stdout.lines().rev() {
        // The line that crosses the budget is still scanned whole; only the
        // NEXT one stops the walk. The last line is always scanned, however
        // long — that is the line the fixed window used to bisect.
        if budget_used > TAIL_BUDGET_CHARS {
            break;
        }
        budget_used += line.chars().count() + 1;

        let line = strip_corruption_padding(line);
        let Some(head) = line.get(..PREFIX.len()) else {
            continue;
        };
        if !head.eq_ignore_ascii_case(PREFIX) {
            continue;
        }

        let json_str = line[PREFIX.len()..].trim();
        if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
            return Some(result);
        }
    }
    None
}

/// One commit the agent made before its stream went silent (D-07, 31-02).
///
/// The subject is carried alongside the sha because a bare sha list is not
/// operator-actionable — D-07's requirement is that the commits be *named*, so
/// that a silent miscount becomes something a human can act on.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct IdleTimeoutCommit {
    /// Full commit sha, as `git log --format=%H` emits it.
    pub sha: String,
    /// Commit subject line (`%s`).
    pub subject: String,
}

/// The pipe-owning monitor's authoritative idle-timeout verdict, as written to
/// [`idle_timeout_path`] BEFORE the child is terminated (D-05, 31-02).
///
/// This is a SIDE CHANNEL, deliberately not the stdout capture. See
/// [`parse_idle_timeout_side_channel`] for why that distinction is a
/// correctness requirement rather than a filing preference.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct IdleTimeoutRecord {
    /// Always [`AgentStatus::IdleTimeout`]'s wire string. Recorded so the file
    /// is self-describing to a human reading `.devflow/` by hand.
    pub status: String,
    /// The idle window that elapsed with no line on the child's stdout.
    pub idle_secs: u64,
    /// The supervised child's pid, from the in-memory `Child` handle — never
    /// re-read from the on-disk pid file, which is exposed to pid reuse
    /// (T-31-07).
    pub agent_pid: u32,
    /// Unix seconds at which the monitor wrote this record.
    pub written_at: u64,
    /// Every commit on the phase branch when the timeout fired. NONE of these
    /// is rolled back — see [`parse_idle_timeout_side_channel`].
    pub commits: Vec<IdleTimeoutCommit>,
}

/// Read the monitor's own idle-timeout verdict, if it wrote one.
///
/// **This is consulted as the FIRST statement of [`evaluate_layer1`], before
/// `read_capture` and before every marker parser. That placement is
/// load-bearing and must not be "tidied" into the `.or_else` chain below it.**
///
/// The obvious-looking alternative — appending the verdict to the stdout
/// capture — is a real correctness bug, not a style choice.
/// `evaluate_layer1`'s chain reaches `parse_devflow_result`'s tail scan only
/// when `parse_claude_event_result` returns `None`, and that parser resolves to
/// the LAST top-level `result` event regardless of what text follows it. On any
/// stream that already completed one successful turn — the normal shape of a
/// run long enough to idle out at all — an appended verdict is therefore never
/// reached, and a stale success stands as the recorded outcome of a run DevFlow
/// itself killed (T-31-06, 31-RESEARCH Pitfall 3).
///
/// Reading before `read_capture` matters for a second reason: that call is an
/// early `return None` when the capture is missing, so a timeout that fired
/// before the child emitted anything at all would otherwise be discarded
/// entirely.
///
/// `decided_by_layer` stays `1`. This is a Layer-1-CLASS authoritative verdict
/// — it just comes from the monitor that supervised the run rather than from
/// parsing what the agent said about itself. It is emphatically not `0`, which
/// is reserved for operator-authored external probe provenance that
/// `classify_validate_outcome` reads as `external`.
///
/// **The file's PRESENCE is the signal; its contents are enrichment.** A record
/// that exists but cannot be read still returns an `IdleTimeout` verdict,
/// carrying a reason that says the details were lost. Returning `None` there
/// would drop the verdict back into the cascade and let precisely the stale
/// success above win — turning a corrupt file into a silent wrong advance,
/// which is the exact failure this function exists to prevent. The asymmetry is
/// the one this whole module is built around: a false failure surfaces as a
/// gate, never as a wrong advance.
///
/// **Nothing here rolls anything back** (D-07, T-31-09). The commits are read
/// and named, never reverted: an idle timeout may be a false positive, and
/// destroying real work on a false positive is unrecoverable.
/// Whether the phase's live capture already carries an explicit quota DENIAL.
///
/// Exists for exactly one caller: [`crate::monitor`]'s idle-timeout path, which
/// must know *why* the stream went quiet before it records a verdict about the
/// silence.
///
/// **The problem this solves.** A quota denial makes the agent go silent — it
/// has nothing left to say. The monitor's idle timer then fires, writes an
/// idle-timeout record, and kills the child. Because
/// [`parse_idle_timeout_side_channel`] is `evaluate_layer1`'s first statement
/// and returns unconditionally (T-31-06), that record shadows
/// [`detect_claude_stream_rate_limit`] — which was sitting in the same capture
/// with the answer. The run is then reported as an idle timeout, "TERMINAL and
/// not retried automatically", when the truth is `RateLimited`, which
/// `outcome_policy` routes to auto-resume.
///
/// Observed 2026-08-08 on a real Code stage: `rate_limit_event` with
/// `status: "rejected"`, `rateLimitType: "seven_day"`,
/// `overageDisabledReason: "out_of_credits"`. Replaying the classifier over that
/// capture returns the denial; the operator was instead told the stream had been
/// silent for 120s. Running out of quota is the likeliest way a long unattended
/// run stops, so it is the failure this phase can least afford to misreport.
///
/// Deliberately delegates to the SAME detector the read path uses rather than
/// re-implementing the check. Two independent notions of "is this a rate limit"
/// would be free to disagree, and the disagreement would be invisible.
#[must_use]
pub fn capture_shows_rate_limit_denial(project_root: &Path, phase: PhaseId) -> bool {
    let Some(raw) = read_capture(&stdout_path(project_root, phase)) else {
        return false;
    };
    detect_claude_stream_rate_limit(&ParsedCapture::parse(&raw).events).is_some()
}

fn parse_idle_timeout_side_channel(project_root: &Path, phase: PhaseId) -> Option<AgentResult> {
    let path = idle_timeout_path(project_root, phase);
    let raw = read_capture(&path)?;

    let Ok(record) = serde_json::from_str::<IdleTimeoutRecord>(&raw) else {
        return Some(idle_timeout_result(
            format!(
                "idle timeout: DevFlow's monitor recorded a timeout verdict at {} but the \
                 record itself is unreadable, so the commit list and idle duration are lost. \
                 The timeout stands regardless — the file's presence is the authoritative \
                 signal. Inspect the phase branch by hand; nothing was rolled back.",
                path.display()
            ),
            None,
        ));
    };

    let named: Vec<String> = record
        .commits
        .iter()
        .map(|commit| {
            let short: String = commit.sha.chars().take(7).collect();
            format!("{short} {}", commit.subject)
        })
        .collect();

    let commit_phrase = if named.is_empty() {
        "No commits were found on the phase branch.".to_string()
    } else {
        format!(
            "The agent made {} commit(s) before going quiet and NONE of them were rolled \
             back: {}.",
            named.len(),
            named.join("; ")
        )
    };

    Some(idle_timeout_result(
        format!(
            "idle timeout: the agent's output stream was silent for {}s, so DevFlow \
             terminated it (agent pid {}). {commit_phrase} Review the branch before deciding \
             what to keep — this run is TERMINAL and is not retried automatically.",
            record.idle_secs, record.agent_pid
        ),
        Some(record.commits.len() as u32),
    ))
}

/// Build the `IdleTimeout` verdict Layer 1 reports for a monitor-recorded
/// timeout.
///
/// `verdict` stays `None` deliberately: at `Stage::Validate`,
/// `classify_validate_outcome` matches `Some(Verdict::Pass)` FIRST and would
/// classify the stage as passed on the strength of that field alone, whatever
/// the status says. A timeout has no verdict to offer, and inventing one here
/// would advance a run that never reported.
fn idle_timeout_result(reason: String, commits: Option<u32>) -> AgentResult {
    AgentResult {
        status: AgentStatus::IdleTimeout,
        exit_code: None,
        reason: Some(reason),
        commits,
        summary: None,
        verdict: None,
        decided_by_layer: Some(1),
    }
}

/// Layer 1: Try to detect agent result from the native per-adapter envelope
/// or the DEVFLOW_RESULT marker in stdout.
///
/// The monitor's own idle-timeout side channel is consulted FIRST, ahead of
/// everything below including `read_capture` itself — see
/// [`parse_idle_timeout_side_channel`], where that ordering is a correctness
/// requirement rather than a preference.
///
/// Precedence: Claude rate-limit envelope (a SPECIFIC failure that must
/// outrank the generic `is_error` check — rate-limit envelopes carry
/// `is_error: true`, and classifying them `Failed` would kill the primary
/// rate-limit resume cron path) → Claude envelope `is_error: true` (authoritative,
/// overrides a success marker) → Claude `stream-json` JSONL event stream (the
/// last `result` event's marker decides; a marker-less last turn defers) →
/// DEVFLOW_RESULT marker (portable; works for plain text and a Claude
/// envelope's unwrapped `result` text) → Codex JSONL event stream
/// (`turn.failed` decisive; `turn.completed` defers) → Codex plain-text
/// rate-limit heuristic (least authoritative, stays last).
///
/// The Claude stream parser's position is load-bearing in BOTH directions
/// (T-30-03). The two single-document detectors stay ahead of it because they
/// remain authoritative for the `--output-format json` envelope that ships
/// today. It goes ahead of `parse_devflow_result` so that an adapter-specific
/// stream capture is owned whole by the parser that understands its framing,
/// rather than letting the generic 4000-character tail scan take a bite of a
/// mid-line window of JSONL first.
pub fn evaluate_layer1(project_root: &Path, phase: PhaseId) -> Option<AgentResult> {
    // FIRST STATEMENT, before `read_capture` and before every parser below.
    // Do not move this into the `.or_else` chain: `parse_claude_event_result`
    // resolves the LAST top-level `result` event and would shadow it on any
    // stream that already had one successful turn. See
    // `parse_idle_timeout_side_channel`'s doc comment (T-31-06).
    if let Some(timed_out) = parse_idle_timeout_side_channel(project_root, phase) {
        return Some(timed_out);
    }

    let stdout = read_capture(&stdout_path(project_root, phase))?;
    detect_claude_rate_limit(&stdout)
        .map(rate_limited_result)
        .or_else(|| detect_claude_envelope_failure(&stdout))
        .or_else(|| parse_claude_event_result(&stdout))
        .or_else(|| parse_devflow_result(&stdout))
        .or_else(|| parse_codex_event_result(&stdout))
        .or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
}

/// Build the `RateLimited` result Layer 1 reports for a detected retry hint.
fn rate_limited_result(retry: String) -> AgentResult {
    AgentResult {
        status: AgentStatus::RateLimited,
        exit_code: None,
        reason: Some(format!("rate limited until {retry}")),
        commits: None,
        summary: None,
        verdict: None,
        decided_by_layer: Some(1),
    }
}

/// Commits on the phase's feature branch that are not on `develop`.
///
/// Derives the branch name from `git_flow.feature_prefix` and the zero-padded
/// `phase`, verifies the branch exists with `rev-parse --verify`, and on
/// success counts `{git_flow.develop}..{branch}` with `rev-list --count`.
/// This is the single implementation of that count — [`evaluate_layer2`],
/// [`evaluate_layer3`] and `pipeline_outcomes::handle_validate_outcome`'s
/// forward-progress check all call it rather than each re-deriving the branch
/// name and re-running the same two git commands, which is what made the
/// counts able to silently diverge before this extraction. That claim was
/// aspirational until 35-01: [`evaluate_layer3`] carried its own inline
/// `rev-list --count` with an independent copy of the lossy zero collapse, and
/// deleting it is what makes "single implementation" true.
///
/// Must be called with the main `project_root`, never a worktree path — git
/// worktrees share refs and the object database, so a commit made inside a
/// linked worktree is immediately visible to a count run from the main
/// checkout, which is the property every caller already relies on.
///
/// The return distinguishes a MEASUREMENT from a measurement FAILURE, which
/// is the whole point of the `Option` (999.77 / D-08, A-06):
///
/// - `Some(n)` — git ran and reported a real number. This includes
///   `Some(0)` for a branch that genuinely does not exist yet, which is
///   normal on a phase's first Validate and is a real observation, not a
///   failure to observe.
/// - `None` — the count could not be established: either the `git` child
///   could not be executed at all (`.output()` returned `Err`), or it ran but
///   produced stdout that does not parse as a `u32`. A-06 splits only the
///   ran/did-not-run axis; the unparseable case is mapped to `None` here
///   because the child produced no usable count, and reporting a forged zero
///   for it would recreate exactly the hazard this signature removes.
///
/// **The two consumers now handle `None` distinctly, and neither collapses it
/// to zero.** `pipeline_outcomes::handle_validate_outcome` treats an
/// unmeasurable cycle as not-progress and leaves its persisted baseline
/// untouched, so the next real measurement still compares against the last
/// real observation. [`evaluate_layer2`] returns `Ok(None)` and falls through
/// to [`evaluate_layer3`], which classifies an unmeasurable count as
/// [`AgentStatus::Unknown`] rather than asserting the negative that no work
/// was done.
///
/// # Changed in v2.5.0 — breaking
///
/// The return type was `u32` before this release; it is now `Option<u32>`
/// (999.77 / 999.87). A call site updating from the old form must decide which
/// of the two states it means, because the old type conflated them:
///
/// - `Some(0)` — git RAN and the branch genuinely has no commits. This is the
///   old `0` in its legitimate sense, and is normal on a phase's first Validate.
/// - `None` — no count was established at all. This is the case the old
///   signature could not express, and `.unwrap_or(0)` is precisely the wrong
///   way to restore it: collapsing it back to zero is the defect this change
///   exists to remove. A transient `git` failure then reads as "no work done",
///   which forged a `consecutive_failures` baseline reset (999.77) and made the
///   result cascade classify a successful agent as `Failed` (999.87).
///
/// The enumeration of this and every other public-surface change in the release
/// is in `CHANGELOG.md` under 2.5.0.
pub fn phase_commit_count(
    project_root: &Path,
    git_flow: &GitFlowConfig,
    phase: PhaseId,
) -> Option<u32> {
    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());

    // A-06: split on whether the command RAN, not on what it answered. An
    // `Err` means the child could not be executed — a measurement failure. An
    // `Ok` with an unsuccessful status means git ran and reported the branch
    // absent, which is a real observation of zero commits.
    match git_command(project_root)
        .args(["rev-parse", "--verify", &branch])
        .output()
    {
        Err(_) => return None,
        Ok(output) if !output.status.success() => return Some(0),
        Ok(_) => {}
    }

    let range = format!("{}..{branch}", git_flow.develop);
    // A-06 again, applied to the second step (CR-01, 35-REVIEW). This arm used
    // to be `.output().ok()?` followed by `.parse().ok()`, which split on
    // whether the output PARSED rather than on whether the command RAN — the
    // opposite of the rule the `rev-parse` step above states and follows. A
    // `rev-list` that runs and exits non-zero writes an empty stdout, so any
    // condition making the range invalid (the configured `develop` absent from
    // the checkout, a shallow clone) parsed to nothing and returned `None`
    // *permanently*, not transiently. That is a measurement the command DID
    // make; it belongs with the branch-absent case above as a real zero.
    let output = match git_command(project_root)
        .args(["rev-list", "--count", &range])
        .output()
    {
        Err(_) => return None,
        Ok(output) => output,
    };
    if !output.status.success() {
        return Some(0);
    }
    // A success whose stdout does not parse is a different animal: git ran,
    // succeeded, and said something this function cannot read. Nothing was
    // established, so it stays `None` rather than being asserted as zero.
    String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}

/// Layer 2: Use exit code + commit count to determine result.
///
/// Reads exit code from `.devflow/phase-NN-exit` file.
/// Counts commits in `feature/phase-NN` branch (if it exists), via
/// [`phase_commit_count`].
///
/// The commit-count gate ("no commits → failed") is scoped to `stage` — it
/// only applies to `Stage::Plan`/`Stage::Code` (checked via an explicit
/// `matches!`, NOT `Stage::is_agent_stage()`, since that also includes
/// `Define`, which legitimately produces zero commits). `exit≠0` is ALWAYS
/// `Failed`, for every stage — only the `exit=0`/zero-commits branch is
/// stage-scoped.
///
/// Decision matrix:
///   exit=137                                             → ResourceKilled (ALL stages, D-07)
///   exit=127                                             → AgentUnavailable (ALL stages, D-07)
///   exit≠0 (excluding 137/127)                           → Failed (ALL stages)
///   exit=0, stage in {Plan, Code}, commits=0             → Failed ("no work done")
///   exit=0, stage in {Plan, Code}, commits>0             → Success
///   exit=0, stage NOT in {Plan, Code} (Define/Validate/Ship), commits=0 → Success
///           (not commit-gated; Validate's real pass signal is its verdict,
///           not a bare zero-commit — see Task 2's turn.completed deferral)
///   exit unknown                                         → fall to Layer 3 (return None)
///   exit=0, stage in {Plan, Code}, commits UNMEASURABLE  → fall to Layer 3 (return None)
///           (CR-01: the ONLY row an unmeasurable count changes. Every other
///           row above is decided by the exit code alone and keeps its verdict
///           with the count rendered as "unknown" in the reason string.)
///
/// WR-06 (13-REVIEW.md): takes only the explicit `project_root` parameter
/// for both the `.devflow/` file paths and the git subprocess `current_dir`
/// — previously it also accepted `state: &State` and used `state.project_root`
/// for the git calls, which every caller happened to pass consistently with
/// `project_root` but which the function itself had no way to enforce.
pub fn evaluate_layer2(
    project_root: &Path,
    phase: PhaseId,
    git_flow: &GitFlowConfig,
    stage: Stage,
) -> Result<Option<AgentResult>, ResultError> {
    let exit_path = devflow_dir(project_root).join(format!("phase-{}-exit", phase.padded()));
    let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
        Ok(s) => s.trim().parse().unwrap_or(-1),
        Err(_) => return Ok(None), // fall to Layer 3
    };

    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
    let commits = phase_commit_count(project_root, git_flow, phase);
    let commit_gated = matches!(stage, Stage::Plan | Stage::Code);

    // D-09 (999.87): an unmeasurable commit count is NOT evidence that no work
    // was done, and the commit gate below would classify it as
    // `Failed — no work done` if it were collapsed to zero.
    //
    // CR-01 (35-REVIEW): the guard belongs HERE, not above the exit-code
    // classification. `commits` is load-bearing for exactly one term —
    // `no_work_done`, which only exists when `commit_gated` holds. Returning
    // early on any `None` also discarded the 137 / 127 / `exit != 0` verdicts
    // and the non-commit-gated `Success`, none of which read the count at all.
    // That mattered because Layer 2 is the SOLE classifier for 137 and 127
    // (Layer 1 sees no marker from a SIGKILLed or never-launched agent, and
    // Layer 3 has no ResourceKilled/AgentUnavailable arm), and the same host
    // fault that OOM-kills an agent also makes the `fork` for `git` fail — so
    // the two observations arrive together, and an infra fault was routed into
    // the Validate loop it is explicitly forbidden from entering
    // (`pipeline_launch.rs`, review consensus #4 / D-08).
    //
    // Fall through ONLY when the missing count is what would have decided.
    if commit_gated && exit_code == 0 && commits.is_none() {
        return Ok(None); // fall to Layer 3
    }

    let no_work_done = commit_gated && commits == Some(0);
    // Reason strings must not invent a number they do not have. Every
    // surviving arm below interpolates the count for context only.
    let commits_desc = match commits {
        Some(n) => format!("{n} commits"),
        None => "an unmeasurable number of commits".to_string(),
    };

    // 137 (SIGKILL, typically OOM) and 127 (command not found) are classified
    // BEFORE the generic `exit_code != 0 -> Failed` catch-all, using the same
    // trusted plain-i32 already parsed above from the monitor-written exit
    // file (D-07, 17b — no ExitStatusExt/signal API per Pitfall 1a).
    let status = if exit_code == 137 {
        AgentStatus::ResourceKilled
    } else if exit_code == 127 {
        AgentStatus::AgentUnavailable
    } else if exit_code != 0 || no_work_done {
        AgentStatus::Failed
    } else {
        AgentStatus::Success
    };

    Ok(Some(AgentResult {
        status,
        exit_code: Some(exit_code),
        reason: if exit_code == 137 {
            Some(format!(
                "agent process was killed (exit code 137, likely OOM) ({commits_desc} on {branch})"
            ))
        } else if exit_code == 127 {
            Some(format!(
                "agent command was unavailable (exit code 127, command not found) \
                 ({commits_desc} on {branch})"
            ))
        } else if exit_code != 0 {
            Some(format!(
                "agent exited with code {exit_code} ({commits_desc} on {branch})"
            ))
        } else if no_work_done {
            Some(format!(
                "no commits found on {branch} (agent exit code was {exit_code})"
            ))
        } else {
            Some(format!(
                "{commits_desc} on {branch} (agent exit code was {exit_code})"
            ))
        },
        commits,
        summary: None,
        verdict: None,
        decided_by_layer: Some(2),
    }))
}

/// Layer 3: Last resort — agent process is gone.
///
/// Split per D-02/D-03 case 3 (17-03): "process gone, commits exist" stays
/// `Unknown` — unverified but there is SOMETHING to account for, and Plan
/// 04's never-advance dispatch gates it downstream (D-04) rather than
/// reclassifying it here. "Process gone, zero commits, nothing declared" is
/// no longer a blanket advanceable `Unknown` — it is reclassified to
/// `Failed` so a vanished agent that produced and declared nothing cannot
/// masquerade as ambiguous-but-fine; the reason flags that human review is
/// needed. This only fires when neither Layer 1 nor Layer 2 produced a
/// definitive result.
///
/// **The split is three-way, not two-way (35-01/F-4).** The two cases above
/// both assume the commit count was actually established. A third case —
/// the count could not be MEASURED at all — is classified `Unknown` with
/// `commits` left absent and a reason naming the measurement failure. It is
/// not `Failed`: that asserts a negative the evidence does not support, and
/// on a transient `git` fault it is the exact misclassification this layer
/// used to produce. An unmeasurable count is strictly less certain than the
/// `commits > 0` case already called `Unknown`, so `Unknown` is the
/// consistent answer.
///
/// The count now comes from [`phase_commit_count`] rather than a second
/// inline derivation. This layer previously ran its own `rev-list --count`
/// that fell soft to a zero default, an independent copy of the same lossy
/// collapse — so fixing only [`evaluate_layer2`] relocated the
/// misclassification here instead of removing it. The two measurable arms'
/// behaviour and reason strings are unchanged.
pub fn evaluate_layer3(
    project_root: &Path,
    phase: PhaseId,
    git_flow: &GitFlowConfig,
) -> Result<AgentResult, ResultError> {
    let branch = format!("{}phase-{}", git_flow.feature_prefix, phase.padded());
    // F-4 (35-01): this layer used to run its OWN inline `rev-list --count`
    // that fell soft to a zero default, an independent copy of the same lossy
    // collapse `phase_commit_count` carried. Because every path that reaches
    // Layer 2 also reaches Layer 3, fixing only Layer 2 relocated the
    // misclassification here instead of removing it. Routed through the shared
    // counter so the cascade's last layer measures the same way every other
    // consumer does.
    let commits = phase_commit_count(project_root, git_flow, phase);

    let (status, reason) = match commits {
        Some(n) if n > 0 => (
            AgentStatus::Unknown,
            format!(
                "unverified — agent process is gone but {} commits exist on {}",
                n, branch
            ),
        ),
        Some(_) => (
            AgentStatus::Failed,
            "no work accounted for — agent process is gone with no commits and no declared \
             external post-condition; human review needed"
                .to_string(),
        ),
        // F-4: an unmeasurable count is not evidence of absent work here
        // either. `Failed` asserts a negative the evidence does not support,
        // and it is the classification this phase exists to stop producing on
        // a transient fault. Layer 3 already reserves `Unknown` for "there is
        // something here I cannot verify"; a count that could not be taken at
        // all is strictly less certain than that, so `Unknown` is the
        // consistent answer. `commits` is left absent rather than forged to
        // zero — "no work" and "could not tell" are different facts.
        None => (
            AgentStatus::Unknown,
            format!(
                "unverified — agent process is gone and the work could not be accounted for: \
                 the commit count on {} could not be measured; human review needed",
                branch
            ),
        ),
    };

    Ok(AgentResult {
        status,
        exit_code: None,
        reason: Some(reason),
        commits,
        summary: None,
        verdict: None,
        decided_by_layer: Some(3),
    })
}

/// Layer 0: run explicitly operator-approved external post-condition probes.
///
/// A failed probe outranks every agent-controlled signal. An approved,
/// all-passing set of declared probes is itself affirmative completion
/// evidence — `Success` — so a legitimately external-only stage with zero
/// commits can still complete cleanly (D-05 gap 2). Evaluated for EVERY
/// stage, not only Code (D-05 gap 1 / D-06). With no declarations (or when
/// disabled), behavior is byte-for-byte the pre-Phase-16 cascade.
///
/// Both DISCOVERY and probe EXECUTION read `execution_root` — the worktree
/// when one is set, `project_root` otherwise (999.76, ROADMAP criterion 6).
///
/// This knowingly OVERTURNS a recorded prior peer-review decision
/// (review Plan 03 MEDIUM, OpenCode). That decision held the two roots must
/// stay distinct, discovery reading `project_root` because
/// `.planning/phases/` "lives there, not in a worktree checkout". **The
/// premise has the direction backwards.** `.planning/` is TRACKED content,
/// so an in-flight phase's `{N}-PLAN.md` is committed on `feature/phase-{N}`
/// and therefore exists INSIDE the worktree while absent from the main checkout for
/// the phase's whole duration. Discovering from `project_root` meant a
/// correctly-declared probe set silently never ran in worktree mode —
/// DevFlow's default operating shape — with no error and no log, and the
/// "PLAN removed" veto below fired in its place. Recorded as an overturn
/// rather than patched quietly, so a later reader can see the direction was
/// reconsidered on evidence rather than overlooked.
///
/// Three sibling reads deliberately KEEP `project_root` and must not be
/// "corrected" to match: [`phase_commit_count`] (git worktrees share refs and
/// the object database, so counting from the main checkout is right), and
/// [`checkpoint_reported_in_capture`] and [`evaluate_layer1`] (both read the
/// stdout capture under `.devflow/`, which lives in the project root).
fn evaluate_layer0(
    project_root: &Path,
    state: &State,
    approved_commands: Option<&[String]>,
) -> Option<AgentResult> {
    if !crate::config::external_verify_enabled(project_root) {
        return None;
    }

    let execution_root = state.worktree_path.as_deref().unwrap_or(project_root);
    let commands = crate::verify::external_verify_commands(execution_root, state.phase);
    if commands.is_empty() {
        return approved_commands.map(|_| AgentResult {
            status: AgentStatus::Failed,
            exit_code: None,
            reason: Some(
                "external verification approval mismatch; PLAN declaration was removed".into(),
            ),
            commits: None,
            summary: None,
            verdict: None,
            decided_by_layer: Some(0),
        });
    }
    let Some(approved_commands) = approved_commands else {
        return Some(AgentResult {
            status: AgentStatus::Failed,
            exit_code: None,
            reason: Some(format!(
                "external verification is not approved; set {} to the reviewed JSON command array",
                crate::verify::TRUST_EXTERNAL_VERIFY_ENV
            )),
            commits: None,
            summary: None,
            verdict: None,
            decided_by_layer: Some(0),
        });
    };
    if commands != approved_commands {
        return Some(AgentResult {
            status: AgentStatus::Failed,
            exit_code: None,
            reason: Some("external verification approval mismatch; PLAN commands changed".into()),
            commits: None,
            summary: None,
            verdict: None,
            decided_by_layer: Some(0),
        });
    }
    match commands
        .into_iter()
        .find(|command| !crate::verify::run_external_verification(command, execution_root))
    {
        Some(command) => Some(AgentResult {
            status: AgentStatus::Failed,
            exit_code: None,
            reason: Some(format!("external verification failed: {command}")),
            commits: None,
            summary: None,
            verdict: None,
            decided_by_layer: Some(0),
        }),
        // Every declared, approved probe passed — affirmative completion
        // evidence on its own (D-05 gap 2), even with zero commits.
        None => Some(AgentResult {
            status: AgentStatus::Success,
            exit_code: None,
            reason: Some(
                "external verification passed — all declared, approved probes succeeded".into(),
            ),
            commits: None,
            summary: None,
            verdict: None,
            decided_by_layer: Some(0),
        }),
    }
}

/// Reconciles Layer 0's affirmative-success result with Layer 1's
/// self-reported verdict at `Stage::Validate` (18e).
///
/// Layer 0's affirmative-success arm above short-circuits the cascade before
/// Layer 1 ever runs (`evaluate_agent_result_inner` returns immediately on
/// any `Some(..)` from Layer 0), but Layer 1 is the ONLY carrier of a
/// `verdict` — `status` reports whether the stage's task ran; `verdict`
/// reports whether validation itself passed (see `AgentResult::verdict`'s
/// doc comment). At `Stage::Validate` that meant an agent's explicit
/// `verdict: pass` was silently discarded and `advance()` computed a failure
/// from it — a regression introduced by this project's own 17-03, fixed
/// here.
///
/// `decided_by_layer` deliberately stays `Some(0)` — Layer 0 still DECIDED
/// the `status`; Layer 1 only supplies the `verdict`. The CLI relies on that
/// value to tell an `external_verify` Validate apart from an ordinary one
/// (`classify_validate_outcome`, 18e).
///
/// Scoped to `Stage::Validate` only (flagged assumption in 18-05-PLAN.md): at
/// every other stage an affirmative Layer 0 success keeps `verdict: None`,
/// unchanged from current behavior. A Layer 0 FAILURE is never passed here —
/// only its affirmative-success arm is, so a failed probe still outranks
/// every agent-controlled signal.
///
/// 31-02 audit (non-exhaustive equality site 2 of 3). The `!= Success` guard
/// below is CORRECT AS-IS for `AgentStatus::IdleTimeout` and is left unchanged.
/// The compiler cannot flag an equality test against a new variant, so this is
/// audited by hand. An idle-timeout result is rejected here by BOTH independent
/// guards, not just one: its status is not `Success`, and its
/// `decided_by_layer` is `Some(1)` (the monitor's side-channel verdict is a
/// Layer-1-class fact), never `Some(0)`. It returns unchanged, which is right —
/// this function exists only to graft Layer 1's `verdict` onto an affirmative
/// Layer 0 probe success, and a timeout is neither.
///
/// # This function is 999.74's real defect site (D-15, ROADMAP criterion 4)
///
/// Until 34-01 the graft read Layer 1's `verdict` and nothing else. A marker of
/// `{"status":"failed","verdict":"pass"}` therefore produced `(Success,
/// Some(Pass), Some(0))`: an agent's self-reported FAILURE laundered into an
/// affirmative pair, which `outcome_policy::decide_action` advances and
/// `classify_validate_outcome` reads as `Passed` — Ship, in `Mode::Auto`, on a
/// run whose agent said it had failed. The status was never inspected, so
/// nothing downstream could see the contradiction; by the time the classifier
/// ran, the status genuinely WAS `Success`.
///
/// The fix consults Layer 1's own `AgentStatus` before transplanting its
/// verdict, because **a verdict attached to a self-reported failure is not a
/// pass**. Only `AgentStatus::Success` from Layer 1 may contribute a verdict;
/// everything else leaves `verdict: None` and the stage classifies `Ambiguous`,
/// which gates.
///
/// The classifier fix (plan 34-03, ROADMAP criterion 3) does **not** close this
/// and never could: gating `classify_validate_outcome`'s `Passed` arm on the
/// derived status passes cleanly here, because the derived status is `Success`.
/// Criterion 3 and criterion 4 are separate deliverables. Regression-pinned by
/// `layer0_verdict_graft_declines_when_layer1_status_is_not_success`, with
/// `layer0_verdict_graft_still_transplants_a_passing_layer1_verdict` as its
/// mandatory opposite-result control.
///
/// `evaluate_layer1` is called on `project_root`, NOT on the execution root,
/// and that asymmetry is deliberate rather than an oversight: Layer 1 reads the
/// stdout capture under `.devflow/`, which lives in the project root, while
/// Layer 0 above DISCOVERS declarations in `.planning/phases/` (project root)
/// and RUNS probes in the worktree. Plan 34-04 moves Layer 0's *discovery* to
/// the execution root; this call stays on `project_root` and is still correct
/// afterwards. Recorded here so a later reader does not "fix" the asymmetry.
fn reconcile_layer0_verdict(
    project_root: &Path,
    state: &State,
    result: AgentResult,
) -> AgentResult {
    if state.stage != Stage::Validate
        || result.status != AgentStatus::Success
        || result.decided_by_layer != Some(0)
    {
        return result;
    }
    let verdict = evaluate_layer1(project_root, state.phase)
        .filter(|layer1| layer1.status == AgentStatus::Success)
        .and_then(|layer1| layer1.verdict);
    AgentResult { verdict, ..result }
}

/// Refuse to let a stream-derived `Success` outrank a contradicting exit code
/// (constraint 9's residual, T-31-15, 31-04).
///
/// # Why this cannot be a parser assertion
///
/// Constraint 9's items 1 and 2 — a torn line at or after the last surviving
/// top-level `result`, and provenance on verdict selection — were closed at the
/// root by the `a557805` refactor that made lossiness and capture kind
/// first-class ([`ParsedCapture`], [`classify`]). What survives is precisely
/// the case no parser can detect: **a capture cut at an exact line boundary is
/// byte-identical to a healthy shorter run.** There is nothing in the bytes to
/// assert on. The writer that died between flushing turn N and turn N+1 also
/// died non-zero, so the exit code is the only remaining signal — and it lives
/// one layer up, in the wiring, which is where this defence had to go.
///
/// # Why the fix is narrow rather than a cascade reordering
///
/// [`evaluate_agent_result_inner`] consults Layer 2 only when Layer 1 abstains,
/// which is why a Layer 1 `Success` wins over a contradicting exit code today.
/// That ordering is correct in the ordinary case: Layer 1 is authoritative
/// precisely so it does not need Layer 2's slower `git rev-list` fallback.
/// Making Layer 2 run first would trade a rare wrong answer for a slow one on
/// every stage. So this arbitrates one verdict rather than reordering anything.
///
/// # Scope
///
/// Fires ONLY on `AgentStatus::Success`. `RateLimited`, `IdleTimeout`,
/// `ResourceKilled`, `AgentUnavailable`, `Failed` and `Unknown` all return
/// untouched, each with a named test. Two of those exclusions are load-bearing
/// rather than tidy: a `RateLimited` downgraded to `Failed` would route the run
/// to a human gate instead of the auto-resume cron it needs, and an
/// `IdleTimeout` downgraded to `Failed` would erase the distinction plan 31-02
/// exists to create — 999.64 reborn inside its own fix.
///
/// 31-02 audit convention (non-exhaustive equality site): the `!= Success`
/// guard below is correct as-is for every current and future variant. Anything
/// that is not an affirmative claimed success has nothing to arbitrate, so
/// passing it through unchanged is the right default for a variant added later.
///
/// # `verdict: None` is load-bearing — do not carry it over for symmetry
///
/// `classify_validate_outcome` (`devflow-cli/src/pipeline_outcomes.rs`) matches
/// `(_, Some(Verdict::Pass)) => ValidateOutcome::Passed` FIRST, with `_`
/// discarding the status entirely. A downgraded result has no verdict to offer
/// and must not invent one. [`idle_timeout_result`] dodges the same trap the
/// same way, and says so. That instruction is unchanged and still binding.
///
/// **Correction (34-01, D-15).** An earlier version of this note went further
/// and claimed a kept `verdict: Pass` on a `status: Failed` "would still
/// classify Validate as **Passed**", making this function a no-op at Validate.
/// That overstated the reachability. `outcome_policy::decide_action` intercepts
/// every non-`Success` status and routes it to a gate BEFORE
/// `classify_validate_outcome` is ever reached, so THIS path is protected and
/// this function is not a no-op. The `verdict: None` above is defence in depth,
/// which is why it stays.
///
/// The route into the inversion that IS reachable is
/// [`reconcile_layer0_verdict`]'s graft — it produced `status: Success` with a
/// self-reported failure's verdict attached, so `decide_action` had nothing to
/// intercept. See that function's own doc comment for the full record. It is
/// closed in plan 34-01; the classifier's own structural fix (gating the
/// `Passed` arm on the derived status) lands in plan 34-03.
///
/// **999.74 / DEN-95** is therefore being CLOSED in Phase 34 rather than
/// deliberately deferred. The caution that motivated the earlier deferral still
/// applies to the classifier half and is discharged there, not here: changing
/// that match arm re-routes `Failed`, `Unknown` and `ResourceKilled`, so 34-03
/// audits all of them explicitly.
///
/// # Exit-code fidelity
///
/// 137 → `ResourceKilled` and 127 → `AgentUnavailable` are preserved rather
/// than collapsed into `Failed`, mirroring [`evaluate_layer2`] exactly:
/// `outcome_policy::decide_action` routes those two to `GateInfra` rather than
/// `GateReview`, and the same exit code must not reach two different operator
/// gates depending on whether a stale Layer 1 success happened to be present.
///
/// Note the `ResourceKilled` arm is currently **unreachable via the
/// `MonitorLaunch::PipeOwning` path**: `run_pipe_owning_monitor` records
/// `status.code().unwrap_or(-1)`, so a SIGKILLed child writes `-1`, not `137`.
/// Recorded rather than silently relabelling a real OOM as `Failed` — the arm
/// is still reachable from the `Legacy` arm's `sh` monitor, whose `$?` does
/// carry `128 + signal`.
///
/// Unreadable or unparseable exit-file content is tolerated exactly as
/// [`evaluate_layer2`] tolerates it — a missing file returns the result
/// unchanged (an absent file is not evidence of failure), and garbage parses to
/// `-1`. Neither is invented behaviour; both match the sibling reader.
fn reconcile_stream_success_against_exit_code(
    project_root: &Path,
    phase: PhaseId,
    result: AgentResult,
) -> AgentResult {
    if result.status != AgentStatus::Success {
        return result;
    }

    let Ok(raw) = std::fs::read_to_string(exit_code_path(project_root, phase)) else {
        return result;
    };
    let exit_code: i32 = raw.trim().parse().unwrap_or(-1);
    if exit_code == 0 {
        return result;
    }

    let (status, lead) = if exit_code == 137 {
        (
            AgentStatus::ResourceKilled,
            format!(
                "the agent's output stream reported SUCCESS but the process was killed \
                 (exit code {exit_code}, likely OOM)"
            ),
        )
    } else if exit_code == 127 {
        (
            AgentStatus::AgentUnavailable,
            format!(
                "the agent's output stream reported SUCCESS but the agent command was \
                 unavailable (exit code {exit_code}, command not found)"
            ),
        )
    } else {
        (
            AgentStatus::Failed,
            format!(
                "the agent's output stream reported SUCCESS but the agent exited with \
                 code {exit_code}"
            ),
        )
    };

    AgentResult {
        status,
        exit_code: Some(exit_code),
        reason: Some(format!(
            "{lead}. A capture cut at an exact line boundary is byte-identical to a healthy \
             shorter run, so no parser assertion can tell the two apart — the exit code is the \
             only remaining signal, and it contradicts the claim. Review the phase branch before \
             deciding what to keep; nothing was rolled back."
        )),
        verdict: None,
        ..result
    }
}

/// Full four-layer evaluation: returns the best available AgentResult.
pub fn evaluate_agent_result(
    project_root: &Path,
    state: &State,
    git_flow: &GitFlowConfig,
) -> Result<AgentResult, ResultError> {
    let approval = crate::verify::external_verification_approval();
    evaluate_agent_result_inner(project_root, state, git_flow, approval.as_deref())
}

fn evaluate_agent_result_inner(
    project_root: &Path,
    state: &State,
    git_flow: &GitFlowConfig,
    approved_commands: Option<&[String]>,
) -> Result<AgentResult, ResultError> {
    // Layer 0: operator-authored external post-condition (authoritative failure)
    if let Some(result) = evaluate_layer0(project_root, state, approved_commands) {
        return Ok(reconcile_layer0_verdict(project_root, state, result));
    }

    // Layer 1: DEVFLOW_RESULT marker (authoritative)
    //
    // Authoritative, but not unconditionally: a CLAIMED success is arbitrated
    // against the recorded exit code before it is returned (31-04, T-31-15).
    // The cascade below is deliberately NOT reordered — see
    // `reconcile_stream_success_against_exit_code` for why Layer 2 running
    // first would be the wrong trade.
    if let Some(result) = evaluate_layer1(project_root, state.phase) {
        return Ok(reconcile_stream_success_against_exit_code(
            project_root,
            state.phase,
            result,
        ));
    }

    // Layer 2: Exit code + commit gate
    if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
        return Ok(result);
    }

    // Layer 3: Process existence + commits
    evaluate_layer3(project_root, state.phase, git_flow)
}

/// Path to the .devflow directory for a project root.
fn devflow_dir(project_root: &Path) -> PathBuf {
    project_root.join(".devflow")
}

/// Path to the stdout file for a given phase.
pub fn stdout_path(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root).join(format!("phase-{}-stdout", phase.padded()))
}

/// Path where the agent's stderr is captured for a given phase.
/// Lives alongside `stdout_path` under `.devflow/`.
pub fn stderr_path(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root).join(format!(
        "phase-{padded}-stderr.log",
        padded = phase.padded()
    ))
}

/// Path to the exit code file for a given phase.
pub fn exit_code_path(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root).join(format!("phase-{}-exit", phase.padded()))
}

/// Path to the file where the monitor records the launched agent's PID.
pub fn agent_pid_path(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root).join(format!("phase-{}-agent-pid", phase.padded()))
}

/// Path to the file holding the stage prompt handed to the pipe-owning
/// monitor (Phase 31).
///
/// The prompt travels `spawn_monitor` → detached monitor process as a FILE,
/// never as argv: DevFlow stage prompts are large and argv has a hard length
/// ceiling, so a prompt passed positionally would fail on exactly the
/// context-heavy stages that matter most.
pub fn prompt_path(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root).join(format!("phase-{}-prompt", phase.padded()))
}

/// Path to the pipe-owning monitor's own log for a phase (Phase 31).
///
/// The monitor is a detached process whose stdio is not the operator's
/// terminal — anything it prints to its own stdout goes nowhere. Every "log
/// loudly" obligation in this phase (the D-04 idle-timeout clamp, the D-11
/// opt-out notice) writes here instead, so a loud message is actually
/// readable after the fact.
pub fn monitor_log_path(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root).join(format!("phase-{}-monitor.log", phase.padded()))
}

/// Path to the pipe-owning monitor's idle-timeout verdict for a phase
/// (D-05/D-06, 31-02).
///
/// A SIDE CHANNEL, deliberately separate from the stdout capture: the capture
/// is the agent's own narration, and a verdict appended to it is shadowed by
/// any earlier genuine `result` event the stream already contained. See
/// [`parse_idle_timeout_side_channel`] — that separation is a correctness
/// requirement (T-31-06), not a filing convention.
///
/// Holds a JSON [`IdleTimeoutRecord`]. Written and fsynced by the monitor
/// BEFORE the child is signalled, so nothing can race the verdict.
pub fn idle_timeout_path(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root).join(format!("phase-{}-idle-timeout", phase.padded()))
}

/// Path to the archived-capture-history directory for a phase (16b).
///
/// `.devflow/history/phase-NN/` holds retained per-stage capture generations
/// so a false-positive self-report can be diagnosed after the fact. Exposed
/// as a constructor (rather than inlined at each call site) so downstream
/// tooling (16h in 16-07's correlation, 16i in 16-05's enumeration) always
/// derives the path from here instead of hardcoding it.
pub fn history_dir(project_root: &Path, phase: PhaseId) -> PathBuf {
    devflow_dir(project_root)
        .join("history")
        .join(format!("phase-{}", phase.padded()))
}

/// Monotonically increasing tie-breaker appended to the nanosecond timestamp
/// used to stamp archived generations, so two archives issued within the
/// same nanosecond (possible in a tight test loop) never collide.
static ARCHIVE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// A stamp unique within this process, used to name an archived generation.
/// The outgoing stage's name is not available at the `archive_phase_files`
/// call site (see `launch_stage` in main.rs), so a monotonic timestamp is
/// used instead — sufficient to order and identify generations.
fn archive_stamp() -> String {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let seq = ARCHIVE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    format!("{nanos}-{seq}")
}

/// Archive the prior stage's stdout/exit captures into bounded per-phase
/// history instead of wiping them outright, so a false-positive self-report
/// can be diagnosed after the fact (16b). Replaces the old
/// `cleanup_phase_files`, which deleted these files unconditionally.
///
/// At most `retain` capture generations are kept per phase; older ones are
/// pruned (see [`prune_history`]). The agent-pid file is still removed
/// outright — it is process bookkeeping, not diagnostic output. When there
/// is nothing to archive (first launch), this is a no-op success.
pub fn archive_phase_files(
    project_root: &Path,
    evidence_root: &Path,
    phase: PhaseId,
    retain: usize,
) -> Result<Option<String>, std::io::Error> {
    archive_phase_files_with_stamp(project_root, evidence_root, phase, retain, &archive_stamp())
}

fn archive_phase_files_with_stamp(
    project_root: &Path,
    evidence_root: &Path,
    phase: PhaseId,
    retain: usize,
    stamp: &str,
) -> Result<Option<String>, std::io::Error> {
    let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
    // The idle-timeout record has the SAME lifetime as the pid file above: it
    // describes one stage attempt and must not outlive it. Clearing it here was
    // simply missed when the side channel was introduced (31-02), and the
    // omission is not benign — [`parse_idle_timeout_side_channel`] is
    // `evaluate_layer1`'s FIRST statement and returns unconditionally
    // (T-31-06), by design, so that nothing can shadow a real timeout. A record
    // that survives its attempt therefore outranks every later stage's real
    // result, forever, for that phase.
    //
    // Observed 2026-08-08: a record written at 22:48 by a killed Plan stage
    // condemned a Define stage that had succeeded 15s earlier, in a 22-second
    // stage, with a message quoting a 120s silence and a pid dead for 14
    // minutes. It survived both `gate reject --note abort` and
    // `devflow start --force`, because nothing anywhere unlinked it.
    //
    // Deleting rather than archiving loses nothing: the verdict is already
    // durable in `advance_evaluated`'s `reason` in `events.jsonl` and in the
    // gate context that quotes it.
    //
    // This must stay ABOVE the "nothing to archive" early return below — the
    // case with a stale record and no capture beside it is exactly the one that
    // needs clearing.
    let _ = std::fs::remove_file(idle_timeout_path(project_root, phase));

    let stdout_src = stdout_path(project_root, phase);
    let exit_src = exit_code_path(project_root, phase);
    let stdout_exists = stdout_src.exists();
    let exit_exists = exit_src.exists();
    if !stdout_exists && !exit_exists {
        return Ok(None); // Nothing to archive — first launch.
    }

    let history_dir = history_dir(project_root, phase);
    crate::workflow::ensure_devflow_dir(&history_dir)?;

    let staging_dir = history_dir.join(format!(".pending-{stamp}"));
    std::fs::create_dir(&staging_dir)?;
    let stdout_stage = staging_dir.join("stdout");
    let exit_stage = staging_dir.join("exit");
    let review_stage = staging_dir.join("REVIEW.md");
    let stdout_dest = history_dir.join(format!("{stamp}-stdout"));
    let exit_dest = history_dir.join(format!("{stamp}-exit"));
    let review_dest = history_dir.join(format!("{stamp}-REVIEW.md"));
    let review_src = phase_review_path(evidence_root, phase);

    let mut stdout_staged = false;
    let mut exit_staged = false;
    let mut stdout_published = false;
    let mut exit_published = false;
    let mut review_published = false;

    let archive_result = (|| -> Result<(), std::io::Error> {
        if stdout_exists {
            std::fs::rename(&stdout_src, &stdout_stage)?;
            stdout_staged = true;
        }
        if exit_exists {
            std::fs::rename(&exit_src, &exit_stage)?;
            exit_staged = true;
        }
        if let Some(review) = &review_src {
            std::fs::copy(review, &review_stage)?;
        }

        if stdout_exists {
            std::fs::rename(&stdout_stage, &stdout_dest)?;
            stdout_staged = false;
            stdout_published = true;
        }
        if exit_exists {
            std::fs::rename(&exit_stage, &exit_dest)?;
            exit_staged = false;
            exit_published = true;
        }
        if review_src.is_some() {
            std::fs::rename(&review_stage, &review_dest)?;
            review_published = true;
        }
        Ok(())
    })();

    if let Err(error) = archive_result {
        let mut rollback_error = None;
        let mut restore = |from: &Path, to: &Path| {
            if let Err(error) = std::fs::rename(from, to)
                && rollback_error.is_none()
            {
                rollback_error = Some(error);
            }
        };
        if stdout_published {
            restore(&stdout_dest, &stdout_src);
        } else if stdout_staged {
            restore(&stdout_stage, &stdout_src);
        }
        if exit_published {
            restore(&exit_dest, &exit_src);
        } else if exit_staged {
            restore(&exit_stage, &exit_src);
        }
        if review_published {
            let _ = std::fs::remove_file(&review_dest);
        }
        let _ = std::fs::remove_dir_all(&staging_dir);

        if let Some(rollback_error) = rollback_error {
            return Err(std::io::Error::new(
                error.kind(),
                format!("{error}; archive rollback failed: {rollback_error}"),
            ));
        }
        return Err(error);
    }

    let _ = std::fs::remove_dir(&staging_dir);

    prune_history(&history_dir, retain);
    Ok(Some(stamp.to_string()))
}

fn phase_review_path(evidence_root: &Path, phase: PhaseId) -> Option<PathBuf> {
    let phases = std::fs::read_dir(evidence_root.join(".planning/phases")).ok()?;
    let prefix = format!("{padded}-", padded = phase.padded());
    for entry in phases.flatten() {
        if entry
            .file_name()
            .to_str()
            .is_some_and(|name| name.starts_with(&prefix))
        {
            let review = entry
                .path()
                .join(format!("{padded}-REVIEW.md", padded = phase.padded()));
            if review.exists() {
                return Some(review);
            }
        }
    }
    None
}

/// Whether `/gsd-verify-work` has produced a `{phase:02}-VERIFICATION.md`
/// artifact for `phase` yet.
///
/// Per D-01 (33-CONTEXT.md), this is the sole mid-arc-vs-genuine-gaps signal
/// a Validate→Code loop-back consults: a phase with no verification artifact
/// is still mid-arc (its remaining plans have not been judged at all), so a
/// loop-back must re-run the phase in full rather than dispatch `--gaps-only`,
/// which matches zero plans and gates unresolvably. Mirrors
/// [`phase_review_path`]'s directory-prefix-scan idiom exactly, but returns a
/// `bool` — no caller needs the artifact's path, only whether it exists. A
/// missing `.planning/phases` directory returns `false` rather than panicking.
///
/// `evidence_root` is the root the Validate agent actually wrote to — the
/// phase's worktree when `state.worktree_path` is set, else the project root.
/// `.planning/` is tracked, so in worktree mode the artifact lands on
/// `feature/phase-N` and is invisible from the main checkout for the phase's
/// entire in-flight duration. Passing the project root in worktree mode is
/// exactly the defect this parameter name exists to prevent (33-CONTEXT.md
/// CR-01); it is NOT interchangeable with the root used for git reads such as
/// [`phase_commit_count`], whose refs and object database are shared across
/// worktrees and which therefore correctly takes the project root.
pub fn phase_verification_exists(evidence_root: &Path, phase: PhaseId) -> bool {
    phase_verification_path(evidence_root, phase).is_some()
}

/// The `{phase:02}-VERIFICATION.md` artifact's path under `evidence_root`, or
/// `None` when no phase directory carries one.
///
/// Extracted from [`phase_verification_exists`] (999.79, 35-05) so the
/// existence probe and the content fingerprint below scan for the artifact in
/// exactly ONE place. Duplicating the prefix scan would let the two answer
/// about different files after any future change to the directory layout — and
/// the freshness rule is only sound while "does it exist" and "what are its
/// bytes" are questions about the same path.
///
/// `evidence_root` carries the same meaning and the same prohibition as it does
/// for [`phase_verification_exists`]: it is the root the Validate agent
/// actually wrote to, never the main checkout in worktree mode.
fn phase_verification_path(evidence_root: &Path, phase: PhaseId) -> Option<PathBuf> {
    let phases = std::fs::read_dir(evidence_root.join(".planning/phases")).ok()?;
    let prefix = format!("{padded}-", padded = phase.padded());
    for entry in phases.flatten() {
        if entry
            .file_name()
            .to_str()
            .is_some_and(|name| name.starts_with(&prefix))
        {
            let verification = entry
                .path()
                .join(format!("{padded}-VERIFICATION.md", padded = phase.padded()));
            if verification.exists() {
                return Some(verification);
            }
        }
    }
    None
}

/// A content fingerprint of the phase's `{phase:02}-VERIFICATION.md`, or `None`
/// when no such artifact exists under `evidence_root` (999.79, 35-05).
///
/// **What it is for.** Nothing deletes, dates or invalidates the artifact, and
/// `devflow start --phase N --force` checks out a branch that still carries the
/// PREVIOUS run's committed copy. That re-run is mid-arc by construction, so its
/// first Validate failure would find the stale artifact, read it as a verdict,
/// and dispatch a `--gaps-only` pass against zero matching plans — gating
/// unresolvably. Comparing this value against the one recorded at the start of
/// the run distinguishes "the Validate agent authored this during this run"
/// from "this was inherited".
///
/// **Why the algorithm is written out rather than borrowed from `std`.** This
/// value is persisted by one process (`devflow start`) and compared by a later
/// one (`devflow advance`), so it must mean the same thing in both.
/// `std::collections::hash_map::DefaultHasher` explicitly does NOT guarantee a
/// stable output across toolchain versions, so an operator who upgraded Rust
/// mid-phase would see every artifact read as "changed" — which is the
/// fail-OPEN direction, dispatching gaps-only exactly where a full execute was
/// correct. This is FNV-1a/64, fixed by these two constants and nothing else.
///
/// **No security property is claimed.** This is change detection over a
/// planning document that is already committed to the repository. It is not
/// collision-resistant and must never be used to authenticate anything; an
/// adversary who can write the artifact can already write whatever verdict they
/// like into it.
///
/// # Companion: [`phase_verification_mtime_nanos`]
///
/// Content alone cannot see an IDEMPOTENT rewrite (WR-06, 35-REVIEW): a
/// Validate agent that re-authors byte-identical content on a later cycle
/// produces the same fingerprint as an artifact nobody touched, and the
/// consumer then classifies its own agent's work as inherited. The mtime is
/// the second input that separates "unchanged because inherited" from
/// "unchanged because idempotent"; it is read from the same resolved path and
/// returns `None` on exactly the same "no artifact" condition, so the two are
/// always consistent about whether an artifact exists.
pub fn phase_verification_fingerprint(evidence_root: &Path, phase: PhaseId) -> Option<u64> {
    let path = phase_verification_path(evidence_root, phase)?;
    let bytes = std::fs::read(path).ok()?;
    // FNV-1a, 64-bit: offset basis and prime are the published constants.
    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
    for byte in bytes {
        hash ^= u64::from(byte);
        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
    }
    Some(hash)
}

/// The mtime of the same `{phase:02}-VERIFICATION.md`
/// [`phase_verification_fingerprint`] hashes, in nanoseconds since the Unix
/// epoch, or `None` when no such artifact exists under `evidence_root`.
///
/// WR-06 (35-REVIEW): the second input the freshness rule needs. A content
/// fingerprint cannot see an IDEMPOTENT rewrite — a Validate agent that
/// re-authors byte-identical content on a later cycle produces the same hash as
/// an artifact nobody touched — so a hash-only rule classifies its own agent's
/// work as inherited and re-runs every plan in the phase from then on. An
/// inherited file's mtime does not advance during a run; a rewritten one's
/// does, whatever the bytes say.
///
/// Resolved through the same [`phase_verification_path`] and returning `None`
/// on the same "no artifact" condition, so the two readings can never disagree
/// about whether the artifact exists.
///
/// **This is not provenance either.** Any writer advances an mtime, so the
/// limitation the fingerprint's doc comment records — a mid-run branch switch
/// or an operator edit reading as authored-this-run — is not closed by this and
/// is marginally widened by it: a checkout restoring byte-identical content
/// used to read as inherited and now reads as authored. That is accepted
/// deliberately, because the case it fixes (a deterministic verification writer
/// on cycle 2 of an unresolved gap) is ordinary rather than exotic.
pub fn phase_verification_mtime_nanos(evidence_root: &Path, phase: PhaseId) -> Option<u64> {
    let path = phase_verification_path(evidence_root, phase)?;
    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
    let since_epoch = modified.duration_since(std::time::UNIX_EPOCH).ok()?;
    // `as` would silently wrap past year 2554; a `None` here degrades to the
    // content-only comparison, which is the pre-WR-06 behaviour.
    u64::try_from(since_epoch.as_nanos()).ok()
}

/// Keep only the newest `retain` capture generations under `history_dir`,
/// deleting older ones. Generations are grouped by their stamp (the shared
/// prefix of a `{stamp}-stdout`/`{stamp}-exit` pair, split off the trailing
/// `-stdout`/`-exit` suffix via `rsplit_once`) and ordered lexicographically,
/// which matches numeric/chronological order for the fixed-width nanosecond
/// stamps `archive_stamp` produces. Ordering parses both numeric components;
/// the process-local sequence is intentionally not fixed-width.
fn prune_history(history_dir: &Path, retain: usize) {
    let Ok(entries) = std::fs::read_dir(history_dir) else {
        return;
    };

    let mut stamps: Vec<String> = entries
        .flatten()
        .filter_map(|entry| {
            let name = entry.file_name().to_str()?.to_string();
            name.rsplit_once('-')
                .map(|(stamp, _suffix)| stamp.to_string())
        })
        .collect();
    stamps.sort_by_key(|stamp| {
        let mut parts = stamp.split('-');
        let nanos = parts
            .next()
            .and_then(|part| part.parse::<u128>().ok())
            .unwrap_or(0);
        let sequence = parts
            .next()
            .and_then(|part| part.parse::<u64>().ok())
            .unwrap_or(0);
        (nanos, sequence)
    });
    stamps.dedup();

    if stamps.len() <= retain {
        return;
    }

    let to_remove = stamps.len() - retain;
    for stamp in &stamps[..to_remove] {
        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-stdout")));
        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-exit")));
        let _ = std::fs::remove_file(history_dir.join(format!("{stamp}-REVIEW.md")));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::GitFlowConfig;
    use crate::mode::Mode;
    use crate::stage::Stage;
    use crate::state::{AgentKind, State};

    fn state_in(root: &Path, phase: PhaseId) -> State {
        let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
        state.stage = Stage::Code;
        state
    }

    fn git(root: &Path, args: &[&str]) {
        let output = crate::test_support::git_command(root)
            .args(args)
            .output()
            .unwrap();
        assert!(
            output.status.success(),
            "git {:?} failed\nstdout: {}\nstderr: {}",
            args,
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
    }

    fn init_repo_with_feature_commit(root: &Path, phase: PhaseId) {
        git(root, &["init"]);
        git(root, &["config", "user.email", "devflow@example.com"]);
        git(root, &["config", "user.name", "DevFlow Tests"]);
        git(root, &["config", "commit.gpgsign", "false"]);
        git(root, &["config", "tag.gpgsign", "false"]);
        git(root, &["config", "core.hooksPath", "/dev/null"]);
        git(root, &["checkout", "-b", "develop"]);
        std::fs::write(root.join("README.md"), "base\n").unwrap();
        git(root, &["add", "README.md"]);
        git(root, &["commit", "-m", "base"]);

        let branch = format!("feature/phase-{padded}", padded = phase.padded());
        git(root, &["checkout", "-b", &branch]);
        std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
        git(root, &["add", "phase.txt"]);
        git(root, &["commit", "-m", "feature work"]);
    }

    /// Like `init_repo_with_feature_commit`, but the feature branch sits at
    /// develop's tip with **no** extra commit (0 commits ahead).
    fn init_repo_with_feature_no_commit(root: &Path, phase: PhaseId) {
        git(root, &["init"]);
        git(root, &["config", "user.email", "devflow@example.com"]);
        git(root, &["config", "user.name", "DevFlow Tests"]);
        git(root, &["config", "commit.gpgsign", "false"]);
        git(root, &["config", "tag.gpgsign", "false"]);
        git(root, &["config", "core.hooksPath", "/dev/null"]);
        git(root, &["checkout", "-b", "develop"]);
        std::fs::write(root.join("README.md"), "base\n").unwrap();
        git(root, &["add", "README.md"]);
        git(root, &["commit", "-m", "base"]);

        let branch = format!("feature/phase-{padded}", padded = phase.padded());
        git(root, &["checkout", "-b", &branch]);
    }

    #[test]
    fn parse_success_marker() {
        let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
    }

    #[test]
    fn parse_failed_marker_with_reason() {
        let stdout =
            "work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.reason.unwrap(), "clippy errors");
    }

    #[test]
    fn parse_missing_marker_returns_none() {
        let stdout = "just some output\nno marker here\n";
        assert!(parse_devflow_result(stdout).is_none());
    }

    #[test]
    fn parse_malformed_json_returns_none() {
        let stdout = "DEVFLOW_RESULT: {not valid json}\n";
        assert!(parse_devflow_result(stdout).is_none());
    }

    #[test]
    fn parse_lowercase_marker() {
        let stdout = "devflow_result: {\"status\":\"success\"}\n";
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
    }

    #[test]
    fn parse_marker_without_space_after_colon() {
        let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
    }

    #[test]
    fn parse_lowercase_no_space_marker() {
        // Lowercase prefix AND no space after the colon — the combination that
        // the Phase 6 review flagged as uncovered.
        let stdout = "devflow_result:{\"status\":\"success\"}\n";
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
    }

    #[test]
    fn parse_finds_last_marker_in_tail() {
        // Multiple markers — should find the last one.
        let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
    }

    #[test]
    fn parse_marker_lines_returns_last_marker_in_long_output() {
        let stdout = format!(
            "{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
             DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
            "prefix".repeat(900),
            "tail output".repeat(100)
        );

        let result = parse_marker_lines(&stdout).unwrap();

        assert_eq!(result.status, AgentStatus::Success);
    }

    #[test]
    fn parse_marker_only_in_last_4000_chars() {
        // Marker beyond 4000 chars from end should not be found.
        let prefix = "a".repeat(5000);
        let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
        assert!(parse_devflow_result(&stdout).is_none());
    }

    #[test]
    fn parse_marker_with_commits_and_summary() {
        let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.commits, Some(3));
        assert_eq!(result.summary.unwrap(), "added tests");
    }

    #[test]
    fn parse_marker_inside_json_result_envelope() {
        // Claude --output-format json wraps the final text in a `result` field
        // with embedded newlines escaped.
        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.commits, Some(2));
    }

    #[test]
    fn parse_failed_marker_inside_json_envelope() {
        let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.reason.unwrap(), "tests failed");
    }

    #[test]
    fn parse_json_envelope_without_marker_returns_none() {
        let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
        assert!(parse_devflow_result(stdout).is_none());
    }

    #[test]
    fn detect_claude_json_rate_limit_by_subtype() {
        let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
        assert_eq!(
            detect_rate_limit(stdout).as_deref(),
            Some("2026-06-18T15:45:30Z")
        );
    }

    #[test]
    fn detect_claude_json_rate_limit_by_429() {
        let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
        assert_eq!(
            detect_rate_limit(stdout).as_deref(),
            Some("Too many requests. Try later.")
        );
    }

    #[test]
    fn detect_codex_try_again_rate_limit() {
        let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
    }

    /// WR-12 (13-REVIEW.md), revised: `json_has_str`/`json_has_i64`/
    /// `json_find_key` run on the coding agent's raw stdout via
    /// `detect_claude_rate_limit`, which every `devflow advance` invocation
    /// goes through. Deeply nested JSON — accidental or adversarial — must
    /// not stack-overflow the process, and a real marker at any depth
    /// serde_json will parse (its default recursion limit is exactly 128)
    /// must still be FOUND — the first WR-12 fix capped traversal at 64 and
    /// silently misclassified rate-limit markers at depths 64–128.
    #[test]
    fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
        // 100 levels: parseable by serde_json (limit 128), deeper than the
        // removed 64-level traversal cap that used to hide the marker.
        const DEPTH: usize = 100;
        let mut stdout = String::new();
        for _ in 0..DEPTH {
            stdout.push_str(r#"{"nested":"#);
        }
        stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
        for _ in 0..DEPTH {
            stdout.push('}');
        }

        // Must return promptly without crashing AND find the buried marker —
        // the iterative worklist traversal has no silent-miss window.
        assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
    }

    #[test]
    fn detect_rate_limit_ignores_normal_stdout() {
        let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
        assert!(detect_rate_limit(stdout).is_none());
    }

    #[test]
    fn claude_envelope_is_error_detected() {
        let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
        let result = detect_claude_envelope_failure(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
    }

    #[test]
    fn claude_is_error_overrides_success_marker() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(9)),
            r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
        )
        .unwrap();

        let result = evaluate_layer1(dir.path(), PhaseId::new(9)).unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
    }

    #[test]
    fn claude_envelope_is_error_false_defers() {
        let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
        assert!(detect_claude_envelope_failure(stdout).is_none());
    }

    #[test]
    fn claude_envelope_marker_still_wins() {
        let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
        assert!(detect_claude_envelope_failure(stdout).is_none());
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.commits, Some(2));
    }

    #[test]
    fn session_id_reads_top_level_string() {
        let stdout = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
        assert_eq!(
            claude_session_id(stdout).as_deref(),
            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
        );
    }

    /// T-28-04 forgery guard: the embedded `DEVFLOW_RESULT` marker carries a
    /// DIFFERENT session id than the envelope's own top-level key. The
    /// top-level id must win — an agent must not be able to redirect which
    /// session DevFlow resumes into by planting its own `session_id` inside
    /// its self-authored marker JSON.
    #[test]
    fn session_id_in_devflow_result_marker_is_not_returned() {
        let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"session_id\": \"forged-by-agent\"}","session_id":"real-top-level-id"}"#;
        assert_eq!(
            claude_session_id(stdout).as_deref(),
            Some("real-top-level-id")
        );
    }

    #[test]
    fn session_id_plain_text_stdout_returns_none() {
        let stdout = "just some plain text output, not JSON\n";
        assert!(claude_session_id(stdout).is_none());
    }

    #[test]
    fn session_id_missing_key_returns_none() {
        let stdout = r#"{"type":"result","result":"done, no session key"}"#;
        assert!(claude_session_id(stdout).is_none());
    }

    #[test]
    fn session_id_non_string_type_returns_none_not_panic() {
        let stdout = r#"{"type":"result","result":"done","session_id":12345}"#;
        assert!(claude_session_id(stdout).is_none());
    }

    #[test]
    fn session_id_from_capture_missing_file_returns_none() {
        let dir = tempfile::tempdir().unwrap();
        assert!(session_id_from_capture(dir.path(), PhaseId::new(42)).is_none());
    }

    #[test]
    fn session_id_from_capture_lossy_reads_invalid_utf8() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        let mut bytes = br#"{"type":"result","result":"done "#.to_vec();
        bytes.push(0xFF); // invalid UTF-8 byte
        bytes.extend_from_slice(br#"","session_id":"lossy-ok"}"#);
        std::fs::write(stdout_path(dir.path(), PhaseId::new(5)), bytes).unwrap();

        assert_eq!(
            session_id_from_capture(dir.path(), PhaseId::new(5)).as_deref(),
            Some("lossy-ok")
        );
    }

    /// Positive fixture built from RESEARCH's *predicted* `**Gate:**`
    /// rendering (a bare, un-spanned value). Kept as a tolerated shape, but
    /// note this is NOT what a real run emits — see
    /// `blocking_human_checkpoint_reported_matches_live_observed_rendering`
    /// for the rendering actually captured on 2026-07-31, which this
    /// prediction missed.
    #[test]
    fn blocking_human_checkpoint_reported_detects_human_gate_line() {
        let stdout = format!(
            "## CHECKPOINT REACHED\n\n**Type:** human-verify\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\n"
        );
        assert!(blocking_human_checkpoint_reported(&stdout));
    }

    /// The Phase 26 near-miss distinction: a plain `blocking` gate must NOT
    /// be classified as a human-blocking checkpoint. `PLAIN_GATE_VALUE` is
    /// local to this test (not a module-level const) — it has no production
    /// use, only this negative fixture's.
    #[test]
    fn blocking_human_checkpoint_reported_false_for_plain_blocking() {
        const PLAIN_GATE_VALUE: &str = "blocking";
        let stdout = format!(
            "## CHECKPOINT REACHED\n\n**Type:** human-verify\n**Gate:** {PLAIN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\n"
        );
        assert!(!blocking_human_checkpoint_reported(&stdout));
    }

    #[test]
    fn blocking_human_checkpoint_reported_false_when_no_gate_field() {
        let stdout = "some ordinary agent failure output, no checkpoint at all\n";
        assert!(!blocking_human_checkpoint_reported(stdout));
    }

    /// The `Gate:` line arrives inside an escaped Claude JSON result
    /// envelope's `result` field — must be found via the unescaped inner
    /// text, not the raw (escaped) JSON string.
    #[test]
    fn blocking_human_checkpoint_reported_true_inside_escaped_envelope() {
        let inner = format!(
            "## CHECKPOINT REACHED\\n\\n**Gate:** {HUMAN_GATE_VALUE} — copy the task's `gate` attribute verbatim so the orchestrator's carve-out sees it\\n"
        );
        let stdout = format!(
            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"abc"}}"#
        );
        assert!(blocking_human_checkpoint_reported(&stdout));
    }

    #[test]
    fn blocking_human_checkpoint_reported_tolerates_whitespace_and_emphasis() {
        let stdout = format!("  **Gate:**   {HUMAN_GATE_VALUE}   \n");
        assert!(blocking_human_checkpoint_reported(&stdout));
    }

    /// REGRESSION — the rendering a real headless run actually produces.
    ///
    /// Transcribed verbatim from `.devflow/phase-91-stdout` of the live A1
    /// run on 2026-07-31 (a genuine `gate="blocking-human"` task driven
    /// through DevFlow's own monitor). The value arrives as a markdown CODE
    /// SPAN, not the bare token RESEARCH.md predicted.
    ///
    /// Before the backtick was added to `text_reports_human_gate`'s trim set
    /// this returned `false`: the leading backtick survived the trim, so the
    /// value `take_while` terminated at once and yielded an empty token. A
    /// real checkpoint was therefore never recognized, and the run fell
    /// through to the generic gate. If this test ever goes red, DevFlow has
    /// stopped recognizing real checkpoints — do not "fix" it by relaxing
    /// the assertion.
    #[test]
    fn blocking_human_checkpoint_reported_matches_live_observed_rendering() {
        let stdout = format!(
            "---\n\n## Checkpoint: Decision\n\n**Plan:** 91-01 Emit the checkpoint\n**Gate:** `{HUMAN_GATE_VALUE}`\n**Progress:** 0/1 tasks complete\n**Task:** Task 1 — Ask the operator to authorize writing the marker file\n"
        );
        assert!(
            blocking_human_checkpoint_reported(&stdout),
            "the live-observed code-span rendering must be recognized; \
             a false negative here means real checkpoints fall through to \
             the generic gate (the 2026-07-31 A1 defect)"
        );
    }

    /// The same live rendering as it actually crosses into DevFlow's capture:
    /// escaped inside the Claude JSON result envelope. This is the exact
    /// path `checkpoint_reported_in_capture` reads in production.
    #[test]
    fn blocking_human_checkpoint_reported_matches_live_rendering_in_envelope() {
        let inner = format!(
            "## Checkpoint: Decision\\n\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Progress:** 0/1 tasks complete\\n"
        );
        let stdout = format!(
            r#"{{"type":"result","subtype":"success","result":"{inner}","session_id":"live-a1"}}"#
        );
        assert!(
            blocking_human_checkpoint_reported(&stdout),
            "the code-span rendering must also be found inside the escaped envelope"
        );
    }

    /// The backtick tolerance must not erode the Phase 26 near-miss
    /// distinction: a code-spanned PLAIN `blocking` gate is still not a
    /// human-blocking checkpoint.
    #[test]
    fn blocking_human_checkpoint_reported_false_for_code_spanned_plain_blocking() {
        let stdout = "## Checkpoint: Decision\n\n**Gate:** `blocking`\n";
        assert!(!blocking_human_checkpoint_reported(stdout));
    }

    #[test]
    fn checkpoint_reported_in_capture_missing_file_returns_false() {
        let dir = tempfile::tempdir().unwrap();
        assert!(!checkpoint_reported_in_capture(
            dir.path(),
            PhaseId::new(42)
        ));
    }

    #[test]
    fn checkpoint_reported_in_capture_reads_true_from_file() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(11)),
            format!("**Gate:** {HUMAN_GATE_VALUE}\n"),
        )
        .unwrap();
        assert!(checkpoint_reported_in_capture(dir.path(), PhaseId::new(11)));
    }

    // ---- stream-capture gate scoping (plan 30-05) --------------------------
    //
    // Fixtures for this cluster live with the other v3 envelopes further down:
    // `V3_USER_EVENT`, `V3_ASSISTANT_TOP_LEVEL_EVENT`,
    // `V3_ASSISTANT_SUBAGENT_EVENT`, `gate_declaration_text` and
    // `gate_documenting_text`. Read their doc comments before adding a case —
    // they record which capture line each envelope came from and that every
    // gate payload is synthetic.
    //
    // Each negative asserts a NEGATIVE CONTROL first: `text_reports_human_gate`
    // must still match the raw capture. Without it a negative would also pass
    // against a fixture that simply contains no gate text, and would keep
    // passing if someone deleted the gate line from the fixture.

    /// **REGRESSION — review constraint 3, the prompt-echo false positive.**
    ///
    /// Under a single-document envelope the only place gate text can appear is
    /// the one `result` field the agent authored, so scanning raw stdout is
    /// safe. A stream capture breaks that invariant: text DevFlow never
    /// authored is echoed back into the same stdout, and a substring scan
    /// cannot tell which event it is inside.
    ///
    /// A failure here means a checkpoint auto-decide can fire, or the resume
    /// ceiling be consumed, on a stage whose prompt merely DISCUSSED
    /// checkpoints — and DevFlow's own planning documents are exactly that kind
    /// of prompt content.
    #[test]
    fn blocking_human_checkpoint_reported_false_for_gate_text_in_user_event() {
        let capture = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        ]);
        assert!(
            text_reports_human_gate(&capture),
            "negative control: the raw capture must still contain matchable \
             gate text, or this test asserts nothing"
        );
        assert!(
            !blocking_human_checkpoint_reported(&capture),
            "gate text inside a `user` event is echoed input, not an agent \
             declaration (review constraint 3)"
        );
    }

    /// **REGRESSION — T-30-22.** Gate text forwarded from a subagent is not the
    /// orchestrator declaring a gate. Mistaking subagent-forwarded content for
    /// orchestrator output is the error that invalidated the v1 experiment
    /// outright and got its whole capture discarded.
    ///
    /// Two independent guards reject this event — the type filter (it is an
    /// `assistant` event) and the provenance filter (its `parent_tool_use_id`
    /// is non-null). The case is kept even though either alone suffices: they
    /// are meant to fail independently, so a future widening of the type filter
    /// cannot silently inherit subagent content.
    #[test]
    fn blocking_human_checkpoint_reported_false_for_subagent_forwarded_gate_text() {
        let capture = stream_capture_of(&[
            &v3_message_event(V3_ASSISTANT_SUBAGENT_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        ]);
        assert!(
            text_reports_human_gate(&capture),
            "negative control: the raw capture must still contain matchable \
             gate text, or this test asserts nothing"
        );
        assert!(
            !blocking_human_checkpoint_reported(&capture),
            "a subagent's forwarded text cannot present as the orchestrator's \
             own gate declaration"
        );
    }

    /// **REGRESSION — T-30-26, the surface cross-AI review found.**
    ///
    /// Narration is not declaration. The envelope is v3 line 6, whose captured
    /// payload is mid-turn narration appearing in NO `result` event of the
    /// capture — so admitting top-level `assistant` events would add a
    /// genuinely new trusted surface, not merely duplicate the result text.
    /// An agent narrating the checkpoint task it is about to work on would then
    /// trip the gate: the prompt-echo false positive, recreated one layer
    /// inward.
    ///
    /// Nothing observed is lost by excluding the class: turn-FINAL assistant
    /// text is duplicated verbatim into the `result` event that follows it
    /// (v3 lines 17→19, 36→37, 53→54).
    #[test]
    fn blocking_human_checkpoint_reported_false_for_top_level_assistant_narration() {
        let capture = stream_capture_of(&[
            &v3_message_event(V3_ASSISTANT_TOP_LEVEL_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        ]);
        assert!(
            text_reports_human_gate(&capture),
            "negative control: the raw capture must still contain matchable \
             gate text, or this test asserts nothing"
        );
        assert!(
            !blocking_human_checkpoint_reported(&capture),
            "intermediate assistant narration discussing a gate is not a live \
             gate declaration"
        );
    }

    /// The positive that stops the scoping from degenerating into always-false
    /// — which would pass every negative above while silently dropping every
    /// real human authorization request (T-30-24).
    #[test]
    fn blocking_human_checkpoint_reported_true_for_top_level_result_declaration() {
        let capture = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, "Execute the plan."),
            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
        ]);
        assert!(
            blocking_human_checkpoint_reported(&capture),
            "a gate declared in a top-level `result` event's own result text \
             must still be detected under a stream capture"
        );
    }

    /// **T-30-27.** Detection asks whether a gate fired ANYWHERE in the stage,
    /// so it deliberately does NOT inherit plan 30-01's last-result-wins
    /// verdict semantics. A gate declared in turn 1 followed by
    /// task-notification wake-up turns — the exact turn shape the v3 capture
    /// archives — must not be dropped in favour of the later, silent results.
    ///
    /// Losing a checkpoint report is the opposite-direction harm from the false
    /// positive this plan closes, and the worse of the two: it silently drops a
    /// request for human authorization to the generic gate.
    #[test]
    fn blocking_human_checkpoint_reported_true_when_only_first_result_declares_gate() {
        let capture = v3_stream_capture(&gate_declaration_text(), NO_MARKER, NO_MARKER);
        assert!(
            blocking_human_checkpoint_reported(&capture),
            "detection must scan every top-level `result` event, not only the \
             last one"
        );
    }

    /// The overcorrection guard: an echo and a genuine declaration can coexist
    /// in one capture, and the scoping must resolve per event rather than
    /// suppressing any capture that contains an echo.
    #[test]
    fn blocking_human_checkpoint_reported_true_when_echo_co_occurs_with_declaration() {
        let capture = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
        ]);
        assert!(
            blocking_human_checkpoint_reported(&capture),
            "an echoed prompt in the same capture must not suppress a genuine \
             declaration"
        );
    }

    /// The same scoping, proven on the path production actually consumes —
    /// `checkpoint_reported_in_capture` reading `.devflow/phase-NN-stdout` from
    /// disk. Both directions are asserted in one test on purpose: the negative
    /// alone cannot distinguish correct scoping from a wrapper that stopped
    /// reading the file at all.
    #[test]
    fn checkpoint_reported_in_capture_scopes_stream_gate_text_to_result_events() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();

        let echo_only = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        ]);
        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), &echo_only).unwrap();
        assert!(
            !checkpoint_reported_in_capture(dir.path(), PhaseId::new(30)),
            "an echoed gate mention read from the capture file must not report \
             a checkpoint"
        );

        let declared = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
        ]);
        std::fs::write(stdout_path(dir.path(), PhaseId::new(31)), &declared).unwrap();
        assert!(
            checkpoint_reported_in_capture(dir.path(), PhaseId::new(31)),
            "a genuine declaration read from the capture file must still \
             report a checkpoint"
        );
    }

    /// **The fail-open regression.** A torn `system`/`init` line must not send
    /// gate scanning back to raw stdout.
    ///
    /// `claude_stream_events` silently drops any line that fails to parse, and
    /// recognition used to require a successfully parsed `init`. So one
    /// truncated first line — a partial write, or a read of a capture still
    /// being appended to — made the whole capture unrecognised, and
    /// `blocking_human_checkpoint_reported` fell back to scanning raw stdout,
    /// which under a stream capture contains the echoed prompt. The constraint-3
    /// scoping failed OPEN, into the exact false positive it exists to close.
    /// Found by cross-AI code review (gpt-5.6-sol, 2026-08-02, High finding 2).
    ///
    /// Envelopes are real (v3 `user` + `result`); the `init` line is a real one
    /// truncated mid-token, and the gate text payload is synthetic — no archived
    /// capture contains gate text or a prompt echo.
    #[test]
    fn blocking_human_checkpoint_reported_false_when_init_is_torn() {
        let torn_init = &V3_INIT_EVENT[..40];
        assert!(
            serde_json::from_str::<serde_json::Value>(torn_init).is_err(),
            "fixture precondition: the truncated init must actually fail to parse"
        );

        let capture = format!(
            "{}\n{}\n{}\n",
            torn_init,
            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        );
        assert!(
            !blocking_human_checkpoint_reported(&capture),
            "a torn init must not re-enable the raw-stdout scan and let the \
             echoed prompt read as a gate declaration"
        );

        // Same capture, init intact — proves the negative above is the torn-init
        // path being handled, not the fixture simply lacking gate text.
        let intact = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        ]);
        assert!(
            !blocking_human_checkpoint_reported(&intact),
            "control: the same capture with a valid init is also false"
        );

        // And a real declaration is still detected with the init torn, so the
        // fix did not degenerate into always-false (T-30-24).
        let declared = format!(
            "{}\n{}\n{}\n",
            torn_init,
            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            v3_result_event(V3_RESULT_TURN1, &gate_declaration_text()),
        );
        assert!(
            blocking_human_checkpoint_reported(&declared),
            "a genuine declaration must still be detected when init is torn"
        );
    }

    /// A stream with NO `init` at all is likewise scoped rather than raw-scanned.
    /// Same fail-open class as the torn-init case; reported by the same review.
    #[test]
    fn blocking_human_checkpoint_reported_false_when_init_is_absent() {
        let capture = format!(
            "{}\n{}\n",
            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        );
        assert!(
            !blocking_human_checkpoint_reported(&capture),
            "an init-less stream must still scope the gate scan to result events"
        );
    }

    /// **The mandatory over-correction controls.** Widening stream recognition
    /// must not divert the three non-stream inputs off the raw-scan path they
    /// have always used (T-30-25). Each carries genuine gate text and must
    /// still report `true`; if any flips to `false`, the widening has started
    /// suppressing real gates.
    #[test]
    fn non_stream_captures_still_use_the_raw_scan_after_widening() {
        let plain = format!("Some narration.\n{}\n", gate_declaration_text());
        assert!(
            blocking_human_checkpoint_reported(&plain),
            "plain text must still be raw-scanned"
        );

        let single_doc = v3_result_event(V3_RESULT_TURN1, &gate_declaration_text());
        assert!(
            blocking_human_checkpoint_reported(&single_doc),
            "a single-document envelope must still be raw-scanned — it is \
             `{{\"type\":\"result\"}}`, which claude_stream_gate_shape excludes"
        );

        let codex = format!(
            "{{\"type\":\"thread.started\",\"thread_id\":\"t1\"}}\n\
             {{\"type\":\"item.completed\",\"item\":{{\"type\":\"agent_message\",\
             \"text\":\"{}\"}}}}\n",
            gate_declaration_text().replace('"', "\\\"")
        );
        assert!(
            blocking_human_checkpoint_reported(&codex),
            "a Codex stream must still be raw-scanned — its top-level types are \
             dotted, so claude_stream_gate_shape excludes it"
        );
    }

    /// **Fourth-pass High.** Decoding must never JOIN tokens across corrupt
    /// bytes. The third pass's remediation dropped invalid bytes, and
    /// `DEVFLOW_RESULT: {"status":"suc<FF>cess"}` with exit 1 decoded to a
    /// fabricated, VALID success marker — Layer 1 then short-circuited the
    /// nonzero exit. Replacement (U+FFFD) keeps the corruption visible: the
    /// status reads `suc\u{FFFD}cess`, no parser trusts it, and the exit code
    /// decides. Edge corruption stays covered by [`strip_corruption_padding`]
    /// — see the sibling third-pass test, which must pass alongside this one.
    #[test]
    fn corrupt_byte_inside_a_marker_is_never_repaired_into_success() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();

        let mut poisoned = b"DEVFLOW_RESULT: {\"status\":\"suc".to_vec();
        poisoned.push(0xff);
        poisoned.extend_from_slice(b"cess\"}");
        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), &poisoned).unwrap();
        assert_ne!(
            evaluate_layer1(dir.path(), PhaseId::new(30)).map(|r| r.status),
            Some(AgentStatus::Success),
            "a corrupt capture with no valid success marker must not be \
             repaired into an authoritative one"
        );

        // Control: the same marker with the byte absent IS a real success.
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(31)),
            br#"DEVFLOW_RESULT: {"status":"success"}"#,
        )
        .unwrap();
        assert_eq!(
            evaluate_layer1(dir.path(), PhaseId::new(31)).map(|r| r.status),
            Some(AgentStatus::Success),
            "control: the intact marker must still parse as success"
        );
    }

    /// **Third-pass High.** A stray invalid byte outside the JSON envelope must
    /// not convert an authoritative failure into a Layer-2 success.
    ///
    /// `from_utf8_lossy` substitutes U+FFFD, which survives `trim()`, so
    /// `detect_claude_envelope_failure`'s `starts_with('{')` guard went false and
    /// Layer 1 abstained on `is_error: true`. The cascade then fell through to
    /// the exit-code check — Ship proceeding on a reported failure. Reachable on
    /// the shipped `--output-format json` envelope; nothing to do with
    /// stream-json.
    #[test]
    fn stray_invalid_byte_does_not_hide_an_envelope_failure() {
        let envelope = br#"{"type":"result","subtype":"error","is_error":true,"result":"boom","session_id":"s"}"#;
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();

        std::fs::write(stdout_path(dir.path(), PhaseId::new(30)), envelope).unwrap();
        assert_eq!(
            evaluate_layer1(dir.path(), PhaseId::new(30)).map(|r| r.status),
            Some(AgentStatus::Failed),
            "control: the intact envelope is an authoritative Layer-1 failure"
        );

        let mut poisoned = vec![0xffu8];
        poisoned.extend_from_slice(envelope);
        std::fs::write(stdout_path(dir.path(), PhaseId::new(31)), &poisoned).unwrap();
        assert_eq!(
            evaluate_layer1(dir.path(), PhaseId::new(31)).map(|r| r.status),
            Some(AgentStatus::Failed),
            "one invalid byte before the envelope must not make Layer 1 abstain \
             and hand a FAILURE to the exit-code fallback"
        );
    }

    /// **Third-pass Medium.** A torn gate-bearing `user` event must not reopen
    /// raw-stdout scanning.
    ///
    /// `claude_stream_gate_shape` keyed stream recognition on system/user/
    /// assistant events. If the echoed `user` event tore *after* carrying the
    /// full gate text and only a later `result` parsed, none of those types
    /// survived, the capture stopped looking like a stream, and the raw scan
    /// read the echoed prompt as a declaration. Every line is still `{`-shaped,
    /// so this is neither the torn-`init` case nor V-01.
    #[test]
    fn torn_gate_bearing_user_event_does_not_reopen_raw_scanning() {
        let echo = v3_message_event(V3_USER_EVENT, &gate_documenting_text());
        let quiet_result = v3_result_event(V3_RESULT_TURN1, NO_MARKER);

        let closed = format!("{}\n{}\n{}\n", V3_INIT_EVENT, echo, quiet_result);
        assert!(
            !blocking_human_checkpoint_reported(&closed),
            "control: with the echo intact the gate mention is correctly scoped out"
        );

        let torn = format!("{}\n{}\n", &echo[..echo.len() - 12], quiet_result);
        assert!(
            !blocking_human_checkpoint_reported(&torn),
            "a torn echo leaving only a result must stay scoped, not fall back to \
             the raw scan that reads the echoed prompt as a declaration"
        );

        // The shipped single-document envelope is ONE result line and must keep
        // taking the raw path (T-30-25).
        let single_doc = v3_result_event(V3_RESULT_TURN1, &gate_declaration_text());
        assert!(
            blocking_human_checkpoint_reported(&single_doc),
            "control: the single-document envelope still uses the raw scan"
        );
    }

    /// **Fourth-pass Medium 3.** Benign prose noise must not block session
    /// recovery — only a torn JSON line can conceal a newer `init`.
    ///
    /// The first fail-closed guard rejected the capture when ANY non-empty line
    /// failed to parse, so one interleaved progress line disabled checkpoint
    /// auto-resume while the verdict parser accepted the same capture. An
    /// `init` is a JSON line; a non-`{` line can never be a torn one.
    #[test]
    fn prose_noise_does_not_block_session_recovery() {
        let stream = format!(
            "{}\nprogress: still working…\n{}\n",
            V3_INIT_EVENT,
            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        );
        assert!(
            claude_stream_session_id(&stream).is_some(),
            "a prose progress line must not fail session recovery closed"
        );

        // Control: the same capture with the noise line made JSON-shaped-but-torn
        // MUST fail closed — that shape could be a torn newer init.
        let torn = format!(
            "{}\n{{\"type\":\"system\",\"subty\n{}\n",
            V3_INIT_EVENT,
            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        );
        assert!(
            claude_stream_session_id(&torn).is_none(),
            "a torn JSON line could be a newer init and must fail closed"
        );
    }

    /// **Third-pass High.** A torn *later* `init` must not resurrect an earlier
    /// session's id.
    ///
    /// Each turn opens its own `init`; the last carries the id a resume must
    /// target. Dropped lines are invisible, so the scan returned the last
    /// PARSEABLE init — a stale token that looks entirely valid. Fails closed
    /// now: `None` costs a resume, the wrong id corrupts one.
    #[test]
    fn torn_later_init_does_not_resurrect_a_stale_session_id() {
        let init =
            |id: &str| format!(r#"{{"type":"system","subtype":"init","session_id":"{id}"}}"#);

        let rotated = format!("{}\n{}\n", init("session-a"), init("session-b"));
        assert_eq!(
            claude_stream_session_id(&rotated).as_deref(),
            Some("session-b"),
            "control: with both init events intact the LAST id wins"
        );

        let init_c = init("session-c");
        let torn = format!(
            "{}\n{}\n{}\n",
            init("session-a"),
            init("session-b"),
            &init_c[..init_c.len() - 10],
        );
        assert_ne!(
            claude_stream_session_id(&torn).as_deref(),
            Some("session-b"),
            "a torn newer init must not hand back the previous session's id"
        );
    }

    /// **V-01 regression.** One stray JSONL-shaped line must not divert a
    /// plain-text capture onto the stream branch and suppress a real gate.
    ///
    /// The first `claude_stream_gate_shape` asked only whether ANY event carried
    /// a stream type. Since the stream branch never consults raw stdout, a single
    /// `{"type":"assistant",…}` line was enough to hide a genuine declaration
    /// sitting in the surrounding plain text — turning the fail-OPEN this
    /// predicate was written to close into a fail-CLOSED that drops a human
    /// authorization request. Found by phase-30 verification after the fix
    /// shipped in `06675da`.
    #[test]
    fn one_stray_json_line_does_not_suppress_a_plain_text_gate() {
        let gate = gate_declaration_text();

        assert!(
            blocking_human_checkpoint_reported(&gate),
            "positive control: the gate text alone must be detected"
        );

        let poisoned =
            format!("{gate}\n{{\"type\":\"assistant\",\"message\":{{\"content\":[]}}}}\n");
        assert!(
            blocking_human_checkpoint_reported(&poisoned),
            "one stray JSONL line must not suppress a real plain-text gate (V-01)"
        );

        // The torn-init capture is still recognised as a stream — the majority
        // rule must not undo the fail-open fix it was added to preserve.
        let torn_init = &V3_INIT_EVENT[..40];
        let torn = format!(
            "{}\n{}\n{}\n",
            torn_init,
            v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        );
        assert!(
            !blocking_human_checkpoint_reported(&torn),
            "control: a torn-init stream must still be scoped, not raw-scanned"
        );
    }

    /// Every byte-prefix of a capture, fed to the gate scanner.
    ///
    /// **Why a sweep and not more hand-written cases.** Phase 30 shipped 116
    /// green tests, seven of them written specifically to prove the prompt-echo
    /// false positive was closed — and a cross-AI review then found that ONE
    /// torn line reverted the whole protection to the raw-stdout path. Every
    /// test fed the parser well-formed input; none fed it a broken one. Hand
    /// -picking more malformed cases would repeat that bias. Truncating at every
    /// offset removes the judgment call: the inputs are generated, not chosen.
    ///
    /// The invariant is one-directional — a prefix may lose detection (it has
    /// strictly less information), but it must never *gain* permissiveness.
    #[test]
    fn truncation_sweep_never_widens_gate_detection() {
        let intact = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, &gate_documenting_text()),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        ]);
        assert!(
            !blocking_human_checkpoint_reported(&intact),
            "precondition: the intact capture must report no gate, or the sweep \
             below proves nothing"
        );

        let mut checked = 0usize;
        for n in 0..=intact.len() {
            if !intact.is_char_boundary(n) {
                continue;
            }
            checked += 1;
            assert!(
                !blocking_human_checkpoint_reported(&intact[..n]),
                "truncating to {n} bytes made an echoed gate MENTION read as a \
                 live declaration — the fail-open class (constraint 9)"
            );
        }
        assert!(
            checked > 500,
            "sweep degenerated to {checked} offsets; it is no longer exercising \
             the capture"
        );
    }

    /// Same sweep against the session-id reader. Truncation may degrade it to
    /// `None` (a failed resume — fail-closed, acceptable); it must never yield a
    /// DIFFERENT id, which would resume the wrong session.
    #[test]
    fn truncation_sweep_never_forges_session_id() {
        let intact = stream_capture_of(&[
            &v3_message_event(V3_USER_EVENT, "session_id: forged-by-agent-text"),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        ]);
        let real = claude_stream_session_id(&intact);
        assert!(
            real.is_some(),
            "precondition: the intact capture yields an id"
        );

        for n in 0..=intact.len() {
            if !intact.is_char_boundary(n) {
                continue;
            }
            let got = claude_stream_session_id(&intact[..n]);
            assert!(
                got.is_none() || got == real,
                "truncating to {n} bytes produced session id {got:?}, which is \
                 neither None nor the CLI-emitted {real:?}"
            );
        }
    }

    /// **Constraint 9 item 2, closed.** A subagent-origin `result` event must
    /// never decide the stage verdict — `last_top_level_result`'s name and doc
    /// always claimed top-level selection, but the first implementation
    /// selected on `type == "result"` alone (code-review M2). Envelope real
    /// (v3 result turn), planted `parent_tool_use_id` synthetic: no archived
    /// capture contains a subagent-origin result, so this pins deterministic
    /// behavior for an unobserved-but-legal shape.
    #[test]
    fn subagent_result_event_never_decides_the_verdict() {
        let subagent_success = v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS).replacen(
            "{",
            "{\"parent_tool_use_id\":\"toolu_child\",",
            1,
        );
        let capture = format!(
            "{}\n{}\n{}\n",
            V3_INIT_EVENT,
            v3_result_event_is_error(V3_RESULT_TURN1, MARKER_FAILED),
            subagent_success,
        );
        assert_eq!(
            parse_claude_event_result(&capture).map(|r| r.status),
            Some(AgentStatus::Failed),
            "a subagent-origin success result must not override the last \
             top-level failure"
        );

        // Control: the same final event WITHOUT the planted parent id is
        // top-level and legitimately wins.
        let top_level = format!(
            "{}\n{}\n{}\n",
            V3_INIT_EVENT,
            v3_result_event_is_error(V3_RESULT_TURN1, MARKER_FAILED),
            v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS),
        );
        assert_eq!(
            parse_claude_event_result(&top_level).map(|r| r.status),
            Some(AgentStatus::Success),
            "control: the same event without a parent id is the final verdict"
        );
    }

    /// D-13 trap 1, pinned: the delivery canary's declared token appears in the
    /// stream as a PROMPT ECHO before it can ever appear as an answer, so a
    /// naive text scan reports delivery on every run — including runs where the
    /// notification path is dead. That echo is what produced the checkpoint
    /// false positive 30-05 fixed.
    ///
    /// Three cases, and the first two are the negative controls that give the
    /// third its meaning: the same token, in the same capture shape, must read
    /// `false` from an echo and from a subagent-origin result, and `true` only
    /// from a top-level `result`.
    #[test]
    fn token_matches_only_inside_top_level_result() {
        const TOKEN: &str = "DEVFLOW-CANARY-7f3a";

        // 1. Echo only: the token is in the operator's own turn, forwarded back
        //    into stdout, and in no result at all.
        let echoed = format!(
            "{}\n{}\n{}\n",
            V3_INIT_EVENT,
            V3_USER_EVENT.replace("__MARKER__", &format!("please return {TOKEN} when done")),
            v3_result_event(V3_RESULT_TURN1, NO_MARKER),
        );
        assert!(
            !token_reported_in_capture(&echoed, TOKEN),
            "a token echoed back in the prompt is not delivery evidence — \
             the CLI forwards the operator's own turn into the same stdout"
        );

        // 2. Subagent-origin result: right event type, wrong provenance.
        let subagent = format!(
            "{}\n{}\n",
            V3_INIT_EVENT,
            v3_result_event(V3_RESULT_TURN2, TOKEN).replacen(
                "{",
                "{\"parent_tool_use_id\":\"toolu_child\",",
                1,
            ),
        );
        assert!(
            !token_reported_in_capture(&subagent, TOKEN),
            "a subagent-origin result must not satisfy the canary — it is the \
             same provenance hole constraint 9 item 2 closed for the verdict"
        );

        // 3. Authoritative: a top-level `result` carrying the token.
        let authoritative = format!(
            "{}\n{}\n",
            V3_INIT_EVENT,
            v3_result_event(V3_RESULT_TURN1, TOKEN),
        );
        assert!(
            token_reported_in_capture(&authoritative, TOKEN),
            "a token inside a top-level result IS the canary's answer"
        );
    }

    /// The Codex arm of the trailing-torn rule — same R1 root cause, and the
    /// Codex adapter is live in production.
    ///
    /// The resurrection shape here is a torn SUPERSEDING marker: codex verdict
    /// precedence is marker-over-`turn.failed` by design (13-06 dogfood
    /// finding), and last-marker-wins — so the tear that matters is one that
    /// conceals a LATER marker contradicting an earlier success.
    #[test]
    fn codex_torn_tail_does_not_resurrect_earlier_success_marker() {
        let intact = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}}\n",
            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"failed\\\"}\"}}\n",
        );
        assert_eq!(
            parse_codex_event_result(intact).map(|r| r.status),
            Some(AgentStatus::Failed),
            "control: intact capture — the LAST marker wins and it is a failure"
        );

        let torn = &intact[..intact.len() - 20];
        assert_ne!(
            parse_codex_event_result(torn).map(|r| r.status),
            Some(AgentStatus::Success),
            "a torn superseding marker must not let the earlier success marker \
             decide the stage"
        );
    }

    /// **Sixth-pass Highs 1–3.** The marker tail scanner — the reader that
    /// decides most production stages today — must survive edge corruption, a
    /// marker line longer than the tail budget, and mixed-case prefixes (its
    /// contract has always said case-insensitive).
    #[test]
    fn marker_tail_scan_survives_corruption_length_and_case() {
        let m = "DEVFLOW_RESULT: {\"status\":\"failed\"}";
        assert_eq!(
            parse_devflow_result(m).map(|r| r.status),
            Some(AgentStatus::Failed),
            "control: the plain marker parses"
        );

        // High 1 — edge corruption on either side must not hide the marker.
        for poisoned in [format!("\u{FFFD}{m}"), format!("{m}\u{FFFD}")] {
            assert_eq!(
                parse_devflow_result(&poisoned).map(|r| r.status),
                Some(AgentStatus::Failed),
                "one stray byte at a line edge must not hide a failure marker"
            );
        }
        // …while interior corruption stays untrusted (fourth-pass hazard).
        assert!(
            parse_devflow_result("DEVFLOW_RESULT: {\"status\":\"fai\u{FFFD}led\"}").is_none(),
            "interior corruption must not parse as a valid status"
        );

        // High 2 — a marker line longer than the tail budget is scanned whole.
        let long_reason = "x".repeat(5000);
        let long =
            format!("DEVFLOW_RESULT: {{\"status\":\"failed\",\"reason\":\"{long_reason}\"}}");
        assert_eq!(
            parse_devflow_result(&long).map(|r| r.status),
            Some(AgentStatus::Failed),
            "the tail budget must never bisect the final marker line"
        );
        // …and the budget still bounds the walk: a marker buried beyond the
        // budget with newer non-marker output after it stays out of reach.
        let buried = format!("{m}\n{}\n", "y\n".repeat(4100));
        assert!(
            parse_devflow_result(&buried).is_none(),
            "control: the budget still cuts off markers deep in old output"
        );

        // High 3 — mixed case matches, per the documented contract.
        assert_eq!(
            parse_devflow_result("DevFlow_Result: {\"status\":\"failed\"}").map(|r| r.status),
            Some(AgentStatus::Failed),
            "mixed-case prefix must match — the contract says case-insensitive"
        );
    }

    /// **Sixth-pass Mediums 4–5.** The codex plain-text rate-limit heuristic:
    /// an edge-corrupt JSON event line must stay excluded from prose scanning,
    /// and "429" only counts as a standalone token.
    #[test]
    fn codex_rate_limit_heuristic_excludes_recovered_json_and_embedded_429() {
        // M4 — a corrupt-prefixed event line is still a JSON line, not prose.
        let doc_line = concat!(
            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
            "\"text\":\"docs mention rate limiting policies\"}}",
        );
        let poisoned =
            format!("{{\"type\":\"thread.started\",\"thread_id\":\"t\"}}\n\u{FFFD}{doc_line}\n");
        assert!(
            detect_codex_rate_limit(&poisoned).is_none(),
            "an edge-corrupt event line must not be prose-scanned for \
             rate-limit vocabulary"
        );
        // Control: genuine plain-text rate-limit output is still detected.
        assert!(
            detect_codex_rate_limit("Rate limit exceeded. Try again at 17:00.").is_some(),
            "control: real plain-text rate-limit output must still be detected"
        );

        // M5 — embedded digits are not rate-limit evidence…
        assert!(
            detect_codex_rate_limit("processed issue #429 successfully").is_none(),
            "'#429' is an issue number, not a rate limit"
        );
        assert!(
            detect_codex_rate_limit("transferred 14290 bytes").is_none(),
            "digits containing 429 are not a rate limit"
        );
        // …while a genuine standalone 429 still is.
        assert!(
            detect_codex_rate_limit("HTTP 429 Too Many Requests").is_some(),
            "control: a standalone 429 status is still detected"
        );
    }

    /// **Fifth-pass High 1.** A replacement-character-prefixed event line must
    /// not classify as prose Noise and slip past the torn-tail guard.
    ///
    /// `read_capture` turns an invalid byte into U+FFFD; a line reading
    /// `\u{FFFD}{"type":…}` fails to parse and does not start with `{`, so it
    /// became Noise — invisible to `torn_json_after_last_matching`. A corrupt
    /// byte in front of a superseding failed marker let the earlier success
    /// marker decide the stage, with the contradicting exit code never
    /// consulted. Live today on the Codex `--json` adapter. The fix recovers
    /// an edge-corrupt-but-intact event by re-parsing the stripped line, so
    /// the TRUE verdict decides — better than merely failing indeterminate.
    #[test]
    fn corruption_prefixed_event_line_is_not_prose_noise() {
        let good = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\"}\"}}\n",
        );
        let failed_line = concat!(
            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"failed\\\"}\"}}\n",
        );

        let intact = format!("{good}{failed_line}");
        assert_eq!(
            parse_codex_event_result(&intact).map(|r| r.status),
            Some(AgentStatus::Failed),
            "control: intact capture — the last (failed) marker decides"
        );

        let poisoned = format!("{good}\u{FFFD}{failed_line}");
        assert_eq!(
            parse_codex_event_result(&poisoned).map(|r| r.status),
            Some(AgentStatus::Failed),
            "an edge-corrupt superseding marker must be recovered (or at worst \
             fail indeterminate) — never let the earlier success decide"
        );

        // Interior corruption stays visible and untrusted: a FFFD INSIDE the
        // marker's status string must not parse as a valid status (the
        // fourth-pass fabrication hazard, still guarded).
        let interior = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"suc\u{FFFD}cess\\\"}\"}}\n",
        );
        assert_ne!(
            parse_codex_event_result(interior).map(|r| r.status),
            Some(AgentStatus::Success),
            "interior corruption must never be repaired into a success"
        );
    }

    /// **Fifth-pass Medium 1.** An edge-corrupt rate-limit envelope must stay
    /// `RateLimited`, not decay into a generic `Failed`.
    ///
    /// The rate-limit detector outranks the generic envelope-failure detector
    /// precisely because rate-limit envelopes carry `is_error: true`. It was
    /// the one single-document reader without `strip_corruption_padding`, so a
    /// stray byte inverted the precedence — auto-resume became review/gating.
    #[test]
    fn edge_corrupt_rate_limit_envelope_stays_rate_limited() {
        let envelope = r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"result":"rate limited","retry_after":"17:00"}"#;
        assert!(
            detect_claude_rate_limit(envelope).is_some(),
            "control: the intact envelope is detected as a rate limit"
        );
        assert!(
            detect_claude_rate_limit(&format!("\u{FFFD}{envelope}")).is_some(),
            "one stray byte must not demote RateLimited to generic Failed"
        );
    }

    /// **Fourth-pass Medium 1.** The generic marker path — the one production
    /// hits today — must overwrite a planted `decided_by_layer`, exactly as the
    /// stream path has since 30-01. `Some(0)` is Layer-0 external-verification
    /// provenance, which `classify_validate_outcome` trusts when classifying a
    /// Validate stage: an agent could force the ambiguous-validation gate with
    /// a verdict it never earned (999.67's class, live instance).
    #[test]
    fn generic_marker_cannot_forge_layer0_provenance() {
        let stdout = r#"DEVFLOW_RESULT: {"status":"success","decided_by_layer":0}"#;
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(
            result.decided_by_layer,
            Some(1),
            "a planted decided_by_layer:0 must be overwritten to Layer 1"
        );

        // Control: an honest marker without the field also normalises to
        // Some(1) — provenance is DERIVED here, never deserialized.
        let honest = r#"DEVFLOW_RESULT: {"status":"success"}"#;
        assert_eq!(
            parse_devflow_result(honest).unwrap().decided_by_layer,
            Some(1)
        );
    }

    /// Codex arm of the T-30-26 provenance overwrite (fourth-pass Medium 1's
    /// class): a `decided_by_layer` planted in the codex marker JSON must be
    /// overwritten, exactly as on the generic and Claude-stream marker paths.
    #[test]
    fn codex_marker_cannot_forge_layer0_provenance() {
        let capture = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",",
            "\"text\":\"DEVFLOW_RESULT: {\\\"status\\\":\\\"success\\\",\\\"decided_by_layer\\\":0}\"}}\n",
        );
        let result = parse_codex_event_result(capture).unwrap();
        assert_eq!(
            result.decided_by_layer,
            Some(1),
            "a planted decided_by_layer:0 must be overwritten to Layer 1"
        );
    }

    /// **Constraint 9 item 1, closed for every DETECTABLE truncation**
    /// (originally committed `#[ignore]`d as a known-red deferral to Phase 31;
    /// the operator's "fix root causes before proceeding" decision pulled it
    /// back into phase 30).
    ///
    /// A truncated terminal `result` used to vanish from the parsed events, so
    /// `last_top_level_result` returned an EARLIER turn's result — a stale
    /// SUCCESS advancing a stage whose real terminal turn failed. Now every
    /// prefix with a torn trailing line yields an indeterminate FAILURE.
    ///
    /// **The named residual — line-boundary truncation is UNDETECTABLE from
    /// content.** A prefix cut exactly at the newline after the success turn is
    /// a well-formed capture: two parsed events, no torn line, byte-identical
    /// to a healthy one-turn-success capture plus nothing. The evidence of loss
    /// is in the bytes that never arrived, so no parser assertion can exist for
    /// it. The remaining defense belongs to the layer that HAS the missing
    /// information: Phase 31's wiring must not let a stream-derived Success
    /// short-circuit a contradicting exit code (a writer that died between
    /// flushing turn N and turn N+1 also died with a non-zero exit). Recorded
    /// in ROADMAP constraint 9.
    #[test]
    fn truncation_sweep_never_upgrades_verdict_to_success() {
        let intact = format!(
            "{}\n{}\n{}\n",
            V3_INIT_EVENT,
            v3_result_event(V3_RESULT_TURN1, MARKER_SUCCESS),
            v3_result_event_is_error(V3_RESULT_TURN2, MARKER_FAILED),
        );
        assert_eq!(
            parse_claude_event_result(&intact).map(|r| r.status),
            Some(AgentStatus::Failed),
            "precondition: intact capture ends in a failure verdict"
        );

        let mut torn_prefixes = 0usize;
        let mut clean_prefixes = 0usize;
        for n in 0..=intact.len() {
            if !intact.is_char_boundary(n) {
                continue;
            }
            let prefix = &intact[..n];
            let got = parse_claude_event_result(prefix).map(|r| r.status);
            if ParsedCapture::parse(prefix).torn_json_line_present() {
                torn_prefixes += 1;
                assert_ne!(
                    got,
                    Some(AgentStatus::Success),
                    "truncating to {n} bytes left a torn tail yet resurrected \
                     an earlier turn's SUCCESS over a failed terminal turn"
                );
            } else {
                clean_prefixes += 1;
            }
        }
        // Negative controls on the sweep itself: both branches must have been
        // exercised, or the loop is asserting over nothing.
        assert!(
            torn_prefixes > 500,
            "sweep degenerated: only {torn_prefixes} torn prefixes"
        );
        assert!(
            clean_prefixes > 2,
            "sweep never produced a well-formed prefix; the residual case \
             documented above is not being exercised"
        );
    }

    #[test]
    fn codex_event_stream_parses_turn_failed() {
        let stdout = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"turn.started\"}\n",
            "{\"type\":\"item.started\",\"item\":{}}\n",
            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
        );
        let result = parse_codex_event_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
    }

    #[test]
    fn codex_turn_completed_no_marker_defers() {
        let stdout = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"turn.started\"}\n",
            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
        );
        assert!(parse_codex_event_result(stdout).is_none());
    }

    /// 13-06 dogfood regression: Codex delivers the DEVFLOW_RESULT marker
    /// inside an `agent_message` item's text, never as a raw stdout line. A
    /// self-reported failure followed by a bare `turn.completed` must parse
    /// as Failed with the agent's reason — not defer to Layer 2 (which would
    /// see exit 0 and call it a success).
    #[test]
    fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
        let stdout = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
        );
        let result = parse_codex_event_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(
            result.reason.as_deref(),
            Some("interactive input unavailable")
        );
    }

    #[test]
    fn codex_agent_message_marker_success_short_circuits() {
        let stdout = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
        );
        let result = parse_codex_event_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
    }

    /// 13-06 dogfood regression: document content echoed into a JSONL event
    /// (GSD reference tables mentioning "rate limiting") must not trip the
    /// plain-text rate-limit heuristic — it returned the entire multi-KB
    /// event line as the "retry time" and that reached the desktop
    /// notification verbatim.
    #[test]
    fn detect_rate_limit_ignores_json_event_lines() {
        let stdout = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
        );
        assert_eq!(detect_rate_limit(stdout), None);
    }

    #[test]
    fn detect_rate_limit_still_reads_codex_plain_text() {
        let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
        assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
    }

    #[test]
    fn codex_event_stream_ignores_progress_and_unparseable_lines() {
        let stdout = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "not json at all\n",
            "{\"type\":\"item.started\",\"item\":{}}\n",
            "{\"type\":\"item.updated\",\"item\":{}}\n",
            "{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
        );
        let result = parse_codex_event_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.reason.as_deref(), Some("boom"));
    }

    #[test]
    fn claude_envelope_not_consumed_by_codex_parser() {
        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
        assert!(parse_codex_event_result(stdout).is_none());
    }

    /// The highest-value isolation test in plan 30-01 (T-30-02).
    ///
    /// The single-document `--output-format json` envelope that ships TODAY
    /// carries `type: "result"` AND a `session_id` — precisely the gate shape
    /// 30-RESEARCH.md offered as an alternative to `system`/`init`. If anyone
    /// widens [`is_claude_event_stream`] to accept it, the stream parser starts
    /// consuming every production capture in use and silently displaces
    /// `parse_devflow_result` in the Layer-1 cascade. This test fails first.
    ///
    /// The first literal is reused verbatim from
    /// `claude_envelope_not_consumed_by_codex_parser` above so the two read as
    /// a matched pair.
    #[test]
    fn single_doc_envelope_not_consumed_by_claude_stream_parser() {
        let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
        assert!(parse_claude_event_result(stdout).is_none());

        // Non-vacuity: the literal above carries no marker, so it would return
        // None even from a WRONGLY-widened gate — on its own it proves little.
        // This envelope does carry one, so it can only return None because the
        // gate declined the document, not because the marker scan came up dry.
        let with_marker = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"Done.\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#;
        assert!(parse_claude_event_result(with_marker).is_none());

        // ...and the shipped path still owns it, so declining costs no verdict.
        assert_eq!(
            parse_devflow_result(with_marker).unwrap().status,
            AgentStatus::Success
        );
    }

    /// Cross-adapter isolation: a Codex `--json` event stream is not consumed
    /// by the Claude stream parser. The two gates are mutually exclusive by
    /// construction — Codex keys on `thread.started`/`turn.*`, Claude on
    /// `system`/`init` — and this pins that.
    #[test]
    fn codex_stream_not_consumed_by_claude_stream_parser() {
        let stdout = concat!(
            "{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
            "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
            "{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
        );
        assert!(parse_claude_event_result(stdout).is_none());

        // The Codex parser still decides it — isolation costs no verdict.
        assert_eq!(
            parse_codex_event_result(stdout).unwrap().status,
            AgentStatus::Success
        );
    }

    /// The same isolation claim in the other direction: a Claude stream capture
    /// is not consumed by the Codex parser, so the two never collide.
    #[test]
    fn claude_stream_not_consumed_by_codex_parser() {
        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
        assert!(parse_codex_event_result(&capture).is_none());
    }

    /// Plain-text stdout is not consumed by the Claude stream parser.
    ///
    /// Non-vacuous by construction: the text carries a real marker, so a gate
    /// that wrongly fired on non-JSON input would change the verdict rather
    /// than merely returning None. The second assertion pins that the marker
    /// path still decides it — the cascade must lose nothing.
    #[test]
    fn plain_text_not_consumed_by_claude_stream_parser() {
        let stdout = "Running the plan...\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
        assert!(parse_claude_event_result(stdout).is_none());
        assert_eq!(
            parse_devflow_result(stdout).unwrap().status,
            AgentStatus::Success
        );
    }

    // ---- Claude `--output-format stream-json` fixtures (plan 30-01) --------
    //
    // Sourced from the archived capture
    // `.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/raw_output_v3.jsonl`,
    // a real 54-line stream from a session that survived three orchestrator
    // turns via task-notification wake-ups. The `init` event is line 5; the
    // three `result` events are lines 19, 37 and 54.
    //
    // TWO documented modifications, both labelled where they occur:
    //   1. Each envelope's `result` string value is replaced with the sentinel
    //      `__MARKER__`, which each test fills in. NO archived capture contains
    //      a real `DEVFLOW_RESULT` marker — the v3 harness produced
    //      acknowledgment prose, not GSD stage output — so every marker payload
    //      below is SYNTHETIC. Envelope shape is real; marker text is not.
    //   2. The `init` event's three inert array payloads are truncated and its
    //      `cwd` is redacted (see `V3_INIT_EVENT`).
    // Everything else is byte-for-byte as captured, including field ORDER —
    // note that `"type":"result"` appears near the END of each result line,
    // long after `result` itself, which is exactly why the parser must key on
    // the parsed object rather than on textual position.

    /// v3 line 5 — the `system`/`init` event that opens the stream and is the
    /// ONLY thing `is_claude_event_stream` gates on.
    ///
    /// Modification 2: verbatim except that `tools`, `mcp_servers` and
    /// `slash_commands` are truncated to a real prefix (verbatim they run to
    /// 5,523 characters of tool and slash-command names that no code path here
    /// reads) and `cwd` is redacted to a neutral path — the captured value
    /// embeds a developer's home directory, and `devflow-core` is published to
    /// crates.io. Both fields are inert for every function under test.
    const V3_INIT_EVENT: &str = r#"{"type":"system","subtype":"init","cwd":"/tmp/scratchpad/999.64-experiment","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","tools":["Task","Bash","Read","Write"],"mcp_servers":[{"name":"github","status":"pending"}],"model":"claude-opus-5[1m]","permissionMode":"bypassPermissions","slash_commands":["gsd-execute-phase"],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"uuid":"597e1613-77cb-4cdd-a716-2aa75dc58c0b"}"#;

    /// v3 line 19 — the FIRST turn's terminal `result` event.
    const V3_RESULT_TURN1: &str = r#"{"is_error":false,"duration_api_ms":8087,"num_turns":3,"stop_reason":"end_turn","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","total_cost_usd":0.2401795,"usage":{"input_tokens":4,"cache_creation_input_tokens":20120,"cache_read_input_tokens":49219,"output_tokens":574,"service_tier":"standard","inference_geo":"not_available","speed":"standard"},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","subtype":"success","api_error_status":null,"result":"__MARKER__","ttft_ms":1381,"time_to_request_ms":91,"type":"result","duration_ms":8315,"uuid":"3dce3044-2d33-4c4d-bfcb-80e1756a5522"}"#;

    /// v3 line 37 — the SECOND turn's terminal `result` event, produced after a
    /// task-notification wake-up. Carries the `origin` key the later turns have
    /// and the first does not.
    const V3_RESULT_TURN2: &str = r#"{"is_error":false,"duration_api_ms":27809,"num_turns":1,"stop_reason":"end_turn","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","total_cost_usd":0.53654625,"usage":{"input_tokens":2,"cache_creation_input_tokens":3147,"cache_read_input_tokens":35393,"output_tokens":124,"service_tier":"standard","inference_geo":"not_available","speed":"standard"},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","origin":{"kind":"task-notification"},"subtype":"success","api_error_status":null,"result":"__MARKER__","ttft_ms":5476,"time_to_request_ms":18,"type":"result","duration_ms":6195,"uuid":"ca58693c-2599-4eb6-955b-e9d1e7444255"}"#;

    /// v3 line 54 — the THIRD and LAST turn's terminal `result` event. This is
    /// the one whose marker must decide the stage.
    const V3_RESULT_TURN3: &str = r#"{"is_error":false,"duration_api_ms":39273,"num_turns":2,"stop_reason":"end_turn","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","total_cost_usd":0.6599295,"usage":{"input_tokens":4,"cache_creation_input_tokens":999,"cache_read_input_tokens":77871,"output_tokens":302,"service_tier":"standard","inference_geo":"not_available","speed":"standard"},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","origin":{"kind":"task-notification"},"subtype":"success","api_error_status":null,"result":"__MARKER__","ttft_ms":2099,"time_to_request_ms":14,"type":"result","duration_ms":5276,"uuid":"dc76186e-3e9a-4d52-9152-27aa5012bc41"}"#;

    // ---- prompt-echo regression fixtures (plan 30-05) ----------------------
    //
    // Message-event envelopes from the same archived capture. Same sentinel
    // discipline as the `result` envelopes above — the innermost text payload
    // is replaced with `__MARKER__` and each test fills it — plus a third
    // documented modification noted per constant where inert bulk is dropped.
    // The ENVELOPE is real: every `type`, `parent_tool_use_id`, `session_id`
    // and `uuid` value, and the nesting shape the extraction path walks, is
    // exactly as captured.
    //
    // NO archived capture contains checkpoint gate text at all — the 30a
    // harness prompt was about background tasks and never mentioned gates. So
    // every gate payload below is SYNTHETIC and must not be described as an
    // observed rendering. What IS observed is the gate VALUE's markdown
    // code-span rendering, transcribed from the live 2026-07-31 A1 run (see
    // `HUMAN_GATE_VALUE`), which every fixture here reproduces.

    /// v3 line 10 — a TOP-LEVEL `user` event (`parent_tool_use_id` null).
    ///
    /// Modification 3: the trailing `tool_use_result` object is dropped. It is
    /// inert for every function under test and embeds both a developer home
    /// directory and the child agent's full prompt; `devflow-core` is published
    /// to crates.io.
    ///
    /// **The archived capture contains no echoed prompt.** Every `user` event
    /// in it is a `tool_result` relay, because the 30a harness ran a single
    /// prompt with no re-injection. This fixture's payload therefore STANDS IN
    /// for an echoed prompt rather than reproducing one. The substitution is
    /// sound for what is under test: the scan's first filter keys on the
    /// event's `type`, which is `user` in both cases, and
    /// `claude_stream_reports_human_gate` excludes that whole class — an echoed
    /// prompt and a re-injected notification summary are the two members of it.
    const V3_USER_EVENT: &str = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01FVk15W8zxiazXutJYn8rsv","type":"tool_result","content":[{"type":"text","text":"__MARKER__"}]}]},"parent_tool_use_id":null,"session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","uuid":"60c5839e-40b3-492a-83e7-00882189f1d3","timestamp":"2026-08-02T00:22:22.603Z"}"#;

    /// v3 line 6 — a TOP-LEVEL `assistant` event (`parent_tool_use_id` null).
    ///
    /// Its captured payload is `I'll spawn both subagents in the background
    /// now.` — mid-turn narration that appears in NO `result` event of the
    /// capture, re-confirmed by re-parsing all 54 lines at execution time. That
    /// property is the entire reason this envelope was chosen: it proves
    /// top-level assistant text is not merely a preview of the result text, so
    /// admitting the class would add a genuinely new trusted surface.
    ///
    /// Modification 3: the `usage.cache_creation` sub-object is dropped (inert).
    const V3_ASSISTANT_TOP_LEVEL_EVENT: &str = r#"{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cdcy3oC1a4rcmbp3avDYX","type":"message","role":"assistant","content":[{"type":"text","text":"__MARKER__"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":18673,"cache_read_input_tokens":15273,"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","uuid":"85e8747b-e551-47b7-af38-fcd3bb1e06f8","timestamp":"2026-08-02T00:22:18.742Z","request_id":"req_011Cdcy3ngzpMCk3bijt1nkE"}"#;

    /// v3 line 11 — a SUBAGENT-forwarded `assistant` event. Its captured
    /// `parent_tool_use_id` (`toolu_01FVk15W8zxiazXutJYn8rsv`, the Task call
    /// that spawned child A) is preserved verbatim: it is the whole point of
    /// the fixture, and the discrimination whose absence invalidated the v1
    /// experiment outright.
    ///
    /// Modification 3: the `usage.cache_creation` sub-object is dropped (inert).
    const V3_ASSISTANT_SUBAGENT_EVENT: &str = r#"{"type":"assistant","message":{"model":"claude-opus-5","id":"msg_011Cdcy4BNkfziogNMFM8V7K","type":"message","role":"assistant","content":[{"type":"text","text":"__MARKER__"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":17705,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":"toolu_01FVk15W8zxiazXutJYn8rsv","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790","uuid":"3fb37d43-86af-48b1-ace4-55147ed47b15","timestamp":"2026-08-02T00:22:23.850Z","request_id":"req_011Cdcy4ASSvG8gf8fRwiWZW","subagent_type":"general-purpose","task_description":"Signal A after 10s"}"#;

    /// Fill a message envelope's innermost text payload. Mirrors
    /// [`v3_result_event`] and is kept separate from it so the assertion names
    /// the right fixture family when a sentinel is lost.
    fn v3_message_event(envelope: &str, text: &str) -> String {
        assert!(
            envelope.contains("__MARKER__"),
            "fixture envelope lost its message-text sentinel"
        );
        envelope.replace("__MARKER__", text)
    }

    /// A checkpoint DECLARATION, as an agent's final message would render it,
    /// escaped for a JSON string field (literal `\n`, the way `claude` emits
    /// an agent's result text).
    ///
    /// The gate value carries the markdown CODE SPAN the live 2026-07-31 run
    /// captured — see [`HUMAN_GATE_VALUE`]. A bare unquoted value would test a
    /// rendering that has never been observed in production.
    fn gate_declaration_text() -> String {
        format!(
            "## CHECKPOINT REACHED\\n\\n**Type:** decision\\n**Gate:** `{HUMAN_GATE_VALUE}`\\n**Plan:** 30-05\\n"
        )
    }

    /// Text that merely DOCUMENTS a gate rendering — the shape a plan file, a
    /// GSD reference document, or an agent narrating its next task carries.
    /// Same code-span rendering as a real declaration, which is precisely why a
    /// substring scan cannot tell the two apart and the EVENT must decide.
    ///
    /// Single line, no double quotes, so it drops into a JSON string field
    /// without further escaping.
    fn gate_documenting_text() -> String {
        format!(
            "The next task is declared **Gate:** `{HUMAN_GATE_VALUE}` in the plan, so the executor must stop rather than auto-select."
        )
    }

    // Synthetic `result`-text payloads (modification 1). Written exactly as
    // they appear INSIDE the envelope's `result` JSON string — escaped quotes
    // and an escaped newline — because that is how `claude` emits an agent's
    // final message. Once serde decodes the field the `\n` becomes a real
    // newline and `parse_marker_lines`' line scan works on it unmodified.
    const MARKER_SUCCESS: &str = r#"Plan complete.\nDEVFLOW_RESULT: {\"status\":\"success\"}"#;
    const MARKER_FAILED: &str =
        r#"Blocked.\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"earlier turn aborted\"}"#;
    const MARKER_PLANTED_LAYER: &str =
        r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"decided_by_layer\":0}"#;
    const NO_MARKER: &str = r#"Acknowledged; nothing to report."#;

    /// Fill one real envelope's `result` field with a synthetic payload.
    fn v3_result_event(envelope: &str, escaped_result_text: &str) -> String {
        assert!(
            envelope.contains("__MARKER__"),
            "fixture envelope lost its result-text sentinel"
        );
        envelope.replace("__MARKER__", escaped_result_text)
    }

    /// Assemble a three-turn Claude stream capture: the real `init` event
    /// followed by all three real `result` envelopes, each carrying the given
    /// payload. Three result events (not two) is load-bearing — a two-event
    /// fixture cannot tell "last wins" apart from "highest index of two".
    fn v3_stream_capture(turn1: &str, turn2: &str, turn3: &str) -> String {
        format!(
            "{}\n{}\n{}\n{}\n",
            V3_INIT_EVENT,
            v3_result_event(V3_RESULT_TURN1, turn1),
            v3_result_event(V3_RESULT_TURN2, turn2),
            v3_result_event(V3_RESULT_TURN3, turn3),
        )
    }

    // ---- rate-limit / envelope-failure fixtures (plan 30-03) --------------

    /// v3 line 15, **VERBATIM** — the only `rate_limit_event` in any archived
    /// capture, and the reason this plan exists in its current form.
    ///
    /// Read it before touching [`detect_claude_stream_rate_limit`]: its
    /// `rate_limit_info.status` is **`allowed`**. The CLI emits these events as
    /// routine quota telemetry on healthy streams — this one sits at line 15 of
    /// a capture that then completed three turns successfully (results at 19,
    /// 37 and 54). Presence of the event type carries NO information about
    /// whether the run was blocked.
    ///
    /// Note the second trap one level down: `overageStatus` is `rejected`. Any
    /// nested search for the token `rejected` (e.g. via [`json_find_key`]) also
    /// misclassifies this healthy event, which is why the classifier reads
    /// `rate_limit_info.status` and nothing else, by direct `.get()`.
    const V3_RATE_LIMIT_EVENT_ALLOWED: &str = r#"{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1785645600,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"out_of_credits","isUsingOverage":false},"uuid":"e73e6774-a79d-4cdf-90bd-53a695f44f5a","session_id":"559fef4d-2053-459e-b7a7-f3200c3b3790"}"#;

    /// A `rate_limit_event` with the given `rate_limit_info.status`, built by
    /// substituting one field of the real archived event above.
    ///
    /// **SYNTHETIC for every status except `allowed`.** No archived capture
    /// contains a blocked stream — the denial fixtures below are constructed,
    /// not observed, and are labelled as such at each use. Every other field
    /// (including `resetsAt`, which supplies the retry hint) is exactly as
    /// captured.
    fn v3_rate_limit_event(status: &str) -> String {
        assert!(
            V3_RATE_LIMIT_EVENT_ALLOWED.contains(r#""status":"allowed""#),
            "fixture lost its status field"
        );
        V3_RATE_LIMIT_EVENT_ALLOWED
            .replace(r#""status":"allowed""#, &format!(r#""status":"{status}""#))
    }

    /// One real `result` envelope with its captured `is_error":false` flipped
    /// to `true`, every other field untouched. The assertion makes the
    /// substitution non-silent: if the fixture text ever changes, the test
    /// fails loudly rather than quietly testing an `is_error: false` envelope.
    fn v3_result_event_is_error(envelope: &str, escaped_result_text: &str) -> String {
        let filled = v3_result_event(envelope, escaped_result_text);
        assert!(
            filled.contains(r#""is_error":false"#),
            "fixture envelope lost its is_error field"
        );
        filled.replace(r#""is_error":false"#, r#""is_error":true"#)
    }

    /// Assemble a capture from the real `init` event followed by the given
    /// lines in order. Unlike [`v3_stream_capture`] this lets a test position a
    /// `rate_limit_event` at an arbitrary index, which is the whole point of
    /// the final-turn scoping assertions.
    fn stream_capture_of(lines: &[&str]) -> String {
        let mut out = String::from(V3_INIT_EVENT);
        for line in lines {
            out.push('\n');
            out.push_str(line);
        }
        out.push('\n');
        out
    }

    /// **The mandatory negative regression.** The real archived stream — whose
    /// `rate_limit_event` says `status: "allowed"` and which then completed
    /// three turns — must NOT classify as `RateLimited`.
    ///
    /// This event is routine quota telemetry, not a block. Classifying its mere
    /// presence as a rate limit would route EVERY healthy Claude stream stage
    /// into `Action::AutoResume` against a fabricated retry time, instead of
    /// advancing the pipeline. That mapping is
    /// `crates/devflow-core/src/outcome_policy.rs:41` — `AgentStatus::RateLimited
    /// => Action::AutoResume`, re-read in this crate at execution time; 30-03's
    /// plan and threat register cite it as `outcome_policy.rs:41` without a
    /// crate, and it is NOT in `devflow-cli`. This is a denial of service on
    /// the whole product, produced by a one-line "detect the event type"
    /// shortcut.
    ///
    /// Two independent guards must both hold here, and the second assertion
    /// pins the one the positioning guard alone would hide: the event is placed
    /// at its real position (before the first `result`, mirroring line 15 vs
    /// 19), AND its status is not a denial. `detect_claude_stream_rate_limit`
    /// is asserted directly on a final-turn placement of the same real event so
    /// the status guard cannot be dropped without this test failing.
    #[test]
    fn claude_stream_real_allowed_rate_limit_event_is_not_rate_limited() {
        let capture = stream_capture_of(&[
            V3_RATE_LIMIT_EVENT_ALLOWED,
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
        ]);

        let result = parse_claude_event_result(&capture)
            .expect("the final turn's success marker still decides this stream");
        assert_eq!(result.status, AgentStatus::Success);
        assert_ne!(result.status, AgentStatus::RateLimited);

        // The status guard on its own: the SAME real event moved into the final
        // turn (after the second-to-last `result`) is still not a rate limit.
        // Without this, deleting the status check would leave the test green.
        let final_turn = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            V3_RATE_LIMIT_EVENT_ALLOWED,
            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
        ]);
        assert!(
            detect_claude_stream_rate_limit(&ParsedCapture::parse(&final_turn).events).is_none()
        );
    }

    /// The positive: an explicit quota DENIAL inside the final turn classifies
    /// as `RateLimited`, so the rate-limit resume path stays reachable under
    /// `stream-json`.
    ///
    /// **The denial fixture is SYNTHETIC.** No archived capture contains a
    /// blocked stream, so the `rejected` status is constructed from the
    /// observed vocabulary of this schema rather than observed in the wild —
    /// the same honest-fixture rule this phase applies to marker payloads. The
    /// retry hint comes from the real `resetsAt` value.
    #[test]
    fn claude_stream_final_turn_denial_rate_limit_event_is_rate_limited() {
        let denial = v3_rate_limit_event("rejected");
        let capture = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            &denial,
            &v3_result_event(V3_RESULT_TURN3, NO_MARKER),
        ]);

        let result = parse_claude_event_result(&capture)
            .expect("a final-turn quota denial must produce a Layer-1 verdict");
        assert_eq!(result.status, AgentStatus::RateLimited);
        assert_eq!(
            result.reason.as_deref(),
            Some("rate limited until 1785645600")
        );
        assert_eq!(result.decided_by_layer, Some(1));

        // Fewer than two `result` events means the whole stream IS the final
        // turn — a run blocked before it ever completed a turn must still
        // classify, or the boundary logic silently swallows the common case.
        let single_turn =
            stream_capture_of(&[&denial, &v3_result_event(V3_RESULT_TURN1, NO_MARKER)]);
        assert_eq!(
            parse_claude_event_result(&single_turn).map(|r| r.status),
            Some(AgentStatus::RateLimited)
        );
    }

    /// Scoping: a denial that predates the final turn cannot outrank the final
    /// turn's own outcome. Rate-limit chatter from an earlier turn must not
    /// decide a stream that later completed — in the real capture the rate
    /// event (line 15) precedes all three results, so an unscoped detector
    /// would let a first-turn event decide a stream that finished forty seconds
    /// later.
    ///
    /// The denial status here is the SAME one the positive test proves does
    /// classify, so this test can only pass because of the POSITION guard.
    #[test]
    fn claude_stream_denial_before_final_turn_does_not_outrank_final_result() {
        let capture = stream_capture_of(&[
            &v3_rate_limit_event("rejected"),
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
        ]);

        let result = parse_claude_event_result(&capture)
            .expect("the final turn's success marker decides this stream");
        assert_eq!(result.status, AgentStatus::Success);
    }

    /// An unrecognised `rate_limit_info.status` DEFERS rather than classifying.
    ///
    /// Deferring is the deliberately safe direction: an unknown denial status
    /// falls through to the envelope/marker paths and is reported `Failed` — a
    /// real degradation (the operator loses automatic resume) but a never-silent
    /// one that still gates. The opposite error auto-resumes a healthy stream
    /// against a retry time the parser invented.
    ///
    /// Positioned in the FINAL turn, so only the status check can decline it.
    #[test]
    fn claude_stream_unrecognised_rate_limit_status_defers() {
        let capture = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            &v3_rate_limit_event("some_future_status"),
            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
        ]);

        assert!(detect_claude_stream_rate_limit(&ParsedCapture::parse(&capture).events).is_none());
        let result = parse_claude_event_result(&capture)
            .expect("the parser must fall through to the marker path");
        assert_eq!(result.status, AgentStatus::Success);
    }

    /// Precedence (T-30-13): when the detector fires, rate limit outranks the
    /// marker path. A rate-limited run classified as generic `Failed` kills the
    /// primary rate-limit resume cron — the one path that exists to recover
    /// from it — which is exactly why `evaluate_layer1` already orders
    /// `detect_claude_rate_limit` ahead of `detect_claude_envelope_failure` for
    /// the single-document path.
    ///
    /// Non-vacuous: the same capture WITHOUT the rate event yields `Failed`, so
    /// this test fails the moment the ordering is reshuffled.
    #[test]
    fn claude_stream_final_turn_denial_outranks_failed_marker() {
        let with_denial = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            &v3_rate_limit_event("rejected"),
            &v3_result_event(V3_RESULT_TURN3, MARKER_FAILED),
        ]);
        assert_eq!(
            parse_claude_event_result(&with_denial).map(|r| r.status),
            Some(AgentStatus::RateLimited)
        );

        let without_denial = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_FAILED);
        assert_eq!(
            parse_claude_event_result(&without_denial).map(|r| r.status),
            Some(AgentStatus::Failed)
        );
    }

    /// A last `result` event with `is_error: true` and NO marker is an
    /// authoritative Layer-1 failure, not a deferral to Layer 2's coarse
    /// exit-code heuristic — matching `detect_claude_envelope_failure` for the
    /// single-document envelope. The reason is drawn from the event's own
    /// `result` text with the `num_turns` suffix, the same shape that function
    /// produces.
    #[test]
    fn claude_stream_last_result_is_error_without_marker_is_failed() {
        let capture = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            &v3_result_event_is_error(V3_RESULT_TURN3, r#"Execution error: context exhausted"#),
        ]);

        let result = parse_claude_event_result(&capture)
            .expect("is_error on the last result must not defer to Layer 2");
        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(
            result.reason.as_deref(),
            Some("Execution error: context exhausted (num_turns: 2)")
        );
        assert_eq!(result.decided_by_layer, Some(1));
    }

    /// Envelope-over-marker (T-30-15): `is_error: true` overrides a SUCCESS
    /// marker in the same event, matching `detect_claude_envelope_failure`'s
    /// documented precedence over a stale or echoed success marker.
    ///
    /// Non-vacuous: the identical capture with `is_error: false` yields
    /// `Success`, so the assertion below can only pass because the envelope
    /// check overrode the marker.
    #[test]
    fn claude_stream_is_error_overrides_success_marker() {
        let capture = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            &v3_result_event_is_error(V3_RESULT_TURN3, MARKER_SUCCESS),
        ]);
        let result = parse_claude_event_result(&capture)
            .expect("is_error must produce a verdict even with a success marker");
        assert_eq!(result.status, AgentStatus::Failed);

        let healthy = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);
        assert_eq!(
            parse_claude_event_result(&healthy).map(|r| r.status),
            Some(AgentStatus::Success)
        );
    }

    // ---- session id from a stream capture (plan 30-03 Task 2) -------------

    /// The single `session_id` every event in the archived v3 capture carries —
    /// all three `init` events (lines 5, 32 and 47) and all three `result`
    /// events agree on it, confirmed by reading the capture.
    const V3_SESSION_ID: &str = "559fef4d-2053-459e-b7a7-f3200c3b3790";

    /// The real `init` event with its `session_id` substituted. Used only to
    /// build a SYNTHETIC mid-stream rotation — no archived capture rotates.
    fn v3_init_event_with_session(session_id: &str) -> String {
        assert!(
            V3_INIT_EVENT.contains(V3_SESSION_ID),
            "fixture lost its session_id"
        );
        V3_INIT_EVENT.replace(V3_SESSION_ID, session_id)
    }

    /// `claude_stream_session_id` reads the CLI-emitted id out of a JSONL
    /// capture built from the archived `init` events (v3 lines 5, 32 and 47 —
    /// all three carry this same value).
    ///
    /// The second half pins LAST-init-wins with a synthetic rotation: the real
    /// capture's three `init` events are identical, so first-wins and last-wins
    /// agree on today's evidence and a fixture built only from it cannot tell
    /// the two apart. Three `init` events do NOT mean three sessions.
    #[test]
    fn claude_stream_session_id_reads_cli_emitted_init_value() {
        let capture = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            V3_INIT_EVENT,
            &v3_result_event(V3_RESULT_TURN2, NO_MARKER),
            V3_INIT_EVENT,
            &v3_result_event(V3_RESULT_TURN3, MARKER_SUCCESS),
        ]);
        assert_eq!(
            claude_stream_session_id(&capture).as_deref(),
            Some(V3_SESSION_ID)
        );

        let rotated = stream_capture_of(&[
            &v3_result_event(V3_RESULT_TURN1, NO_MARKER),
            &v3_init_event_with_session("second-session-id"),
            &v3_result_event(V3_RESULT_TURN2, MARKER_SUCCESS),
        ]);
        assert_eq!(
            claude_stream_session_id(&rotated).as_deref(),
            Some("second-session-id")
        );
    }

    /// D-04 / T-28-04 forgery guard for the stream path — the analog of
    /// `session_id_in_devflow_result_marker_is_not_returned`, which pins the
    /// same contract for the single-document envelope.
    ///
    /// The fixture defeats BOTH plausible wrong implementations at once: a
    /// nested traversal (`json_find_key`/`json_scan`) would reach the
    /// `session_id` the agent planted inside its own `DEVFLOW_RESULT` marker
    /// text, and a "last event carrying a `session_id`" scan would return the
    /// final `result` event's own key. Both are wrong; only the `init` event's
    /// top-level value is CLI-emitted. The divergence between the `result`
    /// event's id and the `init` event's is synthetic — no archived capture
    /// diverges — and exists purely so those two implementations cannot pass.
    #[test]
    fn claude_stream_session_id_ignores_agent_planted_value() {
        const PLANTED_MARKER: &str =
            r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"session_id\":\"forged-by-agent\"}"#;

        let last_result = v3_result_event(V3_RESULT_TURN3, PLANTED_MARKER)
            .replace(V3_SESSION_ID, "result-event-session-id");
        let capture =
            stream_capture_of(&[&v3_result_event(V3_RESULT_TURN1, NO_MARKER), &last_result]);

        // Non-vacuity: both decoys really are present in the capture text, so a
        // wrong implementation has something wrong to find.
        assert!(capture.contains("forged-by-agent"));
        assert!(capture.contains("result-event-session-id"));

        assert_eq!(
            claude_stream_session_id(&capture).as_deref(),
            Some(V3_SESSION_ID)
        );
    }

    /// The stream reader does not shadow or duplicate `claude_session_id`: it
    /// declines the single-document envelope (the exact literal
    /// `session_id_reads_top_level_string` asserts on) and plain text, so the
    /// wrapper's stream-first ordering cannot change today's behavior.
    #[test]
    fn claude_stream_session_id_declines_non_stream_shapes() {
        let envelope = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
        assert!(claude_stream_session_id(envelope).is_none());
        // ...and the shipped reader still owns it, so declining costs nothing.
        assert_eq!(
            claude_session_id(envelope).as_deref(),
            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
        );

        assert!(claude_stream_session_id("just some plain text output\n").is_none());
    }

    /// The wiring that matters: `session_id_from_capture` — the Phase 28
    /// checkpoint-resume reader (`claude --resume` needs an id DevFlow can
    /// read) — returns an id for a JSONL capture, where before this plan it
    /// returned `None` for every stream capture.
    #[test]
    fn claude_stream_session_id_from_capture_reads_jsonl() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(30)),
            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
        )
        .unwrap();

        assert_eq!(
            session_id_from_capture(dir.path(), PhaseId::new(30)).as_deref(),
            Some(V3_SESSION_ID)
        );
    }

    /// The other half of the wiring claim: a single-document envelope capture
    /// still yields exactly what it did before the stream reader was inserted
    /// ahead of `claude_session_id` in the fallback chain.
    #[test]
    fn claude_stream_wiring_leaves_single_document_capture_unchanged() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        let envelope = r#"{"type":"result","subtype":"success","result":"All done.","session_id":"cf29bfec-69e8-45df-a4f3-3da08ab6f66e"}"#;
        std::fs::write(stdout_path(dir.path(), PhaseId::new(8)), envelope).unwrap();

        assert_eq!(
            session_id_from_capture(dir.path(), PhaseId::new(8)).as_deref(),
            claude_session_id(envelope).as_deref()
        );
        assert_eq!(
            session_id_from_capture(dir.path(), PhaseId::new(8)).as_deref(),
            Some("cf29bfec-69e8-45df-a4f3-3da08ab6f66e")
        );
    }

    /// The tracer: a real archived `stream-json` capture written to
    /// `.devflow/phase-NN-stdout` produces a Layer-1 verdict out of
    /// `evaluate_layer1`. Before plan 30-01 this returned `None` for every
    /// JSONL capture — `serde_json::from_str` on the whole multi-line document
    /// is a hard "trailing characters" error, so all four single-document
    /// parsers declined it and the stage fell through to Layer 2's coarse
    /// exit-code+commit heuristic.
    ///
    /// Fixture provenance and its two modifications are documented on
    /// `V3_INIT_EVENT` / `V3_RESULT_TURN1..3` above.
    #[test]
    fn evaluate_layer1_parses_claude_stream_capture() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(30)),
            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
        )
        .unwrap();

        let result = evaluate_layer1(dir.path(), PhaseId::new(30)).unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(1));

        // Non-vacuity guard for the assertion above: this marker omits
        // `decided_by_layer`, and the field is `#[serde(default)]`, so
        // `parse_marker_lines` alone yields `None`. `Some(1)` can therefore
        // only have come from the parser's explicit overwrite.
        assert_eq!(
            parse_marker_lines(r#"DEVFLOW_RESULT: {"status":"success"}"#)
                .unwrap()
                .decided_by_layer,
            None
        );
    }

    // ---- idle-timeout side channel (31-02, D-05/D-06/D-07) ---------------

    /// Write a monitor-shaped idle-timeout record. Field names and types match
    /// `IdleTimeoutRecord` exactly; the monitor writes it via serde, so a drift
    /// between the two shows up as a failing deserialize here.
    fn write_idle_timeout_record(root: &Path, phase: PhaseId, commits: &[(&str, &str)]) {
        let record = IdleTimeoutRecord {
            status: AgentStatus::IdleTimeout.as_wire_str().to_string(),
            idle_secs: 30,
            agent_pid: 4242,
            written_at: 1_700_000_000,
            commits: commits
                .iter()
                .map(|(sha, subject)| IdleTimeoutCommit {
                    sha: (*sha).to_string(),
                    subject: (*subject).to_string(),
                })
                .collect(),
        };
        std::fs::write(
            idle_timeout_path(root, phase),
            serde_json::to_string(&record).unwrap(),
        )
        .unwrap();
    }

    /// T-31-06, and the single most important test in plan 31-02.
    ///
    /// The fixture is a REAL archived three-turn capture in which every
    /// top-level `result` event carries a success marker — the normal shape of
    /// a run that got far enough to idle out. A fixture without a prior
    /// `result` event would pass vacuously while the same mechanism silently
    /// failed in production.
    ///
    /// The negative control is encoded INSIDE the test rather than described in
    /// prose: the same fixture is evaluated first WITHOUT the side channel and
    /// must return `Success`. If that ever stops holding, the `IdleTimeout`
    /// assertion below is proving a verdict nothing was competing with.
    #[test]
    fn idle_timeout_side_channel_wins_over_stale_stream_result() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(40)),
            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
        )
        .unwrap();

        // NEGATIVE CONTROL — must produce the OPPOSITE result.
        assert_eq!(
            evaluate_layer1(dir.path(), PhaseId::new(40))
                .unwrap()
                .status,
            AgentStatus::Success,
            "negative control: without the side channel this fixture must decide Success, \
             otherwise the assertion below is vacuous"
        );

        write_idle_timeout_record(
            dir.path(),
            PhaseId::new(40),
            &[("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "feat: partial")],
        );

        let result = evaluate_layer1(dir.path(), PhaseId::new(40)).unwrap();
        assert_eq!(
            result.status,
            AgentStatus::IdleTimeout,
            "a stale success already in the capture must not shadow the monitor's verdict"
        );
        assert_eq!(result.decided_by_layer, Some(1));
    }

    /// The read must precede `read_capture`'s early `return None`, so a
    /// timeout that fired before the child emitted anything at all is still
    /// authoritative rather than discarded.
    #[test]
    fn idle_timeout_side_channel_is_read_even_when_the_capture_is_missing() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        assert!(
            !stdout_path(dir.path(), PhaseId::new(41)).exists(),
            "fixture precondition: there must be no capture at all"
        );

        // NEGATIVE CONTROL: with neither file present Layer 1 abstains, so the
        // verdict below can only have come from the side channel.
        assert!(evaluate_layer1(dir.path(), PhaseId::new(41)).is_none());

        write_idle_timeout_record(dir.path(), PhaseId::new(41), &[]);

        let result = evaluate_layer1(dir.path(), PhaseId::new(41)).unwrap();
        assert_eq!(result.status, AgentStatus::IdleTimeout);
        assert_eq!(result.commits, Some(0));
    }

    /// D-07: the verdict names the commits, and says they were not rolled back.
    #[test]
    fn idle_timeout_result_carries_the_commits_it_enumerated() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        write_idle_timeout_record(
            dir.path(),
            PhaseId::new(42),
            &[
                ("1111111abcdef0000000000000000000000000000", "feat: first"),
                ("2222222abcdef0000000000000000000000000000", "fix: second"),
            ],
        );

        let result = evaluate_layer1(dir.path(), PhaseId::new(42)).unwrap();

        assert_eq!(result.commits, Some(2));
        let reason = result.reason.expect("an idle timeout must explain itself");
        for fragment in [
            "1111111",     // short sha, first commit
            "feat: first", // its subject
            "2222222",
            "fix: second",
            "30s",                           // how long the stream was silent
            "NONE of them were rolled back", // D-07's non-destruction promise
        ] {
            assert!(
                reason.contains(fragment),
                "reason must name {fragment:?}; got: {reason}"
            );
        }
        // The full sha must not be what is printed — a 40-char sha in a gate
        // message is noise, and the short form is what an operator pastes.
        assert!(!reason.contains("1111111abcdef0000000000000000000000000000"));
    }

    /// Nothing about the pre-existing cascade changes when no timeout fired.
    /// Three shapes, each asserted against the verdict it produced before this
    /// plan existed, with the side channel confirmed absent in every one.
    #[test]
    fn absent_side_channel_leaves_the_cascade_unchanged() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();

        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(43)),
            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
        )
        .unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(44)),
            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_FAILED),
        )
        .unwrap();

        for (phase, expected) in [
            (PhaseId::new(43), Some(AgentStatus::Success)),
            (PhaseId::new(44), Some(AgentStatus::Failed)),
            (PhaseId::new(45), None), // no capture, no side channel
        ] {
            assert!(
                !idle_timeout_path(dir.path(), phase).exists(),
                "fixture precondition: phase {phase} must have no side channel"
            );
            assert_eq!(
                evaluate_layer1(dir.path(), phase).map(|r| r.status),
                expected,
                "the cascade changed for phase {phase} with no timeout on disk"
            );
        }
    }

    /// The file's PRESENCE is the signal; its contents are enrichment.
    ///
    /// A corrupt record must NOT fall back into the cascade — that would let
    /// the stale success in the capture win, converting a damaged file into a
    /// silent wrong advance. This is the same fixture as
    /// `idle_timeout_side_channel_wins_over_stale_stream_result`, so the
    /// Success it would otherwise decide is real and not hypothetical.
    #[test]
    fn an_unreadable_idle_timeout_record_still_produces_the_verdict() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(46)),
            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
        )
        .unwrap();

        // NEGATIVE CONTROL: this capture decides Success on its own.
        assert_eq!(
            evaluate_layer1(dir.path(), PhaseId::new(46))
                .unwrap()
                .status,
            AgentStatus::Success
        );

        std::fs::write(
            idle_timeout_path(dir.path(), PhaseId::new(46)),
            "{ this is not json",
        )
        .unwrap();

        let result = evaluate_layer1(dir.path(), PhaseId::new(46)).unwrap();
        assert_eq!(result.status, AgentStatus::IdleTimeout);
        assert_eq!(
            result.commits, None,
            "an unreadable record must not invent a commit count"
        );
        assert!(result.reason.unwrap().contains("unreadable"));
    }

    /// Last-result-wins. A session kept alive across turns emits one `result`
    /// event per turn; only the final one is the session's verdict.
    ///
    /// Asserts BOTH directions so the test cannot pass by a parser that merely
    /// prefers `success`: failed-then-success yields Success, and
    /// success-then-failed yields Failed. The middle event carries the same
    /// payload as the first, so a parser that stopped at index 1 would also
    /// fail.
    #[test]
    fn claude_stream_last_result_event_wins_over_earlier_results() {
        let last_success = v3_stream_capture(MARKER_FAILED, MARKER_FAILED, MARKER_SUCCESS);
        let result = parse_claude_event_result(&last_success).unwrap();
        assert_eq!(result.status, AgentStatus::Success);

        let last_failed = v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_FAILED);
        let result = parse_claude_event_result(&last_failed).unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.reason.as_deref(), Some("earlier turn aborted"));
    }

    /// T-30-26: `decided_by_layer` is provenance, not decoration.
    /// `crates/devflow-cli/src/pipeline_outcomes.rs` (`classify_validate_outcome`)
    /// computes `external = decided_by_layer == Some(0) && status == Success`
    /// and uses it to tell an externally-probe-verified Validate stage apart
    /// from an ordinary one. An agent that writes `"decided_by_layer": 0` into
    /// its own marker is claiming a Layer-0 probe provenance it did not earn,
    /// so the stream parser overwrites the field unconditionally.
    ///
    /// This is a runtime assertion on the returned struct, not a source grep —
    /// it fails the moment the overwrite is dropped.
    #[test]
    fn claude_stream_overwrites_agent_planted_decided_by_layer() {
        // Non-vacuity guard: prove the planted value really would survive
        // deserialization, so the `Some(1)` below is the overwrite at work and
        // not an artifact of a marker that failed to parse.
        assert_eq!(
            parse_marker_lines(r#"DEVFLOW_RESULT: {"status":"success","decided_by_layer":0}"#)
                .unwrap()
                .decided_by_layer,
            Some(0)
        );

        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_PLANTED_LAYER);
        let result = parse_claude_event_result(&capture).unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(1));
    }

    /// A marker-less final turn defers to Layer 2 rather than reporting an
    /// unconditional Success — the same convention `parse_codex_event_result`
    /// applies to a bare `turn.completed`. A marker-less turn must never
    /// silently advance a stage.
    ///
    /// The FIRST turn carries a success marker, so this also proves the parser
    /// does not fall back to an earlier turn's marker when the last one has
    /// none.
    ///
    /// Plan 30-03 addendum: the deferral must hold specifically for
    /// `is_error: false`, which is what the real captured envelope carries —
    /// asserted below so this reads as a deliberate is_error case rather than
    /// an incidental one. Only `is_error: true` may promote a marker-less turn
    /// to `Failed`.
    #[test]
    fn claude_stream_last_result_without_marker_defers() {
        let capture = v3_stream_capture(MARKER_SUCCESS, NO_MARKER, NO_MARKER);
        assert!(
            capture.contains(r#""is_error":false"#),
            "the archived envelopes carry is_error:false; this test is about that case"
        );
        assert!(parse_claude_event_result(&capture).is_none());
    }

    #[test]
    fn evaluate_layer1_reports_rate_limited_without_marker() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(7)),
            r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
        )
        .unwrap();

        let result = evaluate_layer1(dir.path(), PhaseId::new(7)).unwrap();

        assert_eq!(result.status, AgentStatus::RateLimited);
        assert_eq!(
            result.reason.as_deref(),
            Some("rate limited until 2026-06-18T15:45:30Z")
        );
    }

    /// A real Claude rate-limit envelope carries `is_error: true` alongside
    /// `subtype: "error_rate_limit"`. The specific RateLimited classification
    /// must outrank the generic is_error → Failed path, or the primary
    /// rate-limit resume cron never triggers for the exact case it exists for.
    #[test]
    fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(7)),
            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
        )
        .unwrap();

        let result = evaluate_layer1(dir.path(), PhaseId::new(7)).unwrap();

        assert_eq!(result.status, AgentStatus::RateLimited);
        assert_eq!(
            result.reason.as_deref(),
            Some("rate limited until 2026-06-18T15:45:30Z")
        );
    }

    /// CR-01 (13-REVIEW.md) completion: the monitor path writes raw agent
    /// bytes to the stdout file via sh redirection, so evaluate_layer1 must
    /// tolerate invalid UTF-8 rather than silently disabling all Layer-1
    /// detection (the blocking-mode capture was fixed; the file read here is
    /// the other half of the same bug).
    #[test]
    fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
        bytes.extend_from_slice(
            b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
        );
        std::fs::write(stdout_path(dir.path(), PhaseId::new(5)), bytes).unwrap();

        let result = evaluate_layer1(dir.path(), PhaseId::new(5)).unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.reason.as_deref(), Some("review: bad"));
    }

    #[test]
    fn failing_external_probe_outranks_success_marker() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir
            .path()
            .join(".planning/phases/16-pipeline-reliability-hardening");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-03-PLAN.md"),
            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
        )
        .unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
        )
        .unwrap();
        let state = state_in(dir.path(), PhaseId::new(16));

        let approval = vec!["test -f externally-shipped".to_string()];
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert!(
            result
                .reason
                .as_deref()
                .is_some_and(|reason| reason.contains("external verification failed"))
        );
    }

    /// D-05 gap 1 / D-06 (17-03): Layer 0 now evaluates on every stage, not
    /// only Code.
    ///
    /// This is the MAIN-CHECKOUT MIRROR of
    /// `external_probe_discovers_from_the_worktree_when_the_main_checkout_lacks_the_plan`,
    /// and the two must be read together: with no worktree set, discovery and
    /// probe execution resolve to the SAME root, so 999.76's relocation of
    /// discovery to `execution_root` provably leaves this path untouched.
    /// Without this mirror the worktree fixture alone could not distinguish
    /// "discovery reads the execution root" from "discovery reads any root
    /// that happens to hold the PLAN".
    ///
    /// It previously set `state.worktree_path` and asserted the opposite
    /// direction — that discovery must read `project_root` while probes run in
    /// the worktree (review Plan 03 MEDIUM, OpenCode). 999.76 overturned that
    /// premise (see [`evaluate_layer0`]'s doc comment), so the fixture was
    /// converted rather than deleted: every assertion below is the original
    /// one, including the `"external verification failed"` reason text and the
    /// final `Success` assertion. Only the two roots' coincidence changed.
    #[test]
    fn external_probe_discovers_from_project_root_across_every_stage_without_a_worktree() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f implemented\"\n---\n",
        )
        .unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
        )
        .unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        // No worktree: `execution_root` falls back to `project_root`, so
        // discovery and probe execution read the same directory.
        state.worktree_path = None;
        state.stage = Stage::Plan;

        let approval = vec!["test -f implemented".to_string()];

        // Layer 0 now fires on Plan too — the probe file does not yet exist,
        // so this must fail on the probe itself (NOT a false PLAN-removed
        // veto, which would mean discovery silently returned zero commands).
        let plan_result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(plan_result.status, AgentStatus::Failed);
        assert!(
            plan_result
                .reason
                .as_deref()
                .is_some_and(|reason| reason.contains("external verification failed")),
            "expected a failing-probe reason, not a false PLAN-removed veto: {:?}",
            plan_result.reason
        );

        state.stage = Stage::Code;
        let code_result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(code_result.status, AgentStatus::Failed);

        // The probe executes against execution_root, which without a worktree
        // IS project_root — the coincidence this mirror exists to pin.
        std::fs::write(dir.path().join("implemented"), "done").unwrap();
        let passing = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(passing.status, AgentStatus::Success);
        assert_eq!(passing.decided_by_layer, Some(0));
    }

    /// 999.76 (ROADMAP criterion 6): the INVERSE of the fixture above. The
    /// PLAN lives only under the worktree and `project_root`'s own
    /// `.planning/phases/` is absent entirely — which is what an in-flight
    /// phase actually looks like. `.planning/` is tracked content, so a phase's
    /// `{N}-PLAN.md` sits on `feature/phase-{N}` INSIDE the worktree and is
    /// absent from the main checkout for the phase's whole duration.
    ///
    /// The live provenance measurement for that layout claim is **NC-7**,
    /// recorded in this phase's `34-04-SUMMARY.md`: `git ls-tree -r develop`
    /// vs `git ls-tree -r HEAD` over `.planning/phases`, reported with both
    /// refs' counts. NC-7 is evidence that the layout manufactured here is the
    /// real one — it says nothing about whether this code is correct. That
    /// claim is carried by this fixture and by its main-checkout mirror
    /// `external_probe_discovers_from_project_root_across_every_stage_without_a_worktree`,
    /// which must be read together with it.
    #[test]
    fn external_probe_discovers_from_the_worktree_when_the_main_checkout_lacks_the_plan() {
        let dir = tempfile::tempdir().unwrap();
        let worktree = dir.path().join("phase-worktree");
        // The PLAN exists ONLY under the worktree — `dir.path()`'s own
        // `.planning/phases/` is deliberately never created.
        let phase_dir = worktree.join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f implemented\"\n---\n",
        )
        .unwrap();
        // Captures live in the project root, not the worktree.
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
        )
        .unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        state.worktree_path = Some(worktree.clone());

        let approval = vec!["test -f implemented".to_string()];

        // The probe file does not exist yet, so this must fail ON THE PROBE.
        let failing = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(failing.status, AgentStatus::Failed);
        assert!(
            failing
                .reason
                .as_deref()
                .is_some_and(|reason| reason.contains("external verification failed")),
            "expected a failing-probe reason; a PLAN-removed reason means discovery \
             silently returned zero commands — i.e. discovery still reads project_root \
             and 999.76's fix did not land: {:?}",
            failing.reason
        );

        std::fs::write(worktree.join("implemented"), "done").unwrap();
        let passing = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(passing.status, AgentStatus::Success);
        assert_eq!(passing.decided_by_layer, Some(0));
    }

    #[test]
    fn changed_external_probe_never_inherits_prior_approval() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"touch escaped\"\n---\n",
        )
        .unwrap();
        let state = state_in(dir.path(), PhaseId::new(16));
        let approved = vec!["test -f reviewed-artifact".to_string()];

        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approved),
        )
        .unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert!(result.reason.unwrap().contains("approval mismatch"));
        assert!(!dir.path().join("escaped").exists());
    }

    #[test]
    fn removed_external_probe_fails_closed_against_prior_approval() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\"}\n",
        )
        .unwrap();
        let state = state_in(dir.path(), PhaseId::new(16));
        let approved = vec!["test -f shipped".to_string()];

        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approved),
        )
        .unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert!(result.reason.unwrap().contains("declaration was removed"));
    }

    #[test]
    fn no_external_declaration_preserves_layer1_result() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"done\"}\n",
        )
        .unwrap();
        let state = state_in(dir.path(), PhaseId::new(16));
        let layer1 = evaluate_layer1(dir.path(), PhaseId::new(16)).unwrap();

        let full = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();

        assert_eq!(
            serde_json::to_value(full).unwrap(),
            serde_json::to_value(layer1).unwrap()
        );
    }

    /// D-05 gap 2 (17-03): a declared, operator-approved external
    /// post-condition whose probe passes is affirmative Success evidence on
    /// its own — even with zero commits and on a non-Code stage (Define
    /// here). No agent stdout is written at all, so if Layer 0 did not
    /// short-circuit, there would be nothing for Layer 1 to find and Layer 2
    /// would fall through for lack of an exit-code file.
    #[test]
    fn layer0_affirmative_success_on_non_code_stage_with_zero_commits() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f shipped\"\n---\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("shipped"), "done").unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        state.stage = Stage::Define;

        let approval = vec!["test -f shipped".to_string()];
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(0));
        assert_eq!(result.commits, None);
        // Off-Validate stage: verdict reconciliation does not apply (18e).
        assert_eq!(result.verdict, None);
    }

    /// Review Plan 03 LOW (Codex+OpenCode), 16a: an approved all-passing
    /// Layer 0 probe intentionally outranks a Layer 1 self-reported failure
    /// marker — proven here at the cascade level (`evaluate_agent_result_inner`),
    /// not merely in isolation on `evaluate_layer0`.
    #[test]
    fn layer0_affirmative_success_outranks_layer1_failure_marker() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir
            .path()
            .join(".planning/phases/16-pipeline-reliability-hardening");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-03-PLAN.md"),
            "---\nphase: 16\nexternal_verify: \"test -f externally-shipped\"\n---\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("externally-shipped"), "done").unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"agent self-reported failure\"}\n",
        )
        .unwrap();
        let state = state_in(dir.path(), PhaseId::new(16));

        let approval = vec!["test -f externally-shipped".to_string()];
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(0));
        // Off-Validate stage (Code): verdict reconciliation does not apply,
        // even though Layer 1's marker here reports a (failure) status (18e).
        assert_eq!(result.verdict, None);
    }

    /// D-05/18e: Layer 0's affirmative-success arm at `Stage::Validate` must
    /// consult Layer 1's verdict rather than discard it — the two-signal
    /// reconciliation `reconcile_layer0_verdict` adds. Covers all three
    /// verdict states Layer 1 can produce: pass, gaps, and no marker at all.
    ///
    /// D-15 (34-01) adds a FOURTH case: the self-contradictory marker
    /// `{"status":"failed","verdict":"pass"}`. "Consult Layer 1's verdict" was
    /// implemented as "read Layer 1's verdict and nothing else", so an agent
    /// that reported its own failure while claiming a passing verdict had that
    /// verdict grafted onto Layer 0's `Success` — 999.74's real route. The
    /// fourth case pins `verdict: None` for it; before the fix it observed
    /// `Some(Pass)`.
    #[test]
    fn layer0_affirmative_success_consults_layer1_verdict_at_validate() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f shipped\"\n---\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("shipped"), "done").unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        state.stage = Stage::Validate;
        let approval = vec!["test -f shipped".to_string()];

        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
        )
        .unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(0));
        assert_eq!(result.verdict, Some(Verdict::Pass));

        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"gaps\"}\n",
        )
        .unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(result.verdict, Some(Verdict::Gaps));

        std::fs::remove_file(stdout_path(dir.path(), PhaseId::new(16))).unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(result.verdict, None);

        // D-15: the self-contradictory marker. Layer 1 reports its own run
        // FAILED and simultaneously claims a passing verdict. Pre-fix the graft
        // read only `.verdict` and produced `Some(Pass)`, i.e. an affirmative
        // pair `decide_action` advances and `classify_validate_outcome` reads
        // as Passed — Ship, unattended, on a run whose agent reported failure.
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
        )
        .unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(
            result.verdict, None,
            "a verdict attached to a self-reported failure must not be grafted (D-15)"
        );
        // The fix touches `.verdict` only — Layer 0 still decided the status.
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(0));
    }

    /// D-15 / ROADMAP criterion 4: `reconcile_layer0_verdict` must consult
    /// Layer 1's own `AgentStatus` before transplanting its `verdict`.
    ///
    /// A regression here costs an unattended Ship on a run whose agent reported
    /// failure: the graft would rebuild `(Success, Some(Pass), Some(0))` from a
    /// self-contradictory marker, `decide_action` would advance it, and
    /// `classify_validate_outcome` would classify Validate as `Passed`.
    ///
    /// Also carries NC-5's two discrimination cases, which share this fixture.
    /// The exploit needs BOTH marker fields; removing either must not reach an
    /// affirmative pair. The mandatory opposite-result control lives in
    /// `layer0_verdict_graft_still_transplants_a_passing_layer1_verdict` — if
    /// that test also produced `None` the fix would be indiscriminate and this
    /// one would prove nothing.
    #[test]
    fn layer0_verdict_graft_declines_when_layer1_status_is_not_success() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f shipped\"\n---\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("shipped"), "done").unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        state.stage = Stage::Validate;
        let approval = vec!["test -f shipped".to_string()];

        // The exploit itself: both fields present and mutually contradictory.
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
        )
        .unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(
            result.verdict, None,
            "self-contradictory marker: the verdict must be declined (D-15)"
        );
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(0));

        // NC-5a: removes the `verdict` FIELD, keeps the failed status. `None`
        // both pre- and post-fix, so this case cannot discriminate the fix —
        // that is the point. The failed status alone is not the exploit.
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"failed\"}\n",
        )
        .unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(
            result.verdict, None,
            "NC-5a removes the `verdict` field: there is no verdict to graft, \
             so the result must be None whether or not the fix is present"
        );

        // NC-5b: removes `verdict: pass` SPECIFICALLY by downgrading it to
        // `gaps`, keeping both fields present. Pre-fix this grafted
        // `Some(Gaps)`; post-fix it declines like any other non-Success
        // Layer 1. Neither state is an affirmative pair — the exploit needs
        // `pass`, not merely any verdict.
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"gaps\"}\n",
        )
        .unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_ne!(
            result.verdict,
            Some(Verdict::Pass),
            "NC-5b removes `verdict: pass` by downgrading it to `gaps`: this \
             case must never reach an affirmative pair"
        );
        assert_eq!(result.verdict, None);
    }

    /// NC-5's positive half: the fix declines ONLY when Layer 1's own status is
    /// not `Success`, never indiscriminately.
    ///
    /// This is the case that must produce the OPPOSITE result from
    /// `layer0_verdict_graft_declines_when_layer1_status_is_not_success`. If
    /// both produced `None` the fix would have disabled 18e's legitimate
    /// reconciliation wholesale — re-introducing the 17-03 regression that
    /// `reconcile_layer0_verdict` exists to fix — and the pair would prove
    /// nothing about D-15, because a measurement whose two arms agree is
    /// broken rather than informative.
    #[test]
    fn layer0_verdict_graft_still_transplants_a_passing_layer1_verdict() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f shipped\"\n---\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("shipped"), "done").unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        state.stage = Stage::Validate;
        let approval = vec!["test -f shipped".to_string()];

        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
        )
        .unwrap();
        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(
            result.verdict,
            Some(Verdict::Pass),
            "a passing verdict from a Layer 1 that reported its OWN success \
             must still be transplanted (18e); a None here would mean the \
             D-15 fix is indiscriminate"
        );
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(0));
    }

    /// NC-6: with Layer 0 disabled, the same self-contradictory marker never
    /// gets laundered at all — Layer 1 reports `Failed` verbatim and
    /// `decide_action` routes it to `GateReview`.
    ///
    /// What the control proves: the GRAFT is the mechanism, not the classifier
    /// and not `decide_action`. Removing Layer 0 removes the laundering
    /// entirely, so the exploit's precondition is an affirmative Layer-0 probe
    /// success — which is exactly why plan 34-04 (999.76), by making
    /// `decided_by_layer == Some(0)` common in worktree mode, must not land
    /// without the fix this test pins.
    ///
    /// The routing consequence is asserted here rather than assumed, so a
    /// future change to `decide_action`'s `Failed` arm breaks this test rather
    /// than silently invalidating the control.
    #[test]
    fn layer0_disabled_routes_a_self_reported_failure_to_gate_review() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        // The difference from the fixtures above: Layer 0 is switched off, so
        // the cascade falls through to Layer 1 instead of short-circuiting on
        // an affirmative probe success.
        std::fs::write(
            dir.path().join("devflow.toml"),
            "external_verify_enabled = false\n",
        )
        .unwrap();
        // Belt AND braces, deliberately. `config::external_verify_enabled`
        // consults `DEVFLOW_EXTERNAL_VERIFY_ENABLED` BEFORE `devflow.toml`, and
        // `config::tests::env_overrides_file_external_verification` sets that
        // variable to "true" process-globally under a mutex private to its own
        // module — which cannot serialize against this one. A PLAN declaring
        // `external_verify` would therefore let a parallel run of that test
        // re-enable Layer 0 here and flake this control into a green.
        // Declaring no probe closes that window: with no declared commands and
        // no approval vector, `evaluate_layer0` abstains whatever the env says,
        // so this test is deterministic under every value of the variable.
        std::fs::write(phase_dir.join("16-01-PLAN.md"), "---\nplan: 01\n---\n").unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        state.stage = Stage::Validate;

        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"failed\",\"verdict\":\"pass\"}\n",
        )
        .unwrap();
        // No approval vector — Layer 0 is disabled, so there is nothing to
        // approve, and supplying one would re-arm the very arm being removed.
        let result =
            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
                .unwrap();

        assert_eq!(
            result.status,
            AgentStatus::Failed,
            "with Layer 0 disabled, Layer 1's self-reported failure stands \
             verbatim — there is no affirmative probe success to graft onto"
        );
        assert_eq!(result.decided_by_layer, Some(1));
        assert_eq!(
            crate::outcome_policy::decide_action(Stage::Validate, result.status),
            crate::outcome_policy::Action::GateReview,
            "a self-reported failure must gate for review, never advance"
        );
    }

    /// 18e's reconciliation is scoped to `Stage::Validate` only (flagged
    /// assumption in 18-05-PLAN.md): at every other stage an affirmative
    /// Layer 0 success must keep `verdict: None`, even when Layer 1's marker
    /// carries an explicit verdict.
    #[test]
    fn layer0_affirmative_success_keeps_none_verdict_off_validate() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f shipped\"\n---\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("shipped"), "done").unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(16)),
            "DEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}\n",
        )
        .unwrap();
        let state = state_in(dir.path(), PhaseId::new(16)); // Stage::Code by default
        let approval = vec!["test -f shipped".to_string()];

        let result = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(0));
        assert_eq!(result.verdict, None);
    }

    /// Ordering edge (17a): with multiple declared probes, ALL must pass for
    /// affirmative Success — the first failing probe vetoes the outcome
    /// regardless of which position it occupies among the declarations.
    #[test]
    fn multiple_declared_probes_first_failure_vetoes_regardless_of_order() {
        let dir = tempfile::tempdir().unwrap();
        let phase_dir = dir.path().join(".planning/phases/16-reliability");
        std::fs::create_dir_all(&phase_dir).unwrap();
        // 16-01 comes first alphabetically and passes; 16-02 comes second and fails.
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
        )
        .unwrap();
        std::fs::write(
            phase_dir.join("16-02-PLAN.md"),
            "---\nexternal_verify: \"test -f never-created\"\n---\n",
        )
        .unwrap();
        std::fs::write(dir.path().join("passing-artifact"), "done").unwrap();
        let mut state = state_in(dir.path(), PhaseId::new(16));
        state.stage = Stage::Define;

        let approval = vec![
            "test -f passing-artifact".to_string(),
            "test -f never-created".to_string(),
        ];
        let result_a = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval),
        )
        .unwrap();
        assert_eq!(result_a.status, AgentStatus::Failed);
        assert!(
            result_a
                .reason
                .as_deref()
                .is_some_and(|reason| reason.contains("never-created")),
            "unexpected reason: {:?}",
            result_a.reason
        );

        // Swap which position fails: 16-01 now fails, 16-02 passes. The
        // overall outcome must still veto — order of declaration must not
        // matter.
        std::fs::write(
            phase_dir.join("16-01-PLAN.md"),
            "---\nexternal_verify: \"test -f still-missing\"\n---\n",
        )
        .unwrap();
        std::fs::write(
            phase_dir.join("16-02-PLAN.md"),
            "---\nexternal_verify: \"test -f passing-artifact\"\n---\n",
        )
        .unwrap();
        let approval_swapped = vec![
            "test -f still-missing".to_string(),
            "test -f passing-artifact".to_string(),
        ];
        let result_b = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval_swapped),
        )
        .unwrap();
        assert_eq!(result_b.status, AgentStatus::Failed);

        // Now make BOTH pass: only then is the outcome Success.
        std::fs::write(dir.path().join("still-missing"), "done").unwrap();
        let result_c = evaluate_agent_result_inner(
            dir.path(),
            &state,
            &GitFlowConfig::default(),
            Some(&approval_swapped),
        )
        .unwrap();
        assert_eq!(result_c.status, AgentStatus::Success);
        assert_eq!(result_c.decided_by_layer, Some(0));
    }

    /// A quota denial in the capture must be visible to the monitor BEFORE it
    /// records a verdict about the silence that denial caused.
    ///
    /// The positive arm of the 2026-08-08 misclassification: a real `seven_day`
    /// / `out_of_credits` denial silenced the agent, the idle timer fired, and
    /// the resulting record shadowed the classifier that had the right answer.
    #[test]
    fn a_quota_denial_in_the_capture_is_visible_to_the_monitor() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phase = PhaseId::new(3);
        std::fs::create_dir_all(root.join(".devflow")).unwrap();

        std::fs::write(
            stdout_path(root, phase),
            format!("{V3_INIT_EVENT}\n{}\n", v3_rate_limit_event("rejected")),
        )
        .unwrap();

        assert!(
            capture_shows_rate_limit_denial(root, phase),
            "an explicit `rejected` quota denial must be detectable, or the monitor \
             will record a hang for a pause that is resumable"
        );
    }

    /// Negative control, and the more important half: this must NOT fire on an
    /// ordinary capture, or every genuine hang stops being recorded as one.
    ///
    /// The `allowed` arm is the specific trap — the CLI emits `rate_limit_event`
    /// routinely while healthy, and `overageStatus: "rejected"` sits one level
    /// below `status: "allowed"`, so any loose nested search matches it.
    #[test]
    fn an_ordinary_capture_is_not_mistaken_for_a_quota_denial() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(root.join(".devflow")).unwrap();

        let quiet = PhaseId::new(4);
        std::fs::write(stdout_path(root, quiet), format!("{V3_INIT_EVENT}\n")).unwrap();
        assert!(
            !capture_shows_rate_limit_denial(root, quiet),
            "a capture with no rate-limit event at all must read as no denial"
        );

        let healthy = PhaseId::new(5);
        std::fs::write(
            stdout_path(root, healthy),
            format!("{V3_INIT_EVENT}\n{V3_RATE_LIMIT_EVENT_ALLOWED}\n"),
        )
        .unwrap();
        assert!(
            !capture_shows_rate_limit_denial(root, healthy),
            "a healthy `status: allowed` event carries `overageStatus: rejected` one \
             level down — matching it would suppress the idle timeout on every run"
        );

        let absent = PhaseId::new(6);
        assert!(
            !capture_shows_rate_limit_denial(root, absent),
            "a missing capture must read as no denial, never as one"
        );
    }

    /// A stage attempt's idle-timeout verdict must not survive into the next
    /// attempt.
    ///
    /// Reproduces the 2026-08-08 observation directly: a record written by a
    /// killed Plan stage was still authoritative when the next stage launched,
    /// and because `evaluate_layer1` consults it FIRST and returns
    /// unconditionally, it overrode a stage that had genuinely succeeded.
    #[test]
    fn archive_clears_a_previous_attempts_idle_timeout_verdict() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phase = PhaseId::new(1);
        std::fs::create_dir_all(root.join(".devflow")).unwrap();

        // A verdict left behind by an earlier, killed attempt.
        std::fs::write(
            idle_timeout_path(root, phase),
            r#"{"status":"idle_timeout","idle_secs":120,"agent_pid":501757,"written_at":1786157328,"commits":[]}"#,
        )
        .unwrap();
        // Precondition, asserted rather than assumed: while it exists it is
        // authoritative, which is precisely why it must not persist.
        assert!(
            evaluate_layer1(root, phase).is_some(),
            "fixture precondition: the stale record must be readable as a verdict"
        );

        archive_phase_files(root, root, phase, 5).unwrap();

        assert!(
            !idle_timeout_path(root, phase).exists(),
            "a previous attempt's timeout verdict survived a stage launch — it \
             will now outrank the next stage's real result, for this phase, forever"
        );
    }

    /// Negative control for the test above. The clearing happens at stage
    /// LAUNCH, and it must not be reachable in a way that discards a verdict
    /// before it has been read: a record with no capture beside it is the
    /// stale case, but a record is only ever written mid-attempt, after the
    /// launch that would have cleared it.
    ///
    /// This pins the other half — that clearing the file did not neuter the
    /// mechanism, only its lifetime.
    #[test]
    fn a_current_attempts_idle_timeout_verdict_is_still_authoritative() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phase = PhaseId::new(2);
        std::fs::create_dir_all(root.join(".devflow")).unwrap();

        // Stage launched (archive ran), THEN the monitor recorded a timeout —
        // the real ordering within one attempt.
        archive_phase_files(root, root, phase, 5).unwrap();
        std::fs::write(
            idle_timeout_path(root, phase),
            r#"{"status":"idle_timeout","idle_secs":120,"agent_pid":4242,"written_at":1786159637,"commits":[]}"#,
        )
        .unwrap();

        let verdict = evaluate_layer1(root, phase)
            .expect("a verdict recorded during this attempt must still be honoured");
        assert_eq!(
            verdict.status,
            AgentStatus::IdleTimeout,
            "the timeout mechanism itself must survive the lifetime fix"
        );
    }

    #[test]
    fn archive_moves_captures_into_history_and_removes_pid_file() {
        // 16b: prior-stage captures must survive a simulated next-launch by
        // appearing under .devflow/history/phase-NN/, not be wiped outright.
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(root.join(".devflow")).unwrap();
        std::fs::write(root.join(".devflow/phase-01-stdout"), "prior stdout").unwrap();
        std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
        std::fs::write(root.join(".devflow/phase-01-agent-pid"), "1234").unwrap();

        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();

        // The live capture paths are gone (moved, not merely deleted).
        assert!(!root.join(".devflow/phase-01-stdout").exists());
        assert!(!root.join(".devflow/phase-01-exit").exists());
        // Agent-pid is bookkeeping, not diagnostic — still removed outright.
        assert!(!root.join(".devflow/phase-01-agent-pid").exists());

        let history = history_dir(root, PhaseId::new(1));
        let archived: Vec<_> = std::fs::read_dir(&history)
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect();
        let archived_stdout = archived
            .iter()
            .find(|name| name.ends_with("-stdout"))
            .expect("stdout capture should be archived into history");
        assert!(archived.iter().any(|name| name.ends_with("-exit")));
        let contents = std::fs::read_to_string(history.join(archived_stdout)).unwrap();
        assert_eq!(contents, "prior stdout");
    }

    #[test]
    fn archive_is_noop_when_nothing_to_archive() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // Should not panic when there is nothing to archive (first launch).
        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
        assert!(!history_dir(root, PhaseId::new(1)).exists());
    }

    #[test]
    fn archive_handles_missing_devflow_dir() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // No .devflow dir at all — should not panic.
        archive_phase_files(root, root, PhaseId::new(1), 5).unwrap();
    }

    #[test]
    fn archive_failure_preserves_live_capture_for_retry() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(root.join(".devflow")).unwrap();
        std::fs::write(stdout_path(root, PhaseId::new(1)), "evidence").unwrap();
        // A file where the history directory must be forces create_dir_all
        // to fail before the live capture is moved or a monitor can truncate it.
        std::fs::write(root.join(".devflow/history"), "blocked").unwrap();

        assert!(archive_phase_files(root, root, PhaseId::new(1), 5).is_err());
        assert_eq!(
            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
            "evidence"
        );
    }

    #[test]
    fn archive_second_publish_failure_rolls_back_complete_live_pair() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(root.join(".devflow")).unwrap();
        std::fs::write(stdout_path(root, PhaseId::new(1)), "stdout evidence").unwrap();
        std::fs::write(exit_code_path(root, PhaseId::new(1)), "17").unwrap();
        let history = history_dir(root, PhaseId::new(1));
        std::fs::create_dir_all(history.join("fixed-exit/blocker")).unwrap();

        assert!(archive_phase_files_with_stamp(root, root, PhaseId::new(1), 5, "fixed").is_err());

        assert_eq!(
            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
            "stdout evidence"
        );
        assert_eq!(
            std::fs::read_to_string(exit_code_path(root, PhaseId::new(1))).unwrap(),
            "17"
        );
        assert!(!history.join("fixed-stdout").exists());
        assert!(!history.join(".pending-fixed").exists());
    }

    #[test]
    fn archive_review_copy_failure_rolls_back_complete_live_pair() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let evidence_root = root.join("phase-worktree");
        std::fs::create_dir_all(root.join(".devflow")).unwrap();
        std::fs::write(stdout_path(root, PhaseId::new(1)), "stdout evidence").unwrap();
        std::fs::write(exit_code_path(root, PhaseId::new(1)), "23").unwrap();
        let review = evidence_root.join(".planning/phases/01-example/01-REVIEW.md");
        std::fs::create_dir_all(&review).unwrap();

        assert!(
            archive_phase_files_with_stamp(root, &evidence_root, PhaseId::new(1), 5, "review-copy")
                .is_err()
        );

        assert_eq!(
            std::fs::read_to_string(stdout_path(root, PhaseId::new(1))).unwrap(),
            "stdout evidence"
        );
        assert_eq!(
            std::fs::read_to_string(exit_code_path(root, PhaseId::new(1))).unwrap(),
            "23"
        );
        let history = history_dir(root, PhaseId::new(1));
        assert!(!history.join("review-copy-stdout").exists());
        assert!(!history.join("review-copy-exit").exists());
        assert!(!history.join(".pending-review-copy").exists());
    }

    #[test]
    fn archive_snapshots_current_review_into_same_generation() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let evidence_root = root.join("phase-worktree");
        std::fs::create_dir_all(root.join(".devflow")).unwrap();
        std::fs::write(stdout_path(root, PhaseId::new(1)), "attempt").unwrap();
        let phase_dir = evidence_root.join(".planning/phases/01-example");
        std::fs::create_dir_all(&phase_dir).unwrap();
        std::fs::write(phase_dir.join("01-REVIEW.md"), "review one").unwrap();

        let stamp = archive_phase_files(root, &evidence_root, PhaseId::new(1), 5)
            .unwrap()
            .unwrap();

        assert_eq!(
            std::fs::read_to_string(
                history_dir(root, PhaseId::new(1)).join(format!("{stamp}-REVIEW.md"))
            )
            .unwrap(),
            "review one"
        );
    }

    #[test]
    fn archive_prunes_history_to_retain_count() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        std::fs::create_dir_all(root.join(".devflow")).unwrap();

        for i in 0..7 {
            std::fs::write(root.join(".devflow/phase-01-stdout"), format!("gen {i}")).unwrap();
            std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
            archive_phase_files(root, root, PhaseId::new(1), 3).unwrap();
        }

        let history = history_dir(root, PhaseId::new(1));
        let stdout_count = std::fs::read_dir(&history)
            .unwrap()
            .flatten()
            .filter(|e| e.file_name().to_string_lossy().ends_with("-stdout"))
            .count();
        let exit_count = std::fs::read_dir(&history)
            .unwrap()
            .flatten()
            .filter(|e| e.file_name().to_string_lossy().ends_with("-exit"))
            .count();
        assert_eq!(stdout_count, 3, "expected at most 3 retained generations");
        assert_eq!(exit_count, 3, "expected at most 3 retained generations");
    }

    /// The set of stamp groups currently surviving in a history directory,
    /// derived the same way `prune_history` derives them (`rsplit_once('-')`,
    /// keep the left part) so the assertion measures grouping rather than a
    /// listing length.
    fn surviving_stamps(history: &Path) -> std::collections::BTreeSet<String> {
        std::fs::read_dir(history)
            .unwrap()
            .flatten()
            .filter_map(|entry| {
                let name = entry.file_name().to_str()?.to_string();
                name.rsplit_once('-')
                    .map(|(stamp, _suffix)| stamp.to_string())
            })
            .collect()
    }

    /// ROADMAP criterion 7's retention half. `DEFAULT_CAPTURE_RETENTION` was
    /// `5`, and `archive_phase_files` runs once per launch: a clean five-stage
    /// Define→Plan→Code→Validate→Ship run produces 4 archive events and each
    /// Validate→Code loop-back adds 2. At `5`, the first loop-back's sixth
    /// event evicted Define's capture — silently, with no error and no log.
    ///
    /// What a regression here costs: a stage capture destroyed before the
    /// phase that requested it has read it, which is unrecoverable after the
    /// fact because `.devflow/` is the only copy until it is deliberately
    /// copied out.
    #[test]
    fn prune_history_retains_a_full_five_stage_run_with_loop_backs() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let history = history_dir(root, PhaseId::new(1));
        std::fs::create_dir_all(&history).unwrap();

        let retain = crate::config::DEFAULT_CAPTURE_RETENTION;

        // Twelve generations, strictly increasing. The suffix is load-bearing:
        // `prune_history` derives a stamp with `rsplit_once('-')` and keeps the
        // LEFT part, so a bare `{nanos}-{seq}` name would yield the stamp
        // `{nanos}` and then delete `{nanos}-stdout`, which never exists — the
        // retain half would false-pass via the `stamps.len() <= retain` early
        // return while the evict half could never pass at all.
        let stamps: Vec<String> = (0..12)
            .map(|i| format!("{}-0", 1_700_000_000_000_000_000u128 + i))
            .collect();
        for stamp in &stamps {
            std::fs::write(history.join(format!("{stamp}-stdout")), "capture").unwrap();
        }
        // The oldest generation gets a second suffix so eviction-by-stamp-group
        // is actually exercised rather than assumed: one evicted stamp must
        // take BOTH its files.
        std::fs::write(history.join(format!("{}-exit", stamps[0])), "0").unwrap();

        prune_history(&history, retain);

        let survivors = surviving_stamps(&history);
        assert_eq!(
            survivors.len(),
            12,
            "a five-stage run with loop-backs must not lose a capture at the default \
             retention; found {survivors:?}"
        );
        for stamp in &stamps {
            assert!(
                survivors.contains(stamp),
                "generation {stamp} was evicted at exactly the retention boundary"
            );
        }

        // Opposite-result control. Without this half the test would be
        // measuring a directory listing, not pruning: `prune_history` returns
        // early whenever `stamps.len() <= retain`, so a fixture that never
        // crosses the boundary passes identically against a `prune_history`
        // that does nothing at all.
        let thirteenth = format!("{}-0", 1_700_000_000_000_000_000u128 + 12);
        std::fs::write(history.join(format!("{thirteenth}-stdout")), "capture").unwrap();

        prune_history(&history, retain);

        let after = surviving_stamps(&history);
        assert_eq!(
            after.len(),
            12,
            "crossing the boundary by one must evict exactly one stamp group, not zero \
             and not several; found {after:?}"
        );
        assert!(
            !after.contains(&stamps[0]),
            "the evicted generation must be the OLDEST by stamp order"
        );
        assert!(
            !history.join(format!("{}-exit", stamps[0])).exists(),
            "eviction operates on the stamp GROUP: the oldest generation's -exit file must \
             go with its -stdout, or pruning is leaking partial generations"
        );
        assert!(
            after.contains(&thirteenth),
            "the newest generation must survive its own arrival"
        );
    }

    #[test]
    fn evaluate_agent_result_reads_files_end_to_end() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(6)),
            "done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
        )
        .unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(6)), "0").unwrap();
        let state = state_in(dir.path(), PhaseId::new(6));

        let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.commits, Some(2));
        assert_eq!(result.summary.as_deref(), Some("ok"));
    }

    // ---- exit-code arbitration on a claimed success (31-04, T-31-15) -----
    //
    // Every test below drives the FULL cascade through
    // `evaluate_agent_result_inner`, never the parser's own return value.
    // 31-RESEARCH.md § Pitfall 4 records why: a truncation-boundary test that
    // checks only `parse_claude_event_result` exercises constraint 9's items 1
    // and 2, which the `a557805` root-cause refactor already closed. The
    // residual this arbitration exists for lives in the WIRING — Layer 1
    // returning before Layer 2 is ever consulted — and only the cascade
    // exercises it.

    /// A success marker that also claims `verdict: pass` — the shape a naive
    /// "carry every other field over" downgrade would have preserved. Used to
    /// prove `verdict` is dropped.
    ///
    /// Correction (34-01, D-15): an earlier version of this comment asserted
    /// that keeping the field would classify Validate as Passed because
    /// `classify_validate_outcome` matches `Some(Verdict::Pass)` first with the
    /// status discarded. That overstated the reachability — `decide_action`
    /// intercepts a non-`Success` status before the classifier runs. The
    /// corrected record of how the inversion is actually reached lives on
    /// [`super::reconcile_layer0_verdict`].
    const MARKER_SUCCESS_CLAIMING_PASS: &str =
        r#"Done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"verdict\":\"pass\"}"#;

    /// The residual of constraint 9 that no parser assertion can reach.
    ///
    /// A capture cut at an exact line boundary is byte-identical to a healthy
    /// shorter run, so the stream itself carries no evidence of the tear. The
    /// writer that died between flushing turn N and turn N+1 also died
    /// non-zero, and that exit code is the only signal left.
    #[test]
    fn stream_success_cannot_stand_against_nonzero_exit_code() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(31)),
            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS_CLAIMING_PASS),
        )
        .unwrap();

        // NEGATIVE CONTROL, encoded in the test rather than described in prose:
        // Layer 1 on its own decides Success here AND reports `verdict: Pass`.
        // Without this the assertions below cannot distinguish "the arbitration
        // downgraded a success" from "nothing ever claimed success", nor
        // "`verdict` was dropped" from "`verdict` was never set".
        let layer1 = evaluate_layer1(dir.path(), PhaseId::new(31)).unwrap();
        assert_eq!(layer1.status, AgentStatus::Success);
        assert_eq!(layer1.verdict, Some(Verdict::Pass));

        std::fs::write(exit_code_path(dir.path(), PhaseId::new(31)), "1\n").unwrap();
        let state = state_in(dir.path(), PhaseId::new(31));

        let result =
            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
                .unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.exit_code, Some(1));
        assert!(
            result.reason.as_deref().is_some_and(|r| r.contains("1")),
            "the reason must name the exit code: {:?}",
            result.reason
        );
        // Layer 1 still decided this — the arbitration corrects its verdict, it
        // does not hand the decision to Layer 2.
        assert_eq!(result.decided_by_layer, Some(1));
        // Load-bearing: a downgraded result has no verdict to offer. Carrying
        // `Some(Verdict::Pass)` over would leave Validate classified Passed and
        // make this whole test's premise false at the stage that matters most
        // (999.74 / DEN-95).
        assert_eq!(result.verdict, None);
    }

    /// The matched negative control for the test above. Without it, that test
    /// cannot tell "the arbitration works" from "the arbitration fires on
    /// everything".
    #[test]
    fn stream_success_stands_when_the_exit_code_is_zero() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(32)),
            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS_CLAIMING_PASS),
        )
        .unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(32)), "0\n").unwrap();
        let state = state_in(dir.path(), PhaseId::new(32));

        let result =
            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
                .unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(1));
        // The verdict survives an untouched result — proof that the `None`
        // asserted in the downgrade test is the arbitration's doing and not a
        // property of the fixture.
        assert_eq!(result.verdict, Some(Verdict::Pass));
    }

    /// A missing exit file is not evidence of failure. This matches
    /// `evaluate_layer2`'s own tolerance (`Err(_) => return Ok(None)`); a
    /// stricter reading here would fail every stage whose monitor had not yet
    /// written the file.
    #[test]
    fn stream_success_stands_when_no_exit_file_exists() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(33)),
            v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
        )
        .unwrap();
        assert!(
            !exit_code_path(dir.path(), PhaseId::new(33)).exists(),
            "fixture precondition: there must be no exit file"
        );
        let state = state_in(dir.path(), PhaseId::new(33));

        let result =
            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
                .unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.decided_by_layer, Some(1));
    }

    /// Only a *claimed success* is arbitrated. Downgrading a rate limit to a
    /// generic failure would route the run to a human gate instead of the
    /// auto-resume cron it needs — the exact harm `rate_limited_result`'s
    /// precedence over `detect_claude_envelope_failure` exists to prevent.
    #[test]
    fn rate_limited_verdict_is_not_arbitrated_by_exit_code() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(34)),
            r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
        )
        .unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(34)), "1\n").unwrap();
        let state = state_in(dir.path(), PhaseId::new(34));

        let result =
            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
                .unwrap();

        assert_eq!(result.status, AgentStatus::RateLimited);
        assert_eq!(
            result.reason.as_deref(),
            Some("rate limited until 2026-06-18T15:45:30Z"),
            "the rate-limit reason must survive verbatim — the resume cron reads it"
        );
    }

    /// Plan 31-02's side-channel verdict survives arbitration unchanged. An
    /// `IdleTimeout` collapsed into `Failed` would lose exactly the distinction
    /// 31-02 exists to create, and the monitor writes a NON-zero exit for a
    /// child it killed, so this is not a hypothetical pairing.
    #[test]
    fn idle_timeout_verdict_is_not_arbitrated_by_exit_code() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(35)),
            v3_stream_capture(MARKER_SUCCESS, MARKER_SUCCESS, MARKER_SUCCESS),
        )
        .unwrap();
        write_idle_timeout_record(
            dir.path(),
            PhaseId::new(35),
            &[("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "feat: partial")],
        );
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(35)), "143\n").unwrap();
        let state = state_in(dir.path(), PhaseId::new(35));

        let result =
            evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
                .unwrap();

        assert_eq!(result.status, AgentStatus::IdleTimeout);
        assert_eq!(
            result.exit_code, None,
            "the arbitration must not graft an exit code onto a timeout verdict"
        );
    }

    /// Exit-code fidelity (adversarial review of 31-04, W1). A blanket `Failed`
    /// would flatten the two codes `evaluate_layer2` classifies specially, and
    /// `outcome_policy::decide_action` routes those to `GateInfra` rather than
    /// `GateReview`. The same exit code must not reach two different operator
    /// gates depending on whether a stale Layer 1 success happened to be there.
    #[test]
    fn arbitration_preserves_layer2s_resource_and_unavailable_codes() {
        for (code, expected) in [
            (137, AgentStatus::ResourceKilled),
            (127, AgentStatus::AgentUnavailable),
            (2, AgentStatus::Failed),
        ] {
            let dir = tempfile::tempdir().unwrap();
            std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
            std::fs::write(
                stdout_path(dir.path(), PhaseId::new(36)),
                v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS),
            )
            .unwrap();
            std::fs::write(
                exit_code_path(dir.path(), PhaseId::new(36)),
                format!("{code}\n"),
            )
            .unwrap();
            let state = state_in(dir.path(), PhaseId::new(36));

            let arbitrated =
                evaluate_agent_result_inner(dir.path(), &state, &GitFlowConfig::default(), None)
                    .unwrap();

            assert_eq!(
                arbitrated.status, expected,
                "exit {code} must arbitrate to {expected:?}, matching evaluate_layer2"
            );
            assert_eq!(arbitrated.exit_code, Some(code));
        }
    }

    /// D-12's inverse assertion, and the mirror of
    /// [`single_doc_envelope_not_consumed_by_claude_stream_parser`].
    ///
    /// That test pins one direction: today's shipped `--output-format json`
    /// envelope must NOT be consumed by the stream parser. This pins the other:
    /// a capture produced by plan 31-01's new `stream-json` argv classifies as
    /// [`CaptureKind::ClaudeStream`] and is NOT consumed by the
    /// single-document envelope path. Without both directions, widening either
    /// gate is only half-guarded.
    ///
    /// Cites `classify()` / `CaptureKind::ClaudeStream` deliberately: the gate
    /// predicate `31-CONTEXT.md` and `30-VERIFICATION.md` W-02 still name is no
    /// longer a live function — the `a557805` refactor replaced it.
    #[test]
    fn stream_json_capture_is_not_consumed_by_the_single_document_path() {
        let capture = v3_stream_capture(NO_MARKER, NO_MARKER, MARKER_SUCCESS);

        // The classifier owns it.
        assert!(capture_is_claude_stream(&capture));

        // Every single-document reader declines it...
        assert!(claude_session_id(&capture).is_none());
        assert!(detect_claude_envelope_failure(&capture).is_none());
        assert!(detect_claude_rate_limit(&capture).is_none());

        // ...and the stream parser still owns it, so declining costs no verdict.
        assert_eq!(
            parse_claude_event_result(&capture).unwrap().status,
            AgentStatus::Success
        );

        // Non-vacuity: the single-document readers are not simply broken — the
        // same three answer a real envelope. Without this, the `is_none()`
        // assertions above would pass against a reader that returned `None` for
        // everything.
        let envelope = r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z","session_id":"abc"}"#;
        assert_eq!(claude_session_id(envelope).as_deref(), Some("abc"));
        assert!(detect_claude_envelope_failure(envelope).is_some());
        assert!(detect_claude_rate_limit(envelope).is_some());
        assert!(!capture_is_claude_stream(envelope));
    }

    #[test]
    fn evaluate_layer1_finds_devflow_result_in_file() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(
            stdout_path(dir.path(), PhaseId::new(3)),
            "output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
        )
        .unwrap();

        let result = evaluate_layer1(dir.path(), PhaseId::new(3)).unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.reason.as_deref(), Some("bad output"));
    }

    /// The case `consecutive_failures_reaches_ceiling_across_cycles`
    /// (`pipeline_outcomes.rs`) silently depends on: a repository with no
    /// `feature/phase-NN` branch at all must report 0, not error or panic.
    #[test]
    fn phase_commit_count_reports_zero_without_a_branch() {
        let dir = tempfile::tempdir().unwrap();
        git(dir.path(), &["init"]);
        git(dir.path(), &["config", "user.email", "devflow@example.com"]);
        git(dir.path(), &["config", "user.name", "DevFlow Tests"]);
        git(dir.path(), &["config", "commit.gpgsign", "false"]);
        git(dir.path(), &["config", "tag.gpgsign", "false"]);
        git(dir.path(), &["config", "core.hooksPath", "/dev/null"]);
        git(dir.path(), &["checkout", "-b", "develop"]);
        std::fs::write(dir.path().join("README.md"), "base\n").unwrap();
        git(dir.path(), &["add", "README.md"]);
        git(dir.path(), &["commit", "-m", "base"]);

        let count = phase_commit_count(dir.path(), &GitFlowConfig::default(), PhaseId::new(999));

        assert_eq!(
            count,
            Some(0),
            "git RAN and reported the branch absent — a real observation of zero, \
             not a failure to measure"
        );
    }

    /// The paired opposite-result case for
    /// `phase_commit_count_reports_zero_without_a_branch` directly above, and
    /// the pair is what makes either one mean anything (NC-4).
    ///
    /// The two differ in exactly one respect: whether the `git` child could be
    /// executed at all. The repository is identical in both — no
    /// `feature/phase-NN` branch — so a `Some(0)` here would prove the split
    /// was made on "was the answer zero" rather than on "did the command run",
    /// which is the distinction the whole `Option` exists to carry.
    ///
    /// Deliberately NOT built on a `git` shim that runs and exits non-zero:
    /// that path returns `Ok(status)` from `.output()` and is a real
    /// observation, so it would exercise the case above while appearing to
    /// cover this one (F-1).
    ///
    /// **Why an unspawnable working directory rather than `NoGitPath` here —
    /// F-1b's recorded fallback, taken on measured evidence.** `NoGitPath`
    /// makes `git` unresolvable *process-wide*, and `devflow-core`'s tests
    /// shell out to `git` from eight modules that all compile into ONE
    /// parallel test binary. Installing it here failed 1-5 unrelated sibling
    /// tests per run, nondeterministically, depending on which of them
    /// happened to invoke `git` inside the guarded window. Serializing them
    /// would mean every present and future `git`-touching test in the crate
    /// opting into the same mutex — discipline, not structure, and silently
    /// reopened by the next test that forgets.
    ///
    /// `hermetic_command` sets `cmd.current_dir(dir)`, so a directory that
    /// does not exist makes the spawn itself fail and `.output()` return
    /// `Err` — the identical arm, reached with no environment mutation at all
    /// and therefore no effect on any other test. `phase_commit_count` cannot
    /// tell the two causes apart: it sees only `Err`.
    ///
    /// This route is also independent of the PATH-resolution property C5
    /// flags as a latent fragility of `NoGitPath` (a future refactor to an
    /// absolute `git` path would disarm that guard silently; it would not
    /// disarm this).
    #[test]
    fn phase_commit_count_reports_none_when_git_cannot_run() {
        let dir = tempfile::tempdir().unwrap();
        let unspawnable_root = dir.path().join("this-directory-does-not-exist");
        assert!(
            !unspawnable_root.exists(),
            "the fixture depends on this path being absent"
        );

        let count = phase_commit_count(
            &unspawnable_root,
            &GitFlowConfig::default(),
            PhaseId::new(999),
        );

        assert_eq!(
            count, None,
            "a git child that could not be executed is a measurement FAILURE and must \
             never be reported as a measured zero"
        );
    }

    /// CR-01 (35-REVIEW), the `rev-list` half. The branch EXISTS, so the
    /// `rev-parse` step succeeds and the function reaches its second git call
    /// — but `develop` is absent from the checkout, so `A..B` is an invalid
    /// range and `rev-list` runs, exits non-zero, and writes nothing to
    /// stdout. That used to fall out of `.parse().ok()` as `None`, splitting
    /// on whether the output PARSED rather than on whether the command RAN,
    /// which contradicts this function's own A-06 rule and the `rev-parse`
    /// step directly above it.
    ///
    /// It is the *permanence* that makes this worth a test: unlike a fork
    /// failure, a misconfigured or absent `develop` does not clear on retry,
    /// so before the fix every stage of every phase in such a checkout
    /// measured as unmeasurable, forever.
    ///
    /// `phase_commit_count_reports_none_when_git_cannot_run` is the NC-4
    /// control — the one case that must still be `None`.
    #[test]
    fn phase_commit_count_reports_zero_when_the_range_is_invalid() {
        let dir = tempfile::tempdir().unwrap();
        git(dir.path(), &["init"]);
        git(dir.path(), &["config", "user.email", "devflow@example.com"]);
        git(dir.path(), &["config", "user.name", "DevFlow Tests"]);
        git(dir.path(), &["config", "commit.gpgsign", "false"]);
        git(dir.path(), &["config", "tag.gpgsign", "false"]);
        git(dir.path(), &["config", "core.hooksPath", "/dev/null"]);
        // The feature branch exists; `develop` deliberately does not.
        git(dir.path(), &["checkout", "-b", "feature/phase-999"]);
        std::fs::write(dir.path().join("README.md"), "base\n").unwrap();
        git(dir.path(), &["add", "README.md"]);
        git(dir.path(), &["commit", "-m", "base"]);

        // The fixture is only meaningful if the second git call really is the
        // one that fails, so assert the first one would have succeeded.
        assert!(
            git_command(dir.path())
                .args(["rev-parse", "--verify", "feature/phase-999"])
                .output()
                .expect("git must be runnable for this fixture")
                .status
                .success(),
            "the branch must verify, or this test exercises the rev-parse arm instead"
        );

        let count = phase_commit_count(dir.path(), &GitFlowConfig::default(), PhaseId::new(999));

        assert_eq!(
            count,
            Some(0),
            "git RAN and reported the range unusable — a measurement, not a failure to \
             measure; `None` here is permanent for the whole checkout"
        );
    }

    #[test]
    fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_commit(dir.path(), PhaseId::new(4));
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "0").unwrap();
        let state = state_in(dir.path(), PhaseId::new(4));

        let result = evaluate_layer2(
            dir.path(),
            PhaseId::new(4),
            &GitFlowConfig::default(),
            state.stage,
        )
        .unwrap()
        .unwrap();

        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.exit_code, Some(0));
        assert_eq!(result.commits, Some(1));
        assert!(result.reason.unwrap().contains("1 commits"));
    }

    #[test]
    fn evaluate_layer2_exit_zero_no_commits_is_failed() {
        // exit=0 but the feature branch has 0 commits ahead of develop →
        // "no work done" failure (the Layer 2 middle branch).
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(4));
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "0").unwrap();
        let state = state_in(dir.path(), PhaseId::new(4));

        let result = evaluate_layer2(
            dir.path(),
            PhaseId::new(4),
            &GitFlowConfig::default(),
            state.stage,
        )
        .unwrap()
        .unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.exit_code, Some(0));
        assert_eq!(result.commits, Some(0));
        assert!(result.reason.unwrap().contains("no commits"));
    }

    // HARDEN-07 / criterion 6's two discriminating tests — the layer-level one
    // on `evaluate_layer2` and the cascade-level one on
    // `evaluate_agent_result` — do NOT live here. They need `git` to be
    // unresolvable while `project_root` still EXISTS (Layer 2 reads its exit
    // file from that root, so an unspawnable working directory would make the
    // exit read fail and return `Ok(None)` for the wrong reason), and only a
    // `PATH` guard delivers that combination.
    //
    // A process-global `PATH` guard is not viable in THIS test binary:
    // `devflow-core` shells out to `git` from eight modules that run in
    // parallel, and tests call production code that spawns `git` directly, so
    // no fixture-helper lock can cover them. Measured twice — 1-5 unrelated
    // failures per run before any serialization, and still 1 failure in 8 runs
    // after this module's own `git()` helper took the lock
    // (`evaluate_layer2_exit_zero_no_commits_is_failed`, whose `git` call
    // happens inside `evaluate_layer2` itself).
    //
    // Both tests therefore live in `devflow-cli`'s `pipeline_outcomes.rs`,
    // whose test binary routes every `PATH` mutation through one `ENV_MUTEX`
    // its `git`-touching tests already hold. They call these same `pub`
    // functions directly, so the assertion is unchanged — only the binary it
    // runs in differs. `evaluate_layer2_exit_zero_no_commits_is_failed` below
    // remains their NC-11 opposite-result control and is unedited.

    #[test]
    fn evaluate_layer2_nonzero_exit_is_failed() {
        // Non-zero exit code → failure regardless of commit count.
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_commit(dir.path(), PhaseId::new(4));
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(4)), "1").unwrap();
        let state = state_in(dir.path(), PhaseId::new(4));

        let result = evaluate_layer2(
            dir.path(),
            PhaseId::new(4),
            &GitFlowConfig::default(),
            state.stage,
        )
        .unwrap()
        .unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.exit_code, Some(1));
        assert!(result.reason.unwrap().contains("exited with code 1"));
    }

    #[test]
    fn layer2_nonzero_exit_is_failed_all_stages() {
        // Non-zero exit is Failed regardless of stage — including Define and
        // Validate, which are exempt from the zero-commit gate but NOT from
        // the exit-code check.
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(10));
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(10)), "1").unwrap();

        for stage in [
            Stage::Define,
            Stage::Plan,
            Stage::Code,
            Stage::Validate,
            Stage::Ship,
        ] {
            let result = evaluate_layer2(
                dir.path(),
                PhaseId::new(10),
                &GitFlowConfig::default(),
                stage,
            )
            .unwrap()
            .unwrap();
            assert_eq!(
                result.status,
                AgentStatus::Failed,
                "stage {stage:?} should be Failed on nonzero exit"
            );
        }
    }

    #[test]
    fn layer2_skips_commit_gate_for_define_and_validate() {
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(11));
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(11)), "0").unwrap();

        for stage in [Stage::Define, Stage::Validate] {
            let result = evaluate_layer2(
                dir.path(),
                PhaseId::new(11),
                &GitFlowConfig::default(),
                stage,
            )
            .unwrap()
            .unwrap();
            assert_ne!(
                result.status,
                AgentStatus::Failed,
                "stage {stage:?} should not be Failed for zero commits"
            );
        }

        // Code stage with the same zero-commit inputs is still Failed
        // (existing behavior preserved).
        let result = evaluate_layer2(
            dir.path(),
            PhaseId::new(11),
            &GitFlowConfig::default(),
            Stage::Code,
        )
        .unwrap()
        .unwrap();
        assert_eq!(result.status, AgentStatus::Failed);
    }

    #[test]
    fn evaluate_layer3_falls_back_to_commit_count() {
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_commit(dir.path(), PhaseId::new(5));

        let result =
            evaluate_layer3(dir.path(), PhaseId::new(5), &GitFlowConfig::default()).unwrap();

        assert_eq!(result.status, AgentStatus::Unknown);
        assert_eq!(result.exit_code, None);
        assert_eq!(result.commits, Some(1));
        assert!(result.reason.unwrap().contains("1 commits"));
        assert_eq!(result.decided_by_layer, Some(3));
    }

    /// D-02/D-03 case 3 (17-03): "process gone, nothing accounted for" — zero
    /// commits and no declared external post-condition — is a fail-closed
    /// `Failed` outcome that flags human review, not a blanket advanceable
    /// `Unknown`. The commits-present case above stays `Unknown` (gated
    /// downstream by Plan 04's never-advance dispatch, D-04) — only the
    /// zero-commit sub-case is reclassified here.
    #[test]
    fn evaluate_layer3_zero_commits_is_failed_and_flags_human_review() {
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_no_commit(dir.path(), PhaseId::new(5));

        let result =
            evaluate_layer3(dir.path(), PhaseId::new(5), &GitFlowConfig::default()).unwrap();

        assert_eq!(result.status, AgentStatus::Failed);
        assert_eq!(result.exit_code, None);
        assert_eq!(result.commits, Some(0));
        assert_eq!(result.decided_by_layer, Some(3));
        let reason = result.reason.unwrap();
        assert!(reason.contains("no work"), "reason was: {reason}");
        assert!(
            reason.to_ascii_lowercase().contains("human review"),
            "reason was: {reason}"
        );
    }

    /// F-4 (35-01) / HARDEN-07: Layer 3 used to carry its OWN inline commit
    /// count with the same lossy `.unwrap_or(0)` collapse `phase_commit_count`
    /// had, and classified the resulting zero as `Failed`. Since every path
    /// that reaches Layer 2 also reaches Layer 3, fixing only Layer 2 would
    /// have relocated the misclassification one layer down rather than
    /// removing it.
    ///
    /// A count that could not be measured is not evidence of absent work. It
    /// is strictly less certain than the `commits > 0` case Layer 3 already
    /// calls `Unknown`, so `Unknown` is the consistent classification and
    /// `Failed` — which asserts a negative — is not.
    ///
    /// The two tests directly above are this one's required opposite-result
    /// controls, and they run in the same suite with their bodies unedited: a
    /// branch with one commit still gives `Unknown`/`Some(1)`, and a branch
    /// with no commits still gives `Failed`/`Some(0)`. Without them, an
    /// implementation that returned `Unknown` unconditionally would pass this
    /// test.
    ///
    /// **No assertion here touches `Action` or anything downstream of
    /// `outcome_policy::decide_action` (F-5).** `Failed` and `Unknown` map
    /// identically to `Action::GateReview` today, so a dispatch-level
    /// assertion would pass against the buggy code too. The observable
    /// difference is entirely in the `AgentResult`.
    ///
    /// Uses the same unspawnable-working-directory route as
    /// `phase_commit_count_reports_none_when_git_cannot_run`, for the reason
    /// recorded there (F-1b): a process-wide `PATH` guard broke unrelated
    /// sibling tests in this crate nondeterministically, and this route
    /// reaches the identical `Err` arm with no environment mutation.
    #[test]
    fn evaluate_layer3_unmeasurable_count_is_unknown_not_failed() {
        let dir = tempfile::tempdir().unwrap();
        let unspawnable_root = dir.path().join("this-directory-does-not-exist");
        assert!(
            !unspawnable_root.exists(),
            "the fixture depends on this path being absent"
        );

        let result = evaluate_layer3(
            &unspawnable_root,
            PhaseId::new(5),
            &GitFlowConfig::default(),
        )
        .unwrap();

        assert_ne!(
            result.status,
            AgentStatus::Failed,
            "an unmeasurable commit count must never be classified as absent work — \
             this is the outcome criterion 6 exists to remove"
        );
        assert_eq!(
            result.status,
            AgentStatus::Unknown,
            "asserted positively as well as negatively, so a future change to some \
             other non-Failed value still has to confront this test"
        );
        assert_eq!(
            result.commits, None,
            "the commit figure must be absent, not a forged Some(0) — the difference \
             between 'no work' and 'could not tell' is the whole point"
        );
        assert_eq!(result.decided_by_layer, Some(3));
        let reason = result.reason.unwrap();
        assert!(
            reason.contains("could not be measured"),
            "the reason must name the measurement failure rather than absent work, \
             reason was: {reason}"
        );
    }

    #[test]
    fn parse_devflow_result_reads_verdict() {
        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, Some(Verdict::Gaps));
    }

    #[test]
    fn parse_devflow_result_reads_verdict_pass() {
        let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, Some(Verdict::Pass));
    }

    #[test]
    fn parse_devflow_result_verdict_absent_is_none() {
        let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
        let result = parse_devflow_result(stdout).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, None);
    }

    #[test]
    fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
        // An unknown verdict string must not fail the whole marker parse —
        // status must still come through as Success with verdict None (T-13-14).
        let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
        let result = parse_devflow_result(unknown).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, None);

        // Mis-cased ("Pass" instead of "pass") must also be lenient, not an error.
        let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
        let result = parse_devflow_result(miscased).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, None);
    }

    /// WR-09 (13-REVIEW.md): a `verdict` field present with a non-string
    /// JSON *type* (bool, number, object) must be just as lenient as a
    /// malformed string value — before the fix, deserializing straight to
    /// `Option<String>` errored out the entire `AgentResult` parse for a
    /// type mismatch, defeating the doc comment's "a malformed verdict must
    /// never silently drop a valid status" guarantee for this specific case.
    #[test]
    fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
        let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
        let result = parse_devflow_result(bool_verdict).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, None);

        let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
        let result = parse_devflow_result(numeric_verdict).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, None);

        let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
        let result = parse_devflow_result(object_verdict).unwrap();
        assert_eq!(result.status, AgentStatus::Success);
        assert_eq!(result.verdict, None);
    }

    /// D-07 (17-01): the two new multi-word variants must serialize with
    /// their word boundary preserved — `#[serde(rename_all = "lowercase")]`
    /// alone would collapse `ResourceKilled` to `"resourcekilled"` (Pitfall 1).
    #[test]
    fn multi_word_variants_serialize_with_word_boundary() {
        assert_eq!(
            serde_json::to_string(&AgentStatus::ResourceKilled).unwrap(),
            "\"resource_killed\""
        );
        assert_eq!(
            serde_json::to_string(&AgentStatus::AgentUnavailable).unwrap(),
            "\"agent_unavailable\""
        );
        assert_eq!(
            serde_json::from_str::<AgentStatus>("\"resource_killed\"").unwrap(),
            AgentStatus::ResourceKilled
        );
        assert_eq!(
            serde_json::from_str::<AgentStatus>("\"agent_unavailable\"").unwrap(),
            AgentStatus::AgentUnavailable
        );
    }

    /// Existing variants must keep their pre-existing lowercase wire form
    /// unchanged by the two new variants' additions.
    #[test]
    fn existing_variants_keep_wire_form() {
        assert_eq!(
            serde_json::to_string(&AgentStatus::Success).unwrap(),
            "\"success\""
        );
        assert_eq!(
            serde_json::to_string(&AgentStatus::Failed).unwrap(),
            "\"failed\""
        );
        assert_eq!(
            serde_json::to_string(&AgentStatus::RateLimited).unwrap(),
            "\"ratelimited\""
        );
        assert_eq!(
            serde_json::to_string(&AgentStatus::Unknown).unwrap(),
            "\"unknown\""
        );
    }

    /// review consensus #1: `as_wire_str()` must never diverge from the serde
    /// form for ANY variant — pin it for all seven via a single round-trip
    /// assertion (quotes stripped).
    ///
    /// 31-02: `IdleTimeout` is enumerated here explicitly rather than left to
    /// the compiler. `as_wire_str`'s wildcard-free match makes a MISSING arm a
    /// compile error, but it cannot catch a WRONG one — an arm returning
    /// `"idletimeout"` compiles happily and diverges from the serde form the
    /// `#[serde(rename)]` produces. Only enumerating the variant here pins that.
    #[test]
    fn as_wire_str_matches_serde_form_for_every_variant() {
        for variant in [
            AgentStatus::Success,
            AgentStatus::Failed,
            AgentStatus::RateLimited,
            AgentStatus::Unknown,
            AgentStatus::ResourceKilled,
            AgentStatus::AgentUnavailable,
            AgentStatus::IdleTimeout,
        ] {
            let serde_form = serde_json::to_string(&variant).unwrap();
            let stripped = serde_form.trim_matches('"');
            assert_eq!(
                variant.as_wire_str(),
                stripped,
                "as_wire_str() diverged from serde form for {variant:?}"
            );
        }
    }

    #[test]
    fn evaluate_layer2_exit_137_is_resource_killed() {
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_commit(dir.path(), PhaseId::new(20));
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(20)), "137").unwrap();
        let state = state_in(dir.path(), PhaseId::new(20));

        let result = evaluate_layer2(
            dir.path(),
            PhaseId::new(20),
            &GitFlowConfig::default(),
            state.stage,
        )
        .unwrap()
        .unwrap();

        assert_eq!(result.status, AgentStatus::ResourceKilled);
        assert_eq!(result.exit_code, Some(137));
    }

    #[test]
    fn evaluate_layer2_exit_127_is_agent_unavailable() {
        let dir = tempfile::tempdir().unwrap();
        init_repo_with_feature_commit(dir.path(), PhaseId::new(21));
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), PhaseId::new(21)), "127").unwrap();
        let state = state_in(dir.path(), PhaseId::new(21));

        let result = evaluate_layer2(
            dir.path(),
            PhaseId::new(21),
            &GitFlowConfig::default(),
            state.stage,
        )
        .unwrap()
        .unwrap();

        assert_eq!(result.status, AgentStatus::AgentUnavailable);
        assert_eq!(result.exit_code, Some(127));
    }

    // -----------------------------------------------------------------
    // 27-03 (D-01/D-03): branch-exists + commit-count evidence resolves
    // the caller's own repository under a hostile GIT_DIR, not an
    // unrelated one.
    // -----------------------------------------------------------------

    /// D-03/T-27-08: `evaluate_layer2`'s branch-exists and commit-count
    /// evidence (the two production sites at what were base-commit lines
    /// 574/583) resolves `project_root`'s own repository even when the
    /// process inherited a hostile `GIT_DIR` pointed at an unrelated
    /// repository — proven with a real spawned `git` process, not by
    /// inspecting a `Command` object alone. Mirrors
    /// `version::tests::tag_reads_resolve_caller_root_under_a_hostile_git_dir`
    /// (27-03) and `origin_main_ancestor_status_holds_under_a_hostile_git_dir`
    /// (`git.rs`, 27-01): the hostile `GIT_DIR` this test's own `<verify>`
    /// entries exercise (`GIT_DIR=<hostile>/.git cargo test ... this test`)
    /// is injected the same way any inherited-env attack reaches
    /// `evaluate_layer2` in production — via the whole process's
    /// environment, then down into the spawned child unless the
    /// constructor scrubs it.
    ///
    /// Deliberately tests the mirror direction from the plan's literal
    /// framing (real repo HAS the feature branch with a real commit;
    /// the standard hostile-`GIT_DIR` harness's throwaway repository does
    /// NOT), because the standard harness (`git init -q "$HOSTILE"`, no
    /// `feature/phase-NN` branch) cannot itself manufacture a false
    /// *positive* — an empty repository has no branch to spuriously
    /// report as present. It can, however, still prove the scrub's
    /// necessity by manufacturing a false *negative*: before this plan's
    /// migration, the two unmigrated `Command::new("git")` sites inherit
    /// the poisoned `GIT_DIR` and silently read the hostile repository
    /// instead of `project_root` — `rev-parse --verify` reports the real
    /// branch absent, the commit count is undercounted to zero, and a
    /// real agent's completed work is wrongly classified `Failed`. This
    /// is the same trust-boundary violation T-27-08 names (a foreign
    /// repository's state substituting for the real one), reached from
    /// the opposite direction; the scrub this plan adds removes `GIT_DIR`'s
    /// ability to redirect the spawned child at all, closing both
    /// directions identically.
    /// 27-REVIEW WR-01: this test previously set no hostile environment at
    /// all — it asserted ordinary-path behavior and claimed a hostile-
    /// `GIT_DIR` proof, so it passed identically with or without the scrub
    /// and could never have caught a regression back to a bare
    /// `Command::new("git")`. It now uses the spawned-child shape this
    /// phase established in `staleness.rs`
    /// (`embedded_commit_is_stale_resolves_execution_root_under_a_hostile_git_dir`):
    /// `GIT_DIR` is never set on this process (Rust 2024 `unsafe`, unsound
    /// under threaded tests — Phase 25 D-14), only on one freshly spawned
    /// child that re-invokes this same binary filtered to this one test.
    #[test]
    fn branch_evidence_resolves_caller_root_under_a_hostile_git_dir() {
        const INNER_ROOT: &str = "DEVFLOW_27_03_BRANCH_EVIDENCE_INNER_ROOT";

        if let Ok(root) = std::env::var(INNER_ROOT) {
            // Inner mode: spawned by the outer half below with GIT_DIR
            // pointed at an unrelated foreign repository, scoped to this
            // child process only.
            let root = std::path::PathBuf::from(root);
            let phase = PhaseId::new(27);
            let state = state_in(&root, phase);

            let result = evaluate_layer2(&root, phase, &GitFlowConfig::default(), state.stage)
                .unwrap()
                .unwrap();

            assert_eq!(
                result.status,
                AgentStatus::Success,
                "evaluate_layer2 must see project_root's own branch/commits, \
                 not a hostile GIT_DIR's repository: {result:?}"
            );
            assert_eq!(result.commits, Some(1));
            return;
        }

        // Outer mode: build the real repository (which HAS the feature
        // branch and its commit) plus a second, unrelated foreign
        // repository that has neither. Unscrubbed, the child would read the
        // foreign repo, find no branch, count zero commits, and misreport a
        // real agent's completed work as Failed.
        let dir = tempfile::tempdir().unwrap();
        let phase = PhaseId::new(27);
        init_repo_with_feature_commit(dir.path(), phase);
        std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
        std::fs::write(exit_code_path(dir.path(), phase), "0").unwrap();

        let foreign = tempfile::tempdir().unwrap();
        git(foreign.path(), &["init", "-q"]);

        let exe = std::env::current_exe().expect("current_exe for child re-invocation");
        let out = std::process::Command::new(&exe)
            // Substring filter, NOT `--exact`: the binary's real test name is
            // module-qualified (`agent_result::tests::branch_evidence_...`),
            // so `--exact` against the bare name matches nothing, runs zero
            // tests, and still exits 0 — a false green.
            .arg("branch_evidence_resolves_caller_root_under_a_hostile_git_dir")
            .arg("--test-threads=1")
            .env(INNER_ROOT, dir.path().to_str().unwrap())
            .env("GIT_DIR", foreign.path().join(".git"))
            .output()
            .expect("spawn hostile child test process");

        let stdout = String::from_utf8_lossy(&out.stdout);
        // Assert the child actually RAN the test, not merely that it exited
        // 0. A filter matching nothing exits 0 with "0 passed".
        assert!(
            stdout.contains("1 passed"),
            "child test process must have run exactly the inner test; \
             stdout:\n{stdout}"
        );
        assert!(
            out.status.success(),
            "child test process (hostile GIT_DIR pointed at an unrelated \
             foreign repository) must still resolve project_root's own \
             branch and commits; child exit status {:?}\nstdout:\n{stdout}",
            out.status
        );
    }

    /// D-01 (33-CONTEXT.md): `phase_verification_exists` is the sole signal
    /// a Validate→Code loop-back consults to tell a mid-arc phase apart from
    /// a genuinely gap-flagged one. Covers all three states: no
    /// `.planning/phases` directory at all, a phase directory with no
    /// verification artifact, and a phase directory that has one — mirroring
    /// `phase_review_path`'s directory-prefix-scan idiom.
    #[test]
    fn phase_verification_exists_finds_the_artifact_by_prefix() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();

        assert!(
            !phase_verification_exists(root, PhaseId::new(82)),
            "no .planning/phases directory at all must return false, not panic"
        );

        let phase_dir = root.join(".planning/phases/82-loop-back-fix");
        std::fs::create_dir_all(&phase_dir).unwrap();
        assert!(
            !phase_verification_exists(root, PhaseId::new(82)),
            "a phase directory with no {{N}}-VERIFICATION.md must return false"
        );

        std::fs::write(phase_dir.join("82-VERIFICATION.md"), "verified\n").unwrap();
        assert!(
            phase_verification_exists(root, PhaseId::new(82)),
            "a phase directory holding {{N}}-VERIFICATION.md must return true"
        );
    }

    /// 999.79 (35-05): the fingerprint must be a function of the artifact's
    /// BYTES, not of its existence.
    ///
    /// Both halves are required and neither is redundant. The first half
    /// (different bytes → different values) is satisfied by any hash. The
    /// second half (identical bytes → identical values) is what rules out a
    /// value derived from something incidental — a timestamp, an inode, a
    /// counter — which would make every check read "changed" and permanently
    /// disable the gaps-only path. A constant-returning implementation fails
    /// the first half; a nondeterministic one fails the second.
    #[test]
    fn phase_verification_fingerprint_differs_when_content_differs() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phase_dir = root.join(".planning/phases/84-fingerprint");
        std::fs::create_dir_all(&phase_dir).unwrap();
        let artifact = phase_dir.join("84-VERIFICATION.md");

        std::fs::write(&artifact, "verdict: gaps\n").unwrap();
        let first = phase_verification_fingerprint(root, PhaseId::new(84))
            .expect("an artifact that exists must produce a fingerprint");

        let first_again = phase_verification_fingerprint(root, PhaseId::new(84))
            .expect("an artifact that exists must produce a fingerprint");
        assert_eq!(
            first, first_again,
            "identical bytes must produce identical fingerprints — a value that changes on \
             its own would mark every artifact fresh forever and disable the stale check"
        );

        std::fs::write(&artifact, "verdict: pass\n").unwrap();
        let second = phase_verification_fingerprint(root, PhaseId::new(84))
            .expect("an artifact that exists must produce a fingerprint");
        assert_ne!(
            first, second,
            "different bytes must produce different fingerprints — a constant implementation \
             would report every re-authored artifact as unchanged"
        );
    }

    /// 999.79 (35-05): an absent artifact yields no fingerprint, which is
    /// distinguishable from an artifact whose content happens to hash to zero.
    /// Both are asserted here so "absent" can never be conflated with "hashed
    /// to the zero value".
    #[test]
    fn phase_verification_fingerprint_is_none_when_the_artifact_is_absent() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();

        assert_eq!(
            phase_verification_fingerprint(root, PhaseId::new(85)),
            None,
            "no .planning/phases directory at all must yield None, not panic"
        );

        let phase_dir = root.join(".planning/phases/85-fingerprint");
        std::fs::create_dir_all(&phase_dir).unwrap();
        assert_eq!(
            phase_verification_fingerprint(root, PhaseId::new(85)),
            None,
            "a phase directory with no {{N}}-VERIFICATION.md must yield None"
        );

        std::fs::write(phase_dir.join("85-VERIFICATION.md"), "").unwrap();
        let empty = phase_verification_fingerprint(root, PhaseId::new(85));
        assert!(
            empty.is_some(),
            "an EMPTY artifact still exists and must yield Some — the control against an \
             implementation that conflates 'absent' with 'no bytes'"
        );
    }
}