tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
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
//! The clap command tree, the frozen exit-code scheme, and the process
//! boundary that resolves config, opens the store and hands off to the
//! per-command handlers.
//!
//! The command tree and every long flag mirror `contracts/cli.md` exactly;
//! the exit-code table there is frozen: `0` success, `1` usage/config/
//! unknown-error, `2` target-not-found or selection-ambiguous, `3` external
//! tool missing (`glab`/`linear-cli`, reserved for WP05). Handlers return
//! [`Result<(), TixError>`]; [`exit_code_for`] maps the typed error surface
//! to the exit-code enum at the boundary. Human-readable errors go to
//! stderr; stdout stays clean for the parseability contract.

use std::path::{Path, PathBuf};

use clap::builder::NonEmptyStringValueParser;
use clap::{Args, Parser, Subcommand};

use crate::commands;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;

/// Exit codes frozen by `contracts/cli.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExitCode {
    Success = 0,
    UsageConfig = 1,
    NotFoundAmbiguous = 2,
    ExternalTool = 3,
}

/// Map a domain error to the frozen exit-code table.
///
/// Selection ambiguity shares exit `2` with target-not-found, per the
/// contract's exit-code table ("2 target-not-found or selection-ambiguous");
/// the ambiguity marker is the [`commands::AMBIGUITY_PREFIX`] message prefix.
pub fn exit_code_for(err: &TixError) -> ExitCode {
    match err {
        TixError::NotFound(_) => ExitCode::NotFoundAmbiguous,
        TixError::InvalidInput(s) if s.starts_with(commands::AMBIGUITY_PREFIX) => {
            ExitCode::NotFoundAmbiguous
        }
        TixError::External(_) => ExitCode::ExternalTool,
        _ => ExitCode::UsageConfig,
    }
}

#[derive(Parser)]
#[command(
    name = "tix",
    version,
    about = "Terminal issue tracker: markdown issues with a kanban TUI and CLI"
)]
pub struct Cli {
    #[command(subcommand)]
    pub command: Option<Command>,
}

