vivac 0.12.0

Provenance tree for work: every node knows which node it was born from
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
//! Claude Code, the one harness `vivac setup` knows today.
//!
//! What is Claude Code's own: the two files it reads (`.claude/settings.json`
//! and `.mcp.json`), the skill it looks for under `.claude/skills/`, the
//! shape of a hook entry, and how a command line already there is told apart
//! from a foreign one. `t565` §9: this is the module `INTEGRATION.md` points
//! at for the level-one work of a new harness.

use super::json::{self, Value};
use crate::args::Args;
use crate::failure::Failure;
use crate::output::outln;
use std::path::{Path, PathBuf};

const SETTINGS_LABEL: &str = ".claude/settings.json";
const MCP_LABEL: &str = ".mcp.json";
const SKILL_LABEL: &str = ".claude/skills/vivac-migrate/SKILL.md";
const VIVAC_LABEL: &str = ".vivac/";
const GITIGNORE_LABEL: &str = ".vivac/.gitignore";
const LANE_LABEL: &str = ".vivac/lane";

const SESSION_START_COMMAND: &str = "vivac session start --hook";
const SESSION_END_COMMAND: &str = "vivac session end --hook";

const FRONTMATTER: &str = include_str!("skill-frontmatter.md");
const BODY: &str = include_str!("skill-body.md");

pub fn run(roots: &super::Roots, a: &Args) -> Result<i32, Failure> {
    if a.has("undo") {
        return undo(&roots.here, a);
    }
    // Checked here, before the branch below, rather than inside `apply`
    // alone: a guard that lives in one branch is a guard the other branch
    // does not have, and `--join` used to skip it entirely (`t594`).
    // `--undo` is still excluded, on purpose: undoing whatever
    // an earlier setup wrote there is always safe.
    if let Some(refusal) = super::refuse_home_or_global_store(roots) {
        return Err(refusal);
    }
    // Here for the same reason, and it took a second round to actually put
    // it here: `refuse_second_map`'s own doc already said trees below run
    // in both branches, but the check itself stayed inside it, and
    // `refuse_second_map` is only ever called from `apply` -- so `--join`
    // walked around this one exactly the way it walked around the guard
    // above. §4.5.1 still decides the order within `apply`: a tree below
    // describes a state of the disk that has to be fixed before the
    // product question, or "plant or join", means anything at all, and
    // moving it up here only makes that truer.
    //
    // `d626`: fixed being asked before either branch runs, this still
    // answered every caller with the plant branch's own sentence, since
    // nothing here had looked at `--join` yet to know which door it was
    // answering. The state itself does not wait on the flag; only which
    // sentence names it does, so the flag is read here too, before the
    // branch it would have picked.
    let below = trees_below(&roots.here);
    let join_spec = a.opt("join");
    if !below.is_empty() {
        return Err(match join_spec {
            Some(spec) => tree_below_join_refusal(&roots.here, &below, spec),
            None => tree_below_refusal(&below),
        });
    }
    if let Some(spec) = join_spec {
        return join(roots, spec, a.opt("lane-name"), a.has("dry-run"));
    }
    apply(roots, a)
}

// ---------------------------------------------------------------------------
// Shared: the vivac-command test, and reading the two JSON files.
// ---------------------------------------------------------------------------

/// Whether `word`'s first token, quotes and path stripped, is `vivac`:
/// `t565` §7.4.
fn is_vivac_command(word: &str) -> bool {
    let word = word.replace('"', "");
    let base = word.rsplit(['/', '\\']).next().unwrap_or(word.as_str());
    let stem = if base.len() >= 4 && base[base.len() - 4..].eq_ignore_ascii_case(".exe") {
        &base[..base.len() - 4]
    } else {
        base
    };
    stem.eq_ignore_ascii_case("vivac")
}

struct JsonFile {
    exists: bool,
    raw: String,
    indent: String,
    eol: &'static str,
    trailing_newline: bool,
    /// `Some` once parsed as an object; `None` for a missing file (nothing to
    /// parse) or a conflict (unreadable, or not an object).
    value: Option<Value>,
    /// Line and column of a parse failure, for the conflict message.
    parse_error: Option<(usize, usize)>,
    not_object: bool,
}

fn read_json(path: &Path) -> JsonFile {
    let Ok(raw) = std::fs::read_to_string(path) else {
        return JsonFile {
            exists: false,
            raw: String::new(),
            indent: "  ".to_string(),
            eol: "\n",
            trailing_newline: true,
            value: Some(Value::object(vec![])),
            parse_error: None,
            not_object: false,
        };
    };
    let indent = json::detect_indent(&raw);
    let eol = json::detect_eol(&raw);
    let trailing_newline = json::has_trailing_newline(&raw);
    match json::parse(&raw) {
        Ok(v) if v.is_object() => JsonFile {
            exists: true,
            raw,
            indent,
            eol,
            trailing_newline,
            value: Some(v),
            parse_error: None,
            not_object: false,
        },
        Ok(_) => JsonFile {
            exists: true,
            raw,
            indent,
            eol,
            trailing_newline,
            value: None,
            parse_error: None,
            not_object: true,
        },
        Err(e) => JsonFile {
            exists: true,
            raw,
            indent,
            eol,
            trailing_newline,
            value: None,
            parse_error: Some((e.line(), e.column())),
            not_object: false,
        },
    }
}

// ---------------------------------------------------------------------------
// Hooks: SessionStart and Stop.
// ---------------------------------------------------------------------------

enum HookState {
    Missing,
    Exact,
    Different(String),
}

fn command_first_word(cmd: &str) -> Option<&str> {
    cmd.split_whitespace().next()
}

/// Looks through `event`'s array, under the top-level `hooks` object
/// (`SessionStart` or `Stop` are never top-level keys of their own: Claude
/// Code nests every event under `hooks`), for a vivac command whose
/// arguments start with `session start` or `session end`.
fn hook_state(root: &Value, event: &str, session_word: &str, ours: &str) -> HookState {
    let Some(arr) = root
        .get("hooks")
        .and_then(|h| h.get(event))
        .and_then(Value::as_array)
    else {
        return HookState::Missing;
    };
    for entry in arr {
        let Some(hooks) = entry.get("hooks").and_then(Value::as_array) else {
            continue;
        };
        for h in hooks {
            let Some(cmd) = h.get("command").and_then(Value::as_str) else {
                continue;
            };
            let words: Vec<&str> = cmd.split_whitespace().collect();
            let Some(prog) = command_first_word(cmd) else {
                continue;
            };
            if !is_vivac_command(prog) {
                continue;
            }
            if words.get(1) == Some(&"session") && words.get(2) == Some(&session_word) {
                return if cmd == ours {
                    HookState::Exact
                } else {
                    HookState::Different(cmd.to_string())
                };
            }
        }
    }
    HookState::Missing
}

fn our_hook_entry(command: &str) -> Value {
    Value::object(vec![(
        "hooks",
        Value::Array(vec![Value::object(vec![
            ("type", Value::str("command")),
            ("command", Value::str(command)),
        ])]),
    )])
}

/// Gets `root[key]` as an object, creating it first if it is missing.
fn get_or_insert_object<'a>(root: &'a mut Value, key: &str) -> &'a mut Value {
    if root.get(key).map(Value::is_object) != Some(true) {
        root.set(key, Value::object(vec![]));
    }
    root.as_object_mut()
        .unwrap()
        .iter_mut()
        .find(|(k, _)| k == key)
        .map(|(_, v)| v)
        .unwrap()
}

fn append_hook(root: &mut Value, event: &str, command: &str) {
    let hooks = get_or_insert_object(root, "hooks");
    if hooks.get(event).map(Value::as_array).is_none() {
        hooks.set(event, Value::Array(vec![]));
    }
    let arr = hooks
        .as_object_mut()
        .unwrap()
        .iter_mut()
        .find(|(k, _)| k == event)
        .map(|(_, v)| v)
        .unwrap()
        .as_array_mut()
        .unwrap();
    arr.push(our_hook_entry(command));
}

/// Removes every array entry whose sole hook is exactly `command`, then
/// drops the event key if its array is now empty, and `hooks` itself if
/// that leaves it with nothing. `t565` §7.7.
fn remove_hook(root: &mut Value, event: &str, command: &str) -> bool {
    let mut removed = false;
    let Some(hooks) = root.get("hooks").cloned() else {
        return false;
    };
    let Some(arr) = hooks.get(event).and_then(Value::as_array) else {
        return false;
    };
    let kept: Vec<Value> = arr
        .iter()
        .filter(|entry| {
            let is_ours = entry
                .get("hooks")
                .and_then(Value::as_array)
                .map(|hs| {
                    hs.len() == 1 && hs[0].get("command").and_then(Value::as_str) == Some(command)
                })
                .unwrap_or(false);
            if is_ours {
                removed = true;
            }
            !is_ours
        })
        .cloned()
        .collect();

    let mut new_hooks = hooks;
    if kept.is_empty() {
        if let Some(obj) = new_hooks.as_object_mut() {
            obj.retain(|(k, _)| k.as_str() != event);
        }
    } else {
        new_hooks.set(event, Value::Array(kept));
    }
    if new_hooks.as_object().is_some_and(|o| o.is_empty()) {
        if let Some(obj) = root.as_object_mut() {
            obj.retain(|(k, _)| k.as_str() != "hooks");
        }
    } else {
        root.set("hooks", new_hooks);
    }
    removed
}

// ---------------------------------------------------------------------------
// The MCP server entry.
// ---------------------------------------------------------------------------

enum McpState {
    Missing,
    Ours,
    OtherName(String),
    NameTaken(String),
}