#[derive(Subcommand)]
pub enum Command {
    /// Create a default tix.toml ([kanban.default], issues_dir, [export]).
    ///
    /// When `bd` is on PATH and a beads workspace is reachable, an interactive
    /// run also offers the beads modes; `--beads` decides without a prompt, and a
    /// non-interactive run never prompts (it keeps `off` unless `--beads` says
    /// otherwise).
    Init {
        /// Beads mode to record: off | mirror | sync
        ///
        /// `mirror` reads a beads workspace through `bd` for viewing only;
        /// `sync` installs the `.beads/hooks` that pull beads changes into tix.
        #[arg(long, value_name = "off|mirror|sync")]
        beads: Option<String>,
    },
    /// Append or replace the tix block in AGENTS.md (idempotent)
    InitAgents,
    /// Print the crate version and how this binary was built
    ///
    /// Answers the question `--version` cannot: whether the binary on disk came
    /// from the source on disk. It reports the commit it was built from and when,
    /// so a rebuild that did not happen is visible. Needs no `tix.toml`, so it
    /// works in any directory.
    Version,
    /// Read, or write, a [beads](https://github.com/gastownhall/beads) workspace
    /// through the `bd` CLI
    ///
    /// tix never opens the beads Dolt database: every verb here shells out to
    /// `bd`. The reads (`detect`, `status`, `list`, `show`, `watch`) write
    /// nothing anywhere; the writes (`push`, `pull`, `reconcile`) are refused
    /// while the beads mode is `mirror`, which is read-only by contract. See
    /// `docs/for-users/beads-integration.md`.
    Beads {
        #[command(subcommand)]
        action: BeadsAction,
    },
    /// Create an issue
    Create {
        /// Issue title (prompted when missing on a TTY)
        #[arg(long)]
        title: Option<String>,
        /// Issue body (markdown)
        #[arg(long)]
        body: Option<String>,
        /// Column name (default: first column)
        #[arg(long)]
        status: Option<String>,
        /// Priority: low | medium | high (default medium)
        #[arg(long)]
        priority: Option<String>,
        /// Assignee (@name)
        #[arg(long)]
        assignee: Option<String>,
        /// Labels, comma-separated
        #[arg(long)]
        labels: Option<String>,
        /// Files governing this issue, comma-separated
        #[arg(long)]
        files: Option<String>,
        /// Estimate
        #[arg(long)]
        estimate: Option<String>,
        /// Cycle names, comma-separated
        #[arg(long)]
        cycles: Option<String>,
        /// Milestone names, comma-separated
        #[arg(long)]
        milestones: Option<String>,
        /// Projects to associate the new issue with: ids or names, comma-separated
        ///
        /// Every reference is resolved through the project service **before** the
        /// issue is written, so an unknown id refuses the whole create rather than
        /// leaving a half-associated issue. This flag lives on the shared create
        /// path, which is the handoff point a future template surface (source issue
        /// #174) would populate (mission `first-class-projects-01M3BYBW`).
        #[arg(long, value_name = "ID")]
        projects: Option<String>,
        /// Epics the new issue references: ids or names, comma-separated
        ///
        /// Every reference is resolved through the epic service **before** the issue
        /// is written, so an unknown id refuses the whole create rather than leaving
        /// a half-referenced issue. This flag lives on the shared create path, which
        /// is the handoff point a future template surface (source issue #174) would
        /// populate. Referencing an epic never changes how the issue itself is
        /// navigated: it stays an ordinary issue (mission `epics-work-items-01M3BYW4`).
        #[arg(long, value_name = "ID")]
        epics: Option<String>,
        /// Reviewer (free-form name/identity)
        #[arg(long)]
        reviewer: Option<String>,
        /// Start date (`YYYY-MM-DD`)
        #[arg(long)]
        start_date: Option<String>,
        /// Due date (`YYYY-MM-DD`); must be `>= start_date`
        #[arg(long)]
        due_date: Option<String>,
        /// Create as a child of this issue (number, filename or title fragment)
        ///
        /// At most one parent, so this takes a single selector and there is no list
        /// form (C-001). The parent must already exist; a selector that matches no
        /// issue is refused and nothing is created.
        #[arg(long, value_name = "SELECTOR")]
        parent: Option<String>,
        /// Related issues, comma-separated: a local number/selector, a relative
        /// or absolute path to another issue file, or a repository URL
        /// (`host/path#id`). Free-form; stored verbatim.
        #[arg(long)]
        related_issues: Option<String>,
        /// Issue type (e.g. `bug`, `feature`); `tix lint` warns on a value not in
        /// `custom_types`.
        #[arg(long)]
        r#type: Option<String>,
        /// Create from a stable template ID (`tix template list`)
        ///
        /// The template's defaults and prompts are merged with the flags above
        /// by the pure resolver, then the resolved request goes through the
        /// shared create core — there is no second issue writer. Resolution
        /// never prompts: a required prompt with no value is a diagnostic, the
        /// template and the store are left untouched, and no issue is written.
        #[arg(long, value_name = "STABLE-ID")]
        template: Option<String>,
        /// Template scope: `repository` | `board:<name>` | `team:<label>`
        ///
        /// Only meaningful with `--template` (clap requires it). An omitted
        /// scope is the explicit `repository` default, never an implicit
        /// inheritance. The selected template ID and scope are echoed on
        /// stderr so stdout keeps the `Created <file>` shape.
        #[arg(long, value_name = "SCOPE", requires = "template")]
        scope: Option<String>,
    },
    /// Create, edit, remove, or list typed relationships
    ///
    /// Creating requires `--type`; there is no default kind, so an untyped
    /// relationship is never recorded by accident (C-001). Omit the
    /// subcommand to create: `tix link <from> <to> --type <kind>`.
    Link {
        /// `edit`, `remove`, or `list`. Omit to create a relationship.
        ///
        /// These three words are reserved on `tix link`: an issue whose number,
        /// filename, or title selector is exactly `edit`, `remove`, or `list`
        /// must be addressed by number.
        #[command(subcommand)]
        action: Option<LinkAction>,
        /// [create] Issue number, filename fragment or unique title fragment
        from: Option<String>,
        /// [create] Issue number, filename fragment or unique title fragment
        to: Option<String>,
        /// [create] Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
        #[arg(long, value_name = "KIND")]
        r#type: Option<String>,
    },
    /// Batch editing over a selection
    ///
    /// Preview is the **default**; `--apply` writes. Name exactly one of `--select`,
    /// `--filter` or `--search` — they are different grammars, and a bulk operation
    /// will not guess which issues you meant.
    Bulk {
        #[command(subcommand)]
        action: BulkAction,
    },
    /// Remove a typed relationship between two issues
    Unlink {
        /// Issue number, filename fragment or unique title fragment
        from: String,
        /// Issue number, filename fragment or unique title fragment
        to: String,
        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
        #[arg(long, value_name = "KIND")]
        r#type: Option<String>,
    },
    /// Print the transitive dependency chain an issue holds up
    Chain {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
    },
    /// Inspect the issue hierarchy
    ///
    /// `show`, `children`, or `list`. Every figure comes from the hierarchy
    /// service's typed values, so the CLI, the TUI detail block and the MCP read
    /// tools report the same numbers for the same issue.
    Parent {
        /// `show`, `children`, or `list`
        #[command(subcommand)]
        action: ParentAction,
    },
    /// Edit an issue
    Edit {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// New title
        #[arg(long)]
        title: Option<String>,
        /// New body (markdown)
        #[arg(long)]
        body: Option<String>,
        /// New status
        ///
        /// Setting the status is a **move**, validated by the same service as
        /// `tix mv` and recorded as the same `TransitionDecision` — it is not a
        /// way around the transition policy. `--status ""` clears the field
        /// (matching every other field here) and is a plain field write, not a
        /// transition, because there is no target column to judge.
        #[arg(long)]
        status: Option<String>,
        /// New priority: low | medium | high
        #[arg(long)]
        priority: Option<String>,
        /// New assignee (@name)
        #[arg(long)]
        assignee: Option<String>,
        /// New label list, comma-separated
        #[arg(long)]
        labels: Option<String>,
        /// New files list, comma-separated
        #[arg(long)]
        files: Option<String>,
        /// New estimate
        #[arg(long)]
        estimate: Option<String>,
        /// New cycle list, comma-separated (`--cycles ""` clears)
        #[arg(long)]
        cycles: Option<String>,
        /// New milestone list, comma-separated (`--milestones ""` clears)
        #[arg(long)]
        milestones: Option<String>,
        /// Associate the issue with a project (id or name); repeatable
        ///
        /// Idempotent: an already-present association is a no-op with no History
        /// entry. The reference is resolved through the project service before
        /// anything is written, so an unknown project refuses the edit and leaves
        /// the file byte-identical.
        #[arg(long, value_name = "ID")]
        add_project: Vec<String>,
        /// Dissociate the issue from a project (id or name); repeatable
        ///
        /// Drops only the association — the issue is never deleted. A known project
        /// that is not associated is a no-op; an unresolvable reference that the
        /// issue does not carry is refused as `unknown_project`.
        #[arg(long, value_name = "ID")]
        remove_project: Vec<String>,
        /// Replace the whole association list, comma-separated
        ///
        /// Only a blank value is accepted here: `--projects ""` clears every
        /// association, mirroring the empty-string-clears convention the other list
        /// flags follow. To add or drop individual associations use
        /// `--add-project` / `--remove-project`; a non-blank value is refused rather
        /// than guessed at.
        #[arg(long, value_name = "IDS")]
        projects: Option<String>,
        /// Add an epic reference (id or name); repeatable
        ///
        /// Idempotent: an already-present reference is a no-op with no History
        /// entry. The reference is resolved through the epic service before
        /// anything is written, so an unknown epic refuses the edit and leaves the
        /// file byte-identical.
        #[arg(long = "add-epic", value_name = "ID")]
        add_epic: Vec<String>,
        /// Remove an epic reference; repeatable
        ///
        /// Drops only the reference — the issue is never deleted. A known epic that
        /// is not referenced is a no-op; an unresolvable reference the issue does not
        /// carry is refused as `unknown_epic`. A retained stale id can still be
        /// removed by naming it verbatim.
        #[arg(long = "remove-epic", value_name = "ID")]
        remove_epic: Vec<String>,
        /// Clear every epic reference
        ///
        /// Only a blank value is accepted here: `--epics ""` clears every reference,
        /// mirroring the empty-string-clears convention `--projects` follows. To add
        /// or drop individual references use `--add-epic` / `--remove-epic`; a
        /// non-blank value is refused rather than guessed at.
        #[arg(long, value_name = "IDS")]
        epics: Option<String>,
        /// New reviewer (`--reviewer ""` clears)
        #[arg(long)]
        reviewer: Option<String>,
        /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
        #[arg(long)]
        start_date: Option<String>,
        /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
        #[arg(long)]
        due_date: Option<String>,
        /// Commit hash to record (`auto` resolves it from git history, `clear` removes it)
        #[arg(long)]
        commit: Option<String>,
        /// Set or change the parent (number, filename or title fragment)
        ///
        /// `--parent ""` clears the parent, mirroring `--assignee ""`; omitting the
        /// flag leaves it untouched. Validated before any write, so a self-parent,
        /// an unknown parent, or a change that would close a cycle leaves the file
        /// byte-identical.
        #[arg(long, value_name = "SELECTOR")]
        parent: Option<String>,
        /// Why a transition policy is being overridden by `--status`
        ///
        /// `--status` is validated exactly as `tix mv` is, so moving an issue out
        /// of a terminal category needs the same reasoned override, and the reason
        /// is recorded in the same History decision. `--status` deliberately has
        /// no `--yes`: acknowledging blockers is spelled `tix move … --yes`, and
        /// giving this flag a second spelling would give the audit trail two.
        #[arg(long, value_name = "TEXT")]
        reason: Option<String>,
        /// New related-issues list, comma-separated (`--related-issues ""` clears).
        /// A local number/selector, a relative or absolute path to another issue
        /// file, or a repository URL (`host/path#id`).
        #[arg(long)]
        related_issues: Option<String>,
        /// New issue type (`--type ""` clears it); `tix lint` warns on a value not
        /// in `custom_types`.
        #[arg(long)]
        r#type: Option<String>,
    },
    /// Move an issue to a column
    Move {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// Target column
        column: String,
        /// Commit hash to record when moving to a completion column (`auto`
        /// resolves it from git history; omitted, the current HEAD is captured
        /// automatically)
        ///
        /// "Completion" is whatever the board's category mapping calls
        /// `completed` — not a column literally named `done`. On a board with
        /// no `[kanban.<name>.category_map]` the board's `done_column` is the
        /// completion column.
        #[arg(long, value_name = "HASH")]
        commit: Option<String>,
        /// Why a transition policy is being overridden
        ///
        /// Required to move an issue *out of* a terminal category (`completed`
        /// or `canceled`) when the board declares a `category_map`. The reason
        /// is recorded in the issue's History, so the audit trail explains
        /// itself later. A blank value is not a reason and is refused.
        ///
        /// A board with no `category_map` has no transition policy to override:
        /// it keeps the completion definition and the commit rule, and moving
        /// out of `done` stays permitted.
        #[arg(long, value_name = "TEXT")]
        reason: Option<String>,
        /// Confirm without prompting
        ///
        /// Also the acknowledgement a move toward the board's completion column
        /// needs when the issue has open blockers. Without it that move is
        /// refused, and a recorded acknowledgement is appended to the issue's
        /// History so the decision stays auditable.
        #[arg(long)]
        yes: bool,
        /// Authorize overriding a **blocking work-in-progress limit**
        ///
        /// `--override` is a request, not an authorization. The move only proceeds
        /// when `--reason` states why and `--yes` confirms it, and the override is
        /// recorded — actor, timestamp, reason and the limit it was authorized
        /// against — as a History entry on the issue. Without `--override` a move
        /// that would exceed a `block` limit is refused and nothing is written; a
        /// `warn` limit never needs one.
        #[arg(long)]
        r#override: bool,
    },
    /// Inspect or configure work-in-progress limits
    ///
    /// Limits live in `tix.toml` under `[kanban.<board>.wip]`: a column's ceilings
    /// (`max_items`, `max_estimate`), the unit an estimate ceiling counts in, and
    /// whether exceeding it `warn`s or `block`s. A board with no limits behaves
    /// exactly as it did before they existed.
    Wip {
        #[command(subcommand)]
        action: WipAction,
    },
    /// Print an issue, or manage saved views (#172)
    ///
    /// `tix view <issue>` prints the issue file, unchanged. Without an issue
    /// selector the subcommands below manage repository-owned **saved views**:
    /// named, stable-ID query definitions with explicit scope metadata and a
    /// normalized plan over the existing filter/sort/group/visibility grammar.
    ///
    /// The nine subcommand words (`list`, `show`, `create`, `update`,
    /// `duplicate`, `delete`, `apply`, `status`, `share`) are **reserved** on
    /// `tix view`: an issue whose selector is exactly one of them must be
    /// addressed by number, exactly as `tix link` reserves `edit` / `remove` /
    /// `list`.
    #[command(visible_alias = "show")]
    View {
        /// `list`, `show`, `create`, `update`, `duplicate`, `delete`, `apply`,
        /// `status`, or `share`. Omit to print an issue.
        #[command(subcommand)]
        action: Option<ViewAction>,
        /// [print] Issue number, filename fragment or unique title fragment
        issue: Option<String>,
        /// [print] Extra information to append, comma-separated
        ///
        /// `relationships` appends the service-derived relationship block, the
        /// transitive chain, and the blocked reason. Without it the issue file
        /// is printed verbatim, so `tix view <n> > file` still round-trips.
        #[arg(long, value_name = "relationships")]
        show: Option<String>,
    },
    /// Manage recurring issue series (tix #175)
    ///
    /// A series is a **normal issue** that carries a `series:` table in its
    /// frontmatter: the issue holds the rule, the lifecycle state, and the
    /// template an occurrence is created from; each occurrence is another normal
    /// issue carrying the series id, the date and the idempotency key.
    ///
    /// `detect` is read-only and reports what is due; `generate` and every
    /// lifecycle action need `--yes`, because no refresh, timer or detection may
    /// change a schedule on its own.
    Recurring {
        #[command(subcommand)]
        action: RecurringAction,
    },
    /// One line per issue: number, id and title
    ///
    /// The default listing is **scoped**: your issues (the git author identity,
    /// when it can be derived) in the board's entry column — `todo` on the board
    /// `tix init` writes. `--all` drops the scope, and `--status`/`--filter`
    /// replace it. The title is cropped to the terminal's width, so every issue
    /// occupies exactly one line; the full board is `tix render`.
    List {
        /// Only issues in this status
        #[arg(long)]
        status: Option<String>,
        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
        #[arg(long)]
        filter: Option<String>,
        /// Restrict the listing to one project (stable id, or a display name)
        ///
        /// Resolved through the project service and applied through the shared
        /// `project:` criterion, so `tix list --project <id>` and
        /// `tix search "project:<id>"` return the same matching set.
        #[arg(long)]
        project: Option<String>,
        /// Restrict the listing to one epic (stable id, or a display name)
        ///
        /// Resolved through the epic service and applied through the shared
        /// `epic:` criterion, so `tix list --epic <id>` and
        /// `tix search "epic:<id>"` return the same matching set. An unknown
        /// reference matches nothing, so a typo yields a visible empty listing
        /// rather than a silent unfiltered one.
        #[arg(long)]
        epic: Option<String>,
        /// Include archived issues, marked with an ARCHIVED column
        ///
        /// Off by default, and off for a reason: archived issues are skipped
        /// without being read, so a repository with thousands of them lists fast.
        #[arg(long)]
        with_archived: bool,
        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `project`, `epic`, `status`, `priority`)
        #[arg(long)]
        sort: Option<String>,
        /// Group key (`cycle` | `milestone` | `project` | `epic`)
        #[arg(long)]
        group: Option<String>,
        /// Comma-separated columns to display
        ///
        /// The full set: `num`, `id`, `title`, `status`, `priority`, `cycle`,
        /// `milestone`, `reviewer`, `start`, `due`, `overdue`, `blocked`,
        /// `relations`, `progress`, `projects`, `epics`, `category`, `archived`,
        /// `wip`, `health`. The `value_name` used to list only the first ten, so a
        /// column this build had supported for two missions looked unsupported in
        /// `--help`; a column left out of this list is the same lie about a smaller
        /// set.
        #[arg(long, value_name = "COLUMNS")]
        columns: Option<String>,
        /// Extra information to include, comma-separated
        ///
        /// `relationships` adds a relationship summary and a blocked marker
        /// per issue, read from the relationship service so the CLI and the
        /// TUI cannot disagree.
        ///
        /// `progress` adds a parent rollup; `category` adds the issue's resolved
        /// category label, read from the shared service so this surface and the
        /// TUI cannot spell one category two ways.
        ///
        /// `wip` adds the column's usage against its limit, and `health` adds the
        /// issue's health (its own value, with the derived rollup beside it when the
        /// two differ), read from the one portable health document.
        #[arg(long, value_name = "SHOW")]
        show: Option<String>,
        /// List every issue, ignoring the default scope
        ///
        /// The default listing is scoped to your issues in the board's entry
        /// column. `--all` drops both halves of that scope — the listing is then
        /// every active issue, exactly as it was before the scope existed.
        #[arg(long)]
        all: bool,
        /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
        ///
        /// One record per issue, keyed by the columns being displayed, under the
        /// single root `issues` — so `--columns` chooses the fields. JSON is
        /// deliberately not one of them.
        #[arg(long, value_name = "FORMAT")]
        format: Option<String>,
    },
    /// The next issue to start: unblocked, highest priority first
    ///
    /// The `tix list` a working session opens with. It shows every issue that
    /// nothing blocks and that has not begun — a column the board maps to `backlog`
    /// or `unstarted`, no further along than the column new work lands in — ordered
    /// by priority (highest first, then oldest first), so the **first row** is the
    /// issue to pick up next. Blocked means an **open** blocker: the same graph the
    /// `blocked` column reads, so finishing a blocker returns its dependents here.
    Ready {
        /// Comma-separated columns to display
        ///
        /// The default set is `num,id,priority,title`: the identity pair, the
        /// priority the list is ordered by, and the title.
        #[arg(long, value_name = "COLUMNS")]
        columns: Option<String>,
        /// Show at most this many issues
        ///
        /// Applied after the ordering, so `--limit 1` is the single next issue.
        #[arg(long, value_name = "N")]
        limit: Option<usize>,
        /// Restrict the listing to one project (stable id, or a display name)
        #[arg(long)]
        project: Option<String>,
        /// Restrict the listing to one epic (stable id, or a display name)
        #[arg(long)]
        epic: Option<String>,
        /// Emit a structured document instead of the table: `xml`, `yml` or `toml`
        #[arg(long, value_name = "FORMAT")]
        format: Option<String>,
    },
    /// Inspect the status-category vocabulary, a board's mapping, and the
    /// effective transition policy
    ///
    /// A read-only surface. It never writes and never repairs, so it is safe to run
    /// on a repository whose `tix.toml` is mid-edit.
    Workflow {
        #[command(subcommand)]
        action: WorkflowAction,
    },
    /// Board summary: count per column + total
    Status {
        /// Token query (`cycle:`, `milestone:`, `reviewer:`, `overdue`, etc.)
        #[arg(long)]
        filter: Option<String>,
        /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`, `status`, `priority`)
        #[arg(long)]
        sort: Option<String>,
        /// Group key (`cycle` | `milestone` | `project` | `epic`)
        #[arg(long)]
        group: Option<String>,
        /// Add the health summary: issue counts per state, with diagnostics
        ///
        /// Read from the same portable health document `tix health board` and the
        /// TUI render, so the figures are the same figures. Off by default: the board
        /// summary is about columns, and health is metadata about the work (C-006).
        #[arg(long)]
        health: bool,
    },
    /// Project and issue health: read a scope, roll it up, or record a change
    ///
    /// Health is **metadata**, not the board's status: it never replaces a status
    /// category and never means "done". The current value lives in an issue's
    /// frontmatter, every change is an append-only History entry carrying an actor, a
    /// date and a rationale, and a scope's derived value comes from what contributes
    /// to it under a versioned policy (`health-v1`).
    ///
    /// `set` and `override` are the mutations, and both refuse — having written
    /// nothing — when an audit field is missing. `show`, `rollup`, `history` and
    /// `board` read.
    Health {
        #[command(subcommand)]
        action: HealthAction,
    },
    /// Read and act on your `@mentions` (issue-mentions)
    ///
    /// A mention is `@handle` or `@name@host` in an issue's title or body, recorded
    /// per mentioned target under `docs/mentions/`. This command is the
    /// **non-interactive** surface: it lists what is pending for you and applies the
    /// three answers without a prompt, so the flow works from a script and without a
    /// TTY. The TUI has the same three answers on the bottom indicator row's chord.
    Mentions {
        #[command(subcommand)]
        action: MentionsAction,
    },
    /// Triage the inbox: read-only queue views and the four decisions
    Triage {
        #[command(subcommand)]
        action: TriageAction,
    },
    /// Delete an issue file
    Delete {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// Confirm without prompting
        #[arg(long)]
        yes: bool,
    },
    /// Archive issue files: rename each in place to a dot-prefixed name
    ///
    /// The default views (`tix list`, `tix search`) skip archived issues; pass
    /// their `--with-archived` flag or the `archived:true` filter to see them.
    Archive {
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
    /// Restore archived issue files: the inverse of `tix archive`
    Restore {
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
    /// Manage planning cycles
    Cycle {
        #[command(subcommand)]
        command: CycleCommand,
    },
    /// Configure and inspect cycle, team and assignee capacity
    ///
    /// A ceiling is a scope (one cycle, one team label, or one assignee identity)
    /// with a non-negative value in an estimate unit the active board declares.
    /// `calculate` reports the shared committed/completed/remaining/overflow/
    /// utilization values and never mutates an issue; `snapshot` reads back the
    /// immutable evidence a cycle close captured.
    Capacity {
        #[command(subcommand)]
        command: CapacityCommand,
    },
    /// Record and review actual effort against issues, in whole minutes
    ///
    /// A time entry is a duration, an optional `YYYY-MM-DD` calendar date, an author
    /// and an optional note — there is no timer, no timezone conversion and no overlap
    /// rule. Current entries live in the issue's own frontmatter, every correction and
    /// deletion appends an attributable History event, and a deletion removes the
    /// current value only. `add`, `edit` and `delete` are the mutations (`edit` and
    /// `delete` require `--reason`; `delete` also requires `--confirm`), and `show`,
    /// `history` and `total` are reads whose numbers come from one shared calculation.
    Time {
        #[command(subcommand)]
        command: TimeCommand,
    },
    /// Manage milestones
    Milestone {
        #[command(subcommand)]
        command: MilestoneCommand,
    },
    /// Create, edit, list, inspect, delete and populate projects
    ///
    /// A project is a stable id (a registry key in `tix.toml`) with a mutable
    /// display name. An issue carries **project ids**, so a rename breaks no
    /// association: the id is the identity and the name is a label. Every listing
    /// prints the id beside the name, because without it a rename is
    /// indistinguishable from a new project.
    ///
    /// `delete` removes the **registry entry only**. The issues that referenced
    /// the project keep their references, which then read as diagnosable missing
    /// references (`tix lint` names each one), so nothing is destroyed by deleting
    /// a project and there is deliberately no `--cascade` and no bulk-delete of
    /// issues.
    ///
    /// `add`, `remove` and `bulk-add` process each issue selector
    /// **independently**: a rejection on one selector is reported and never aborts
    /// the rest. Their scope is project association only — general bulk editing is
    /// a different feature.
    Project {
        #[command(subcommand)]
        command: ProjectCommand,
    },
    /// Create, edit, list, inspect, navigate and delete epics; associate work items
    ///
    /// An epic is a stable id (a registry key in `tix.toml`) with a mutable
    /// display name. A work item references epic **ids**, so a rename breaks no
    /// reference: the id is the identity and the name is a label. Every listing
    /// prints the id beside the name, because without it a rename is
    /// indistinguishable from a new epic.
    ///
    /// A "task" is an ordinary issue: `tix edit <sel> --labels task --add-epic
    /// <id>` adds a reference to an issue. No task entity, file or frontmatter key
    /// is created, and direct issue navigation stays available from every view.
    ///
    /// `delete` removes the **registry entry only**. The issues that referenced the
    /// epic keep their references (which then read as missing references) and the
    /// child epics keep their recorded parent (which then reads as unresolved), so
    /// nothing is destroyed by deleting an epic and there is deliberately no
    /// `--cascade`.
    ///
    /// `add`, `remove` and `bulk-add` process each issue selector
    /// **independently**: a rejection on one selector is reported and never aborts
    /// the rest. Their scope is epic reference only — general bulk editing is a
    /// different feature.
    Epic {
        #[command(subcommand)]
        command: EpicCommand,
    },
    /// Create, edit, list, inspect, delete and associate roadmap initiatives
    ///
    /// An initiative is a stable id (a registry key in `tix.toml`) with a mutable
    /// display name, an optional target date/status/owner, and explicit
    /// project/epic/milestone associations. The id is the identity, so a rename
    /// breaks no association. Every listing prints the id, because without it a
    /// rename is indistinguishable from a new initiative.
    ///
    /// `delete` removes the **registry entry only**. The record's own references are
    /// reported and nothing cascades: no project, epic, milestone or issue is
    /// touched, and no association is silently repaired.
    ///
    /// `associate`/`dereference` name exactly one of `--project`, `--epic` or
    /// `--milestone`; a well-formed reference that names nothing is stored and stays
    /// diagnosable (`tix roadmap` and `tix lint` report it), and a repeated
    /// association is an idempotent no-op.
    Initiative {
        #[command(subcommand)]
        command: InitiativeCommand,
    },
    /// List and inspect the roadmap: initiatives ordered by target date
    ///
    /// Read-only. Each row carries the target date (or the explicit `undated`
    /// state), the source-attributed summary — one contribution per referenced
    /// project/epic/milestone with its own value and shared policy version — and the
    /// ordered diagnostics, so a missing reference is visible on the row and an
    /// empty summary is explicit. No figure is blended across sources.
    Roadmap {
        #[command(subcommand)]
        command: RoadmapCommand,
    },
    /// Read the burndown and scope analytics of a cycle or milestone, or write its
    /// deterministic chart document
    ///
    /// Read-only. `burndown` prints the daily remaining/completed series the shared fold
    /// produces, `projection` the read-only end-date forecast at the caller's `--as-of`,
    /// and `export` the versioned local chart document (WP04's target, called by this
    /// verb rather than re-serialized). The period comes from the cycle/milestone's own
    /// planning data and `--from`/`--to` replace the bound they name; count and estimate
    /// modes are explicit and an estimate without a compatible unit is refused rather
    /// than guessed. A value the record cannot support prints as `-` and never as a zero,
    /// a day with no recorded history is explicit about its coverage, and nothing here
    /// mutates an issue, a cycle or a snapshot. The remote `gitlab`/`linear` export
    /// namespaces are a different command (`tix export`) and are never consulted.
    Analytics {
        #[command(subcommand)]
        command: AnalyticsCommand,
    },
    /// Export local issues to an external tracker via glab/linear-cli, or write
    /// them into a beads workspace via `bd`
    Export {
        /// Target tracker: gitlab | linear | ics | beads
        #[arg(long, value_name = "gitlab|linear|ics|beads")]
        target: String,
        /// Print what would change without invoking the remote tracker
        #[arg(long)]
        dry_run: bool,
        /// Override the configured project path (gitlab) or team (linear)
        #[arg(long)]
        project: Option<String>,
        /// Cycle to export for ICS output
        #[arg(long)]
        cycle: Option<String>,
        /// Output path for ICS export
        #[arg(long)]
        output: Option<String>,
        /// List the per-issue diagnostics — which local fields beads cannot hold
        ///
        /// Off by default: a board of a few thousand issues produces hundreds of
        /// them, and a run that sends three issues should not rehearse the notes
        /// for the other thousand. The counts are always reported either way.
        #[arg(long)]
        verbose: bool,
    },
    /// Import open issues from an external tracker via glab/linear-cli, mirror a
    /// local GitHub Spec Kit project, or pull a beads workspace through `bd`
    Import {
        /// Target: gitlab | linear (remote trackers) | speckit (local mirror) |
        /// beads (a beads workspace, through `bd`)
        #[arg(long, value_name = "gitlab|linear|speckit|beads")]
        target: String,
        /// Where an imported issue lands in the local triage inbox (WP07)
        ///
        /// `entry` routes into `[triage].entry_status` and refuses a project with
        /// no `[triage]` section instead of quietly falling back; `remote` keeps
        /// the column the remote mapping named. Absent is the documented default:
        /// route when the project has an inbox, otherwise leave the mapping alone.
        /// Either way the report names the mapping that was used.
        #[arg(long, value_name = "entry|remote")]
        triage_policy: Option<String>,
    },
    /// Roll open issues forward from expired cycles to the next successor
    Rollover {
        /// Restrict rollover to one source cycle
        #[arg(long)]
        cycle: Option<String>,
        /// Skip interactive confirmation
        #[arg(long)]
        yes: bool,
    },
    /// Search issue files for a string (case-insensitive substring over full markdown)
    Search {
        /// Case-insensitive substring to match against the full markdown of every issue
        #[arg(value_name = "STRING", value_parser = NonEmptyStringValueParser::new())]
        string: String,
        /// Include archived issues in the search corpus
        #[arg(long)]
        with_archived: bool,
    },
    /// Render the current board once and exit
    Render,
    /// Prune the centralized backups and remove the scattered ones
    ///
    /// Backups live in `.tix-backups/` beside the issues directory (#1581);
    /// `--keep` (default 10) bounds how many are kept per issue. A legacy
    /// `*.tixbak` or `*.md.bak` from an older release, still beside an issue, is
    /// removed too — by default each such file is confirmed interactively.
    Clean {
        /// Remove every legacy scattered backup without prompting
        #[arg(short, long)]
        force: bool,
        /// Backups to keep per issue in `.tix-backups/`
        #[arg(long, default_value_t = 10)]
        keep: usize,
    },
    /// Open the TUI with the create-issue form already open
    ///
    /// The session `tix` starts, with the modal that `n` opens already in front of
    /// you: one command to file an issue, instead of "start the board, then press
    /// n". It is the interactive twin of `tix create`, which never opens the TUI.
    New,
    /// Validate frontmatter option values in all issue files
    ///
    /// Every finding renders as a rustc-style diagnostic on stderr: the file and
    /// line, the offending value, and a `help:` line naming the fix. An **error**
    /// sets the exit code (a broken file); a **warning** does not (a valid file
    /// that is worth a look) — so a store with warnings still exits `0`.
    ///
    /// What is checked:
    ///
    /// - per issue: `status` against the board's columns; `priority`, `title`,
    ///   `type`, `created`/`updated`, `estimate`; the planning fields `cycles`,
    ///   `milestones`, `start_date`, `due_date`; a `## History` section that is
    ///   not first in the body (fenced code examples are skipped) or an entry the
    ///   grammar cannot read; and unrecognised top-level keys (a warning, since
    ///   the key is preserved on write);
    /// - the typed relationships: self-links, missing or conflicting targets,
    ///   cycles, an unknown kind, and the retired `links:` key;
    /// - the `parent:` hierarchy: `self_parent`, `unresolved_parent`,
    ///   `unknown_parent`, `cycle`;
    /// - `related_issues` entries: blank, or a local selector that resolves to
    ///   nothing (paths and URLs are accepted verbatim);
    /// - the configuration: board column/category mapping, `[layout]` drift, and
    ///   a `status:` that contradicts the issue's own recorded History.
    ///
    /// Exit: `0` clean or warnings only, `1` any error, `2` usage or an internal
    /// failure.
    Lint {
        /// Do not lint the foreign repositories reachable from `related_issues`
        /// for this run (overrides `lint_foreign_repositories` in tix.toml).
        #[arg(long)]
        no_foreign: bool,
    },
    /// Inspect or migrate the configured on-disk issue layout (`[layout]`)
    Layout {
        #[command(subcommand)]
        action: LayoutAction,
    },
    /// Serve the MCP protocol (JSON-RPC 2.0 over stdio) for agent tools
    Mcp,
    /// Manage stable-ID issue/form templates
    ///
    /// Templates live in `[issue_templates.<id>]` in `tix.toml`, keyed by an
    /// immutable stable ID with a mutable display name and an explicit scope.
    /// `list`/`show` are read-only; `create`/`update`/`duplicate`/`delete`
    /// persist through one atomic save, and a refused operation writes nothing.
    Template {
        #[command(subcommand)]
        action: TemplateAction,
    },
}

/// The `tix template` management tree (`contracts/cli-surface.md`).
///
/// `list`/`show` are read-only; the four mutations call one service that
/// validates the complete candidate registry before a single atomic
/// `tix.toml` save, so a refused operation leaves the file byte-identical.
/// Identity is the immutable stable ID: `update` changes a display name
/// without touching scope, defaults, prompts, references, or preserved
/// unknown metadata.
#[derive(Subcommand)]
pub enum TemplateAction {
    /// List templates: one `id<TAB>name<TAB>scope` row each, in declaration order
    List,
    /// Show one template by stable ID (or a unique display name)
    Show {
        /// Stable ID, or a display name that names exactly one template
        reference: String,
    },
    /// Create a template under a new stable ID
    Create {
        /// The new stable ID: ASCII letters, digits, `-` or `_`
        #[arg(long, value_name = "ID")]
        id: String,
        /// Display name; omitted means the ID is shown in its place
        #[arg(long, value_name = "NAME")]
        name: Option<String>,
        /// Scope: `repository` (default) | `board:<name>` | `team:<label>`
        #[arg(long, value_name = "SCOPE")]
        scope: Option<String>,
        /// A normal create default, `FIELD=VALUE` (repeatable). Supplying any
        /// `--default` replaces the whole defaults block.
        #[arg(long = "default", value_name = "FIELD=VALUE")]
        defaults: Vec<String>,
        /// A structured prompt,
        /// `key=<field>[,kind=text|select|multi-select|date][,required=true|false][,default=<v>][,label=<l>][,options_ref=<source>]`
        /// (repeatable). Supplying any `--prompt` replaces the whole prompt list.
        #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
        prompts: Vec<String>,
        /// A typed reference,
        /// `kind=label|assignee|file|cycle|milestone|relationship,value=<v>[,required=true|false]`
        /// (repeatable). Supplying any `--reference` replaces the whole list.
        #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
        references: Vec<String>,
    },
    /// Update the flags supplied, leaving every other stored field as it was
    Update {
        /// Stable ID, or a display name that names exactly one template
        reference: String,
        /// New display name (the stable ID never changes)
        #[arg(long, value_name = "NAME")]
        name: Option<String>,
        /// New scope: `repository` | `board:<name>` | `team:<label>`
        #[arg(long, value_name = "SCOPE")]
        scope: Option<String>,
        /// Replacement defaults, `FIELD=VALUE` (repeatable)
        #[arg(long = "default", value_name = "FIELD=VALUE")]
        defaults: Vec<String>,
        /// Replacement prompts (repeatable; same grammar as `create`)
        #[arg(long = "prompt", value_name = "KEY,ATTR=VALUE...")]
        prompts: Vec<String>,
        /// Replacement references (repeatable; same grammar as `create`)
        #[arg(long = "reference", value_name = "KIND=VALUE,ATTR=VALUE...")]
        references: Vec<String>,
    },
    /// Copy a template to a new stable ID, leaving the source untouched
    Duplicate {
        /// Stable ID, or a display name that names exactly one template
        reference: String,
        /// The new stable ID; omitted allocates `<source>-copy`, `-copy-2`, …
        #[arg(long, value_name = "ID")]
        id: Option<String>,
        /// Display name for the copy; omitted keeps the source's label
        #[arg(long, value_name = "NAME")]
        name: Option<String>,
    },
    /// Delete a template definition (never the issues created from it)
    Delete {
        /// Stable ID, or a display name that names exactly one template
        reference: String,
        /// Confirm without prompting
        #[arg(long)]
        yes: bool,
    },
}

/// The `tix triage` tree (`contracts/cli-surface.md`, WP05 T023–T025).
///
/// `list` and `status` are read-only queue views; the four decision verbs take a
/// single issue; `batch` is the selection-shaped twin of `tix bulk`, preview by
/// default and writing only under an explicit `--apply`.
///
/// ## Why the reason flag is `Option` on `accept` and the rest
///
/// `contracts/decision-history.md` splits it: decline, duplicate and defer
/// *require* a reason, accept records one when supplied. A required clap argument
/// on three of the four would make the fourth's absence look like an oversight,
/// and — more importantly — the enforcement belongs to the service, which refuses
/// `missing_reason` before any write on every surface (NFR-003). The flag is
/// therefore optional everywhere and the *rule* is the service's, so the CLI,
/// TUI and MCP cannot end up with three different ideas of which decision needs
/// one. `tix triage decline 1` with no `--reason` still fails, at the service,
/// with the contract's own kind.
/// `tix health`'s actions.
#[derive(Subcommand)]
pub enum HealthAction {
    /// Record an issue's health state (append-only; needs --rationale)
    ///
    /// Writes the frontmatter value and **one** History entry, together or not at
    /// all. Any of the four states is accepted; anything else is refused by name.
    Set {
        /// The issue, by number or slug
        issue: String,
        /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
        #[arg(long)]
        state: String,
        /// Who is making the change (defaults to the configured git identity)
        #[arg(long)]
        actor: Option<String>,
        /// When, as `YYYY-MM-DD` (defaults to today)
        #[arg(long)]
        date: Option<String>,
        /// Why — required: a health change nobody can explain is not recorded
        #[arg(long)]
        rationale: Option<String>,
    },
    /// Show one issue's health: its own value, its derived rollup, its diagnostics
    Show {
        /// The issue, by number or slug
        issue: String,
    },
    /// Show a scope's derived health, with its contributors and policy version
    Rollup {
        /// `issue` | `project` | `epic` | `cycle` | `milestone`
        #[arg(long)]
        scope: String,
        /// The scope's stable id (an issue number for `--scope issue`)
        #[arg(long)]
        id: String,
    },
    /// Layer an explicit override over a derived scope's health
    ///
    /// An override is a deliberate act about a whole scope, so it needs `--confirm`:
    /// it does not replace what the children say, it is recorded *over* it.
    Override {
        /// `project` | `epic` | `cycle` | `milestone`
        #[arg(long)]
        scope: String,
        /// The scope's stable id
        #[arg(long)]
        id: String,
        /// One of `unknown`, `on_track`, `needs_attention`, `at_risk`
        #[arg(long)]
        state: String,
        /// Who is making the change (defaults to the configured git identity)
        #[arg(long)]
        actor: Option<String>,
        /// When, as `YYYY-MM-DD` (defaults to today)
        #[arg(long)]
        date: Option<String>,
        /// Why — required
        #[arg(long)]
        rationale: Option<String>,
        /// Confirm an override of derived health
        #[arg(long)]
        confirm: bool,
    },
    /// The append-only health History entries about a scope
    History {
        /// A scope: `issue:42`, `cycle:2026-10`, `milestone:m1`, …
        scope: String,
    },
    /// Every scope's health, with what contributes to each
    Board,
}

#[derive(Subcommand)]
pub enum TriageAction {
    /// Read the inbox queue: one row per entry (FR-001)
    List {
        /// Sort by entry age, oldest first; an unknown age sorts last
        #[arg(long)]
        age: bool,
        /// Print the counts only, with no entries
        #[arg(long)]
        count: bool,
    },
    /// Read the inbox summary: decision counts and the age histogram (FR-005)
    Status,
    /// Accept: move into the normal workflow, validated by #183's transition policy
    Accept {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// Reason recorded in History; accept records one when supplied
        #[arg(long)]
        reason: Option<String>,
        /// Target column; defaults to `[triage].accepted_status`
        #[arg(long, value_name = "COLUMN")]
        to: Option<String>,
        /// Who decided; defaults to the git identity
        #[arg(long)]
        actor: Option<String>,
    },
    /// Decline: move to `[triage].declined_status`; never deletes or archives
    Decline {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// Why it is declined (required)
        #[arg(long)]
        reason: Option<String>,
        /// Who decided; defaults to the git identity
        #[arg(long)]
        actor: Option<String>,
    },
    /// Duplicate: record a typed `duplicates` relation; never merges files
    Duplicate {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// The issue this one duplicates
        #[arg(long, value_name = "ISSUE")]
        of: String,
        /// Why it is a duplicate (required)
        #[arg(long)]
        reason: Option<String>,
        /// Who decided; defaults to the git identity
        #[arg(long)]
        actor: Option<String>,
    },
    /// Defer: stay in the inbox with a refreshed entry time
    Defer {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
        /// Why it is deferred (required)
        #[arg(long)]
        reason: Option<String>,
        /// Who decided; defaults to the git identity
        #[arg(long)]
        actor: Option<String>,
    },
    /// One decision across a selection: preview by default, `--apply` to write
    Batch {
        /// Comma-separated selectors: number, filename or title fragment
        #[arg(long, value_name = "LIST")]
        select: Option<String>,
        /// A `tix list --filter` query selecting the issues
        #[arg(long, value_name = "QUERY")]
        filter: Option<String>,
        /// Which decision to make: accept | decline | duplicate | defer
        #[arg(long, value_name = "KIND")]
        decision: String,
        /// Reason recorded on every applied event; required for three of four
        #[arg(long)]
        reason: Option<String>,
        /// Who decided; defaults to the git identity
        #[arg(long)]
        actor: Option<String>,
        /// The duplicate target selector, for `--decision duplicate`
        #[arg(long, value_name = "ISSUE")]
        of: Option<String>,
        /// The accept target column, for `--decision accept`
        #[arg(long, value_name = "COLUMN")]
        to: Option<String>,
        /// Apply the previewed plan; without it nothing is written
        #[arg(long)]
        apply: bool,
    },
}

#[derive(Subcommand)]
pub enum CycleCommand {
    Create {
        #[arg(long)]
        name: Option<String>,
        #[arg(long)]
        description: Option<String>,
        #[arg(long = "start-date")]
        start_date: Option<String>,
        #[arg(long = "due-date")]
        due_date: Option<String>,
    },
    List,
    Edit {
        #[arg(long)]
        name: String,
        #[arg(long = "new-name")]
        new_name: Option<String>,
        #[arg(long)]
        description: Option<String>,
        #[arg(long = "start-date")]
        start_date: Option<String>,
        #[arg(long = "due-date")]
        due_date: Option<String>,
    },
    Delete {
        #[arg(long)]
        name: String,
        #[arg(long)]
        yes: bool,
    },
    /// Manage repeating cycle schedules (`[cycle_schedules.<id>]`)
    ///
    /// A schedule is a stable id (the registry key) with a mutable display name;
    /// generated cycle instances are ordinary cycle records this family creates
    /// idempotently from the schedule's definition. Detection is read-only; every
    /// mutation requires `--confirm`.
    Schedule {
        #[command(subcommand)]
        command: CycleScheduleCommand,
    },
}