fn describe_command(v: &Value) -> String {
    let cmd = v.get("command").and_then(Value::as_str).unwrap_or("");
    let args: Vec<String> = v
        .get("args")
        .and_then(Value::as_array)
        .map(|a| {
            a.iter()
                .filter_map(|x| x.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default();
    if args.is_empty() {
        cmd.to_string()
    } else {
        format!("{cmd} {}", args.join(" "))
    }
}

fn is_our_mcp_entry(v: &Value) -> bool {
    let is_vivac = v
        .get("command")
        .and_then(Value::as_str)
        .is_some_and(is_vivac_command);
    let args_ok = v
        .get("args")
        .and_then(Value::as_array)
        .is_some_and(|a| a.len() == 1 && a[0].as_str() == Some("mcp"));
    is_vivac && args_ok
}

fn mcp_state(root: &Value) -> McpState {
    let Some(servers) = root.get("mcpServers").and_then(Value::as_object) else {
        return McpState::Missing;
    };
    if let Some((_, v)) = servers.iter().find(|(k, _)| k == "vivac") {
        return if is_our_mcp_entry(v) {
            McpState::Ours
        } else {
            McpState::NameTaken(describe_command(v))
        };
    }
    for (name, v) in servers {
        if is_our_mcp_entry(v) {
            return McpState::OtherName(name.clone());
        }
    }
    McpState::Missing
}

fn our_mcp_entry() -> Value {
    Value::object(vec![
        ("type", Value::str("stdio")),
        ("command", Value::str("vivac")),
        ("args", Value::Array(vec![Value::str("mcp")])),
    ])
}

/// `root` with `mcpServers.vivac` removed, and `mcpServers` itself dropped
/// once that leaves it empty -- the same "an empty container does not
/// linger" rule `remove_hook` applies to `hooks`.
fn without_our_mcp_server(root: &Value) -> Value {
    let mut root = root.clone();
    let Some(mut servers) = root.get("mcpServers").cloned() else {
        return root;
    };
    if let Some(obj) = servers.as_object_mut() {
        obj.retain(|(k, _)| k != "vivac");
    }
    if servers.as_object().is_some_and(|o| o.is_empty()) {
        if let Some(obj) = root.as_object_mut() {
            obj.retain(|(k, _)| k.as_str() != "mcpServers");
        }
    } else {
        root.set("mcpServers", servers);
    }
    root
}

// ---------------------------------------------------------------------------
// The skill file.
// ---------------------------------------------------------------------------

enum SkillState {
    Missing,
    Same,
    Replaceable,
    Conflict,
}

fn marker_line(fingerprint: u64) -> String {
    format!(
        "<!-- written by vivac setup; fingerprint {fingerprint:016x}; vivac setup \
         claude-code --undo removes it while the text is unchanged -->\n"
    )
}

fn skill_content_without_marker() -> String {
    format!("{FRONTMATTER}{BODY}")
}

fn skill_fingerprint() -> u64 {
    super::fnv1a64(skill_content_without_marker().as_bytes())
}

fn skill_text() -> String {
    format!("{FRONTMATTER}{}{BODY}", marker_line(skill_fingerprint()))
}

/// Splits `text` into its frontmatter, the marker's claimed fingerprint (as
/// the hex it was written with) and the content the fingerprint should have
/// been taken over -- `text` with the marker line and its newline removed.
/// `None` when there is no frontmatter or no line right after it: `t565`
/// §7.4's "any other case" for a skill with no marker at all.
fn extract_marker(text: &str) -> Option<(String, String)> {
    let lines: Vec<&str> = text.split('\n').collect();
    if lines.first() != Some(&"---") {
        return None;
    }
    let close = lines.iter().skip(1).position(|&l| l == "---")? + 1;
    let marker_idx = close + 1;
    let marker = *lines.get(marker_idx)?;
    let fp = marker
        .strip_prefix("<!-- written by vivac setup; fingerprint ")?
        .split(';')
        .next()?
        .trim()
        .to_string();
    let mut without = lines;
    without.remove(marker_idx);
    Some((fp, without.join("\n")))
}

fn skill_state(existing: &str) -> SkillState {
    if existing == skill_text() {
        return SkillState::Same;
    }
    match extract_marker(existing) {
        Some((fp_hex, content)) => {
            let claimed = u64::from_str_radix(&fp_hex, 16).ok();
            let actual = super::fnv1a64(content.as_bytes());
            if claimed == Some(actual) {
                SkillState::Replaceable
            } else {
                SkillState::Conflict
            }
        }
        None => SkillState::Conflict,
    }
}

/// Whether an existing skill's fingerprint is intact, regardless of whether
/// its text still matches what this version would write today. `--undo`
/// only ever removes a file it (or an earlier vivac) actually wrote.
fn skill_fingerprint_intact(existing: &str) -> bool {
    matches!(
        skill_state(existing),
        SkillState::Same | SkillState::Replaceable
    )
}

// ---------------------------------------------------------------------------
// Paths.
// ---------------------------------------------------------------------------

struct Paths {
    settings: PathBuf,
    mcp: PathBuf,
    skill: PathBuf,
}

fn paths(root: &Path) -> Paths {
    Paths {
        settings: root.join(".claude").join("settings.json"),
        mcp: root.join(".mcp.json"),
        skill: root
            .join(".claude")
            .join("skills")
            .join("vivac-migrate")
            .join("SKILL.md"),
    }
}

// ---------------------------------------------------------------------------
// Recognizing an existing product, before planting a second map of it:
// `t594` §4.5, case 3 -- reached only when there is no tree above `here`
// at all. Checked in this order because §4.5.1 describes a state of the
// disk that has to be fixed before either of the other two questions
// means anything: a tree below (`trees_below`), then a product this
// machine's registry already tracks (`sharing_repos`).
// ---------------------------------------------------------------------------

/// The deepest a nested tree can sit beneath the folder being set up, the
/// same two levels `repos::scan` fixes for a repository -- and for the
/// same reason: it also keeps a symlink cycle from running away with the
/// walk.
const TREE_SCAN_DEPTH: u32 = 2;

/// Every `.vivac/` holding a tree (`events` or `config`) strictly inside
/// `folder`: the same walk `repos::scan` does over `.git` -- two levels
/// down, never descending into a repository or into a `.vivac/` already
/// found -- but looking for a tree instead of a repository, and never
/// checking `folder` itself. That last part used to be unreachable rather
/// than absent: the only caller skipped calling this at all once `folder`
/// already had a tree of its own. `t594` made that call
/// reachable, and it surfaced the gap -- calling this on a folder that
/// already holds a tree used to report the folder itself as a tree
/// sitting "below" it.
fn trees_below(folder: &Path) -> Vec<PathBuf> {
    let mut found = Vec::new();
    for sub in child_folders(folder) {
        walk_for_trees(&sub, 1, &mut found);
    }
    found.sort();
    found
}

/// `dir`'s own immediate subdirectories, `.vivac/` excluded, in a fixed
/// order: the one piece `trees_below` and `walk_for_trees`'s own
/// recursive step both need.
fn child_folders(dir: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut subdirs: Vec<PathBuf> = entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_dir())
        .filter(|p| p.file_name().is_some_and(|n| n != crate::store::DIR))
        .collect();
    subdirs.sort();
    subdirs
}

fn walk_for_trees(dir: &Path, depth: u32, found: &mut Vec<PathBuf>) {
    if crate::store::already_planted(dir) {
        found.push(dir.to_path_buf());
        // Never descend into a tree already found: whatever sits inside
        // it belongs to that tree, not to this walk.
        return;
    }
    if dir.join(".git").exists() {
        return;
    }
    if depth == TREE_SCAN_DEPTH {
        return;
    }
    for sub in child_folders(dir) {
        walk_for_trees(&sub, depth + 1, found);
    }
}

/// `path`'s own folder name, or `None` when the redaction guard rejects
/// it: this text reaches an agent's context (`d600`), the same rule
/// `registry::folder_name` already follows for a copy's folder.
fn guarded_folder_name(path: &Path) -> Option<String> {
    let name = path.file_name()?.to_string_lossy().into_owned();
    match crate::redact::check_field("folder name", &name) {
        Some(_) => None,
        None => Some(name),
    }
}

/// §6.4: a tree already sitting inside this folder. Named, unless the
/// guard withholds a name; with two or more, the withheld ones are simply
/// left out rather than replaced one by one.
fn tree_below_refusal(paths: &[PathBuf]) -> Failure {
    let names: Vec<Option<String>> = paths.iter().map(|p| guarded_folder_name(p)).collect();
    if let [only] = names.as_slice() {
        let label = crate::registry::label_for(only.as_deref());
        return Failure::Model(format!(
            "  There is already a tree inside this folder, in {label}.\n  \
             Planting another one here would split this project: sessions opened in\n  \
             {label} would use that one, and the rest this one.\n\n  \
             Move that tree up here, then run setup again. From inside {label}:\n      \
             vivac relocate .."
        ));
    }
    let quoted: Vec<String> = names
        .iter()
        .filter_map(|n| n.as_deref())
        .map(|n| format!("\"{n}\""))
        .collect();
    let quoted_refs: Vec<&str> = quoted.iter().map(String::as_str).collect();
    let where_clause = if quoted_refs.is_empty() {
        "under names this tool will not write down".to_string()
    } else {
        format!("in {}", join_with_and(&quoted_refs))
    };
    Failure::Model(format!(
        "  There are trees inside this folder, {where_clause}.\n  \
         vivac cannot merge trees: keep one per product, move it up here with\n  \
         vivac relocate, and leave the others as they are."
    ))
}

/// `d626`: the same disk state `tree_below_refusal` names for a plant,
/// met by `--join` instead. The remedy is not the same door -- nothing
/// here was about to be planted, so "move that tree up, then run setup
/// again" would have pointed at a choice nobody was making, and naming
/// the folder to run `relocate` from, rather than a destination for it,
/// is what actually matches how `relocate` works: it runs from inside
/// the tree it moves, not from above it. `spec` is printed back exactly
/// as typed and quoted, the same as every other refusal in this module
/// names something -- it is the choice being made, not a tree this call
/// went looking for and resolved.
///
/// Every tree found is named, following `tree_below_refusal`'s own
/// shape for the same disk state: whoever fixes the first and hits this
/// refusal again would only be learning the same thing twice.
///
/// A route is withheld whole when any segment of it trips the redaction
/// guard (`guarded_relative`, `d600`) -- the guard covers the folder
/// name it was built to cover, and a route this refusal prints can be
/// several of those deep. With a mix of withheld and shown routes, only
/// the shown ones are listed, and how many are missing is never said:
/// the count is also something the guard would be handing over.
fn tree_below_join_refusal(here: &Path, below: &[PathBuf], spec: &str) -> Failure {
    let routes: Vec<Option<String>> = below.iter().map(|p| guarded_relative(here, p)).collect();
    let shown: Vec<&str> = routes.iter().filter_map(|r| r.as_deref()).collect();

    if let [only] = routes.as_slice() {
        return match only {
            Some(rel) => Failure::Model(format!(
                "  There is another product's tree below this folder:\n    \
                 {rel}\n\n  \
                 This folder cannot be a lane of \"{spec}\" while that tree is there: one\n  \
                 folder answers for one product, and a lane that contains another\n  \
                 product's tree would answer for two.\n\n  \
                 If the tree below is part of \"{spec}\", move it up. From inside {rel}:\n      \
                 vivac relocate ..\n  \
                 If it is a different product, join from a folder that does not contain it."
            )),
            None => Failure::Model(format!(
                "  There is another product's tree below this folder, under a name this tool\n  \
                 will not write down.\n\n  \
                 This folder cannot be a lane of \"{spec}\" while that tree is there: one\n  \
                 folder answers for one product, and a lane that contains another\n  \
                 product's tree would answer for two.\n\n  \
                 Join from a folder that does not contain it, or move that tree up from\n  \
                 inside it:   vivac relocate .."
            )),
        };
    }

    if shown.is_empty() {
        return Failure::Model(format!(
            "  There are other products' trees below this folder, under names this tool\n  \
             will not write down.\n\n  \
             This folder cannot be a lane of \"{spec}\" while any of them is there: one\n  \
             folder answers for one product, and a lane that contains another\n  \
             product's tree would answer for two.\n\n  \
             Join from a folder that does not contain them, or move them up from\n  \
             inside each one:   vivac relocate .."
        ));
    }

    let listed: String = shown.iter().map(|r| format!("    {r}\n")).collect();
    Failure::Model(format!(
        "  There are other products' trees below this folder:\n\
         {listed}\n  \
         This folder cannot be a lane of \"{spec}\" while any of them is there: one\n  \
         folder answers for one product, and a lane that contains another\n  \
         product's tree would answer for two.\n\n  \
         Any of them that belongs to \"{spec}\" can move up, from inside it:\n      \
         vivac relocate ..\n  \
         For the rest, join from a folder that does not contain them."
    ))
}

/// `path`'s own route down from `base`, forward slashes on every
/// platform, the same convention `event::Repo::relative` already prints
/// a repository under -- or `None` when any segment of that route trips
/// the redaction guard: `guarded_folder_name` only ever checked the last
/// one, and a route `tree_below_join_refusal` prints can run several
/// folders deep, any of which might be the one that should not travel
/// (`d600`).
fn guarded_relative(base: &Path, path: &Path) -> Option<String> {
    let rel = path.strip_prefix(base).unwrap_or(path);
    let mut parts = Vec::new();
    for c in rel.components() {
        let part = c.as_os_str().to_string_lossy().into_owned();
        match crate::redact::check_field("folder name", &part) {
            Some(_) => return None,
            None => parts.push(part),
        }
    }
    Some(parts.join("/"))
}

/// §6.4's mirror image, upward: a folder with no `.vivac/` of its own,
/// told to `--join` a tree somewhere else while the tree it already
/// resolves to sits above it.
///
/// `Failure::already_a_lane` used to answer here, and its own doc says
/// what is wrong with that: it is for "a folder that already carries
/// somebody else's `.vivac/lane`", and this folder carries none at all.
/// The refusal itself was never in doubt -- joining would split the
/// product either way -- so what changes is only the sentence, which now
/// says the thing that is true and where to go and read it.
///
/// Named, and the name withheld when the redaction guard rejects it
/// (`d600`), the same as every other folder this module names.
fn tree_above_refusal(tree_root: &Path) -> Failure {
    let label = crate::registry::label_for(guarded_folder_name(tree_root).as_deref());
    Failure::Model(format!(
        "  A tree sits above this folder, in {label}, so this folder already belongs to\n  \
         that product. Joining it to a different tree would split the two. To see\n  \
         where it belongs:  vivac brief"
    ))
}

/// §6.3: this folder's own repositories already belong to a project the
/// registry tracks. `here_repos` names the repositories printed --
/// **this** folder's own, per `repos::scan`, never the other project's.
fn product_registered_refusal(
    sharing: &crate::registry::Sharing,
    here_repos: &[crate::event::Repo],
) -> Failure {
    let mut repo_names: Vec<&str> = here_repos
        .iter()
        .filter(|r| {
            r.root
                .as_deref()
                .is_some_and(|root| sharing.shared.iter().any(|s| s == root))
        })
        .map(|r| r.path.as_str())
        .collect();
    repo_names.sort_unstable();
    let repo_list = repo_names.join(", ");
    match &sharing.name {
        Some(name) => Failure::Model(format!(
            "  Some repositories here are already tracked by project \"{name}\":\n  \
             {repo_list}.\n  \
             Planting another tree would give this product two maps.\n\n  \
             To work on {name} from this folder:\n      \
             vivac setup claude-code --join {}\n  \
             To plant a separate tree anyway:\n      \
             vivac setup claude-code --new-tree",
            crate::registry::quote_if_needed(name)
        )),
        None => Failure::Model(format!(
            "  Some repositories here are already tracked by another project on this\n  \
             machine: {repo_list}.\n  \
             Planting another tree would give this product two maps.\n\n  \
             To work on it from this folder, give the path to its folder:\n      \
             vivac setup claude-code --join <path to that folder>\n  \
             To plant a separate tree anyway:\n      \
             vivac setup claude-code --new-tree"
        )),
    }
}

/// `t594` §4.5, case 3's own second refusal: this folder's repositories
/// already belong to a project the registry tracks. Skipped for
/// `bypass_registered` -- `--new-tree` (`t594` §4.5's own escape for two
/// forks that share a root commit) -- and skipped when there is a tree
/// above `here` at all, since with one this is an ordinary join and the
/// product question does not arise.
///
/// Case 3's *first* refusal, a tree below, is **not** here: it is `run`'s
/// own, checked before it picks a branch at all. It lived here once, which
/// made it a guard only the planting branch ever ran -- the same shape
/// that let `--join` walk around `refuse_home_or_global_store`. The order
/// §4.5.1 fixes is unchanged, and firmer: a tree below describes a state
/// of the disk that has to be fixed before the product question means
/// anything, and `run` now refuses one before this is ever called.
fn refuse_second_map(roots: &super::Roots, bypass_registered: bool) -> Result<(), Failure> {
    if roots.located.is_some() {
        return Ok(());
    }
    if bypass_registered {
        return Ok(());
    }
    let (here_repos, _excluded) = filtered_repos(crate::repos::scan(&roots.here));
    let root_commits: Vec<String> = here_repos.iter().filter_map(|r| r.root.clone()).collect();
    if root_commits.is_empty() {
        return Ok(());
    }
    let Some(store_dir) = crate::store::store_dir() else {
        return Ok(());
    };
    let best = crate::registry::sharing_repos(&store_dir, &root_commits)
        .into_iter()
        .find(|s| !crate::anchor::same_folder(&s.root, &roots.here));
    match best {
        Some(sharing) => Err(product_registered_refusal(&sharing, &here_repos)),
        None => Ok(()),
    }
}

/// §6.5: this folder's own tree -- freshly planted, or the closer one it
/// just joined -- itself sits inside yet another one, found by continuing
/// the very same upward walk past it. `t594` §4.5, case 2's own extra
/// check: it never blocks anything, and it is checked for a fresh plant
/// too, where it always reads `None` -- `store::locate` already walked
/// every ancestor of `here` looking for exactly this, and found nothing,
/// or there would be a tree above to join instead of planting.
fn tree_root_above(tree_root: &Path) -> Option<PathBuf> {
    let mut d = tree_root.to_path_buf();
    while d.pop() {
        if crate::store::already_planted(&d) {
            return Some(d);
        }
    }
    None
}

fn tree_above_warning(name: Option<&str>) -> String {
    let label = crate::registry::label_for(name);
    format!(
        "\n  This tree sits inside another one, in folder {label}. Sessions opened\n  \
         above this folder use that one: keep one tree per product.\n"
    )
}

// ---------------------------------------------------------------------------
// The lane: `t594` §4.5, joining the tree above rather than planting a
// second one.
// ---------------------------------------------------------------------------

/// What this run has to do about the lane `roots.here` is, worked out
/// before anything is written so the plan can say it.
struct LanePlan {
    lane_id: String,
    name: String,
    repos: Vec<crate::event::Repo>,
    /// This folder does not carry `.vivac/lane` yet, so this run has to
    /// write it before it can declare (`t594` §4.5.2, case (c)). The id
    /// this points back at is minted here, since it never depends on the
    /// tree's own state; the project it points back at does, and is
    /// worked out at write time instead (`write_lane`).
    is_new: bool,
    /// Whether the config still needs `lock_lanes_in_config`: absent for
    /// a tree that does not exist yet, which always needs it once
    /// planted, and read off the existing one otherwise.
    needs_lock: bool,
    /// The tree already says exactly this (`t594` §4.5.2, case (e)):
    /// nothing to write, and running `setup` twice in a row does not
    /// leave two events behind.
    unchanged: bool,
    /// How many repositories the redaction guard kept out, and the first
    /// rule that caught one. `d600`: they are still missing from the
    /// declaration, and that is said rather than left silent, without
    /// repeating which repository it was.
    excluded: Option<(usize, &'static str)>,
    /// Other lanes in this tree that joined as a worktree of one of these
    /// repositories while it still had no root commit recorded, and are
    /// still declared with none (`f609`): each one's id, its name kept as
    /// it was, and the repository it shares with this folder's own, now
    /// carrying the root commit this run just found for it.
    stale_worktrees: Vec<(String, String, crate::event::Repo)>,
}

/// `scanned`, filtered through the redaction guard (`d600`): what is left
/// to declare, and the count and first rule of whatever it kept out.
/// Shared by declaring a lane's own folder and by declaring `main` on the
/// tree's own folder, whether that happens because someone asked for it
/// or because `ensure_first_event` needs to seed it -- one piece of work,
/// one place that does it.
fn filtered_repos(
    scanned: Vec<crate::event::Repo>,
) -> (Vec<crate::event::Repo>, Option<(usize, &'static str)>) {
    let mut excluded_count = 0usize;
    let mut excluded_rule: Option<&'static str> = None;
    let repos = scanned
        .into_iter()
        .filter(
            |r| match crate::redact::check_field("repository path", &r.path) {
                Some(f) => {
                    excluded_count += 1;
                    excluded_rule.get_or_insert(f.rule);
                    false
                }
                None => true,
            },
        )
        .collect();
    (
        repos,
        (excluded_count > 0).then(|| (excluded_count, excluded_rule.unwrap())),
    )
}

/// The tree at `tree_root`, folded once. A `.vivac/` that is empty or not
/// there at all (`f566`, or no tree yet) folds to `Tree::default`, which
/// answers every question below the same way absence always has --
/// `main_claimed: false`, nothing declared -- so callers never need to
/// know which kind of "nothing" they got. Shared by `plan_lane`'s own
/// decision and by `existing_lane`, so a `setup` run folds the tree once
/// rather than once per question asked of it.
fn fold_tree(tree_root: &Path) -> crate::model::Tree {
    let (events, broken) =
        crate::store::read_all_from(&tree_root.join(crate::store::DIR).join(crate::store::LOG))
            .unwrap_or_default();
    crate::model::fold(&events, broken)
}

/// What the tree already says about `lane_id`, read without writing
/// anything: `Store::open` would fill a missing `config` in on its own,
/// and that write is one `--dry-run` must never trigger just by asking
/// what a tree is on (`t594`). `config_version` reads
/// `ConfigVersion::One` for a tree with no config at all -- the same
/// answer `Store::open` would settle on for a tree with no lane and no
/// pillar or rule either, so `needs_lock` comes out right either way
/// without this having to know why the file is missing.
struct ExistingLane {
    config_version: crate::store::ConfigVersion,
    declared: Option<(String, Vec<crate::event::Repo>)>,
}

fn existing_lane(tree: &Path, lane_id: &str, folded: &crate::model::Tree) -> ExistingLane {
    ExistingLane {
        config_version: crate::store::peek_config_version(tree)
            .unwrap_or(crate::store::ConfigVersion::One),
        declared: folded
            .lanes
            .get(lane_id)
            .map(|s| (s.name.clone(), s.repos.clone())),
    }
}

/// `t594` §4.5.2's five cases, decided from `roots` alone: whether there is
/// a tree above `here` at all, and whether `here` already carries its own
/// `.vivac/lane` (`Located::lane_dir == here`, rather than some ancestor's)
/// -- plus a sixth, `t594`: `here` holds the tree, has no
/// lane file, and `main` has already been claimed by another folder
/// (`main_claimed`). Declaring `main` there again would be a lie about
/// where `main` actually lives, so this mints `here` a lane of its own
/// instead, the same as any other folder that never had one.
fn plan_lane(roots: &super::Roots, lane_name: Option<&str>) -> LanePlan {
    let (repos, excluded) = filtered_repos(crate::repos::scan(&roots.here));

    let folder_name = roots
        .here
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    // `--lane-name` (`t594` §4.5's own `--lane-name <name>`), or this
    // folder's own name when nobody named it: the word `declared_name`
    // guards below either way, for every lane -- `main` included since
    // `d624`, which made `main_lane` (`:891-897`) fall back to this same
    // folder name instead of staying literally `main` when nobody names
    // it. Accepting `--lane-name` and silently doing nothing with it --
    // §2.3 names both planting and joining -- would be worse than either
    // using it or refusing it outright (`t594`).
    let requested_name = lane_name.unwrap_or(&folder_name);
    let here_has_its_own_vivac = roots
        .located
        .as_ref()
        .is_some_and(|l| l.lane_dir == roots.here);
    // Folded once, ahead of the decision below, which needs to know
    // whether `main` has already been claimed elsewhere before it can
    // tell "here is main" apart from "here holds the tree, but is not
    // main any more" -- and `existing_lane`, further down, needs the
    // very same fold.
    let folded = fold_tree(&roots.tree);

    let (lane_id, name, is_new) = match &roots.located {
        None => main_lane(lane_name, &folder_name),
        Some(l) if here_has_its_own_vivac && l.lane.is_none() && !folded.main_claimed => {
            main_lane(lane_name, &folder_name)
        }
        Some(l) if here_has_its_own_vivac && l.lane.is_none() => {
            let id = crate::lane::new_id();
            let name = crate::lane::declared_name(&id, requested_name);
            (id, name, true)
        }
        Some(l) if here_has_its_own_vivac => {
            let id = l.lane.as_ref().unwrap().id.clone();
            let name = crate::lane::declared_name(&id, requested_name);
            (id, name, false)
        }
        Some(_) => {
            let id = crate::lane::new_id();
            let name = crate::lane::declared_name(&id, requested_name);
            (id, name, true)
        }
    };

    let existing = existing_lane(&roots.tree, &lane_id, &folded);
    let needs_lock = existing.config_version != crate::store::ConfigVersion::Lanes;
    let unchanged = existing
        .declared
        .is_some_and(|(n, r)| n == name && r == repos);
    let stale_worktrees = stale_worktree_roots(&roots.here, &repos, &lane_id, &folded);

    LanePlan {
        lane_id,
        name,
        repos,
        is_new,
        needs_lock,
        unchanged,
        excluded,
        stale_worktrees,
    }
}

/// The already-declared worktree lanes one of `here`'s own repositories
/// explains but never told: each one joined while its matching repository
/// here still had no root commit recorded, copied that absence forward
/// (`ops::resolve_whose`), and nothing has revisited it since -- the
/// tree's own fold has no way to tell a worktree lane's folder apart from
/// any other lane's, so this reads it straight off git's own worktree
/// bookkeeping instead of guessing at it from the fold alone (`f609`).
///
/// Skips `lane_id`: a repository whose own root just changed already gets
/// declared by the caller through the ordinary path, and finding it here
/// too would only redeclare it a second time under the same identity.
fn stale_worktree_roots(
    here: &Path,
    repos: &[crate::event::Repo],
    lane_id: &str,
    folded: &crate::model::Tree,
) -> Vec<(String, String, crate::event::Repo)> {
    let mut out = Vec::new();
    for repo in repos {
        let Some(root) = &repo.root else { continue };
        for worktree in linked_worktrees_of(&here.join(&repo.path)) {
            let Ok(Some(lane)) = crate::lane::read(&worktree.join(crate::store::DIR)) else {
                continue;
            };
            if lane.id == lane_id {
                continue;
            }
            let Some(state) = folded.lanes.get(&lane.id) else {
                continue;
            };
            let pending_shape = [crate::event::Repo {
                path: ".".to_string(),
                root: None,
            }];
            if state.repos == pending_shape {
                out.push((
                    lane.id,
                    state.name.clone(),
                    crate::event::Repo {
                        path: ".".to_string(),
                        root: Some(root.clone()),
                    },
                ));
            }
        }
    }
    out
}

/// Every worktree git still links to the repository at `repo_root`, read
/// off `.git/worktrees/*/gitdir` rather than spawning `git worktree list`:
/// one file read costs nothing beside the `git rev-list` `repos::scan`
/// already pays for this same folder, and a worktree git has pruned
/// leaves no `gitdir` file behind for this to find in the first place
/// (`f609`).
fn linked_worktrees_of(repo_root: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(repo_root.join(".git").join("worktrees")) else {
        return Vec::new();
    };
    entries
        .flatten()
        .filter_map(|e| std::fs::read_to_string(e.path().join("gitdir")).ok())
        .filter_map(|raw| PathBuf::from(raw.trim()).parent().map(Path::to_path_buf))
        .collect()
}

/// `main`'s id never changes. Its name falls back to this folder's own
/// name exactly like every other lane's (`:837`), unless `lane_name`
/// asks for a different one (`d624`).
fn main_lane(lane_name: Option<&str>, folder_name: &str) -> (String, String, bool) {
    let requested_name = lane_name.unwrap_or(folder_name);
    let name = crate::lane::declared_name(crate::lane::MAIN, requested_name);
    (crate::lane::MAIN.to_string(), name, false)
}

/// The tree's own first event id, seeding one when there is none: a brand
/// new lane's own `.vivac/lane` needs a stable id to point back at
/// (`resolve_lane`, `store.rs` -- it reads a tree's first line as the
/// cheap fingerprint that ties a lane to the right tree), and there is
/// nothing stable to point at in a tree that has never written anything,
/// which a tree fresh out of `init` or a bare plant still is.
///
/// The seed is the tree's own implicit `main` declaring itself for real,
/// with its own folder's actual repositories -- the same walk declaring
/// `main` by hand would do, and not a placeholder: task 8 decides with
/// this list whether a linked worktree is one of the lane's own
/// repositories or a lane apart, and an empty list would hand it the
/// wrong answer (`t594`). Taken and released under its
/// own lock, before the new lane's own lock is taken, since a second
/// attempt to lock the same file from this same process would otherwise
/// wait on itself.
///
/// If this write succeeds and the log's first line still will not parse
/// as an id right after, that is not this call's own failure to undo --
/// it already appended a real event and already locked the config, and
/// the log only ever grows. The error says so, since the caller cannot.
fn ensure_first_event(tree: &Path) -> Result<String, Failure> {
    if let Some(id) = crate::store::first_event_id(tree) {
        return Ok(id);
    }
    let (repos, _excluded) = filtered_repos(crate::repos::scan(tree));
    let store = crate::store::Store::open(tree.to_path_buf())?;
    let mut ctx = crate::ops::Ctx::load_for_write(
        store,
        crate::ops::Whose::Declared(crate::lane::MAIN.to_string(), tree.to_path_buf()),
    )?;
    ctx.lock_for_write()?;
    crate::ops::declare_lane(&mut ctx, crate::lane::MAIN.to_string(), repos)?;
    crate::store::first_event_id(tree).ok_or_else(|| {
        Failure::Io(std::io::Error::other(
            "this folder's main lane was just declared to give the tree a first \
             event, and locked its config to match, and the tree's own first \
             line is still unreadable after that -- the log only ever grows, \
             so what was just written stays either way",
        ))
    })
}

/// What this run actually does, in order: this folder's own `.vivac/lane`
/// on disk first -- only for a brand new lane, and with no lock held over
/// it at all -- and only then `declare_lane`, which takes the write lock,
/// locks the config and emits `lane.declared` together.
///
/// That is *not* `t594` §4.5.2's own order, which puts the file inside the
/// lock and after the config is closed. This one is at least as safe: if
/// the process dies between the file and the lock, the folder already
/// knows whose thread it is and the tree finds out the moment the fold
/// sees the matching event, which is exactly what dying between the file
/// and the event -- the ordering the spec itself calls safe -- already
/// leaves behind. If it dies between the file and the *config* closing
/// specifically, the tree does not have a lane event yet either, so an
/// older vivac reading it in between is not being lied to. What the file
/// must never do is land *after* the event: that is the one ordering that
/// leaves a folder signing as `main` while the tree already says
/// otherwise, and nothing here permits it.
fn write_lane(roots: &super::Roots, plan: &LanePlan) -> Result<(), Failure> {
    if plan.is_new {
        let project = ensure_first_event(&roots.tree)?;
        let lane = crate::lane::Lane {
            version: 1,
            id: plan.lane_id.clone(),
            project,
        };
        crate::lane::write(&roots.here.join(crate::store::DIR), &lane)?;
    }

    let store = crate::store::Store::open(roots.tree.clone())?;
    // `Whose::Declared`, not `Whose::Resolved`: this lane is `plan`'s own
    // decision, already made from `roots` and `repos::scan` above, and
    // `t594` §2.3's own resolution -- built for a folder that has not
    // said which lane it is yet -- would ask a question this call already
    // answered, and could answer it differently for a worktree `setup`
    // is declaring by hand rather than leaving to join on its own
    // (`t594`). `roots.here`, not `roots.tree`: `plan.repos` was scanned
    // from `roots.here` too, and a redeclaration reads this folder back
    // through `where_to_write` -- a lane joined from elsewhere is not
    // sitting at the tree's own root.
    let mut ctx = crate::ops::Ctx::load_for_write(
        store,
        crate::ops::Whose::Declared(plan.lane_id.clone(), roots.here.clone()),
    )?;
    ctx.lock_for_write()?;
    crate::ops::declare_lane(&mut ctx, plan.name.clone(), plan.repos.clone())?;
    redeclare_stale_worktrees(&mut ctx, plan)
}

/// Just `plan`'s stale-worktree redeclarations (`f609`), for a run whose
/// own lane has nothing new to declare -- `write_lane` above is not
/// reached at all in that case, and a worktree stuck with no root commit
/// from before this folder's own ever had one would otherwise stay stuck
/// on every such run, forever, once this folder's own declaration has
/// settled. Opens the tree's write lock on its own, the same way
/// `relock_lanes` does, since there is no other write in this run to
/// share it with.
fn redeclare_only_stale_worktrees(roots: &super::Roots, plan: &LanePlan) -> Result<(), Failure> {
    let store = crate::store::Store::open(roots.tree.clone())?;
    let mut ctx = crate::ops::Ctx::load_for_write(
        store,
        crate::ops::Whose::Declared(plan.lane_id.clone(), roots.here.clone()),
    )?;
    ctx.lock_for_write()?;
    redeclare_stale_worktrees(&mut ctx, plan)
}

/// `plan.stale_worktrees`, applied one at a time under `ctx`'s already-held
/// lock. Shared by `write_lane`, which reaches it right after declaring
/// this folder's own lane, and by `redeclare_only_stale_worktrees`, which
/// has no declaration of its own to declare first.
fn redeclare_stale_worktrees(ctx: &mut crate::ops::Ctx, plan: &LanePlan) -> Result<(), Failure> {
    for (lane, name, repo) in plan.stale_worktrees.clone() {
        redeclare_worktree_root(ctx, lane, name, repo)?;
    }
    Ok(())
}

/// Redeclares a stale worktree lane's own repository with the root commit
/// its founding lane just learned, straight through `Store::append`
/// rather than `Ctx::emit` (`f609`). `emit` would run `where_to_write`
/// against `ctx.lane_dir`, which is wherever this run is standing --
/// `roots.here`, never the worktree's own folder this call never visited
/// -- and hand that lane a location that is not its own. Writing only
/// `lane.declared` says the one thing this run actually knows: the
/// repository's root commit, and nothing about where that lane is right
/// now.
fn redeclare_worktree_root(
    ctx: &mut crate::ops::Ctx,
    lane: String,
    name: String,
    repo: crate::event::Repo,
) -> Result<(), Failure> {
    let lock = ctx
        .lock
        .as_ref()
        .ok_or_else(|| Failure::Io(std::io::Error::other("write without the tree's lock")))?;
    let appended = ctx.store.append(
        lock,
        &lane,
        vec![crate::event::Body::LaneDeclared {
            lane: lane.clone(),
            name,
            repos: vec![repo],
        }],
        ctx.tree.seq,
        ctx.tree.has_governance,
    )?;
    for e in &appended.events {
        ctx.tree.apply(e.seq, &e.ts, &e.lane, &e.payload);
    }
    Ok(())
}

/// Locks the tree's config to `t594`'s own sentence without touching the
/// log: for a lane whose declaration already matches (`unchanged`), there
/// is nothing new to say, but the config can still have lost the lock
/// underneath it -- by hand, or by an older `Store::open` regenerating one
/// that went missing before it knew a lane event counts too (`t594`).
/// `unchanged` must never decide this on its own: a folder
/// that has nothing new to declare can still be the reason the config
/// needs relocking.
fn relock_lanes(tree: &Path) -> Result<(), Failure> {
    let mut store = crate::store::Store::open(tree.to_path_buf())?;
    let lock = store.lock_for_write()?;
    store.lock_lanes_in_config(&lock)?;
    Ok(())
}

/// The clause text for a `Failure`, without doubling an `Io` variant's own
/// "Input/output error:" prefix once `failure_with_rollback` wraps it a
/// second time (`t594`): `Failure::message` already adds
/// that prefix for `Io`, and the planting failure this mirrors uses a raw
/// `std::io::Error` -- which has no such prefix to begin with -- for the
/// exact same reason.
fn detail_of(e: &Failure) -> String {
    match e {
        Failure::Io(io) => io.to_string(),
        other => other.message(),
    }
}

/// The exit-5 text for a lane declaration or a config relock that failed,
/// after `unrestored` -- what `super::rollback` could not put back among
/// the settings/mcp/skill/gitignore pieces -- is already known.
///
/// Unlike `failure_with_rollback`, this never says every file came back:
/// by the time either call above can fail, a real event may already sit
/// in the tree's own log (`ensure_first_event`'s seed) or the config may
/// already be locked, and neither of those is a file `rollback` ever
/// touches or could undo. `t565` §7.7 accepts the same gap for planting,
/// on the same reasoning -- but planting never writes anything of
/// informational value before it can fail, and a lane's own event does,
/// so this says the log stays instead of claiming a rollback it did not
/// do and cannot do.
fn lane_failure_with_rollback(clause: String, unrestored: &[PathBuf]) -> Failure {
    let mut message = clause;
    if unrestored.is_empty() {
        message.push_str(
            ", so setup put the settings, the server entry and the skill back\n  \
             as they were. Whatever this already wrote to the tree's own log stays\n  \
             either way: the log only ever grows.",
        );
    } else {
        message.push_str(", and setup could not put these back as they were:\n");
        for p in unrestored {
            message.push_str(&format!("      {}\n", p.display()));
        }
        message.push_str(
            "  setup keeps no copy on disk, so the only other copy is whatever\n  \
             version control holds. Whatever this already wrote to the tree's own\n  \
             log stays either way: the log only ever grows.",
        );
    }
    Failure::Io(std::io::Error::other(message))
}

/// Every root commit any lane of `tree` has declared, deduplicated and
/// sorted: the same union `relocate::union_repo_roots` computes, for the
/// same reason -- `note_registry`'s own `Sighting.repos` wants every
/// repository this tree's lanes declare, not just the one this run
/// happens to be about, so a later `setup` elsewhere can tell that a
/// folder it has never seen still holds this product (`t594` §4.8,
/// `registry::Sighting.repos`'s own doc).
fn union_repo_roots(tree: &crate::model::Tree) -> Vec<String> {
    let mut roots: Vec<String> = tree
        .lanes
        .values()
        .flat_map(|state| state.repos.iter())
        .filter_map(|repo| repo.root.clone())
        .collect();
    roots.sort();
    roots.dedup();
    roots
}

/// Notes `tree` in this machine's registry, the same bookkeeping every
/// ordinary command already does on its way out (`main.rs`). `setup`
/// itself never used to reach that block -- it returns before it
/// (`f277`) -- and that was harmless while every folder it touched was
/// found by walking up from itself. It stopped being harmless the moment
/// `setup` could join a folder whose only path back to its tree is the
/// registry: a linked worktree that sits beside the tree's own folder
/// rather than above it, which `resolve_lane` (`store.rs`) can only ever
/// find through here (`t594`). Quiet when there is
/// nowhere to note or nothing to note it with yet, the same as the
/// ordinary path.
fn note_registry(roots: &super::Roots) {
    let Some(store_dir) = crate::store::store_dir() else {
        return;
    };
    if let Some(project_id) = crate::store::first_event_id(&roots.tree) {
        let lane = roots.located.as_ref().and_then(|l| {
            l.lane
                .as_ref()
                .map(|lane| (lane.id.as_str(), l.lane_dir.as_path()))
        });
        // The tree is folded once more here, past whatever `plan_lane`
        // already folded: this call always runs after every write this
        // run makes, so it is the one place that can report the whole
        // tree's repositories as they stand once this run is done, the
        // same union `relocate` already writes on a move (`t594` §4.8).
        let repos = union_repo_roots(&fold_tree(&roots.tree));
        let noted = crate::registry::note(
            &store_dir,
            &project_id,
            crate::registry::Sighting {
                root: &roots.tree,
                lane,
                repos: Some(&repos),
            },
        );
        // Left for `registry::warn_if_wrote` to decide, once this run is
        // done and can say whether it actually wrote anything: the
        // `nothing_to_write` branch above reaches this call too, and that
        // one is a read (`t594`).
        crate::registry::set_pending(noted);
    }
}

// ---------------------------------------------------------------------------
// Formatting: the two-column plan lines `t565` §7.8 fixes the width of.
// ---------------------------------------------------------------------------

fn piece_line(label: &str, status: &str) -> String {
    format!("    {label:<41}{status}\n")
}

fn sub_line(label: &str, value: &str) -> String {
    format!("        {label:<15}{value}\n")
}

fn wrapped_piece_line(label: &str, first: &str, second: &str) -> String {
    format!("    {label:<41}{first}\n{:45}{second}\n", "")
}

// ---------------------------------------------------------------------------
// `--join`: `t594` §4.5's own escape from §6.3, and the remedy `--new-tree`
// or a fresh `setup` plants past instead. Narrower than `apply`, on
// purpose: it resolves a tree that lives somewhere else, writes this
// folder's own `.vivac/lane` and declares the lane there -- and nothing
// about the hooks, the server or the skill, since a folder that already
// ran setup somewhere else has no reason to run it a second time here.
// ---------------------------------------------------------------------------

/// `spec`, printed back exactly as typed when the tree it names cannot be
/// joined: a person's own words, the same reasoning `relocate`'s own
/// destination is printed under -- not a path this tool went looking for.
fn join(
    roots: &super::Roots,
    spec: &str,
    lane_name: Option<&str>,
    dry_run: bool,
) -> Result<i32, Failure> {
    let target = crate::registry::resolve(spec)?;
    if !crate::store::already_planted(&target) {
        return Err(Failure::Model(format!(
            "  \"{spec}\" has no tree yet, so there is nothing to join.\n  \
             Plant one there first:  vivac setup claude-code"
        )));
    }
    // §4.5: refuses when this folder already is a lane of *another* tree --
    // joining the very one it already resolves to does nothing at all, since
    // there is nothing left to do. A folder that holds a tree of its own
    // gets a different text: it carries no lane to redirect, it carries
    // the tree (`t594`).
    if let Some(l) = &roots.located {
        if !crate::anchor::same_folder(&l.root, &target) {
            if crate::anchor::same_folder(&roots.here, &l.root) {
                return Err(Failure::already_has_a_tree());
            }
            // Two different folders reach this line, and only one of them
            // is a lane: the one that carries `.vivac/lane` itself.
            // Everything else here has no `.vivac/` of its own at all and
            // simply resolves up into the tree above it, which is a
            // different sentence -- `already_a_lane` names a file that
            // folder does not have.
            if lane_carried_by(l, &roots.here).is_some() {
                return Err(Failure::already_a_lane());
            }
            return Err(tree_above_refusal(&l.root));
        }
        // The same tree, and this folder already carries the lane file
        // that says so: everything below would mint a second lane id for
        // a folder that already has one, orphaning the stack, the focus
        // and the counters the first one holds. Nothing is written and
        // nothing is appended, so this returns ahead of `--dry-run` too:
        // what that flag reports is what a run would do, and this run
        // would do nothing either way.
        if let Some(id) = lane_carried_by(l, &roots.here) {
            say_nothing_was_done(&target, id, lane_name);
            return Ok(0);
        }
    }
    // Never `spec`, and never `target` either (`t594`):
    // unlike the "no tree yet" refusal above, this is the one place `join`
    // would otherwise echo a path back that a person did not necessarily
    // type themselves -- `spec` might have resolved through a project
    // name, not a path at all.
    let Some(project) = crate::store::first_event_id(&target) else {
        return Err(Failure::Model(
            "  That tree has no events yet, so there is nothing to join: it has\n  \
             no identity yet for a lane to point back at."
                .to_string(),
        ));
    };

    let id = crate::lane::new_id();
    let folder_name = roots
        .here
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    let name = crate::lane::declared_name(&id, lane_name.unwrap_or(&folder_name));
    let (repos, _excluded) = filtered_repos(crate::repos::scan(&roots.here));

    // `--dry-run` promises nothing is written by any path: `apply` got
    // that back once and `--join` reopened it (`t594`), so nothing below
    // this point runs.
    if dry_run {
        match crate::registry::folder_name(&target) {
            Some(name) => outln!("  This folder would become a lane of the tree in \"{name}\"."),
            None => {
                outln!("  This folder would become a lane of a tree elsewhere on this machine.")
            }
        }
        outln!("  Nothing written: --dry-run.");
        return Ok(0);
    }

    // The file first, unlocked, then the event under the target's own
    // lock: the same order `write_lane` already follows and the same
    // reason -- the one ordering that must never happen is the event
    // landing first, which would leave this folder signing as `main`
    // while the tree it just joined already says otherwise.
    crate::lane::write(
        &roots.here.join(crate::store::DIR),
        &crate::lane::Lane {
            version: 1,
            id: id.clone(),
            project,
        },
    )?;

    let store = crate::store::Store::open(target.clone())?;
    let mut ctx = crate::ops::Ctx::load_for_write(
        store,
        crate::ops::Whose::Declared(id.clone(), target.clone()),
    )?;
    ctx.lock_for_write()?;
    crate::ops::declare_lane(&mut ctx, name, repos)?;

    // The same registry bookkeeping `note_registry` does for `apply`, but
    // keyed by `target` -- this folder's own tree, not `roots.tree`, which
    // still names no tree of its own at all. Quiet on any failure, the
    // same promise `note_registry` already makes -- but not thrown away:
    // `lane::write` and `declare_lane` above have already written for
    // real by the time this runs, so whatever `note` says here is left
    // for `registry::warn_if_wrote` to act on (`t594`).
    if let Some(store_dir) = crate::store::store_dir() {
        if let Some(project_id) = crate::store::first_event_id(&target) {
            let target_repos = union_repo_roots(&fold_tree(&target));
            let noted = crate::registry::note(
                &store_dir,
                &project_id,
                crate::registry::Sighting {
                    root: &target,
                    lane: Some((&id, &roots.here)),
                    repos: Some(&target_repos),
                },
            );
            crate::registry::set_pending(noted);
        }
    }

    // What a person does not already know after joining, which is the whole
    // of `d595` in two sentences: the knowledge stayed the product's and the
    // thread became this folder's. The tree is named by its folder, never by
    // its path, and the name is withheld when the redaction guard rejects it
    // (`d600`) -- the sentence survives without it.
    match crate::registry::folder_name(&target) {
        Some(name) => outln!("  This folder is now a lane of the tree in \"{name}\"."),
        None => outln!("  This folder is now a lane of a tree elsewhere on this machine."),
    }
    outln!("  The nodes and their numbering are the product's; the stack, the focus");
    outln!("  and the last stop are this folder's.");
    Ok(0)
}

/// The id of the lane `here` itself is, or `None` for a folder that merely
/// resolves up into a tree above it. The one criterion, asked in the two
/// places `join` needs it: a lane file, carried by this folder rather than
/// by some ancestor. `same_folder`, never a path compared as text -- a
/// second spelling of the same folder is the same folder (`f612`).
fn lane_carried_by<'a>(l: &'a crate::store::Located, here: &Path) -> Option<&'a str> {
    let lane = l.lane.as_ref()?;
    crate::anchor::same_folder(&l.lane_dir, here).then_some(lane.id.as_str())
}

/// What a person learns from a `--join` that had nothing left to do: that
/// it is done already, and that this run left it alone. It reads as an
/// answer rather than as a refusal because a re-run of the provisioning a
/// team shares is the ordinary way to arrive here -- the same reason a
/// plain `setup` run twice says the tree was already there.
///
/// The second sentence is for `--lane-name` asking for a name the lane
/// does not have: the flag was read and not acted on, and a flag accepted
/// in silence leaves nothing behind to say it was ignored (`t594`).
/// Asking for the name it already carries needs no sentence --
/// nothing was left undone. The tree is folded only for that question, so
/// a run without the flag reads no log at all.
fn say_nothing_was_done(target: &Path, lane_id: &str, lane_name: Option<&str>) {
    outln!("  This folder is already a lane of that tree, and setup changed nothing in it.");
    let Some(requested) = lane_name else {
        return;
    };
    // `declared_name` is what the name would have become had it been
    // written, redaction guard and all (`d600`): comparing the raw request
    // instead would report a difference the write itself would have
    // collapsed.
    let requested = crate::lane::declared_name(lane_id, requested);
    let current = fold_tree(target)
        .lanes
        .get(lane_id)
        .map(|s| s.name.clone())
        .unwrap_or_default();
    if current != requested {
        outln!("  The lane name it already has was left as it is.");
    }
}

// ---------------------------------------------------------------------------
// Applying: plan, ask, write.
// ---------------------------------------------------------------------------

fn apply(roots: &super::Roots, a: &Args) -> Result<i32, Failure> {
    // `run` already refused the home folder and the global store before
    // reaching here (`t594`): both guards used to live in
    // this function alone, which is exactly what let `--join` skip them.
    refuse_second_map(roots, a.has("new-tree"))?;

    let here = &roots.here;
    let tree = &roots.tree;
    // `t594` §4.5, case 2's own extra check (§6.5): never blocks anything,
    // so it is worked out once, up front, and printed alongside whichever
    // of the three exits below this run actually reaches.
    let above_warning =
        tree_root_above(tree).map(|p| tree_above_warning(guarded_folder_name(&p).as_deref()));
    let paths = paths(here);
    let settings = read_json(&paths.settings);
    let mcp = read_json(&paths.mcp);
    let skill_raw = std::fs::read_to_string(&paths.skill).ok();

    let mut conflicts: Vec<String> = Vec::new();
    if let Some((line, col)) = settings.parse_error {
        conflicts.push(unreadable_conflict(SETTINGS_LABEL, line, col));
    } else if settings.not_object {
        conflicts.push(not_object_conflict(SETTINGS_LABEL));
    }
    if let Some((line, col)) = mcp.parse_error {
        conflicts.push(unreadable_conflict(MCP_LABEL, line, col));
    } else if mcp.not_object {
        conflicts.push(not_object_conflict(MCP_LABEL));
    }

    let mcp_root = mcp.value.clone().unwrap_or_else(|| Value::object(vec![]));
    let mcp_server_state = if mcp.parse_error.is_none() && !mcp.not_object {
        mcp_state(&mcp_root)
    } else {
        McpState::Missing
    };
    if let McpState::NameTaken(cmd) = &mcp_server_state {
        conflicts.push(mcp_name_conflict(cmd));
    }

    let skill_file_state = match &skill_raw {
        None => SkillState::Missing,
        Some(text) => skill_state(text),
    };
    if matches!(skill_file_state, SkillState::Conflict) {
        conflicts.push(skill_conflict());
    }

    if !conflicts.is_empty() {
        let mut msg = conflicts.join("\n\n");
        msg.push_str("\n\n  Nothing written.");
        return Err(Failure::Model(msg));
    }

    let settings_root = settings.value.clone().unwrap();
    let start_hook_state = hook_state(
        &settings_root,
        "SessionStart",
        "start",
        SESSION_START_COMMAND,
    );
    let stop_hook_state = hook_state(&settings_root, "Stop", "end", SESSION_END_COMMAND);

    let vivac_missing = !crate::store::already_planted(tree);
    // A tree this run plants already carries its `.gitignore`, straight out
    // of `Store::create`: only a tree from before `t594` §4.9 can lack it.
    let gitignore_missing = !vivac_missing
        && !tree
            .join(crate::store::DIR)
            .join(crate::store::GITIGNORE)
            .is_file();
    let start_missing = matches!(start_hook_state, HookState::Missing);
    let stop_missing = matches!(stop_hook_state, HookState::Missing);
    let mcp_missing = matches!(mcp_server_state, McpState::Missing);
    let skill_missing_or_replaceable = matches!(
        skill_file_state,
        SkillState::Missing | SkillState::Replaceable
    );

    let lane = plan_lane(roots, a.opt("lane-name"));

    let nothing_to_write = !vivac_missing
        && !gitignore_missing
        && !start_missing
        && !stop_missing
        && !mcp_missing
        && !skill_missing_or_replaceable
        && lane.unchanged
        && !lane.needs_lock
        && lane.stale_worktrees.is_empty();

    let piece_block = render_piece_block(
        here,
        tree,
        vivac_missing,
        gitignore_missing,
        settings.exists,
        mcp.exists,
        &start_hook_state,
        &stop_hook_state,
        start_missing,
        stop_missing,
        &mcp_server_state,
        &skill_file_state,
        &lane,
    );

    // Asked once per run, and before either early exit below, so a log
    // already tracked is flagged whether this run has anything else to
    // write or not: someone already set up is exactly who never reaches
    // the branch that used to be the only one carrying this warning.
    let log_tracked = crate::anchor::in_working_tree(tree)
        && crate::anchor::tracks(tree, ".vivac/events") == Some(true);

    // Checked before `nothing_to_write`, not after: that branch notes the
    // registry (`note_registry`), and `--dry-run` promises to write
    // nothing anywhere, the machine's registry included (`t594`).
    // An already-set-up project asking for `--dry-run` used
    // to reach the other branch first and note it anyway.
    if a.has("dry-run") {
        outln!("{piece_block}{TRAILING_PARAGRAPH}\n  Nothing written: --dry-run.");
        if log_tracked {
            print!("{}", tracked_git_warning());
        }
        if let Some(w) = &above_warning {
            print!("{w}");
        }
        return Ok(0);
    }

    if nothing_to_write {
        // A real run, never `--dry-run`, thanks to the check above: noting
        // the registry is bookkeeping every ordinary command already does
        // on a pure read, not a write this promise is about.
        note_registry(roots);
        outln!("{piece_block}  Nothing to write: this project is already set up.");
        if log_tracked {
            print!("{}", tracked_git_warning());
        }
        if let Some(w) = &above_warning {
            print!("{w}");
        }
        return Ok(0);
    }

    if !a.has("yes") && !super::stdin_is_terminal() {
        return Err(Failure::Model(NO_TERMINAL_TEXT.to_string()));
    }

    print!("{piece_block}{TRAILING_PARAGRAPH}");
    let proceed = a.has("yes") || super::ask("\n  Write it? [y/N] ");
    if !proceed {
        outln!("\n  Nothing written.");
        return Ok(0);
    }

    // Build every write, then commit them together (`t565` §7.3: "se
    // pregunta una sola vez por todo y se escribe todo o nada").
    let mut writes = Vec::new();
    if start_missing || stop_missing {
        let mut new_settings = settings_root.clone();
        if start_missing {
            append_hook(&mut new_settings, "SessionStart", SESSION_START_COMMAND);
        }
        if stop_missing {
            append_hook(&mut new_settings, "Stop", SESSION_END_COMMAND);
        }
        let rendered = json::finalize(
            &json::render(&new_settings, &settings.indent),
            settings.eol,
            settings.trailing_newline,
        );
        let before = settings_root.clone();
        writes.push(super::PlannedWrite {
            path: paths.settings.clone(),
            action: super::Action::Write(rendered),
            original: settings.exists.then(|| settings.raw.clone().into_bytes()),
            preserved: Some(Box::new(move |updated| json::extends(&before, updated))),
        });
    }

    if mcp_missing {
        let mut new_mcp = mcp_root.clone();
        let mut servers = new_mcp
            .get("mcpServers")
            .cloned()
            .unwrap_or_else(|| Value::object(vec![]));
        servers.set("vivac", our_mcp_entry());
        new_mcp.set("mcpServers", servers);
        let rendered = json::finalize(
            &json::render(&new_mcp, &mcp.indent),
            mcp.eol,
            mcp.trailing_newline,
        );
        let before = mcp_root.clone();
        writes.push(super::PlannedWrite {
            path: paths.mcp.clone(),
            action: super::Action::Write(rendered),
            original: mcp.exists.then(|| mcp.raw.clone().into_bytes()),
            preserved: Some(Box::new(move |updated| json::extends(&before, updated))),
        });
    }

    if skill_missing_or_replaceable {
        writes.push(super::PlannedWrite::write(
            paths.skill.clone(),
            skill_text(),
            skill_raw.clone().map(String::into_bytes),
        ));
    }

    if gitignore_missing {
        writes.push(super::PlannedWrite::write(
            tree.join(crate::store::DIR).join(crate::store::GITIGNORE),
            "*\n".to_string(),
            None,
        ));
    }

    super::commit(&writes)?;

    // Planting is the one step this run takes after the commit above, which
    // may already have written `.vivac/.gitignore` (`gitignore_missing`) --
    // so `.vivac/` is not untouched by the time this runs. What stays true
    // is narrower: planting itself never rolls back. A failure here undoes
    // the JSON commit by hand, but whatever `Store::create` managed to
    // write in `.vivac/` before failing is left exactly as it is (`t565`
    // §7.7).
    if vivac_missing {
        if let Err(e) = crate::store::Store::create(tree) {
            let unrestored = super::rollback(&writes);
            return Err(super::failure_with_rollback(
                format!("the tree could not be planted ({e})"),
                &unrestored,
            ));
        }
    }

    // Declaring the lane, or just relocking the config, goes right after
    // planting, next to it: never rolled back on its own, only the JSON
    // commit undone by hand if it fails -- `write_lane`'s own doc explains
    // why that is still safe.
    if !lane.unchanged {
        if let Err(e) = write_lane(roots, &lane) {
            let unrestored = super::rollback(&writes);
            return Err(lane_failure_with_rollback(
                format!("the lane could not be declared ({})", detail_of(&e)),
                &unrestored,
            ));
        }
    } else {
        // Nothing new about this lane's own declaration, but a worktree
        // from before this fix existed can still be stuck with no root
        // commit, and `write_lane` above is only ever reached when this
        // lane itself has something new to say (`f609`).
        if !lane.stale_worktrees.is_empty() {
            if let Err(e) = redeclare_only_stale_worktrees(roots, &lane) {
                let unrestored = super::rollback(&writes);
                return Err(lane_failure_with_rollback(
                    format!("the lane could not be declared ({})", detail_of(&e)),
                    &unrestored,
                ));
            }
        }
        if lane.needs_lock {
            // Nothing new to declare, but the config still needs the lock
            // `unchanged` must never decide on its own (`t594`):
            // here the only write is the lock itself, so a failure has
            // nothing irreversible to own up to and the ordinary wording
            // is accurate as it stands.
            if let Err(e) = relock_lanes(tree) {
                let unrestored = super::rollback(&writes);
                return Err(super::failure_with_rollback(
                    format!(
                        "the tree's config could not be relocked ({})",
                        detail_of(&e)
                    ),
                    &unrestored,
                ));
            }
        }
    }

    // What *this run* actually did to the tree, for `written_text`
    // (`t594`): every one of these is independent, and `needs_lock`
    // decides `config_locked` regardless of which branch above closed
    // it -- both `write_lane`'s own `declare_lane` and `relock_lanes`
    // close the same lock, and only ever do it for real when it was
    // still open beforehand.
    let written = Written {
        connection: start_missing || stop_missing || mcp_missing,
        skill: skill_missing_or_replaceable,
        planted: vivac_missing,
        gitignore_created: gitignore_missing,
        lane_declared: !lane.unchanged || !lane.stale_worktrees.is_empty(),
        config_locked: lane.needs_lock,
        undoable: start_missing
            && stop_missing
            && mcp_missing
            && matches!(skill_file_state, SkillState::Missing),
    };
    note_registry(roots);
    print!("\n{}", written_text(&written));
    if log_tracked {
        print!("{}", tracked_git_warning());
    }
    if let Some(w) = &above_warning {
        print!("{w}");
    }
    Ok(0)
}

#[allow(clippy::too_many_arguments)]
fn render_piece_block(
    here: &Path,
    tree: &Path,
    vivac_missing: bool,
    gitignore_missing: bool,
    settings_exists: bool,
    mcp_exists: bool,
    start_hook_state: &HookState,
    stop_hook_state: &HookState,
    start_missing: bool,
    stop_missing: bool,
    mcp_server_state: &McpState,
    skill_file_state: &SkillState,
    lane: &LanePlan,
) -> String {
    let mut s = format!("  vivac setup claude-code, in {}\n\n", here.display());

    // `t579` §4's warning: only when `here` sits inside a repository but is
    // not its root, so nobody has to guess which folder Claude Code was
    // actually opened in.
    if !here.join(".git").exists() {
        if let Some(git_root) = super::git_root_above(here) {
            s.push_str(&format!(
                "  This folder is inside the repository at {}, not at its root.\n  \
                 Claude Code reads these files only from the folder it is opened in: if\n  \
                 you open it at {}, run setup there instead.\n\n",
                git_root.display(),
                git_root.display()
            ));
        }
    }

    let vivac_status = if vivac_missing {
        "plant the tree".to_string()
    } else if tree == here {
        "already there".to_string()
    } else {
        format!("already there, in {}", tree.display())
    };
    s.push_str(&piece_line(VIVAC_LABEL, &vivac_status));
    if gitignore_missing {
        // Two different files, in two different folders, can both need
        // this line in the same run -- the tree's own, from before `t594`
        // §4.9, and a brand new lane's own (below). Only then does the
        // tree's own copy say whose it is; on its own it reads exactly as
        // it always has (`t594`).
        let status = if lane.is_new {
            "create: keeps the tree's .vivac/ out of version control"
        } else {
            "create: keeps .vivac/ out of version control"
        };
        s.push_str(&piece_line(GITIGNORE_LABEL, status));
    }

    let settings_status = match (settings_exists, start_missing, stop_missing) {
        (_, false, false) => "already has both hooks",
        (_, true, false) => "add the SessionStart hook",
        (_, false, true) => "add the Stop hook",
        (false, true, true) => "create, with two hooks",
        (true, true, true) => "add two hooks",
    };
    s.push_str(&piece_line(SETTINGS_LABEL, settings_status));
    match start_hook_state {
        HookState::Missing => s.push_str(&sub_line("SessionStart", SESSION_START_COMMAND)),
        HookState::Different(cmd) => {
            s.push_str(&sub_line("SessionStart", &format!("already runs  {cmd}")))
        }
        HookState::Exact => {}
    }
    match stop_hook_state {
        HookState::Missing => s.push_str(&sub_line("Stop", SESSION_END_COMMAND)),
        HookState::Different(cmd) => s.push_str(&sub_line("Stop", &format!("already runs  {cmd}"))),
        HookState::Exact => {}
    }

    let mcp_status = match mcp_server_state {
        McpState::Missing if !mcp_exists => "create, with the server \"vivac\"".to_string(),
        McpState::Missing => "add the server \"vivac\"".to_string(),
        McpState::Ours => "already has the server \"vivac\"".to_string(),
        McpState::OtherName(name) => format!("already runs vivac mcp as \"{name}\""),
        McpState::NameTaken(_) => unreachable!("a name conflict never reaches the plan"),
    };
    s.push_str(&piece_line(MCP_LABEL, &mcp_status));
    if matches!(mcp_server_state, McpState::Missing) {
        s.push_str("        vivac mcp\n");
    }

    match skill_file_state {
        SkillState::Missing => s.push_str(&wrapped_piece_line(
            SKILL_LABEL,
            "create: how an agent brings",
            "another memory into vivac",
        )),
        SkillState::Replaceable => s.push_str(&piece_line(
            SKILL_LABEL,
            "replace the copy an earlier vivac wrote",
        )),
        SkillState::Same => s.push_str(&piece_line(SKILL_LABEL, "already there")),
        SkillState::Conflict => unreachable!("a skill conflict never reaches the plan"),
    }

    if !lane.unchanged {
        if lane.is_new {
            s.push_str(&piece_line(
                LANE_LABEL,
                &format!(
                    "create: this folder becomes lane \"{}\" of the tree above",
                    lane.name
                ),
            ));
            s.push_str(&piece_line(
                GITIGNORE_LABEL,
                "create: keeps .vivac/ out of version control",
            ));
        } else {
            // One sentence for both: declaring `main` on the tree's own
            // folder and redeclaring a lane that already existed are the
            // same write, and neither creates a file the way a brand new
            // lane does above -- it is the log that changes.
            s.push_str(&piece_line(
                ".vivac/events",
                &format!(
                    "record: this folder is lane \"{}\", with its repositories",
                    lane.name
                ),
            ));
        }
    }
    // Independent of `unchanged` too: a worktree can be stuck with no root
    // commit from before this folder's own repositories ever had one,
    // which a run that finds nothing new of its own to declare still
    // repairs (`f609`).
    if !lane.stale_worktrees.is_empty() {
        let count = lane.stale_worktrees.len();
        let noun = if count == 1 { "lane" } else { "lanes" };
        s.push_str(&piece_line(
            ".vivac/events",
            &format!("redeclare {count} worktree {noun} with the repositories this run found"),
        ));
    }
    // What the redaction guard kept out is the folder's own state, not a
    // change: it is still true on a run that declares nothing new, so it
    // is said every time rather than only on the run that first found it
    // (`t594`).
    if let Some((count, rule)) = lane.excluded {
        let noun = if count == 1 {
            "repository"
        } else {
            "repositories"
        };
        s.push_str(&sub_line(
            "kept out",
            &format!("{count} {noun}, refused: {rule}"),
        ));
    }
    // Independent of `unchanged`: the config can need the lock even when
    // nothing about the declaration itself changed (`t594`).
    if lane.needs_lock {
        s.push_str(&piece_line(
            "config",
            "lock: from now on this tree needs vivac 0.12 or newer",
        ));
    }

    s.push('\n');
    s
}

const TRAILING_PARAGRAPH: &str = "  The hooks run a command in every session, and the server is how the\n  agent writes to the tree. Nothing outside this directory is touched,\n  and no file is copied.\n";

const NO_TERMINAL_TEXT: &str = "  setup asks before writing, and there is no terminal here to ask.\n  See what it would write:  vivac setup claude-code --dry-run\n  Then write it:            vivac setup claude-code --yes";

/// What this run wrote, which decides how it ends (`t579` §15.5): a
/// paragraph is only printed when it is true of this run, and it says
/// what that run did, no more and no less (`t594`) -- every one of
/// these is a separate thing `apply` can write to the tree or the
/// folder, and any subset of them can be true together.
struct Written {
    /// A hook or the server, which only a new session picks up.
    connection: bool,
    /// The skill, where it was missing or an earlier release's copy.
    skill: bool,
    /// The tree, planted by this run rather than found.
    planted: bool,
    /// The tree's own `.vivac/.gitignore`, on a tree from before `t594`
    /// §4.9 that never got one (`gitignore_missing`). Independent of
    /// everything else here: a tree can be missing this and have its
    /// lanes fully settled, or the other way round.
    gitignore_created: bool,
    /// This run declared this folder's lane, redeclared an existing one,
    /// or redeclared a worktree lane stuck with no root commit (`f609`):
    /// a real change to the tree's own log, either way.
    lane_declared: bool,
    /// This run closed the lanes lock, whether that happened on its own
    /// (nothing else changed) or alongside declaring the lane above
    /// (`t594` first tried to treat these as mutually
    /// exclusive, which they are not: a brand new lane commonly closes
    /// the lock in the very same write that declares it).
    config_locked: bool,
    /// All four of setup's pieces, the skill among them missing before:
    /// `--undo` removes all four, so only then does it take back exactly
    /// this run.
    undoable: bool,
}

fn written_text(w: &Written) -> String {
    let mut s = String::from("  Written.\n");
    if w.connection {
        s.push_str(SESSION_PARAGRAPH);
    } else if w.skill {
        s.push_str(SKILL_PARAGRAPH);
    }
    if w.planted {
        s.push_str(MIGRATE_PARAGRAPHS);
    } else {
        s.push_str(&tree_paragraph(
            w.gitignore_created,
            w.lane_declared,
            w.config_locked,
        ));
    }
    s.push_str(FILES_PARAGRAPH);
    if w.undoable {
        s.push_str(UNDO_LINE);
    }
    s
}

/// The paragraph about the tree itself, once planting it is ruled out
/// (`MIGRATE_PARAGRAPHS` covers that): `TREE_KEPT_PARAGRAPH` when none of
/// the three actually happened, and one sentence naming exactly the ones
/// that did otherwise -- never more than what this run wrote, and never
/// silent about any of it.
///
/// The three are independent, and saying so in the type is the fix: they
/// were mutually exclusive branches before, so a run that did two of them
/// could only name one, and a run that only wrote the tree's `.gitignore`
/// had no branch at all and claimed to have changed nothing -- two lines
/// under its own plan announcing that write (`t594`).
fn tree_paragraph(gitignore_created: bool, lane_declared: bool, config_locked: bool) -> String {
    let mut clauses = Vec::new();
    if gitignore_created {
        clauses.push("its own .gitignore");
    }
    if lane_declared {
        clauses.push("this folder's own thread");
    }
    if config_locked {
        clauses.push("the sentence that stops an older vivac from reading it");
    }
    if clauses.is_empty() {
        return TREE_KEPT_PARAGRAPH.to_string();
    }
    // Noun phrases rather than verb phrases: they share one subject, so
    // two of them join without the reader having to carry a verb across
    // the list, and none of them can be read as belonging to this run
    // rather than to the tree.
    format!(
        "\n{}",
        wrapped(&format!(
            "The tree was already there, and setup wrote in it: {}.",
            join_with_and(&clauses)
        ))
    )
}

/// `text`, wrapped to the same width every other paragraph in this file
/// already wraps to by hand, each line indented by two spaces. A plain
/// greedy word wrap is all this needs: nothing it ever wraps runs past a
/// short sentence naming one to three clauses.
fn wrapped(text: &str) -> String {
    const WIDTH: usize = 76;
    let mut out = String::new();
    let mut line = String::from("  ");
    for word in text.split_whitespace() {
        if line.len() + word.len() + 1 > WIDTH && line.trim() != "" {
            out.push_str(line.trim_end());
            out.push('\n');
            line = String::from("  ");
        }
        line.push_str(word);
        line.push(' ');
    }
    out.push_str(line.trim_end());
    out.push('\n');
    out
}

/// `items`, in English list form: one on its own, two joined by "and",
/// three or more comma-separated with "and" before the last.
fn join_with_and(items: &[&str]) -> String {
    match items {
        [] => String::new(),
        [one] => one.to_string(),
        [a, b] => format!("{a} and {b}"),
        _ => {
            let (last, rest) = items.split_last().expect("checked non-empty above");
            format!("{} and {last}", rest.join(", "))
        }
    }
}

const SESSION_PARAGRAPH: &str = "\n  Open a new Claude Code session in this folder. The brief arrives on its\n  own when it starts. If Claude Code asks whether to use the \"vivac\" server\n  from .mcp.json, say yes: it is what lets the agent write to the tree.\n";

const SKILL_PARAGRAPH: &str = "\n  The vivac-migrate skill is now the one this version of vivac ships.\n  Sessions opened from now on use it.\n";

const MIGRATE_PARAGRAPHS: &str = "\n  Nothing has been brought in from anywhere yet. To bring in what this\n  project already knows, from another memory system, the harness's own\n  memory, instruction files or its documents, ask the agent:\n\n      Use the vivac-migrate skill to bring everything this project knows\n      into vivac.\n\n  It shows you a plan before writing anything, checks what it wrote, and\n  offers to retire the other maps one at a time, only if you say yes.\n\n  Until then, another memory system you use keeps talking to the agent as\n  before, and may tell it to use that system first. That is expected: the\n  skill only reads from it.\n";

const TREE_KEPT_PARAGRAPH: &str =
    "\n  The tree was already there, and setup changed nothing in it.\n";

const FILES_PARAGRAPH: &str = "\n  The hooks, the server and the skill are plain files in this project:\n  commit them if everyone who works here uses vivac, and keep them out of\n  version control if only you do. .vivac/ is never committed: it is this\n  machine's record, and a copy of it in every clone would diverge from the\n  others. Its own .gitignore keeps it out.\n";

const UNDO_LINE: &str = "\n  Undo:  vivac setup claude-code --undo\n";

/// The words come from `anchor::EVENTS_TRACKED_WARNING` (`f619`), wrapped
/// to this file's own paragraph width: `check` reads that very same
/// constant, so the two can no longer drift the way they once did, and
/// `check`'s copy never named a worktree at all.
fn tracked_git_warning() -> String {
    format!("\n{}", wrapped(crate::anchor::EVENTS_TRACKED_WARNING))
}

fn unreadable_conflict(label: &str, line: usize, column: usize) -> String {
    format!(
        "  {label} is not JSON setup can read (line {line}, column {column}), so\n  \
         it will not touch it: a file it cannot read is a file it could only\n  \
         overwrite."
    )
}

fn not_object_conflict(label: &str) -> String {
    format!(
        "  {label} holds JSON whose top level is not an object, so setup will\n  \
         not touch it: a file it cannot read is a file it could only overwrite."
    )
}

fn mcp_name_conflict(command_and_args: &str) -> String {
    format!(
        "  .mcp.json already has a server called \"vivac\", and it does not run\n  \
         vivac mcp:\n      {command_and_args}\n  \
         setup never rewrites an entry it did not write. Rename or remove that\n  \
         one, then run setup again."
    )
}

fn skill_conflict() -> String {
    "  .claude/skills/vivac-migrate/SKILL.md is already there, and either setup\n  \
     did not write it or it was changed since. setup never overwrites it:\n  \
     move it away, then run setup again."
        .to_string()
}

// ---------------------------------------------------------------------------
// `--undo`.
// ---------------------------------------------------------------------------

fn undo(root: &Path, a: &Args) -> Result<i32, Failure> {
    let paths = paths(root);
    let settings = read_json(&paths.settings);
    let mcp = read_json(&paths.mcp);
    let skill_raw = std::fs::read_to_string(&paths.skill).ok();

    let mut conflicts: Vec<String> = Vec::new();
    if let Some((line, col)) = settings.parse_error {
        conflicts.push(unreadable_conflict(SETTINGS_LABEL, line, col));
    } else if settings.not_object {
        conflicts.push(not_object_conflict(SETTINGS_LABEL));
    }
    if let Some((line, col)) = mcp.parse_error {
        conflicts.push(unreadable_conflict(MCP_LABEL, line, col));
    } else if mcp.not_object {
        conflicts.push(not_object_conflict(MCP_LABEL));
    }
    if !conflicts.is_empty() {
        let mut msg = conflicts.join("\n\n");
        msg.push_str("\n\n  Nothing written.");
        return Err(Failure::Model(msg));
    }

    let settings_root = settings.value.clone().unwrap();
    let start_hook_state = hook_state(
        &settings_root,
        "SessionStart",
        "start",
        SESSION_START_COMMAND,
    );
    let stop_hook_state = hook_state(&settings_root, "Stop", "end", SESSION_END_COMMAND);
    let mcp_root = mcp.value.clone().unwrap();
    let mcp_server_state = mcp_state(&mcp_root);
    let skill_ours = skill_raw.as_deref().is_some_and(skill_fingerprint_intact);

    let start_ours = matches!(start_hook_state, HookState::Exact);
    let stop_ours = matches!(stop_hook_state, HookState::Exact);
    let mcp_ours = matches!(mcp_server_state, McpState::Ours);

    let nothing_to_undo = !start_ours && !stop_ours && !mcp_ours && !skill_ours;
    if nothing_to_undo {
        outln!("  Nothing to undo: none of what setup writes is here.");
        return Ok(0);
    }

    // Preview the settings.json result to know whether it empties out.
    let mut preview = settings_root.clone();
    if start_ours {
        remove_hook(&mut preview, "SessionStart", SESSION_START_COMMAND);
    }
    if stop_ours {
        remove_hook(&mut preview, "Stop", SESSION_END_COMMAND);
    }
    let settings_becomes_empty = preview
        .as_object()
        .is_some_and(|s: &[(String, Value)]| s.is_empty());

    let mcp_becomes_empty = mcp_ours
        && without_our_mcp_server(&mcp_root)
            .as_object()
            .is_some_and(|s: &[(String, Value)]| s.is_empty());

    let settings_status: String = match (start_ours, stop_ours) {
        (true, true) if settings_becomes_empty => {
            "remove the two hooks setup wrote;\nNOTHING_ELSE".to_string()
        }
        (true, true) => "remove the two hooks setup wrote".to_string(),
        (true, false) => "remove the SessionStart hook".to_string(),
        (false, true) => "remove the Stop hook".to_string(),
        (false, false) => "left as it is".to_string(),
    };

    let mut s = format!(
        "  vivac setup claude-code --undo, in {}\n\n",
        root.display()
    );
    if settings_status.contains("NOTHING_ELSE") {
        s.push_str(&wrapped_piece_line(
            SETTINGS_LABEL,
            "remove the two hooks setup wrote;",
            "nothing else is left, so it goes",
        ));
    } else {
        s.push_str(&piece_line(SETTINGS_LABEL, &settings_status));
    }
    if let HookState::Different(_) = &start_hook_state {
        s.push_str(&sub_line(
            "SessionStart",
            "runs vivac another way; left as it is",
        ));
    }
    if let HookState::Different(_) = &stop_hook_state {
        s.push_str(&sub_line("Stop", "runs vivac another way; left as it is"));
    }

    if mcp_becomes_empty {
        s.push_str(&wrapped_piece_line(
            MCP_LABEL,
            "remove the server \"vivac\";",
            "nothing else is left, so it goes",
        ));
    } else {
        s.push_str(&piece_line(
            MCP_LABEL,
            if mcp_ours {
                "remove the server \"vivac\""
            } else {
                "left as it is"
            },
        ));
    }

    s.push_str(&piece_line(
        SKILL_LABEL,
        if skill_ours {
            "remove"
        } else if skill_raw.is_some() {
            "changed since setup wrote it; left as it is"
        } else {
            "left as it is"
        },
    ));

    s.push_str(&piece_line(VIVAC_LABEL, "kept: the tree is not setup's"));
    s.push('\n');

    if a.has("dry-run") {
        outln!("{s}  Nothing written: --dry-run.");
        return Ok(0);
    }

    print!("{s}");
    let proceed = a.has("yes") || super::ask("  Undo it? [y/N] ");
    if !proceed {
        outln!("\n  Nothing written.");
        return Ok(0);
    }

    // Every change -- a rewrite or a removal -- is one commit, the same
    // all-or-nothing guarantee `apply` gives (`t565` §7.6).
    let mut writes = Vec::new();
    if start_ours || stop_ours {
        let original = settings.raw.clone().into_bytes();
        if settings_becomes_empty {
            writes.push(super::PlannedWrite::delete(
                paths.settings.clone(),
                original,
            ));
        } else {
            let mut new_settings = settings_root.clone();
            if start_ours {
                remove_hook(&mut new_settings, "SessionStart", SESSION_START_COMMAND);
            }
            if stop_ours {
                remove_hook(&mut new_settings, "Stop", SESSION_END_COMMAND);
            }
            let rendered = json::finalize(
                &json::render(&new_settings, &settings.indent),
                settings.eol,
                settings.trailing_newline,
            );
            let before = settings_root.clone();
            writes.push(super::PlannedWrite {
                path: paths.settings.clone(),
                action: super::Action::Write(rendered),
                original: Some(original),
                preserved: Some(Box::new(move |updated| {
                    json::contained_in(updated, &before)
                })),
            });
        }
    }
    if mcp_ours {
        let original = mcp.raw.clone().into_bytes();
        if mcp_becomes_empty {
            writes.push(super::PlannedWrite::delete(paths.mcp.clone(), original));
        } else {
            let new_mcp = without_our_mcp_server(&mcp_root);
            let rendered = json::finalize(
                &json::render(&new_mcp, &mcp.indent),
                mcp.eol,
                mcp.trailing_newline,
            );
            let before = mcp_root.clone();
            writes.push(super::PlannedWrite {
                path: paths.mcp.clone(),
                action: super::Action::Write(rendered),
                original: Some(original),
                preserved: Some(Box::new(move |updated| {
                    json::contained_in(updated, &before)
                })),
            });
        }
    }
    if skill_ours {
        writes.push(super::PlannedWrite::delete(
            paths.skill.clone(),
            skill_raw.clone().unwrap().into_bytes(),
        ));
    }

    super::commit(&writes)?;

    // Best-effort, and only once the commit above is known to have
    // succeeded: an empty directory left behind costs nothing to leave for
    // a later run, but is tidier gone.
    if skill_ours {
        remove_if_empty(paths.skill.parent());
        remove_if_empty(paths.skill.parent().and_then(Path::parent));
        remove_if_empty(
            paths
                .skill
                .parent()
                .and_then(Path::parent)
                .and_then(Path::parent),
        );
    }

    outln!("  Undone. The tree in .vivac/ is untouched.");
    Ok(0)
}

fn remove_if_empty(dir: Option<&Path>) {
    if let Some(dir) = dir {
        let _ = std::fs::remove_dir(dir);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `tree_below_refusal`'s own fallback for two or more trees below
    /// whose names the redaction guard withholds entirely: unspecified by
    /// `t594` §1.2, which only names the plural form's shape, not what it
    /// says once nothing is nameable at all -- so it earns its keep by
    /// having a test rather than by being removed (`t594`).
    #[test]
    fn tree_below_refusal_with_every_name_withheld_says_so_without_naming_anyone() {
        let secret_a = "someone@example.com";
        let secret_b = "other@example.com";
        assert!(
            crate::redact::check_field("folder name", secret_a).is_some(),
            "the guard must actually reject this name, or the test proves nothing"
        );
        let paths = vec![
            PathBuf::from("/tmp").join(secret_a),
            PathBuf::from("/tmp").join(secret_b),
        ];

        let msg = tree_below_refusal(&paths).message();

        assert!(
            msg.contains("under names this tool will not write down"),
            "{msg}"
        );
        assert!(!msg.contains(secret_a), "{msg}");
        assert!(!msg.contains(secret_b), "{msg}");
    }

    /// The same promise for the refusal's mirror image, upward: the tree
    /// above is named, and a name the redaction guard rejects is not
    /// written down at all -- the sentence still says where to go and
    /// read it.
    #[test]
    fn tree_above_refusal_with_the_name_withheld_says_so_without_naming_anyone() {
        let secret = "someone@example.com";
        assert!(
            crate::redact::check_field("folder name", secret).is_some(),
            "the guard must actually reject this name, or the test proves nothing"
        );

        let msg = tree_above_refusal(&PathBuf::from("/tmp").join(secret)).message();

        assert!(
            msg.contains("A tree sits above this folder, in another folder,"),
            "{msg}"
        );
        assert!(msg.contains("vivac brief"), "{msg}");
        assert!(!msg.contains(secret), "{msg}");
    }

    #[test]
    fn is_vivac_command_strips_quotes_path_and_extension() {
        assert!(is_vivac_command("vivac"));
        assert!(is_vivac_command("VIVAC"));
        assert!(is_vivac_command("\"vivac\""));
        assert!(is_vivac_command("C:/tools/vivac.exe"));
        assert!(is_vivac_command("C:\\tools\\vivac.EXE"));
        assert!(is_vivac_command("/usr/local/bin/vivac"));
        assert!(!is_vivac_command("vivacx"));
        assert!(!is_vivac_command("notvivac"));
    }

    #[test]
    fn the_fingerprint_matches_the_known_hash_of_the_literal_text() {
        // Computed independently (Python's own FNV-1a/64) over the exact
        // frontmatter and body this file embeds.
        assert_eq!(skill_fingerprint(), 0x53833e2dadbcf537);
    }

    #[test]
    fn extract_marker_reads_back_what_skill_text_writes() {
        let text = skill_text();
        let (fp_hex, content) = extract_marker(&text).unwrap();
        let fp = u64::from_str_radix(&fp_hex, 16).unwrap();
        assert_eq!(fp, skill_fingerprint());
        assert_eq!(content, skill_content_without_marker());
    }
}