/// The `tix cycle schedule` subcommands (mission
/// `repeating-cycle-schedules-01M3C7W1`, WP06).
///
/// The surface contract's spelling (`contracts/cli-surface.md`): a schedule
/// namespace under `tix cycle`, with stable-ID selectors, explicit date/timezone
/// context and confirmed mutations. This enum is a thin adapter — the schedule
/// record, the read model, evaluation, generation and the lifecycle all live in
/// `crate::cycle_schedule`; nothing here re-derives a cadence, a cursor or a
/// History entry.
#[derive(Debug, Clone, Subcommand)]
pub enum CycleScheduleCommand {
    /// List schedules, one tab-separated row per schedule
    ///
    /// Columns: `id`, `name`, `state`, `cadence`, `duration`, `start_date`,
    /// `look_ahead`, `next_sequence`, `generated`.
    List,
    /// Show one schedule: identity, cadence, duration, cursor and its instances
    Show {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
    },
    /// Define a new repeating schedule; validated before persistence, requires `--confirm`
    Create(CycleScheduleCreateArgs),
    /// Change a schedule's fields, keeping its stable id; requires `--confirm`
    ///
    /// Only the flags you name change; the rest of the record (including its
    /// generation cursor and preserved metadata) is carried over.
    Edit {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleEditArgs,
        /// Confirm the change without prompting
        #[arg(long)]
        confirm: bool,
    },
    /// Delete a schedule's registry entry; requires `--confirm`
    ///
    /// The generated cycles it created and their snapshots are **not** deleted:
    /// they keep their schedule reference, which then reads as a diagnosable
    /// missing schedule, so nothing is destroyed by deleting a definition.
    Delete {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        /// Confirm the deletion without prompting
        #[arg(long)]
        confirm: bool,
    },
    /// Report ended/due/look-ahead warnings, read-only
    ///
    /// Prints the warnings and the explicit decisions each accepts. It writes
    /// nothing: no file, no cursor move, no History entry.
    Detect {
        /// Evaluate against this `YYYY-MM-DD` date instead of today
        #[arg(long, value_name = "YYYY-MM-DD")]
        date: Option<String>,
    },
    /// Create the schedule's future cycles; idempotent, requires `--confirm`
    ///
    /// A repeated call under the same stable keys is a no-op, never a duplicate.
    Generate {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        /// Generate every candidate whose start date is on or before this date
        #[arg(long, value_name = "YYYY-MM-DD")]
        through: Option<String>,
        /// Confirm the generation without prompting
        #[arg(long)]
        confirm: bool,
    },
    /// Close an instance: capture its immutable snapshot and mark it closed
    Close {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleReasonArgs,
    },
    /// Reopen a closed instance: restore `active` and append one History event
    ///
    /// The closed cycle's capacity snapshot is **not** rewritten: it stays the
    /// immutable evidence the close captured, and the appended `cycle_reopened`
    /// event names it. Reopening an open instance is a no-op, and the action
    /// requires both a reason and the instance it names.
    Reopen {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleReasonArgs,
    },
    /// Push an instance's due date out by a cadence period
    Extend {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleExtendArgs,
    },
    /// Record a sequence as skipped so it is never regenerated
    Skip {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleReasonArgs,
    },
    /// Pause a schedule and its non-terminal instances
    Pause {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleLifecycleArgs,
    },
    /// Resume a paused schedule and its instances
    Resume {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleLifecycleArgs,
    },
    /// Re-derive every future instance's dates from the schedule's definition
    AdjustFuture {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleReasonArgs,
    },
    /// Close a warning without deciding anything; nothing is written
    Ignore {
        /// Stable schedule id, or its unique display name
        #[arg(value_name = "SCHEDULE")]
        schedule: String,
        #[command(flatten)]
        args: CycleScheduleLifecycleArgs,
    },
}

/// The flags that define a schedule (`tix cycle schedule create`).
#[derive(Debug, Clone, Args)]
pub struct CycleScheduleCreateArgs {
    /// Stable schedule id (letters, digits, `-`, `_`, `:`); never reused
    #[arg(long, value_name = "ID")]
    pub id: String,
    /// Display label, unique case-insensitively within the registry
    #[arg(long, value_name = "NAME")]
    pub name: String,
    /// Cadence family: `daily`, `weekly`, `monthly` or `interval`
    ///
    /// `interval` is the family that carries `--unit`; the other three are the
    /// calendar-step families and refuse a unit.
    #[arg(long, value_name = "KIND")]
    pub cadence: String,
    /// How many cadence units between cycle starts (1-1000; absent means 1)
    #[arg(long, value_name = "N")]
    pub interval: Option<u32>,
    /// The unit an `interval` cadence counts in: `days`, `weeks`, `months`
    #[arg(long, value_name = "UNIT")]
    pub unit: Option<String>,
    /// How long one cycle lasts, as a positive count
    #[arg(long, value_name = "N")]
    pub duration: u32,
    /// The duration's unit: `days`, `weeks`, `months`
    #[arg(long = "duration-unit", value_name = "UNIT")]
    pub duration_unit: String,
    /// The schedule's stable anchor, a `YYYY-MM-DD` calendar date
    #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
    pub start_date: String,
    /// Fixed UTC offset the schedule is expressed in: `UTC` or `±HH:MM`
    ///
    /// `allow_hyphen_values` so a west-of-UTC offset (`-05:00`) reaches the
    /// validator instead of being swallowed by clap as an unknown flag.
    #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
    pub timezone: String,
    /// How many future cycles to consider beyond the current one (0 when absent)
    #[arg(long = "look-ahead", value_name = "N")]
    pub look_ahead: Option<u32>,
    /// Confirm the creation without prompting
    #[arg(long)]
    pub confirm: bool,
}

/// What `tix cycle schedule edit` may change. Every field is optional; only the
/// named ones change, so a partial edit keeps the rest of the record.
#[derive(Debug, Clone, Args)]
pub struct CycleScheduleEditArgs {
    /// New display label
    #[arg(long, value_name = "NAME")]
    pub name: Option<String>,
    /// New cadence family: `daily`, `weekly`, `monthly` or `interval`
    #[arg(long, value_name = "KIND")]
    pub cadence: Option<String>,
    /// New cadence interval (1-1000)
    #[arg(long, value_name = "N")]
    pub interval: Option<u32>,
    /// New cadence unit (only meaningful for the `interval` family)
    #[arg(long, value_name = "UNIT")]
    pub unit: Option<String>,
    /// New duration count
    #[arg(long, value_name = "N")]
    pub duration: Option<u32>,
    /// New duration unit
    #[arg(long = "duration-unit", value_name = "UNIT")]
    pub duration_unit: Option<String>,
    /// New anchor date, `YYYY-MM-DD`
    #[arg(long = "start-date", value_name = "YYYY-MM-DD")]
    pub start_date: Option<String>,
    /// New timezone: `UTC` or `±HH:MM` (`allow_hyphen_values` for `-05:00`)
    #[arg(long, value_name = "TZ", allow_hyphen_values = true)]
    pub timezone: Option<String>,
    /// New look-ahead count
    #[arg(long = "look-ahead", value_name = "N")]
    pub look_ahead: Option<u32>,
}

/// The flags every `tix cycle schedule` lifecycle action shares.
#[derive(Debug, Clone, Args)]
pub struct CycleScheduleLifecycleArgs {
    /// Target instance, `<schedule-id>:<sequence>` (e.g. `cs-sprint:000001`)
    #[arg(long, value_name = "CYCLE")]
    pub cycle: Option<String>,
    /// Select the instance by its start date, `YYYY-MM-DD`
    #[arg(long, value_name = "YYYY-MM-DD")]
    pub from: Option<String>,
    /// Confirm the action without prompting
    #[arg(long)]
    pub confirm: bool,
}

/// The lifecycle flags plus the reason the recording actions require.
#[derive(Debug, Clone, Args)]
pub struct CycleScheduleReasonArgs {
    #[command(flatten)]
    pub lifecycle: CycleScheduleLifecycleArgs,
    /// Why; recorded in the instance's History
    #[arg(long, value_name = "TEXT")]
    pub reason: Option<String>,
}

/// `tix cycle schedule extend`'s flags: the reason plus the period count.
#[derive(Debug, Clone, Args)]
pub struct CycleScheduleExtendArgs {
    #[command(flatten)]
    pub reason: CycleScheduleReasonArgs,
    /// How many cadence periods to extend by; the shared lifecycle supports 1
    #[arg(long, value_name = "N")]
    pub by: Option<u32>,
}

/// The `tix capacity` subcommands (mission `cycle-capacity-planning-01M3C85C`, WP05).
///
/// A thin adapter: the `[capacity]` section, its validation, the shared calculation,
/// the findings and the close evidence all live in `crate::capacity`. This enum owns
/// only the spelling of the surface — the scope and the unit are always explicit
/// (C-003/C-004), and no flag here can change an issue.
#[derive(Debug, Clone, Subcommand)]
pub enum BeadsAction {
    /// Report what `bd` and its workspace look like (read-only)
    ///
    /// Runs only `bd version`, `bd where` and `bd config get`, so it is safe on a
    /// repository that has not opted in — and it names a workspace whose events
    /// journal is off, because a live mirror would then see nothing.
    Detect,
    /// A read-only summary of the beads workspace
    ///
    /// The open/closed/blocked/ready figures are computed the way beads derives
    /// them — from the `blocks` dependencies — so they agree with `bd ready`
    /// rather than guessing.
    Status,
    /// List beads issues, read-only
    List {
        /// Include closed issues
        #[arg(long)]
        all: bool,
    },
    /// Show one beads issue, read-only
    Show {
        /// Beads id, or a unique fragment of one (or of a title)
        issue: String,
    },
    /// Follow the beads events journal, printing each change as it commits
    ///
    /// Reads the journal in pages and re-reads from its own checkpoint, so it
    /// survives a gap between reads. Requires the journal to be enabled wherever
    /// beads writes; `detect` says whether it is.
    Watch {
        /// Sequence to resume from (default: the beginning of what is retained)
        #[arg(long)]
        since: Option<u64>,
        /// Seconds between reads
        #[arg(long, default_value_t = 1)]
        interval: u64,
    },
    /// Write local issues into beads through `bd import`
    ///
    /// **Incremental by default**: only issues whose exported row changed since
    /// the last push are sent, so a repeat run with nothing changed invokes `bd`
    /// not at all. `--all` forces every issue through, which is what a discarded
    /// state file or a rebuilt beads workspace needs.
    Push {
        /// Print the JSONL instead of importing it
        #[arg(long)]
        dry_run: bool,
        /// Send every issue, ignoring the export watermark
        #[arg(long)]
        all: bool,
        /// List the per-issue diagnostics (which local fields beads cannot hold)
        #[arg(long)]
        verbose: bool,
    },
    /// Pull beads issues into local files
    Pull {
        /// Print nothing on success (used by the beads hooks)
        #[arg(long)]
        quiet: bool,
    },
    /// Pull, then push — the full both-ways pass
    Reconcile {
        /// Print nothing on success beyond the push report
        #[arg(long)]
        quiet: bool,
    },
}

#[derive(Debug, Clone, Subcommand)]
pub enum CapacityCommand {
    /// List every configured ceiling, one tab-separated row per entry
    ///
    /// Columns: `id`, `scope`, `reference`, `display`, `capacity`, `unit`,
    /// `enabled`. The header prints even with no entries, so an empty section is
    /// visibly empty rather than indistinguishable from a failure.
    List,
    /// Show one ceiling by the reference it names
    ///
    /// A reference is a cycle's name or stable id, a team label, or an assignee
    /// identity; `--scope` narrows it when one reference names two scopes.
    Show {
        /// Cycle name/stable id, team label, or assignee identity
        #[arg(value_name = "REFERENCE")]
        reference: String,
        /// Restrict the match to one scope: `cycle`, `team` or `assignee`
        #[arg(long, value_name = "SCOPE")]
        scope: Option<String>,
    },
    /// Set one scope's ceiling; validated before persistence
    ///
    /// The scope and the unit are explicit: a value with no unit, a unit no board
    /// declares, an unknown cycle/team, or a second ceiling for one scope is
    /// refused before `tix.toml` changes.
    Set {
        /// Scope dimension: `cycle`, `team` or `assignee`
        #[arg(long, value_name = "SCOPE")]
        scope: String,
        /// The scope's reference (a cycle name or stable id, a team label, an identity)
        #[arg(long, value_name = "ID")]
        id: String,
        /// The ceiling, in `--unit`, at or above zero
        ///
        /// A negative value is accepted by the parser and refused by the shared
        /// validator with its field path (`capacity.<id>.capacity`), which is a better
        /// message than clap's "unexpected argument".
        #[arg(long, value_name = "N", allow_hyphen_values = true)]
        value: i64,
        /// The estimate unit the ceiling counts in (declared by the active board)
        #[arg(long, value_name = "UNIT")]
        unit: String,
        /// Keep the value and the unit, but enforce nothing
        #[arg(long)]
        disabled: bool,
    },
    /// Delete one scope's ceiling
    Delete {
        /// Scope dimension: `cycle`, `team` or `assignee`
        #[arg(long, value_name = "SCOPE")]
        scope: String,
        /// The scope's reference (a cycle name or stable id, a team label, an identity)
        #[arg(long, value_name = "ID")]
        id: String,
    },
    /// Calculate one scope's committed/completed/remaining/overflow/utilization
    ///
    /// Every value comes from the shared calculation DTO; a value the calculation
    /// could not derive prints as `-`, never as a zero. Findings go to stderr.
    Calculate {
        /// Scope dimension: `cycle`, `team` or `assignee`
        #[arg(long, value_name = "SCOPE")]
        scope: String,
        /// The scope's reference (a cycle name or stable id, a team label, an identity)
        #[arg(long, value_name = "ID")]
        id: String,
        /// Also render the overflow report (estimate and reference findings always render)
        #[arg(long)]
        warnings: bool,
    },
    /// Read back a closed cycle's immutable capacity evidence
    Snapshot {
        /// Cycle name or stable `<schedule-id>:<sequence>` id
        #[arg(value_name = "CYCLE")]
        cycle: String,
        /// Also render the overflow finding the close recorded
        #[arg(long)]
        warnings: bool,
    },
}

/// The `tix time` subcommands (mission `per-issue-time-tracking-01M3C9A4`, WP04).
///
/// A thin adapter: the entry model and its validation, the mutation service and its
/// append-only History, and every filter, total and finding live under
/// `crate::time_tracking`. This enum owns only the spelling of the surface — the
/// duration is whole minutes, a date is a calendar `YYYY-MM-DD`, and there is no flag
/// here that could express a timer, a timezone, an overlap rule or a billing unit
/// (C-002/C-009).
#[derive(Debug, Clone, Subcommand)]
pub enum TimeCommand {
    /// Record effort against an issue, in whole minutes
    ///
    /// The date is optional and is a calendar date, never an instant: an entry without
    /// one is real, and a period-bounded read reports it as undated rather than as zero.
    Add {
        /// Issue number, filename fragment or unique title fragment
        #[arg(value_name = "ISSUE")]
        issue: String,
        /// The effort, in whole minutes at or above zero
        ///
        /// A negative value is accepted by the parser and refused by the shared entry
        /// validator with its field name, which is a better message than clap's
        /// "unexpected argument".
        #[arg(long, value_name = "N", allow_hyphen_values = true)]
        minutes: i64,
        /// The calendar date the effort belongs to, `YYYY-MM-DD`
        #[arg(long, value_name = "YYYY-MM-DD")]
        date: Option<String>,
        /// Who did the work: a handle (`ada`, `@ada`) or a `Name <email>` identity
        #[arg(long, value_name = "IDENTITY")]
        author: String,
        /// Free-text context, preserved verbatim
        #[arg(long, value_name = "TEXT")]
        note: Option<String>,
    },
    /// Correct one entry, naming only the fields that change
    ///
    /// A field the command does not name is left exactly as it was, including the entry's
    /// date, note and any metadata another build attached. `--clear-date` and
    /// `--clear-note` are how an optional value is removed (an empty `--note ""` would be
    /// a value, not an absence). A correction requires `--reason`: the prior value stays
    /// in History, and the reason is what makes the change attributable.
    Edit {
        /// Issue number, filename fragment or unique title fragment
        #[arg(value_name = "ISSUE")]
        issue: String,
        /// The stable id of the entry to correct (`tix time show <ISSUE>` lists them)
        #[arg(long, value_name = "ID")]
        entry: String,
        #[command(flatten)]
        change: TimeEditArgs,
    },
    /// Remove one entry from the issue's current entries
    ///
    /// The entry leaves the frontmatter; its value and its event stay in History, so a
    /// deletion is auditable rather than a rewrite. Requires `--reason` and `--confirm`.
    Delete {
        /// Issue number, filename fragment or unique title fragment
        #[arg(value_name = "ISSUE")]
        issue: String,
        /// The stable id of the entry to remove
        #[arg(long, value_name = "ID")]
        entry: String,
        /// Why the entry is being removed; recorded in History
        #[arg(long, value_name = "TEXT")]
        reason: Option<String>,
        /// Confirm the removal without prompting
        #[arg(long)]
        confirm: bool,
    },
    /// Show an issue's current entries and its total
    Show {
        /// Issue number, filename fragment or unique title fragment
        #[arg(value_name = "ISSUE")]
        issue: String,
    },
    /// Show what History retains for an issue: current entries beside its audit
    History {
        /// Issue number, filename fragment or unique title fragment
        #[arg(value_name = "ISSUE")]
        issue: String,
    },
    /// Total recorded effort, by issue, assignee and calendar period
    ///
    /// Every value comes from the shared calculation; a scope with nothing to total and a
    /// scope whose entries carry no usable duration print `-` with their state, never a
    /// zero. Deleted entries are excluded unless `--audit` asks for them.
    Total {
        /// Issue number, filename fragment or unique title fragment (alternative to `--issue`)
        #[arg(value_name = "ISSUE")]
        selector: Option<String>,
        #[command(flatten)]
        filter: TimeTotalArgs,
    },
}

/// The fields an entry correction can name (T020).
///
/// A patch, not a replacement: a flag that is absent leaves its field alone, which is why
/// removal is spelled `--clear-date`/`--clear-note` rather than an empty value. The reason
/// is required — by the service, which refuses a missing one by name — and is what the
/// History event keeps once the prior value is replaced.
#[derive(Debug, Clone, Args)]
pub struct TimeEditArgs {
    /// Replacement duration, in whole minutes
    #[arg(long, value_name = "N", allow_hyphen_values = true)]
    pub minutes: Option<i64>,
    /// Replacement calendar date, `YYYY-MM-DD`
    #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "clear_date")]
    pub date: Option<String>,
    /// Remove the entry's date
    #[arg(long)]
    pub clear_date: bool,
    /// Replacement note
    #[arg(long, value_name = "TEXT", conflicts_with = "clear_note")]
    pub note: Option<String>,
    /// Remove the entry's note
    #[arg(long)]
    pub clear_note: bool,
    /// Replacement author identity
    #[arg(long, value_name = "IDENTITY")]
    pub author: Option<String>,
    /// Why the entry is being corrected; recorded in History, and required
    #[arg(long, value_name = "TEXT")]
    pub reason: Option<String>,
}

/// The read-only scope a `tix time total` is evaluated under (T021).
///
/// Every criterion is optional and every named one must match, and the period is
/// inclusive. The dates are the caller's: this surface never resolves a `today`, so a
/// total means exactly the calendar window it was asked for.
#[derive(Debug, Clone, Args)]
pub struct TimeTotalArgs {
    /// Stable issue number to scope the total to
    #[arg(long, value_name = "N")]
    pub issue: Option<u32>,
    /// The identity that did the work (an entry's author)
    #[arg(long, value_name = "IDENTITY")]
    pub assignee: Option<String>,
    /// Inclusive period start, `YYYY-MM-DD`
    #[arg(long, value_name = "YYYY-MM-DD")]
    pub from: Option<String>,
    /// Inclusive period end, `YYYY-MM-DD`
    #[arg(long, value_name = "YYYY-MM-DD")]
    pub to: Option<String>,
    /// Also report the entries deletions left in History (never added to the total)
    #[arg(long)]
    pub audit: bool,
    /// Break the total down: `issue`, `assignee` or `date`
    #[arg(long, value_name = "DIMENSION")]
    pub by: Option<String>,
}

#[derive(Subcommand)]
pub enum MilestoneCommand {
    Create {
        #[arg(long)]
        name: String,
        #[arg(long = "target-date")]
        target_date: Option<String>,
        #[arg(long)]
        description: Option<String>,
    },
    List,
    Edit {
        #[arg(long)]
        name: String,
        #[arg(long = "new-name")]
        new_name: Option<String>,
        #[arg(long = "target-date")]
        target_date: Option<String>,
        #[arg(long)]
        description: Option<String>,
    },
    Delete {
        #[arg(long)]
        name: String,
        #[arg(long)]
        yes: bool,
    },
}

/// The `tix project` subcommands (mission `first-class-projects-01M3BYBW`, WP05).
///
/// A **required** subcommand, like `tix milestone` and `tix cycle`: `tix project`
/// alone is a usage error, not a listing, because guessing which of ten verbs the
/// user meant is worse than asking.
///
/// Selection is by the stable id (the registry key); a name is accepted as a
/// convenience and resolves to the same project, since `tix.toml` guarantees the
/// names are unique.
#[derive(Subcommand)]
pub enum ProjectCommand {
    /// Create a project and allocate a stable id
    ///
    /// The name is a display label, not the identity: it can be changed later with
    /// `tix project edit --name`, and every issue association survives because the
    /// issues reference the id.
    Create {
        /// Display name (unique within the registry, case-insensitively)
        #[arg(value_name = "NAME")]
        name: String,
        /// Long-form context
        #[arg(long)]
        description: Option<String>,
        /// Free-form project status (not a board column)
        #[arg(long)]
        status: Option<String>,
        /// Lead identity string
        #[arg(long)]
        lead: Option<String>,
        /// Start date (`YYYY-MM-DD`)
        #[arg(long = "start-date")]
        start_date: Option<String>,
        /// Due date (`YYYY-MM-DD`); must be `>= start-date`
        #[arg(long = "due-date")]
        due_date: Option<String>,
    },
    /// Edit a project's fields; a rename changes the label only
    ///
    /// Nothing is re-keyed: the id stays, so every association and every derived
    /// figure is untouched. An optional text flag with a blank value clears it
    /// (`--lead ""`), the same convention `tix edit` uses.
    Edit {
        /// Project id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// New display name
        #[arg(long)]
        name: Option<String>,
        /// New long-form context (`--description ""` clears)
        #[arg(long)]
        description: Option<String>,
        /// New free-form status (`--status ""` clears)
        #[arg(long)]
        status: Option<String>,
        /// New lead identity string (`--lead ""` clears)
        #[arg(long)]
        lead: Option<String>,
        /// New start date (`YYYY-MM-DD`; `--start-date ""` clears)
        #[arg(long = "start-date")]
        start_date: Option<String>,
        /// New due date (`YYYY-MM-DD`; `--due-date ""` clears)
        #[arg(long = "due-date")]
        due_date: Option<String>,
    },
    /// List projects in declaration order, with ids and member counts
    List,
    /// Show one project's record and its members
    Show {
        /// Project id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
    },
    /// Delete a project's registry entry (its issues are never deleted)
    ///
    /// The issues that referenced the project keep their references; the report
    /// names them so the dangling references can be repaired deliberately.
    Delete {
        /// Project id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Confirm without prompting
        #[arg(long)]
        yes: bool,
    },
    /// Associate one or more issues with a project
    ///
    /// Each selector is processed independently: a rejection on one is reported
    /// and does not abort the rest.
    Add {
        /// Project id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
    /// Dissociate one or more issues from a project
    ///
    /// Drops only the association; no issue is deleted, and each selector is
    /// processed independently.
    ///
    /// A reference that does not name a live project is used **verbatim**, because
    /// removing a retained stale id is the repair path a project deletion leaves
    /// open (C-005). A typo that matches nothing is therefore an inert no-op that
    /// reports `0 changed`, not a refusal — which is the one place this differs
    /// from `tix edit <issue> --remove-project <id>`, where an unknown reference the
    /// issue does not carry is refused as `unknown_project`.
    Remove {
        /// Project id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
    /// Bulk-associate one or more issues with a project
    ///
    /// Project association only — general bulk editing is a different feature.
    /// Every selector is processed independently and every outcome is reported,
    /// so one unknown selector does not discard the associations that succeeded.
    ///
    /// An unknown **project** reference refuses the whole command before anything
    /// is written (all entries then fail with `unknown_project`); use
    /// `tix project remove` to drop a retained stale id, whose reference is matched
    /// verbatim and reported as `0 changed` rather than refused.
    BulkAdd {
        /// Project id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
}

/// The `tix epic` subcommands (mission `epics-work-items-01M3BYW4`, WP05).
///
/// A **required** subcommand, like `tix project`. Selection is by the stable id
/// (the registry key); a name resolves to the same epic as a convenience. A work
/// item is an ordinary issue (`tix create --epics`, `tix edit --add-epic`), so
/// there is no task verb here.
#[derive(Subcommand)]
pub enum EpicCommand {
    /// Create an epic and allocate a stable id
    ///
    /// The name is a display label, not the identity: `tix epic edit --name`
    /// changes it and every issue reference survives, because issues reference the id.
    Create {
        /// Display name (unique within the registry, case-insensitively)
        #[arg(value_name = "NAME")]
        name: String,
        /// Long-form context
        #[arg(long)]
        description: Option<String>,
        /// Free-form epic status (not a board column, not a category)
        #[arg(long)]
        status: Option<String>,
        /// Owner identity string
        #[arg(long)]
        owner: Option<String>,
        /// Start date (`YYYY-MM-DD`)
        #[arg(long = "start-date")]
        start_date: Option<String>,
        /// Due date (`YYYY-MM-DD`); must be `>= start-date`
        #[arg(long = "due-date")]
        due_date: Option<String>,
        /// Positive relative size, used by the epic rollup
        ///
        /// `allow_hyphen_values` so `--weight -5` reaches the service's
        /// `invalid_weight` refusal rather than being swallowed by clap as an
        /// unknown flag — the same invalid input must report one thing whichever
        /// spelling is used.
        #[arg(long, allow_hyphen_values = true)]
        weight: Option<String>,
        /// Project ids this epic concerns, comma-separated
        ///
        /// Shape-checked only, never resolved: a well-formed id the registry does
        /// not hold is stored and diagnosed by `tix lint`, so it is repairable.
        #[arg(long, value_name = "ID")]
        projects: Option<String>,
        /// Milestone name; stored verbatim, never resolved
        #[arg(long)]
        milestone: Option<String>,
        /// Cycle name; stored verbatim, never resolved
        #[arg(long)]
        cycle: Option<String>,
    },
    /// Edit an epic's fields; a rename changes the label only
    ///
    /// Nothing is re-keyed, so every issue reference and derived figure stays
    /// intact. A blank optional value clears the field (`--owner ""`).
    Edit {
        /// Epic id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// New display name
        #[arg(long)]
        name: Option<String>,
        /// New long-form context (`--description ""` clears)
        #[arg(long)]
        description: Option<String>,
        /// New free-form status (`--status ""` clears)
        #[arg(long)]
        status: Option<String>,
        /// New owner identity string (`--owner ""` clears)
        #[arg(long)]
        owner: Option<String>,
        /// New start date (`--start-date ""` clears)
        #[arg(long = "start-date")]
        start_date: Option<String>,
        /// New due date (`--due-date ""` clears)
        #[arg(long = "due-date")]
        due_date: Option<String>,
        /// New positive relative size (`--weight ""` clears)
        ///
        /// `allow_hyphen_values` so `--weight -5` reaches the service's
        /// `invalid_weight` refusal rather than clap's unknown-flag error.
        #[arg(long, allow_hyphen_values = true)]
        weight: Option<String>,
        /// Set or change the parent epic; `--parent ""` detaches it
        ///
        /// Validated by the epic service before any write, so a self-parent or a
        /// cycle-closing change leaves the registry byte-identical.
        #[arg(long, value_name = "ID")]
        parent: Option<String>,
        /// Replace the whole project list, comma-separated (`--projects ""` clears)
        ///
        /// Shape-checked only, never resolved: a dangling but well-formed id is
        /// stored and diagnosed by `tix lint`.
        #[arg(long, value_name = "ID")]
        projects: Option<String>,
        /// New milestone name (`--milestone ""` clears); stored verbatim
        #[arg(long)]
        milestone: Option<String>,
        /// New cycle name (`--cycle ""` clears); stored verbatim
        #[arg(long)]
        cycle: Option<String>,
    },
    /// List epics in declaration order, with ids, member and child counts
    List,
    /// Show one epic's record, hierarchy, progress and members
    Show {
        /// Epic id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
    },
    /// Navigate an epic's hierarchy: parent, children, ancestors and depth
    ///
    /// Direct issue navigation stays available: the member list is included and a
    /// member is an ordinary issue, reachable with `tix show <number>`.
    Navigate {
        /// Epic id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
    },
    /// Delete an epic's registry entry (its issues and child epics are never deleted)
    ///
    /// The issues that referenced the epic keep their references and the child
    /// epics keep their parent reference; both are reported so the dangling
    /// references can be repaired deliberately.
    Delete {
        /// Epic id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Confirm without prompting
        #[arg(long)]
        yes: bool,
    },
    /// Reference an epic from one or more issues
    ///
    /// Each selector is processed independently: a rejection on one is reported
    /// and does not abort the rest.
    Add {
        /// Epic id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
    /// Remove an epic reference from one or more issues
    ///
    /// Drops only the reference; no issue is deleted. A reference that does not
    /// name a live epic is used **verbatim**, so a retained stale id can still be
    /// removed and is reported as `0 changed` rather than refused.
    Remove {
        /// Epic id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
    /// Bulk-reference one or more issues from an epic
    ///
    /// Epic reference only — general bulk editing is a different feature. Every
    /// selector is independent, so one unknown selector does not discard the rest.
    /// An unknown **epic** reference refuses the whole command before anything is
    /// written; use `tix epic remove` to drop a retained stale id.
    BulkAdd {
        /// Epic id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Issue numbers, filename fragments or unique title fragments
        #[arg(required = true, value_name = "ISSUE")]
        issues: Vec<String>,
    },
}

/// The `tix initiative` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
///
/// A **required** subcommand, like `tix project` and `tix epic`. Selection is by the
/// stable id (the registry key); a name resolves to the same initiative as a
/// convenience. A rename through `edit --name` changes the label only: the id is not
/// re-keyed, so every association and derived value survives.
#[derive(Subcommand)]
pub enum InitiativeCommand {
    /// Create an initiative and allocate a stable id
    ///
    /// The name is a display label, not the identity: `tix initiative edit --name`
    /// changes it and every association survives, because the record is keyed by id.
    Create {
        /// Display name (unique within the registry, case-insensitively)
        #[arg(value_name = "NAME")]
        name: String,
        /// Optional strategic outcome
        #[arg(long)]
        outcome: Option<String>,
        /// Owner identity string; no lookup, never inferred
        #[arg(long)]
        owner: Option<String>,
        /// Free-form initiative status (not a board column)
        #[arg(long)]
        status: Option<String>,
        /// Initiative priority (a known spelling is normalized; an unknown one is kept)
        #[arg(long)]
        priority: Option<String>,
        /// Filterable labels, comma-separated
        #[arg(long)]
        labels: Option<String>,
        /// Target date (`YYYY-MM-DD`)
        #[arg(long = "target-date")]
        target_date: Option<String>,
        /// Project ids this initiative concerns, comma-separated
        ///
        /// Shape-checked only, never resolved: a well-formed id the registry does not
        /// hold is stored and diagnosed by `tix roadmap`/`tix lint`, so it is
        /// repairable.
        #[arg(long, value_name = "ID")]
        projects: Option<String>,
        /// Epic ids this initiative concerns, comma-separated
        #[arg(long, value_name = "ID")]
        epics: Option<String>,
        /// Milestone names this initiative concerns, comma-separated
        #[arg(long, value_name = "NAME")]
        milestones: Option<String>,
    },
    /// Edit an initiative's fields; a rename changes the label only
    ///
    /// Nothing is re-keyed, so every association and derived value stays intact. An
    /// optional value with a blank spelling clears the field (`--owner ""`), and a
    /// blank reference list clears it (`--projects ""`).
    Edit {
        /// Initiative id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// New display name
        #[arg(long)]
        name: Option<String>,
        /// New strategic outcome (`--outcome ""` clears)
        #[arg(long)]
        outcome: Option<String>,
        /// New owner identity string (`--owner ""` clears)
        #[arg(long)]
        owner: Option<String>,
        /// New free-form status (`--status ""` clears)
        #[arg(long)]
        status: Option<String>,
        /// New priority (`--priority ""` clears)
        #[arg(long)]
        priority: Option<String>,
        /// Replace the label list, comma-separated (`--labels ""` clears)
        #[arg(long)]
        labels: Option<String>,
        /// New target date (`YYYY-MM-DD`; `--target-date ""` clears)
        #[arg(long = "target-date")]
        target_date: Option<String>,
        /// Replace the project ids, comma-separated (`--projects ""` clears)
        #[arg(long, value_name = "ID")]
        projects: Option<String>,
        /// Replace the epic ids, comma-separated (`--epics ""` clears)
        #[arg(long, value_name = "ID")]
        epics: Option<String>,
        /// Replace the milestone names, comma-separated (`--milestones ""` clears)
        #[arg(long, value_name = "NAME")]
        milestones: Option<String>,
    },
    /// List initiatives in declaration order, with ids, target dates and status
    List,
    /// Show one initiative's record, metadata and references
    Show {
        /// Initiative id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
    },
    /// Delete an initiative's registry entry (nothing else is ever deleted)
    ///
    /// The record's own references are reported and nothing cascades: no project,
    /// epic, milestone or issue is touched, and no association is silently repaired.
    Delete {
        /// Initiative id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Confirm without prompting
        #[arg(long)]
        yes: bool,
    },
    /// Associate an initiative with exactly one project, epic or milestone
    ///
    /// Name exactly one of `--project`, `--epic`, `--milestone`; the kind is part of
    /// the record, so guessing is worse than asking. A well-formed reference that
    /// names nothing is stored and stays diagnosable, and a repeated association is
    /// an idempotent no-op that writes no bytes.
    Associate {
        /// Initiative id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Project **id** to associate
        #[arg(long, value_name = "ID")]
        project: Option<String>,
        /// Epic **id** to associate
        #[arg(long, value_name = "ID")]
        epic: Option<String>,
        /// Milestone **name** to associate
        #[arg(long, value_name = "NAME")]
        milestone: Option<String>,
    },
    /// Drop an explicit association from an initiative
    ///
    /// Name exactly one of `--project`, `--epic`, `--milestone`. Dropping an
    /// association that is not recorded is an idempotent no-op that writes no bytes.
    Dereference {
        /// Initiative id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
        /// Project **id** to drop
        #[arg(long, value_name = "ID")]
        project: Option<String>,
        /// Epic **id** to drop
        #[arg(long, value_name = "ID")]
        epic: Option<String>,
        /// Milestone **name** to drop
        #[arg(long, value_name = "NAME")]
        milestone: Option<String>,
    },
}

/// The `tix roadmap` subcommands (mission `roadmap-initiatives-01M3C8SX`, WP04).
///
/// Read-only. `list` applies the one normalized [`crate::roadmap::filter::RoadmapFilter`];
/// `show` prints the single-row detail in the same rendering.
#[derive(Subcommand)]
pub enum RoadmapCommand {
    /// List initiatives in roadmap order: target date ascending, then stable id
    ///
    /// An undated initiative sorts after every dated one and renders the explicit
    /// `undated` state — never a blank or an invented date. A bounded period filter
    /// excludes it, because inventing a period is exactly what the contract forbids.
    List {
        /// Inclusive lower bound on the target date (`YYYY-MM-DD`)
        #[arg(long, value_name = "DATE")]
        from: Option<String>,
        /// Inclusive upper bound on the target date (`YYYY-MM-DD`)
        #[arg(long, value_name = "DATE")]
        to: Option<String>,
        /// Owner to match, compared trimmed and case-insensitively
        #[arg(long, value_name = "ID")]
        owner: Option<String>,
        /// Initiative status to match, compared trimmed and case-insensitively
        #[arg(long, value_name = "STATE")]
        status: Option<String>,
        /// A label the initiative must carry; repeat for an AND over labels
        #[arg(long = "label", value_name = "LABEL")]
        labels: Vec<String>,
        /// Free text matched case-insensitively against the searchable fields
        #[arg(long, value_name = "TEXT")]
        query: Option<String>,
    },
    /// Show one initiative's roadmap row: summary contributions and diagnostics
    Show {
        /// Initiative id (a name is accepted as a convenience)
        #[arg(value_name = "ID")]
        reference: String,
    },
}

/// The `tix parent` subcommands.
///
/// A **required** subcommand, unlike `tix link`'s three optional verbs. `tix link`
/// has a meaningful bare form (create); `tix parent` with no argument is a user
/// mistake, and guessing which of three reports they wanted is worse than asking.
#[derive(Debug, Clone, Subcommand)]
pub enum ParentAction {
    /// Report the parent, direct children, child count and rollup for one issue
    Show {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
    },
    /// List an issue's direct children, ordered by issue number
    Children {
        /// Issue number, filename fragment or unique title fragment
        issue: String,
    },
    /// List every issue that has children, with a child count
    List,
}

/// The `tix workflow` verbs, per `contracts/cli-surface.md` §1.
///
/// A real `#[command(subcommand)]` tree rather than an optional-verb enum like
/// [`LinkAction`]: the three verbs share nothing but the prefix, and there is no
/// sensible bare `tix workflow`. Making the verb **required** means `tix workflow`
/// alone is a usage error rather than an empty success — printing the vocabulary
/// when the user asked for the policy would be guessing at their intent.
#[derive(Subcommand)]
pub enum WorkflowAction {
    /// The global category vocabulary, in order
    Categories,
    /// A board's columns, resolved categories, and where each resolution came from
    Show {
        /// Board name; omitted means the active board (`default`, or the first
        /// declared). A name that is not configured is an error listing the boards
        /// that are.
        board: Option<String>,
    },
    /// The effective transition policy and its override escape hatch
    Transitions,
}

/// `tix layout` subcommands.
#[derive(Subcommand)]
pub enum LayoutAction {
    /// Print the effective `[layout]` template and a worked example
    Show,
    /// Relocate every issue to the configured layout (dry run unless `--apply`)
    Migrate {
        /// Write the moves; without it the command only prints the plan
        #[arg(long)]
        apply: bool,
    },
}

/// The `tix link` subcommands.
///
/// `tix link <from> <to> --type <kind>` is the create form and carries no
/// subcommand, so the three verbs below are optional rather than required
/// (C-001 removes the old untyped symmetric form, not the bare create form the
/// CLI surface contract specifies).
#[derive(Subcommand)]
pub enum LinkAction {
    /// Retarget and/or re-kind an existing relationship, keeping its id
    Edit {
        /// Relationship id, as printed by `tix link list`
        id: String,
        /// New target issue number, filename fragment or unique title fragment
        #[arg(long)]
        to: Option<String>,
        /// New relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
        #[arg(long, value_name = "KIND")]
        r#type: Option<String>,
    },
    /// Remove a relationship by id. An absent id is an idempotent no-op
    Remove {
        /// Relationship id, as printed by `tix link list`
        id: String,
    },
    /// List relationships for one issue, or for the whole board
    List {
        /// Issue number, filename fragment or unique title fragment. Omit for every issue.
        issue: Option<String>,
        /// Only relationships of this kind
        #[arg(long, value_name = "KIND")]
        kind: Option<String>,
    },
}

/// What `tix recurring` does.
///
/// Every read-only subcommand resolves its date explicitly (`--date`), and every
/// mutating one requires the confirmation flag it documents. The subcommands are a
/// thin adapter: they parse, call one service, and print.
#[derive(Debug, Clone, Subcommand)]
pub enum RecurringAction {
    /// List every series, one tab-separated row per series
    ///
    /// Columns: `id`, `name`, `state`, `next_cursor`, `skipped`.
    List,
    /// Show one series: identity, rule, state, cursor, skipped dates, occurrences
    Show {
        /// Stable series id, or its unique display name
        series: String,
    },
    /// Create a series: the issue that *is* the definition
    ///
    /// The issue's own fields are the template an occurrence is created from, so
    /// `--title`, `--labels` and the rest are also what each occurrence inherits.
    /// The rule is validated **before** the issue is written.
    Create(RecurringCreateArgs),
    /// Change a series' name and/or rule, keeping its stable id
    ///
    /// Only the flags you name change; the rest of the rule is carried over, so
    /// `tix recurring edit <id> --name X` is a rename and nothing else.
    Edit {
        /// Stable series id, or its unique display name
        series: String,
        #[command(flatten)]
        args: RecurringEditArgs,
    },
    /// Report what is due, read-only, for an explicit date
    Detect {
        /// Evaluate against this `YYYY-MM-DD` date instead of today
        #[arg(long, value_name = "YYYY-MM-DD")]
        date: Option<String>,
        /// Timezone policy to carry on the context, e.g. `+02:00`
        #[arg(long, value_name = "TZ")]
        timezone: Option<String>,
    },
    /// Create the occurrence for one due date; requires `--yes`
    ///
    /// Idempotent: the occurrence key is checked first, so a repeated call is a
    /// no-op and never a second issue.
    Generate {
        /// Stable series id, or its unique display name
        series: String,
        /// The occurrence date, `YYYY-MM-DD`
        #[arg(long, value_name = "YYYY-MM-DD")]
        date: String,
        #[command(flatten)]
        confirmation: RecurringLifecycleArgs,
    },
    /// Suspend generation until an explicit resume
    Pause {
        /// Stable series id, or its unique display name
        series: String,
        #[command(flatten)]
        confirmation: RecurringLifecycleArgs,
    },
    /// Resume a paused series; never inferred from a refresh
    Resume {
        /// Stable series id, or its unique display name
        series: String,
        #[command(flatten)]
        confirmation: RecurringLifecycleArgs,
    },
    /// End a series: terminal for generation
    End {
        /// Stable series id, or its unique display name
        series: String,
        #[command(flatten)]
        confirmation: RecurringLifecycleArgs,
    },
    /// Record one occurrence date as skipped, so it is never generated
    Skip {
        /// Stable series id, or its unique display name
        series: String,
        /// The occurrence date, `YYYY-MM-DD`
        #[arg(long, value_name = "YYYY-MM-DD")]
        date: String,
        #[command(flatten)]
        confirmation: RecurringLifecycleArgs,
    },
    /// Move the candidate past a date without recording it as skipped
    Defer {
        /// Stable series id, or its unique display name
        series: String,
        /// The occurrence date, `YYYY-MM-DD`
        #[arg(long, value_name = "YYYY-MM-DD")]
        date: String,
        #[command(flatten)]
        confirmation: RecurringLifecycleArgs,
    },
    /// Stop offering one occurrence date: recorded, like `skip`
    Ignore {
        /// Stable series id, or its unique display name
        series: String,
        /// The occurrence date, `YYYY-MM-DD`
        #[arg(long, value_name = "YYYY-MM-DD")]
        date: String,
        #[command(flatten)]
        confirmation: RecurringLifecycleArgs,
    },
}

/// `tix wip`'s subcommands (mission `configurable-wip-limits-01M3C6WX`, WP04).
///
/// The CLI parses and routes; the policy service owns every evaluation, and the
/// schema's own validation decides whether a configuration is acceptable. Nothing
/// here decides what a limit *means*.
#[derive(Debug, Clone, Subcommand)]
pub enum WipAction {
    /// Show the configured limits, their units, and the current usage
    Show {
        /// Board name; default is the active board
        #[arg(long, value_name = "BOARD")]
        board: Option<String>,
    },
    /// Set or update one column's limit
    ///
    /// Only the flags you name change, exactly as `tix recurring edit` works, so
    /// `tix wip set "in progress" --max-items 4` keeps the column's estimate
    /// ceiling, unit and policy.
    Set {
        /// Column name, as `[kanban.<board>.columns]` spells it
        column: String,
        /// The most cards the column may hold
        #[arg(long, value_name = "N")]
        max_items: Option<u32>,
        /// The most estimate the column's cards may add up to
        #[arg(long, value_name = "N")]
        max_estimate: Option<u32>,
        /// The unit `--max-estimate` counts in; it must be declared with `tix wip unit`
        #[arg(long, value_name = "UNIT")]
        unit: Option<String>,
        /// What exceeding the limit does: `warn` or `block`
        #[arg(long, value_name = "warn|block")]
        policy: Option<String>,
        /// Keep the numbers but enforce nothing
        #[arg(long)]
        disabled: bool,
        /// Board name; default is the active board
        #[arg(long, value_name = "BOARD")]
        board: Option<String>,
    },
    /// Remove one column's limit
    Unset {
        /// Column name
        column: String,
        /// Board name; default is the active board
        #[arg(long, value_name = "BOARD")]
        board: Option<String>,
    },
    /// Declare an estimate unit, or remove one
    ///
    /// A unit is a name and its factor to the board's base unit: with `d = 1` and
    /// `w = 5`, an issue estimated `1w` counts as `5d`. A unit the board does not
    /// declare is a `missing_conversion` refusal wherever it is used, never a zero.
    Unit {
        /// Unit name, e.g. `d`
        name: String,
        /// The factor to the board's base unit (at least 1)
        factor: Option<u32>,
        /// Remove the unit instead of declaring it
        #[arg(long)]
        remove: bool,
        /// Board name; default is the active board
        #[arg(long, value_name = "BOARD")]
        board: Option<String>,
    },
}

/// The rule flags `tix recurring create` and `edit` share.
#[derive(Debug, Clone, Args)]
pub struct RecurringRuleArgs {
    /// Family: `daily`, `weekly`, `monthly`, `interval`, `cron-like`
    #[arg(long, value_name = "KIND")]
    pub rule: Option<String>,
    /// Schedule origin, `YYYY-MM-DD` (required by every family)
    #[arg(long, value_name = "YYYY-MM-DD")]
    pub anchor: Option<String>,
    /// Cadence, where the family takes one (1-1000)
    #[arg(long, value_name = "N")]
    pub interval: Option<u32>,
    /// What an `interval` rule counts in: `days`, `weeks`, `months`
    #[arg(long, value_name = "UNIT")]
    pub unit: Option<String>,
    /// Cron-like expression: `@daily`, `@weekly`, `@monthly`
    #[arg(long, value_name = "EXPR")]
    pub expression: Option<String>,
    /// Timezone policy, e.g. `+02:00`
    #[arg(long, value_name = "TZ")]
    pub timezone: Option<String>,
}

impl RecurringRuleArgs {
    /// Whether the caller named any rule field at all.
    pub const fn is_any(&self) -> bool {
        self.rule.is_some()
            || self.anchor.is_some()
            || self.interval.is_some()
            || self.unit.is_some()
            || self.expression.is_some()
            || self.timezone.is_some()
    }
}

/// The confirmation every mutating `tix recurring` subcommand takes.
#[derive(Debug, Clone, Args)]
pub struct RecurringLifecycleArgs {
    /// Confirm the action without prompting
    #[arg(long)]
    pub yes: bool,
    /// Why; recorded in the series' History
    #[arg(long, value_name = "TEXT")]
    pub reason: Option<String>,
}

/// The fields of the definition issue plus its rule.
#[derive(Debug, Clone, Args)]
pub struct RecurringCreateArgs {
    /// Title of the series issue (and the default series name)
    #[arg(long, value_name = "TITLE")]
    pub title: String,
    /// Body an occurrence inherits
    #[arg(long, value_name = "BODY")]
    pub body: Option<String>,
    /// Column the definition (and its occurrences) starts in
    #[arg(long, value_name = "STATUS")]
    pub status: Option<String>,
    /// Priority: `low`, `medium`, `high`
    #[arg(long, value_name = "PRIORITY")]
    pub priority: Option<String>,
    /// Labels, comma-separated
    #[arg(long, value_name = "LABELS")]
    pub labels: Option<String>,
    /// Assignee (`@name`)
    #[arg(long, value_name = "ASSIGNEE")]
    pub assignee: Option<String>,
    /// Display name; defaults to the title
    #[arg(long, value_name = "NAME")]
    pub name: Option<String>,
    /// Stable series id; generated when omitted
    #[arg(long, value_name = "ID")]
    pub id: Option<String>,
    #[command(flatten)]
    pub rule: RecurringRuleArgs,
}

/// What `tix recurring edit` may change.
#[derive(Debug, Clone, Args)]
pub struct RecurringEditArgs {
    /// New display name; omitted means keep the current one
    #[arg(long, value_name = "NAME")]
    pub name: Option<String>,
    #[command(flatten)]
    pub rule: RecurringRuleArgs,
}

/// The `tix view` saved-view subcommands (#172; mission
/// `saved-issue-views-01M3C926`, WP04).
///
/// A **real** subcommand tree, not an optional-verb enum like [`LinkAction`]:
/// there is no meaningful bare `tix view` (bare `tix view` would be the
/// issue-printer, which takes an issue selector), so every saved-view operation
/// names its verb. The nine verbs are the surface contract's spelling
/// (`contracts/cli-surface.md`).
///
/// The plan-shaping flags are [`ViewPlanArgs`] and the scope flags are
/// [`ViewScopeArgs`], flattened into the verbs that take them so `create`,
/// `update`, `share`, and `status` cannot drift apart.
#[derive(Subcommand)]
pub enum ViewAction {
    /// List saved views, in declaration order, as tab-separated rows
    List {
        /// Only views in this scope family: `repository` | `project` | `user`
        #[arg(long, value_name = "repository|project|user")]
        scope: Option<String>,
    },
    /// Show one saved view: stable id, name, scope, normalized plan, diagnostics
    Show {
        /// Stable view id, or its display name
        reference: String,
    },
    /// Save a new view from the existing query grammar
    ///
    /// The id is **generated** and stable (spec §3: "use stable generated IDs;
    /// names are display labels"); it is printed on the first stdout line so a
    /// script can capture it.
    Create {
        /// Display label
        #[arg(long, value_name = "NAME")]
        name: String,
        #[command(flatten)]
        scope: ViewScopeArgs,
        #[command(flatten)]
        plan: ViewPlanArgs,
    },
    /// Replace an existing view's definition, keeping its stable id
    ///
    /// Only the fields you name change; the rest are carried over from the
    /// stored definition, so `tix view update <id> --name X` is a rename and
    /// nothing else (identity survives a rename, C-002).
    Update {
        /// Stable view id, or its display name
        reference: String,
        /// New display label; omitted means keep the current one
        #[arg(long, value_name = "NAME")]
        name: Option<String>,
        #[command(flatten)]
        plan: ViewPlanArgs,
    },
    /// Copy a view under a **new** stable id
    Duplicate {
        /// Stable view id, or its display name
        reference: String,
        /// Label for the copy; omitted means the source's label
        #[arg(long, value_name = "NAME")]
        name: Option<String>,
    },
    /// Remove a view **definition** only; requires `--confirm`
    ///
    /// Never deletes an issue, comment, or relationship.
    Delete {
        /// Stable view id, or its display name
        reference: String,
        /// Acknowledge that only the saved definition is removed
        #[arg(long)]
        confirm: bool,
    },
    /// Apply a saved view read-only: render its plan, result, and active marker
    Apply {
        /// Stable view id, or its display name
        reference: String,
    },
    /// Report the saved plan against a live plan, field by field
    ///
    /// Without an id it reports the (absent) active marker — never a silent
    /// "in sync". With an id, the live plan is assembled from the plan flags
    /// given here, in the existing grammar.
    Status {
        /// Stable view id, or its display name; omitted reports the active marker
        reference: Option<String>,
        #[command(flatten)]
        live: ViewPlanArgs,
    },
    /// Change a view's explicit sharing/visibility scope metadata
    ///
    /// Scope is metadata: `user` scope stores an owner/shared-with label and
    /// grants no permission and creates no account (C-003/C-009).
    Share {
        /// Stable view id, or its display name
        reference: String,
        #[command(flatten)]
        scope: ViewScopeArgs,
    },
}

/// The explicit scope flags shared by `create` and `share`.
///
/// One flatten per verb rather than two hand-maintained copies, so a new scope
/// field cannot be added to one and forgotten on the other.
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct ViewScopeArgs {
    /// Scope family: `repository` (the default) | `project` | `user`
    #[arg(long, value_name = "repository|project|user")]
    pub scope: Option<String>,
    /// Project id, for `--scope project`
    ///
    /// Resolved through the project registry; a project this repository does
    /// not know is refused by the registry, not by this flag.
    #[arg(long, value_name = "PROJECT")]
    pub project: Option<String>,
    /// Owner identity, for `--scope user` (metadata only — not an account)
    #[arg(long, value_name = "IDENTITY")]
    pub owner: Option<String>,
    /// Comma-separated shared-with identities, for `--scope user`
    #[arg(long, value_name = "LIST")]
    pub shared_with: Option<String>,
}

/// The plan-shaping flags shared by `create`, `update`, and `status`.
///
/// The grammar is the **existing** one: `--filter` is the token query
/// `tix list --filter` accepts, `--sort`/`--group` are that grammar's control
/// tokens, and the visibility pair mirrors the board's `hidden_columns` /
/// `show_mismatch`. Nothing here parses a second query language (C-004).
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct ViewPlanArgs {
    /// Existing token query (`status:todo`, `label:urgent`, `category:done`, …)
    #[arg(long, value_name = "QUERY")]
    pub filter: Option<String>,
    /// Sort key (`created`, `due`, `start`, `reviewer`, `cycle`, `milestone`,
    /// `status`, `priority`)
    #[arg(long, value_name = "KEY")]
    pub sort: Option<String>,
    /// Group key (`cycle` | `milestone`)
    #[arg(long, value_name = "KEY")]
    pub group: Option<String>,
    /// Board columns to hide when the view is applied (comma-separated)
    ///
    /// Giving this **or** `--show-mismatch` makes visibility an absolute
    /// snapshot; omitting both means the view inherits the live board's
    /// visibility.
    #[arg(long, value_name = "LIST")]
    pub hide_columns: Option<String>,
    /// Show the computed "Mismatched" column when applied (`true` | `false`)
    #[arg(long, value_name = "true|false", num_args = 1, action = clap::ArgAction::Set)]
    pub show_mismatch: Option<bool>,
}

impl ViewPlanArgs {
    /// Whether the caller named **any** plan field.
    ///
    /// The question `update` asks: no field named means "leave the stored plan
    /// alone", so a rename cannot silently wipe it.
    #[must_use]
    pub const fn is_any(&self) -> bool {
        self.filter.is_some()
            || self.sort.is_some()
            || self.group.is_some()
            || self.hide_columns.is_some()
            || self.show_mismatch.is_some()
    }
}

/// `tix bulk <action>` — four actions, and each carries **its own** flags.
///
/// The flags are on the subcommand rather than on the parent, which is what makes the
/// surface unambiguous by construction rather than by a runtime check:
/// `tix bulk archive --priority high` is a **clap usage error**, not a command that
/// accepts a field flag and ignores it. `edit` is the only action with field flags,
/// `link`/`unlink` are the only ones with `--target`, and `archive` takes neither — so
/// there is no spelling of `tix bulk` that a flag can apply to by accident.
///
/// `archive` has no `--type`: archiving is not a relationship, and offering a kind
/// would suggest it is one.
/// The flags every `tix bulk` action carries: which issues, and whether to write.
///
/// Flattened into **each** action rather than held on `Command::Bulk`, because clap
/// parses a parent's flags *before* its subcommand — so a parent would spell the
/// contract's `tix bulk edit --select <...>` as `tix bulk --select <...> edit`, which
/// is a different surface from the one the contract names. Four `#[command(flatten)]`
/// of one struct gives the contract's spelling and one vocabulary, so a new flag
/// cannot be added to one action and forgotten on the other three.
#[derive(Args, Debug, Clone, PartialEq, Eq)]
pub struct BulkSelection {
    /// Comma-separated issue selectors: number, filename fragment or unique title
    /// fragment
    #[arg(long, value_name = "LIST")]
    pub select: Option<String>,
    /// A `--filter` query, in the existing filter grammar
    #[arg(long, value_name = "QUERY")]
    pub filter: Option<String>,
    /// A `--search` query, in the existing search grammar
    #[arg(long, value_name = "QUERY")]
    pub search: Option<String>,
    /// Which issues are in play: `active`, `active+archived` or `archived`
    #[arg(long, value_name = "SCOPE")]
    pub scope: Option<String>,
    /// Write the batch. Omit for a preview, which is the default.
    #[arg(long)]
    pub apply: bool,
}

impl BulkAction {
    /// The shared selection flags, whichever action carries them.
    ///
    /// One accessor for four flattened fields, so the routing code reads the flags
    /// once and a new action cannot forget to pass them.
    pub fn selection(&self) -> &BulkSelection {
        match self {
            Self::Edit { selection, .. }
            | Self::Link { selection, .. }
            | Self::Unlink { selection, .. }
            | Self::Archive { selection } => selection,
        }
    }

    /// Whether `--apply` was given. A preview is the default, so this is a positive
    /// question rather than "is this not a preview".
    pub fn is_apply(&self) -> bool {
        self.selection().apply
    }
}

#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum BulkAction {
    /// Apply a typed field patch to every selected issue
    Edit {
        #[command(flatten)]
        selection: BulkSelection,
        /// Target column
        #[arg(long, value_name = "COLUMN")]
        status: Option<String>,
        /// `low`, `medium`, `high`, or `` for unset
        #[arg(long, value_name = "PRIORITY")]
        priority: Option<String>,
        /// Bare handle or full identity
        #[arg(long, value_name = "ASSIGNEE")]
        assignee: Option<String>,
        /// Comma-separated labels
        #[arg(long, value_name = "LABELS")]
        labels: Option<String>,
        /// `YYYY-MM-DD`
        #[arg(long, value_name = "DATE")]
        start_date: Option<String>,
        /// `YYYY-MM-DD`
        #[arg(long, value_name = "DATE")]
        due_date: Option<String>,
        /// Free-form estimate
        #[arg(long, value_name = "ESTIMATE")]
        estimate: Option<String>,
        /// Comma-separated cycle names
        #[arg(long, value_name = "CYCLES")]
        cycles: Option<String>,
        /// Comma-separated milestone names
        #[arg(long, value_name = "MILESTONES")]
        milestones: Option<String>,
        /// Empty a field. Takes one of the clearable field names.
        #[arg(long, value_name = "FIELD")]
        clear: Option<String>,
    },
    /// Link every selected issue to `--target`
    Link {
        #[command(flatten)]
        selection: BulkSelection,
        /// The other end of the relationship
        #[arg(long, value_name = "SELECTOR")]
        target: String,
        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
        #[arg(long, value_name = "KIND")]
        r#type: Option<String>,
    },
    /// Unlink every selected issue from `--target`
    Unlink {
        #[command(flatten)]
        selection: BulkSelection,
        /// The other end of the relationship
        #[arg(long, value_name = "SELECTOR")]
        target: String,
        /// Relationship kind: `blocks`, `blocked_by`, `duplicates`, `related`
        #[arg(long, value_name = "KIND")]
        r#type: Option<String>,
    },
    /// Ask the archive service to archive every selected issue
    Archive {
        #[command(flatten)]
        selection: BulkSelection,
    },
}

/// The `tix mentions` verbs.
///
/// `list` and `show` are read-only. `read` is the one mutating answer; `ignore` is
/// deliberately the **absence** of a write, which is what makes the mention show
/// again the next time the issue is opened.
#[derive(Subcommand)]
pub enum MentionsAction {
    /// List the mentions pending for you
    List,
    /// Print one mention in full
    Show {
        /// Mention id, as shown by `tix mentions list`
        id: String,
    },
    /// Mark a mention read, so it is never shown again
    Read {
        /// Mention id, as shown by `tix mentions list`
        id: String,
    },
    /// Leave a mention pending (writes nothing; it shows again later)
    Ignore {
        /// Mention id, as shown by `tix mentions list`
        id: String,
    },
    /// Print where a mention sits in its issue
    Jump {
        /// Mention id, as shown by `tix mentions list`
        id: String,
    },
}

/// The `tix analytics` subcommands (mission `burndown-scope-analytics-01M3C9J1`, WP05).
///
/// A thin adapter over the shared analytics DTOs. The daily fold, the unit conversion
/// and the projection live in `crate::analytics`, and the versioned chart document is
/// WP04's (`crate::commands::export::chart_document`), which every verb here calls rather
/// than re-composing. This enum owns only the spelling of the surface: the scope, the
/// period and the mode/unit are always explicit, and no flag here can change an issue, a
/// cycle date, a milestone or a snapshot.
#[derive(Debug, Clone, Subcommand)]
pub enum AnalyticsCommand {
    /// Print the daily remaining/completed series for one cycle or milestone
    ///
    /// A tab-separated table with a `date` column followed by the point's own fields —
    /// `scope_total`, `remaining`, `completed`, `scope_added`, `completions` — and the
    /// day's `coverage`. A value the record cannot support is `-`, never a zero, and a
    /// day with no recorded history is `unknown` or `coverage_limited`. The scope,
    /// period, mode, unit, formula version and coverage tally print above the header
    /// row, and the findings render to stderr with their remedies.
    Burndown {
        #[command(flatten)]
        args: AnalyticsScopeArgs,
    },
    /// Print the read-only end-date forecast over one cycle or milestone
    ///
    /// The planning end date is the scope's own (`--to` overrides it) and `--as-of` is
    /// the caller's day, never a clock. A forecast the record cannot answer prints its
    /// `status` (`insufficient_data` or `unknown`) with `-` for every value it could not
    /// derive — no completion date is ever invented, and no planning date or issue
    /// changes.
    Projection {
        #[command(flatten)]
        args: AnalyticsScopeArgs,
        /// The forecast origin, a `YYYY-MM-DD` calendar date
        #[arg(long, value_name = "YYYY-MM-DD")]
        as_of: String,
    },
    /// Write the deterministic local chart document (WP04's target)
    ///
    /// The document goes to stdout, or to `--output` through the atomic export writer the
    /// other local targets use. `--mode` is required: the document states the mode it was
    /// folded in, so a default would be a guess about which arithmetic produced the
    /// numbers. `--as-of` asks the same document for a forecast. The remote
    /// `gitlab`/`linear` namespaces are never consulted and cannot collide with a chart
    /// document.
    Export {
        #[command(flatten)]
        args: AnalyticsScopeArgs,
        /// Ask the document for a forecast at this origin, a `YYYY-MM-DD` calendar date
        #[arg(long, value_name = "YYYY-MM-DD")]
        as_of: Option<String>,
        /// Write the document to this path instead of stdout
        #[arg(long, value_name = "PATH")]
        output: Option<String>,
    },
}

/// The scope, period, mode and unit flags every `tix analytics` verb shares.
///
/// The period is the scope's own planning data unless a bound is named here, and the mode
/// is explicit rather than inferred: an estimate valuation is only drawn through the
/// project's configured unit conversion, so a mode the record cannot support is refused
/// instead of silently falling back to a count.
#[derive(Debug, Clone, Args)]
pub struct AnalyticsScopeArgs {
    /// Scope kind: `cycle` or `milestone`
    #[arg(long, value_name = "cycle|milestone")]
    pub scope: String,
    /// The scope's planning identity: a cycle name or stable id, or a milestone name
    #[arg(long, value_name = "ID")]
    pub id: String,
    /// The series' inclusive start, `YYYY-MM-DD`; absent means the scope's own start
    #[arg(long, value_name = "YYYY-MM-DD")]
    pub from: Option<String>,
    /// The series' inclusive end, `YYYY-MM-DD`; absent means the scope's own planning end
    #[arg(long, value_name = "YYYY-MM-DD")]
    pub to: Option<String>,
    /// How the series counts: `count` (issue counts, the default) or `estimate`
    ///
    /// `estimate` requires `--unit` and a unit the project declares a conversion for;
    /// a missing or incompatible unit is refused with the shared remedy, never guessed.
    #[arg(long, value_name = "count|estimate")]
    pub mode: Option<String>,
    /// The unit estimate mode counts in; count mode ignores it (an issue count is unitless)
    #[arg(long, value_name = "UNIT")]
    pub unit: Option<String>,
}

/// Process entry: route a parsed [`Cli`] through config resolution, store
/// opening and dispatch, then return the exit code for `main` to exit with.
pub fn run(cli: Cli) -> u8 {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    match &cli.command {
        Some(Command::Lint { no_foreign }) => run_lint(&cwd, *no_foreign),
        Some(Command::Mcp) => match run_mcp(&cwd) {
            Ok(()) => ExitCode::Success as u8,
            Err(e) => {
                eprintln!("{}", crate::error::render_error(&e));
                exit_code_for(&e) as u8
            }
        },
        Some(Command::Render) => match run_render(&cwd) {
            Ok(()) => ExitCode::Success as u8,
            Err(e) => {
                eprintln!("{}", crate::error::render_error(&e));
                exit_code_for(&e) as u8
            }
        },
        // A bulk batch is the one command whose **status** is a result rather than an
        // error: a partial batch completes and reports non-zero, with every outcome
        // retained (`contracts/cli-surface.md`). So it is routed here, where the exit
        // code is decided, and not through `commands::dispatch`, whose arms are all
        // `Result<()>`.
        Some(Command::Bulk { action }) => {
            let result = resolve(&cwd).and_then(|(_, store)| {
                commands::bulk::run(
                    &store,
                    action,
                    action.selection(),
                    if action.is_apply() {
                        commands::bulk::Mode::Apply
                    } else {
                        commands::bulk::Mode::Preview
                    },
                )
            });
            match result {
                Ok(code) => code as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        // `tix triage` shares bulk's exit-code shape for the same reason: a batch
        // that partly succeeded is a *completed* command with a status, not an
        // error, so `--apply` can print one outcome per selected issue and still
        // exit non-zero (T026). It is routed here rather than through
        // `commands::dispatch`, whose arms are all `Result<()>`.
        Some(Command::Triage { action }) => {
            let result = resolve(&cwd).and_then(|(_, store)| commands::triage::run(&store, action));
            match result {
                Ok(code) => code as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        // `tix archive`/`tix restore` share the batch exit-code shape: every
        // selector is processed independently, a failure on one does not roll back
        // another, and the process still exits non-zero if any failed. Routed here
        // for the same reason `tix bulk` and `tix triage` are.
        Some(Command::Archive { issues }) => {
            route_archive(&cwd, commands::archive::Action::Archive, issues)
        }
        Some(Command::Restore { issues }) => {
            route_archive(&cwd, commands::archive::Action::Restore, issues)
        }
        // `tix project` needs a **mutable** `Config` — an accepted create/edit/delete
        // *is* `tix.toml` changing — which `commands::dispatch` (whose arms all borrow
        // `&Config`) cannot express, exactly as `tix wip` and `tix view <subcommand>`
        // do. Its batch verbs (`add`, `remove`, `bulk-add`) also carry a **status**
        // rather than an error: each selector is independent and a partly-successful
        // run exits non-zero. Both properties are why it is routed here, where the
        // config is owned and the exit code is decided.
        Some(Command::Project { command }) => {
            let result = resolve(&cwd).and_then(|(mut config, store)| {
                commands::project::run(&mut config, &store, command)
            });
            match result {
                Ok(code) => code as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        // `tix epic` shares `tix project`'s two properties exactly: its lifecycle
        // verbs (`create`/`edit`/`delete`) mutate `tix.toml`, so it needs the
        // **mutable** `Config` only `run` owns, and its batch verbs
        // (`add`/`remove`/`bulk-add`) carry a **status** rather than an error,
        // because each selector is independent and a partly-successful run exits
        // non-zero. Both are why it is routed here.
        Some(Command::Epic { command }) => {
            let result = resolve(&cwd)
                .and_then(|(mut config, store)| commands::epic::run(&mut config, &store, command));
            match result {
                Ok(code) => code as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        // `tix initiative` shares `tix project`'s property: its lifecycle verbs
        // (`create`/`edit`/`delete`) and its association verbs mutate `tix.toml`, so
        // it needs the **mutable** `Config` only `run` owns, and an accepted change
        // is written through the registry's atomic boundary. `tix roadmap` is
        // read-only and fits `commands::dispatch`, so it is routed there.
        Some(Command::Initiative { command }) => {
            let result = resolve(&cwd).and_then(|(mut config, store)| {
                commands::roadmap::run_initiative(&mut config, &store, command)
            });
            match result {
                Ok(code) => code as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        // Only `tix cycle schedule …` needs the **mutable** config: an accepted
        // `schedule create|edit|delete` *is* `tix.toml` changing, and
        // `commands::dispatch` (whose arms all borrow `&Config`) cannot provide it.
        // The frozen legacy verbs are deliberately **not** intercepted: they fall
        // through to `run_subcommand`, so they keep `commands::dispatch`'s legacy
        // arm *and* the rollover advisory that every `tix cycle …` emitted at the
        // baseline — behavior WP06 must not change.
        Some(Command::Cycle {
            command: CycleCommand::Schedule { command },
        }) => {
            let result = resolve(&cwd).and_then(|(mut config, store)| {
                commands::cycle::run_schedule(&mut config, &store, command)
            });
            match result {
                Ok(()) => ExitCode::Success as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        // `tix capacity set|delete` mutate `tix.toml`, so they need the **mutable**
        // `Config` only the process entry owns — the same reason `tix wip` is routed
        // here. The read verbs (`list`/`show`/`calculate`/`snapshot`) ride along so
        // the family has one boundary, and none of them writes.
        Some(Command::Capacity { command }) => {
            let result = resolve(&cwd).and_then(|(mut config, store)| {
                commands::capacity::run(&mut config, &store, command)
            });
            match result {
                Ok(()) => ExitCode::Success as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        // `tix template`: management mutates `tix.toml`, so it needs a **mutable**
        // config and the path it came from — neither of which `commands::dispatch`
        // carries. Routed here, before the store is opened, exactly like the TUI
        // and `tix mcp`.
        Some(Command::Template { action }) => match commands::templates::run(&cwd, action) {
            Ok(()) => ExitCode::Success as u8,
            Err(e) => {
                eprintln!("{}", crate::error::render_error(&e));
                exit_code_for(&e) as u8
            }
        },
        // `tix version` needs neither a store nor a config, so it is answered
        // before any repository resolution: it works in a directory where every
        // other command would refuse to start, which is exactly where someone
        // checking "did this build?" may be standing.
        Some(Command::Version) => {
            commands::version::print();
            ExitCode::Success as u8
        }
        Some(Command::Beads { action }) => {
            let result = resolve(&cwd)
                .and_then(|(config, store)| commands::beads::run(&config, &store, action));
            match result {
                Ok(()) => ExitCode::Success as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
        _ => {
            let result = match &cli.command {
                None => run_tui(&cwd),
                Some(Command::New) => run_tui_new(&cwd),
                Some(Command::Init { beads }) => commands::init::run_with(&cwd, beads.as_deref()),
                Some(Command::InitAgents) => {
                    let root = Config::project_root(&cwd).unwrap_or(cwd.clone());
                    commands::agents::run(&root).map(|_| ())
                }
                Some(cmd) => run_subcommand(&cwd, cmd),
            };
            match result {
                Ok(()) => ExitCode::Success as u8,
                Err(e) => {
                    eprintln!("{}", crate::error::render_error(&e));
                    exit_code_for(&e) as u8
                }
            }
        }
    }
}

/// Process one `tix archive`/`tix restore` batch and map it to an exit code.
///
/// Each selector is independent: an outcome is printed per item and a failure on
/// one item neither aborts the rest nor rolls back a successful rename. The
/// process exits with the first failure's code, or `0` when every item succeeded.
fn route_archive(cwd: &Path, action: commands::archive::Action, issues: &[String]) -> u8 {
    match resolve(cwd).and_then(|(_, store)| commands::archive::run(&store, action, issues)) {
        Ok(code) => code as u8,
        Err(e) => {
            eprintln!("{}", crate::error::render_error(&e));
            exit_code_for(&e) as u8
        }
    }
}

/// `tix lint` owns its own boundary: its exit-code table (`0` healthy, `1`
/// findings, `2` usage/internal) does not match the generic
/// [`exit_code_for`] mapping, which would collapse internal errors into `1`.
fn run_lint(cwd: &Path, no_foreign: bool) -> u8 {
    let (config, store) = match resolve(cwd) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("{}", crate::error::render_error(&e));
            return 2;
        }
    };
    match commands::lint::run_with(&config, &store, !no_foreign) {
        Ok(true) => 1,
        Ok(false) => 0,
        Err(e) => {
            eprintln!("{}", crate::error::render_error(&e));
            2
        }
    }
}

/// `tix` with no subcommand: launch the TUI, or fail with the contract's
/// exact message when no `tix.toml` is reachable (exit 1).
///
/// tix #1632: on a terminal, a missing `tix.toml` is offered a one-key escape —
/// create the default configuration and continue into the TUI — rather than
/// being turned away with a message naming a command to run. A declined prompt,
/// and a stdin that cannot be asked (not a terminal), both keep the exact
/// contract message.
fn run_tui(cwd: &Path) -> Result<()> {
    let config = tui_config(cwd)?;
    crate::app::run(&config)
}

/// `tix new`: the same session, opened on the create-issue form instead of the
/// board. Same repository resolution as [`run_tui`], so a missing `tix.toml`
/// fails with the same message rather than opening a form that could not be
/// submitted.
fn run_tui_new(cwd: &Path) -> Result<()> {
    let config = tui_config(cwd)?;
    crate::app::run_new_issue(&config)
}

/// Repository resolution for the TUI commands, offering to scaffold a repository
/// first when none is reachable (tix #1632).
fn tui_config(cwd: &Path) -> Result<Config> {
    if !offer_first_run_init(cwd)? {
        return Err(TixError::Config(
            "no tix.toml found — run \"tix init\" first".into(),
        ));
    }
    repo_config(cwd)
}

/// Ask, once, whether a missing `tix.toml` should be created with the defaults.
///
/// Returns `true` when the caller may proceed (a repository was already there, or
/// the user said yes to `tix init` default values). Returns `false` when there is
/// no repository and the user declined — or could not be asked at all, since a
/// non-terminal stdin cannot consent, and silently writing a project into a pipe's
/// directory would be the wrong answer.
fn offer_first_run_init(cwd: &Path) -> Result<bool> {
    use std::io::IsTerminal;

    if Config::project_root(cwd).is_some() {
        return Ok(true);
    }
    if !std::io::stdin().is_terminal() {
        return Ok(false);
    }
    eprint!("tix: no tix.toml found — create one with default settings? [Y/n] ");
    let mut line = String::new();
    std::io::stdin()
        .read_line(&mut line)
        .map_err(TixError::Io)?;
    let answer = line.trim();
    if answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes") {
        commands::init::run_with(cwd, Some("off"))?;
        return Ok(true);
    }
    eprintln!("tix: aborted — run \"tix init\" when you are ready");
    Ok(false)
}

/// Strict repository resolution shared by the TUI and `tix mcp` (FR-015):
/// upward `tix.toml` discovery, failing with the contract's exact `no
/// tix.toml found` guidance (exit 1) when no repository is reachable —
/// never a guessed default store. This is the "CLI hot path" that
/// `contracts/mcp-protocol.md` §7 requires `tix mcp` to reuse.
fn repo_config(cwd: &Path) -> Result<Config> {
    let root = Config::project_root(cwd)
        .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
    let mut config = Config::load(&root)?;
    // `tags_dir`, `[repos]` and `parent_repo` are relative to the project root
    // (tix #1529/#1530), so the TUI can spawn tix in a child from anywhere.
    config.resolve_repo_paths(&root);
    Ok(config)
}

/// `tix mcp`: strict repository resolution (same hot path as `tix` with no
/// subcommand), then serve the MCP protocol synchronously over stdio. Outside
/// a repository the process fails with exit 1 BEFORE reading any protocol
/// bytes (FR-016, `contracts/mcp-protocol.md` §7).
fn run_mcp(cwd: &Path) -> Result<()> {
    let config = repo_config(cwd)?;
    let store = Store::open(&config)?;
    // Absolutize milestones against the same project root as issues_dir so the
    // MCP `create_issue`/`update_issue` planning validators and the CLI resolve
    // milestone files to the identical directory (parity cross-checker).
    let mut config = config;
    if config.milestones_dir().is_relative() {
        let root = Config::project_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
        let configured = config.milestones_dir();
        config.milestones_dir = Some(root.join(configured));
    }
    crate::mcp::serve(&config, &store)
}

/// `tix render`: strict repository resolution like `tix`/`tix mcp` so the
/// command fails with the same no-config guidance outside a repository.
fn run_render(cwd: &Path) -> Result<()> {
    let config = repo_config(cwd)?;
    let store = Store::open(&config)?;
    commands::dispatch(&Command::Render, &config, &store)
}

/// Config resolution for subcommands: defaults when no `tix.toml` exists
/// (relative to the project root via upward walk), with a one-time stderr
/// notice. Only explicit per-command requirements fail hard.
fn resolve(cwd: &Path) -> Result<(Config, Store)> {
    let root = Config::project_root(cwd);
    let mut config = match &root {
        Some(r) => Config::load(r)?,
        None => Config::default(),
    };
    // Resolve a relative issues_dir against the project root (or cwd when
    // unconfigured) so subcommands behave identically from any subdirectory.
    if config.issues_dir.is_relative() {
        let base = root.as_deref().unwrap_or(cwd);
        config.issues_dir = base.join(&config.issues_dir);
    }
    // Milestones resolve against the same base as issues_dir: the planning
    // validators (create/edit) and `lint` must agree on where milestone
    // registry files live regardless of the invocation directory.
    if config.milestones_dir().is_relative() {
        let base = root.as_deref().unwrap_or(cwd);
        let configured = config.milestones_dir();
        config.milestones_dir = Some(base.join(configured));
    }
    // `tags_dir`, `[repos]` and `parent_repo` resolve against the same base.
    config.resolve_repo_paths(root.as_deref().unwrap_or(cwd));
    if root.is_none() {
        eprintln!("tix: no tix.toml found (run 'tix init' to create one)");
    }
    let store = Store::open(&config)?;
    Ok((config, store))
}

fn run_subcommand(cwd: &Path, cmd: &Command) -> Result<()> {
    let (mut config, store) = resolve(cwd)?;
    // `tix view <subcommand>` is the one command tree that needs the config
    // **mutably**: an accepted view mutation *is* `config.views` changing, and
    // `commands::dispatch` (whose every arm borrows `&Config`) cannot express
    // that. Routed here, at the process boundary where the config is owned, for
    // the same reason `tix bulk` and `tix triage` are routed in `run`.
    if let Command::View {
        action: Some(action),
        ..
    } = cmd
    {
        return commands::views::run(&mut config, &store, action);
    }
    // `tix wip set/unset/unit` mutate `tix.toml`, so they are routed here for the
    // same reason the view mutations are: `commands::dispatch`'s arms borrow
    // `&Config`, and an accepted WIP edit *is* the configuration changing.
    if let Command::Wip { action } = cmd {
        return commands::wip::run(&mut config, &store, action);
    }
    if !matches!(cmd, Command::Rollover { .. }) {
        let planner = crate::planning::Planner::from_config(config.clone(), cwd.to_path_buf());
        let plan = planner.rollover_plan(&store, &crate::model::today());
        if let Some(first) = plan.first() {
            eprintln!(
                "hint: rollover of {} issue(s) from \"{}\" to \"{}\" is suggested; run 'tix rollover' to apply",
                first.issues.len(),
                first.from_cycle,
                first.to_cycle
            );
        }
    }
    commands::dispatch(cmd, &config, &store)
}