magi-cli 0.19.0

Blind multi-agent implementation competition: N agents implement, M judges rank blind, deliberate, vote privately, winner survives double review + E2E gate
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
//! Run configuration: the agent roster, the shape of the graph, and the
//! blindness / verification policy.
//!
//! Discovery order (first hit wins):
//!
//! 1. `--config <path>`
//! 2. `<repo>/magi.toml`
//! 3. `<repo>/.magi/config.toml`
//! 4. `<config_dir>/magi/config.toml`
//! 5. built-in defaults, with the agent roster derived from the agent CLIs
//!    actually installed on this machine
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context as _, Result, bail};
use serde::{Deserialize, Serialize};

/// Which CLI drives an agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentKind {
    /// Anthropic Claude Code (`claude -p`).
    Claude,
    /// opencode (`opencode run`).
    Opencode,
    /// Antigravity CLI (`agy -p`). Gemini CLI is deliberately absent: Google
    /// retired the standalone client for individual accounts in favour of this
    /// one, so an adapter for it would be dead code on a live machine.
    Antigravity,
    /// OpenAI Codex CLI (`codex exec`). The one roster member with a real
    /// read-only mode: `--sandbox read-only` is enforced by the CLI, not by
    /// the prompt.
    Codex,
    /// oh-my-pi (`omp -p --mode=json`). Another CLI with no read-only mode of
    /// its own - `--auto-approve` gates reads and writes together - so a
    /// read-only seat rests on the prompt and on the worktree discipline the
    /// other non-codex seats already rely on. It is also the roster member that
    /// reaches DeepSeek, whose models ship in `omp`'s own catalog.
    Omp,
    /// Arbitrary command. The escape hatch, and what the test suite drives.
    Command,
}

impl AgentKind {
    /// Executable that must be on `PATH` for this kind, if any.
    pub fn program(self) -> Option<&'static str> {
        match self {
            Self::Claude => Some("claude"),
            Self::Opencode => Some("opencode"),
            Self::Antigravity => Some("agy"),
            Self::Codex => Some("codex"),
            Self::Omp => Some("omp"),
            Self::Command => None,
        }
    }

    /// Lowercase name as written in the config file.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Claude => "claude",
            Self::Opencode => "opencode",
            Self::Antigravity => "antigravity",
            Self::Codex => "codex",
            Self::Omp => "omp",
            Self::Command => "command",
        }
    }

    /// Every kind the roster can name, in display order.
    ///
    /// This is what `magi doctor` lists, and it has to be one list rather than
    /// the same set typed out again wherever a kind is enumerated. The last
    /// time it was typed out twice, `omp` was added as a roster member and the
    /// doctor output went on saying the machine had four CLIs - which reads as
    /// "that agent is not installed" to the person the command exists for.
    pub const ALL: [Self; 6] = [
        Self::Claude,
        Self::Opencode,
        Self::Antigravity,
        Self::Codex,
        Self::Omp,
        Self::Command,
    ];
}

/// How the prompt reaches the agent process.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Delivery {
    /// Piped on stdin.
    Stdin,
    /// Passed as a positional argument. Beware OS command-line limits.
    Argv,
    /// Written to a file; the agent is told to read it. No length limit.
    File,
}

/// One addressable agent in the roster.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AgentSpec {
    /// Stable identifier used by `[roles]` and by the stats tables.
    pub id: String,
    /// Which CLI to drive.
    pub kind: AgentKind,
    /// Model passed through to the CLI (`--model` / `-m`). CLI default if unset.
    #[serde(default)]
    pub model: Option<String>,
    /// `kind = "command"` only: argv. Supports `{prompt_file}`, `{cwd}`,
    /// `{label}`, `{session}` placeholders.
    #[serde(default)]
    pub command: Vec<String>,
    /// Extra arguments appended to the built command line.
    #[serde(default)]
    pub extra_args: Vec<String>,
    /// Extra environment variables for the child process.
    #[serde(default)]
    pub env: BTreeMap<String, String>,
    /// Override the per-kind prompt delivery default.
    #[serde(default)]
    pub prompt_delivery: Option<Delivery>,
}

impl AgentSpec {
    /// Default prompt delivery for this agent.
    ///
    /// `opencode` and `agy` take the prompt as an argument, which on Windows
    /// caps out around 32 KiB — well under a judging prompt carrying three
    /// patches — so both get a file instead.
    pub fn delivery(&self) -> Delivery {
        self.prompt_delivery.unwrap_or(match self.kind {
            AgentKind::Claude | AgentKind::Command => Delivery::Stdin,
            // `codex exec -` reads the prompt from stdin, so the whole
            // instruction arrives without an argv length limit and without a
            // tool round-trip to open a file.
            AgentKind::Codex => Delivery::Stdin,
            // `omp -p` reads the prompt from stdin too, and a judging prompt
            // carrying three patches is well past the Windows argv cap, so this
            // is the only delivery that works for every node.
            AgentKind::Omp => Delivery::Stdin,
            AgentKind::Opencode | AgentKind::Antigravity => Delivery::File,
        })
    }

    /// Human-facing label, e.g. `opus (claude:opus)`.
    pub fn display(&self) -> String {
        match &self.model {
            Some(m) => format!("{} ({}:{m})", self.id, self.kind.as_str()),
            None => format!("{} ({})", self.id, self.kind.as_str()),
        }
    }
}

/// Explicit role assignment. Empty lists are filled in by
/// [`Config::resolve_roles`] by rotating the roster.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Roles {
    /// Agents that implement the task, one worktree each.
    pub implementers: Vec<String>,
    /// Agents that rank the candidates blind.
    pub judges: Vec<String>,
    /// Agents that review the winning patch.
    pub reviewers: Vec<String>,
    /// Agent that applies review findings. Defaults to the winner's author.
    pub fixer: Option<String>,
    /// Agent that answers the standing chat's turns (`src/talk.rs`).
    ///
    /// Unset picks a `claude` seat, else the first runnable agent in roster
    /// order (see [`crate::agent::pick`]) - which is roster *order*, not a
    /// judgement about who converses well. Naming one here matters once an
    /// agent is also sitting as a judge: the chat is opened far more often
    /// than any single competition, and every open competes with that judge
    /// seat for the same account's concurrency. A timeout on an ordinary chat
    /// turn traced to exactly this - `opus` triple-booked as chatter and judge
    /// - is what this field exists to let an operator break apart.
    pub chatter: Option<String>,
    /// Agent that arranges the queue between polls: `crate::conduct`'s single
    /// seat, called once per cycle to decide a runnable task's `blocked_by`
    /// and how a stalled or finished task recovers.
    ///
    /// The same precedent as [`Self::chatter`] for a seat that stands alone
    /// rather than rotating through the roster - resolved through
    /// [`crate::agent::pick`], so unset falls back to its own default order
    /// (a claude seat, else the first runnable agent) rather than reusing a
    /// judge or reviewer seat that the conductor's own poll-cycle cadence
    /// would otherwise compete with for the same account's concurrency.
    pub conductor: Option<String>,
}

/// Graph shape and limits.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Graph {
    /// Parallel implementations of the same task. **One by default.**
    ///
    /// Competition is the thing magi is for, and it is still here - it is just
    /// no longer what every task buys without being asked. Three days and 13
    /// runs on this repository, which is the workload these numbers are drawn
    /// from:
    ///
    /// - **0 of 13** competed runs reached a merge. Everything that landed in
    ///   that window went through `magi review` - the cheap half, no
    ///   competition - and passed on the first try.
    /// - The judges' first choices **split 73% of the time** (8 of 11
    ///   tallies). Candidates that close together make the ranking a weak
    ///   signal for what it costs to produce.
    /// - One run's own breakdown: implement 60min, judge 40min, fix 40min,
    ///   review 28min, **verify 5min** - and verify is the node that caught a
    ///   defect every reviewer had passed as clean. The cheapest step is the
    ///   one that earns its place every time.
    ///
    /// It is not worthless: `oc` won 3 of those tallies against `sonnet`, so a
    /// single-seat default would have shipped the worse implementation in
    /// roughly a quarter of them. That is exactly why this is a *default* and
    /// not a removal - `magi run --candidates N` and a per-task seat count are
    /// how a task that deserves a competition gets one.
    ///
    /// A single-candidate run needs no special case: `Runner::review`'s doc
    /// records that `execute` already degrades to implement -> review -> gate
    /// -> merge, because `judge` skips a one-candidate field, `deliberate` has
    /// no two first choices to reconcile and `vote` returns early.
    pub candidates: usize,
    /// Independent judges.
    pub judges: usize,
    /// Deliberation rounds when the judges' first choices disagree.
    pub deliberate_rounds: usize,
    /// Reviewers per review round. **Three by default** - the smallest panel
    /// a lens cycle (see [`crate::prompt::Lens`]) covers exactly once, so the
    /// default panel reads the patch for spec compliance, regressions, and
    /// simplicity without repeating an angle. Review is also the one stage
    /// [`Self::candidates`]'s doc describes as running on every task
    /// regardless of competition, which is what makes a panel worth its cost
    /// here even though `candidates` itself defaults to one.
    pub reviewers: usize,
    /// Maximum review+fix rounds before the run is declared blocked.
    pub review_rounds: usize,
    /// Maximum agent processes running at once.
    pub max_parallel: usize,
    /// Language for the prose the agents write (`en` / `ja` / any language name).
    pub language: String,
    /// Keep one CLI conversation per seat, so a judge remembers its own
    /// argument across deliberation rounds and the fixer remembers its own
    /// implementation across review rounds.
    ///
    /// Sessions are scoped to a *seat*, never to an agent id: the same model
    /// sitting as implementer and as judge gets two unrelated conversations,
    /// which is what keeps blind judging blind.
    pub sessions: bool,
    /// Per-node timeouts, seconds.
    pub timeout_implement: u64,
    /// Per-node timeouts, seconds.
    pub timeout_judge: u64,
    /// Per-node timeouts, seconds.
    pub timeout_review: u64,
    /// Timeout for `verify.e2e` and `verify.gate`, seconds. Separate from
    /// [`Self::timeout_review`] so shrinking a reviewer's budget cannot
    /// silently shrink a real-machine command's budget too — the two used to
    /// share `timeout_review`, and turning a slow reviewer down cut the
    /// timeout `cargo test --all-targets` runs under along with it. When
    /// omitted, preserves legacy configurations by using
    /// [`Self::timeout_review`]. Set an explicit value to make verification
    /// independent of later review-seat budget changes.
    pub timeout_verify: Option<u64>,
    /// Per-node timeouts, seconds.
    pub timeout_fix: u64,
    /// Wall-clock limit for one turn of [`crate::talk`]'s standing
    /// conversation, seconds.
    ///
    /// An hour: the operator is not watching this turn resolve in real time,
    /// so the budget can match what the work - reading files, running
    /// commands, checking their output - actually needs rather than what a
    /// person waiting on a phone can tolerate.
    pub timeout_talk: u64,
    /// Retries for an agent invocation that fails or returns nothing usable.
    pub retries: usize,
    /// Root for candidate / judge worktrees. Defaults to `~/wt/magi`.
    pub worktree_root: Option<PathBuf>,
    /// After the pull request is open, keep going: watch its checks and
    /// reviews, run a fix round when they are unhappy, and ask to merge.
    ///
    /// On, because stopping at an open pull request left the operator doing
    /// the watching by hand - six times in the session this was built in - and
    /// that is the work the loop exists to take. It only engages for
    /// `merge = "pr"`; every other merge mode ends the run as before.
    ///
    /// Turning this on does **not** hand magi the merge button:
    /// [`Graph::land_approval`] is on too, and nothing merges without an
    /// explicit answer. Setting both to their non-defaults is the only way to
    /// get an unattended merge, and it has to be chosen twice.
    pub land: bool,
    /// Land rounds - watch, fix, push - before the run is left for a human.
    pub land_rounds: usize,
    /// Ask the owner before merging, showing what is about to land.
    ///
    /// On, and it is what makes `land` safe to have on: the question carries a
    /// rendered panel - the diffstat, the patch, the checks, the review
    /// comments that were addressed, and the subject the squash will use - so
    /// the decision is made on evidence rather than on trust, from wherever
    /// the operator happens to be.
    ///
    /// Silence is a hold. An unanswered approval never merges, and neither
    /// does any answer other than the word `merge`.
    pub land_approval: bool,
    /// How long to wait for an owner to answer a question before the run is
    /// abandoned, seconds. A parked run costs nothing, so this is generous;
    /// it exists so a forgotten question cannot pin a worktree forever.
    pub answer_timeout: u64,
    /// What a round does when one or more reviewer seats never answered
    /// (timeout, crash, unparsable output).
    pub incomplete_review: IncompleteReviewPolicy,
    /// Run `verify.e2e` on every round, even one that already has blocking
    /// findings and another round left to try.
    ///
    /// Off by default: a round with a blocking finding and rounds still left
    /// is going back to the fixer regardless of what `verify.e2e` says, so
    /// running it first only spends the round's slowest step (minutes, on a
    /// Rust repo's `cargo test --all-targets`) on a head about to be
    /// rewritten anyway. `verify.e2e` still runs once a round has no
    /// blocking findings left (a round cannot go `clean` without it) and the
    /// final `verify.gate` always runs on the actual tree that would land —
    /// deferring is about *when* e2e runs mid-loop, never about skipping it.
    ///
    /// Set this to restore the old every-round diagnostic behaviour: e2e
    /// output from a round that still has blocking findings is occasionally
    /// useful on its own (a runtime failure a reviewer's panel would not
    /// have caught by reading), and this is the way back to seeing it every
    /// round instead of only once the panel has nothing left to flag.
    pub e2e_every_round: bool,
}

impl Default for Graph {
    fn default() -> Self {
        Self {
            candidates: 1,
            judges: 3,
            deliberate_rounds: 1,
            reviewers: 3,
            review_rounds: 6,
            max_parallel: 4,
            language: "en".to_owned(),
            sessions: true,
            timeout_implement: 3600,
            timeout_judge: 1200,
            timeout_review: 1200,
            timeout_verify: None,
            timeout_fix: 1800,
            timeout_talk: 3600,
            retries: 1,
            worktree_root: None,
            land: true,
            land_rounds: 4,
            land_approval: true,
            answer_timeout: 86_400,
            incomplete_review: IncompleteReviewPolicy::Block,
            e2e_every_round: false,
        }
    }
}

impl Graph {
    /// Effective machine-command budget. Older configuration files had only
    /// `timeout_review`, which also governed verification, so absence is a
    /// compatibility fallback rather than a new 1200-second default.
    pub fn verify_timeout(&self) -> u64 {
        self.timeout_verify.unwrap_or(self.timeout_review)
    }
}

/// What a review round does when a reviewer seat never answered.
///
/// A round where half the panel timed out is not evidence of a clean patch —
/// it is evidence of nothing. The default refuses to call that clean; `warn`
/// exists for an operator who would rather keep a flaky seat from stalling
/// every run, and accepts that the gap is on them to read in the report.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum IncompleteReviewPolicy {
    /// A round with a missing seat is never `clean`: with nothing raised to
    /// fix, the round is re-reviewed instead of gating; with the max rounds
    /// exhausted, the run is left `Blocked` rather than declared ready.
    Block,
    /// A round with a missing seat can still gate as clean, once every seat
    /// that *did* answer raised nothing blocking and verification is green.
    /// The record keeps the gap visible (`magi show`, `magi stats`) even
    /// though the run does not wait on it.
    Warn,
}

/// What to do when vendor-identifying text is found in material shown to judges.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LeakPolicy {
    /// Record the leak, show the patch unmodified.
    Warn,
    /// Replace the token with `[REDACTED]` in the presented patch.
    Redact,
    /// Abort the run.
    Fail,
}

/// Blindness policy.
///
/// Commit messages and candidate summaries are *always* stripped of
/// attribution trailers and redacted — that is where signatures actually
/// appear. [`Blind::on_leak`] governs the patch body only, where blanket
/// redaction would corrupt the artifact under judgement.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Blind {
    /// Install a per-worktree `commit-msg` hook that deletes attribution
    /// trailers before they can land in a candidate's history.
    pub commit_msg_hook: bool,
    /// Literal, case-insensitive substrings. A line containing any of them is
    /// dropped from commit messages and summaries; the `commit-msg` hook is
    /// generated from the same list.
    pub strip_lines: Vec<String>,
    /// Case-insensitive substrings that identify a vendor or model.
    pub vendor_tokens: Vec<String>,
    /// Policy for vendor tokens found in the patch body.
    pub on_leak: LeakPolicy,
    /// Seed for label assignment and per-judge presentation order. Derived from
    /// the run id when unset; set it to make a run reproducible.
    pub seed: Option<u64>,
}

impl Default for Blind {
    fn default() -> Self {
        Self {
            commit_msg_hook: true,
            strip_lines: [
                "Co-Authored-By:",
                "Signed-off-by:",
                "Assisted-by:",
                "Generated-by:",
                "Generated with",
                "\u{1f916}",
            ]
            .iter()
            .map(|s| (*s).to_owned())
            .collect(),
            vendor_tokens: [
                "claude",
                "anthropic",
                "codex",
                "openai",
                "chatgpt",
                "gemini",
                "grok",
                "xai",
                "copilot",
                "opencode",
                "qoder",
                "cursor",
                "\u{1f916}",
            ]
            .iter()
            .map(|s| (*s).to_owned())
            .collect(),
            on_leak: LeakPolicy::Warn,
            seed: None,
        }
    }
}

/// Shell commands that gate the winner.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Verify {
    /// Run in the winner's worktree once per review round. Its output is fed
    /// back to the fixer. This is the "real machine" leg of the review.
    pub e2e: Vec<String>,
    /// Final gate. Must all exit 0 before a merge is attempted.
    pub gate: Vec<String>,
    /// Shell used to run the commands above. Defaults to `sh -c`, or
    /// `cmd /C` when `sh` is not on `PATH`.
    pub shell: Option<Vec<String>>,
}

impl Verify {
    /// The `CARGO_TARGET_DIR=` value of the first rendered command that sets
    /// one, if any. See [`crate::disk::extract_cargo_target_dir`] for the shape
    /// this reads back. One rendering is enough - they all set the same
    /// rendered `{{ vars.cache }}` path via the same shell - and the first e2e
    /// command is checked before the gate because the e2e rebuilds the crate.
    pub fn cache_dir(&self) -> Option<PathBuf> {
        self.e2e
            .iter()
            .chain(self.gate.iter())
            .find_map(|cmd| crate::disk::extract_cargo_target_dir(cmd))
    }
}

/// Disk hygiene: how hard magi is allowed to press on the machine's free space.
///
/// The numbers below come from one incident, not from theory: a machine with
/// 951.8 GB free ran a few competitions and plans and best read 6.7 GB free.
/// Three multi-gigabyte classes of junk accumulated side by side - per-run
/// worktrees that end as `Merged`/`Ready`/`Failed`, a shared build cache whose
/// each verify round and each implementation wave recompiles the derived
/// section of the project into, and the outputs of runs that were removed but
/// whose folders nobody deleted.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Disk {
    /// Free space, in bytes, below which no new run may start: the daemon and
    /// the `magi run` gate answer with "the disk is full" instead of letting
    /// the graph fill it the rest of the way. `0` turns the gate off.
    ///
    /// Default 8 GiB. The incident ran down to 6.7 GB free of 951.8 GB total
    /// before anybody noticed; 8 GiB is enough headroom for the compile a fresh
    /// competition triggers and small enough that a 1 TB disk with 100 GB free
    /// is nowhere near the threshold.
    pub min_free_bytes: u64,
    /// Fold finished runs without being asked. `Merged`, `Ready` and `Failed`
    /// runs older than [`fold_grace_secs`](Self::fold_grace_secs) have their
    /// worktrees removed. `0` turns the janitor off.
    ///
    /// Default true.
    pub auto_fold: bool,
    /// How old a finished run must be before the janitor folds it, seconds.
    ///
    /// Default 6 hours. A run that `Ready` at 8am is the operator's answer; a
    /// run that `Ready` a week ago is worktrees holding a compile each. Six
    /// hours is long enough that nobody loses an answer in the gap between
    /// reading the report and starting from it, and short enough that a backlog
    /// cannot pile up across two nights.
    pub fold_grace_secs: u64,
    /// Ceiling for the shared build cache (`CARGO_TARGET_DIR` in the rendered
    /// verify commands), in bytes. When the janitor runs and the cache is over
    /// it, files are dropped oldest-first until it is not. `0` turns pruning
    /// off - the cache then only ever grows, which is the operator's call.
    ///
    /// Default 10 GiB. This is what the incident measured: 30.61 GB sat in the
    /// shared cache on top of ~16 GB in the primary target directory and 6.7-
    /// 11.15 GB in each of four per-worktree targets. 10 GiB holds a healthy
    /// stack of prebuilt dependencies (cargo's per-file fingerprinting means
    /// pruning only costs the rebuild of the dropped files, not of the world)
    /// without letting one addled cache swallow the machine.
    pub cache_limit_bytes: u64,
}

impl Default for Disk {
    fn default() -> Self {
        Self {
            min_free_bytes: 8 * 1024 * 1024 * 1024,
            auto_fold: true,
            fold_grace_secs: 6 * 60 * 60,
            cache_limit_bytes: 10 * 1024 * 1024 * 1024,
        }
    }
}

/// What to do with the winning branch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MergeMode {
    /// Leave the branch alone and print the merge command matching
    /// [`Merge::style`].
    None,
    /// Merge into the base branch in the primary worktree, using
    /// [`Merge::style`].
    Local,
    /// Push the branch and open a PR with `gh pr create`. Landing this PR
    /// (`[graph] land`) always squashes — see `land`'s module doc — so
    /// [`Merge::style`] does not apply here.
    Pr,
}

/// How the winning branch is attached to the base branch: what
/// `mode = "local"` runs, and what `mode = "none"`'s printed guidance tells
/// the operator to run by hand.
///
/// Read from configuration rather than asked of the repository at run time
/// (e.g. `gh api repos/{owner}/{repo}/rulesets`) for two reasons: it keeps
/// `mode = "none"`'s guidance a pure function of `RunState`, assertable in a
/// unit test the same way `land::decide` is kept pure (see that module's
/// doc), and it works for a base branch that is not hosted on GitHub, or not
/// reachable at all, at the moment the report is rendered. An operator whose
/// base branch enforces a ruleset already knows what it allows; declaring it
/// once here is cheaper than magi re-discovering it, with a network call, on
/// every render.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum MergeStyle {
    /// `git merge --no-ff`: every candidate commit lands, plus a merge
    /// commit that records the merge as its own event in history. Rejected
    /// by a base branch whose ruleset requires linear history or forbids
    /// merge commits outright.
    #[default]
    Merge,
    /// `git merge --squash` followed by a commit under an explicit message:
    /// every candidate commit folds into one, and none of the candidate's
    /// own placeholder subjects (`magi: candidate A (uncommitted work)`)
    /// reach the base branch. No merge commit, so this satisfies a linear-
    /// history ruleset.
    Squash,
    /// A fast-forward-only merge: every candidate commit lands verbatim, in
    /// order, with no merge commit. Only succeeds because the winner was
    /// already rebased onto the tracked base tip before this runs (see
    /// `Runner::sync_to_base`) — equivalent to GitHub's "rebase and merge"
    /// once that has happened.
    Rebase,
}

/// Merge policy.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Merge {
    /// Default is [`MergeMode::None`]: magi never touches your base branch
    /// unless you ask it to.
    pub mode: MergeMode,
    /// Base branch. Defaults to the branch checked out when the run started.
    pub base: Option<String>,
    /// How the winner is attached to `base`; see [`MergeStyle`]. Ignored by
    /// `mode = "pr"`.
    pub style: MergeStyle,
    /// Remote for `mode = "pr"`.
    pub remote: String,
    /// After a `mode = "pr"` run lands, open a `chore/release-vX.Y.Z` pull
    /// request sized to the change by an agent's own judgement, so a version
    /// bump does not depend on a human remembering to cut one.
    ///
    /// On by default. **Turning this off means a merge landed from the phone
    /// never becomes a release**, so `POST /api/upgrade` keeps reporting
    /// "already on the newest release" against a `main` that has moved past
    /// it - the same gap this feature exists to close. Only for a repository
    /// that wants to keep cutting releases by hand.
    pub release_bump: bool,
}

impl Default for Merge {
    fn default() -> Self {
        Self {
            mode: MergeMode::None,
            base: None,
            style: MergeStyle::default(),
            remote: "origin".to_owned(),
            release_bump: true,
        }
    }
}

/// How magi keeps itself current.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum UpdateMode {
    /// Never check.
    Off,
    /// Check in the background and print a one-line banner when a newer
    /// release exists.
    Notify,
    /// Check and install silently.
    Install,
}

/// Self-update policy.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Update {
    /// Default is [`UpdateMode::Notify`]: magi tells you, and lets you decide.
    pub mode: UpdateMode,
    /// Minimum time between checks, e.g. `24h`. kaishin's default when unset.
    pub interval: Option<String>,
}

impl Default for Update {
    fn default() -> Self {
        Self {
            mode: UpdateMode::Notify,
            interval: None,
        }
    }
}

/// Top-level configuration.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
    /// Agent roster.
    pub agents: Vec<AgentSpec>,
    /// Role assignment.
    pub roles: Roles,
    /// Graph shape.
    pub graph: Graph,
    /// Blindness policy.
    pub blind: Blind,
    /// Verification commands.
    pub verify: Verify,
    /// Disk hygiene.
    pub disk: Disk,
    /// Merge policy.
    pub merge: Merge,
    /// Self-update policy.
    pub update: Update,
    /// Project-specific text appended to the node prompts.
    pub prompts: Prompts,
    /// How the operator is told a run is waiting on them.
    pub notify: Notify,
    /// Local repositories the plan surface can start or derive a conversation
    /// against.
    pub repos: Repos,
    /// Policy for the standing conversation ([`crate::talk`]).
    pub talk: Talk,
    /// How many runs `magi serve`'s own loop drives at once.
    pub daemon: Daemon,
}

/// How the daemon loop itself behaves, as opposed to what one run does.
///
/// Machine-layer material in the same sense [`Repos::roots`] is: how many
/// competitions this machine's own loop is willing to babysit at once is a
/// fact about the machine running `magi serve`, not about any one
/// repository's task, so it belongs in `<config_dir>/magi/config.toml`
/// rather than a repository's own `magi.toml` - though, like `Repos::roots`,
/// nothing stops a repository from setting it too, since a scalar field
/// takes whichever layer has the highest precedence.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Daemon {
    /// How many runs `magi serve` may have actively in flight at once.
    /// **One by default** - today's behaviour, one run at a time.
    ///
    /// This is a different knob from [`Graph::max_parallel`], and the two
    /// must not be confused: `max_parallel` bounds how many agent *processes*
    /// one run starts inside itself (implementers, judges, reviewers -
    /// candidates competing on a single task); this field bounds how many
    /// *runs* - whole competitions, each with its own `max_parallel` budget -
    /// the loop drives side by side, possibly across different tasks and
    /// different repositories. Raising `max_parallel` buys a bigger panel for
    /// one task; raising this buys more tasks worked at once. A config file
    /// that meant one and wrote the other would either starve a competition
    /// of judges or leave the rest of the backlog waiting for no reason.
    ///
    /// A run parked waiting on the operator's land-merge approval - see
    /// [`crate::land`] - does not hold one of these slots while it waits: the
    /// whole point of parking there is to let the loop spend the slot on
    /// something runnable instead of sitting on a decision only a human can
    /// make. So even at the default of `1`, an approval that comes back does
    /// not queue behind whatever else the loop happens to be running.
    pub max_concurrent_runs: usize,
}

impl Default for Daemon {
    fn default() -> Self {
        Self {
            max_concurrent_runs: 1,
        }
    }
}

/// Where `magi repos` and `GET /api/repos` look for local checkouts.
///
/// `roots` is one of the array keys [`array_merge_policy`] marks as
/// append-across-layers: which checkouts exist in general is a *machine*
/// fact in the same way the agent roster is - a repository's own `magi.toml`
/// cannot state where its siblings live before magi has resolved which
/// repository to read that file from in the first place - but a repository
/// that genuinely has an extra root worth scanning is not forced to choose
/// between an error and losing the machine's roots outright. Both layers'
/// roots are scanned; see [`Config::refuse_split_arrays`] for the keys that
/// are still refused.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Repos {
    /// Roots to scan for a ghq-layout checkout: `<root>/<host>/<owner>/<repo>`
    /// with a `.git` directory. Empty by default - nothing is scanned unless
    /// asked to be.
    pub roots: Vec<PathBuf>,
    /// How long a scan is trusted before the next request re-scans it,
    /// seconds. `0` means never trust it: scan on every request. Defaults to
    /// a day, the same order of magnitude as [`Graph::answer_timeout`] for
    /// the same reason - a checkout does not usually appear or vanish inside
    /// a session, so there is little to gain from scanning more often than
    /// that, and an explicit refresh exists for the moment one does.
    pub scan_ttl: u64,
}

impl Default for Repos {
    fn default() -> Self {
        Self {
            roots: Vec::new(),
            scan_ttl: 86_400,
        }
    }
}

/// Project-specific text appended to each node's prompt.
///
/// **Additive by construction.** These fields cannot replace magi's prompts,
/// only extend them, and that restriction is the whole design. The built-in
/// prompts carry the invariants the competition rests on: a judging prompt
/// names no authors, every structured answer must arrive as one fenced `json`
/// block, and a judge is told not to speculate about who wrote what. A config
/// that could overwrite them would let a typo silently un-blind the panel or
/// break the parser, and the symptom would be "the judges got worse" rather
/// than an error.
///
/// Repository-wide context belongs in `AGENTS.md`, which every agent already
/// reads from the checkout. Use these fields for the things a *magi node*
/// needs to know and a repository file cannot say - for instance that
/// reviewers here should ignore formatting because a hook owns it.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Prompts {
    /// Appended to every node's prompt.
    pub all: String,
    /// Appended for implementers.
    pub implement: String,
    /// Appended for judges, both ranking and voting.
    pub judge: String,
    /// Appended for reviewers.
    pub review: String,
    /// Appended for the fixer.
    pub fix: String,
}

impl Prompts {
    /// The overlay for one node, or `None` when nothing is configured.
    ///
    /// `node` is the graph's own node name, so a new node gets no overlay
    /// rather than the wrong one.
    pub fn overlay(&self, node: &str) -> Option<String> {
        let specific = match node {
            "implement" => &self.implement,
            "judge" | "vote" | "deliberate" => &self.judge,
            "review" => &self.review,
            "fix" => &self.fix,
            _ => "",
        };
        let mut parts: Vec<&str> = Vec::new();
        for p in [self.all.trim(), specific.trim()] {
            if !p.is_empty() {
                parts.push(p);
            }
        }
        if parts.is_empty() {
            return None;
        }
        Some(parts.join("\n\n"))
    }
}

/// How the operator is told that a run is waiting on them.
///
/// A command rather than a built-in integration: magi is one binary with no
/// network dependencies, and every operator's notification path is different -
/// ntfy, a Slack webhook, a Windows toast, an SSH to a machine that beeps.
/// Shelling out keeps all of them possible and none of them magi's problem.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Notify {
    /// Command and arguments. `{summary}`, `{run}` and `{url}` are replaced.
    /// Empty means no notification - the web UI is then the only surface.
    pub command: Vec<String>,
}

/// Policy for [`crate::talk`], the standing conversation.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields, default)]
pub struct Talk {
    /// Let the conversation's agent edit files in the repository instead of
    /// filing a task for one. **Off by default** - see
    /// [`crate::talk`]'s module doc for why an edit made mid-conversation is
    /// an edit no run and no review can be attributed to, which is exactly
    /// the property a repository entered in a competition depends on. A
    /// repository that is never judged - dotfiles, a personal config
    /// checkout - has nothing to lose by turning this on in its own
    /// `magi.toml`, and a one-line fix stops costing a queued task to get.
    pub allow_write: bool,
}

/// Roles resolved to concrete agent specs for one run.
#[derive(Debug, Clone)]
pub struct ResolvedRoles {
    /// One per candidate.
    pub implementers: Vec<AgentSpec>,
    /// One per judge.
    pub judges: Vec<AgentSpec>,
    /// One per reviewer slot.
    pub reviewers: Vec<AgentSpec>,
    /// Explicit fixer, if configured.
    pub fixer: Option<AgentSpec>,
    /// Queue conductor, explicitly selected or resolved by the standalone-seat fallback.
    pub conductor: AgentSpec,
}

/// Every array-valued key in a config table, as a dotted path.
///
/// Dotted so the error names `roles.implementers` rather than `implementers`:
/// an operator with three config files needs to know which key, not just that
/// there was one. `vars` is skipped because it is teravars' own input, merged
/// on purpose and never deserialised into `Config`.
fn array_keys(table: &toml::value::Table, prefix: &str) -> Vec<String> {
    let mut out = Vec::new();
    for (k, v) in table {
        if prefix.is_empty() && k == "vars" {
            continue;
        }
        let path = if prefix.is_empty() {
            k.clone()
        } else {
            format!("{prefix}.{k}")
        };
        match v {
            toml::Value::Array(_) => out.push(path),
            toml::Value::Table(t) => out.extend(array_keys(t, &path)),
            _ => {}
        }
    }
    out
}

/// How an array key behaves when two config layers both declare it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ArrayMerge {
    /// Two layers may both declare it; the composed value is the
    /// low-to-high-priority concatenation teravars already produces (see
    /// [`Config::load_layers`]'s doc for why that order and no dedup).
    Append,
    /// Two layers declaring it is refused; see
    /// [`Config::refuse_split_arrays`].
    Replace,
}

/// The single place that decides, for a dotted array key (as returned by
/// [`array_keys`]), whether declaring it in two config layers is a
/// concatenation the operator asked for or a silent accident.
///
/// Kept as one match so the whole policy is visible in one place - the same
/// reason `claude_quota` and `dropped_stream` close their own classification
/// in one spot elsewhere in this codebase. Anything not listed defaults to
/// [`ArrayMerge::Replace`]: refusing is the safe default for a key nobody has
/// reasoned about yet, and a new array key added later has to be added here
/// deliberately to become appendable.
///
/// - `verify.e2e` / `verify.gate` — a "run all of these, all must exit 0"
///   gate. Concatenating two of them is exactly the checks both layers
///   wanted, which is what lets a common gate (e.g. `editorconfig-checker`)
///   live in a shared layer while a repository's own layer adds its own
///   command, instead of every repository copying the shared command into
///   its own file.
/// - `repos.roots` — a set of directories to scan for checkouts. A
///   repository adding its own root on top of the machine's is additive by
///   nature, not a replacement of where the machine looks; see
///   [`Repos::roots`].
///
/// Left on the refuse side, and why:
/// - `roles.implementers` / `roles.judges` / `roles.reviewers` — an ordered
///   list of *seats*, not a set. A machine's two implementers plus a
///   repository's one is three seats nobody asked for and nobody is paying
///   for on purpose.
/// - `notify.command` — an argv. Concatenating two argvs does not produce a
///   program that runs; it produces `["ntfy", "publish", "curl", "-X"]`.
/// - `blind.strip_lines` — technically safe to concatenate (each entry is
///   matched as an independent substring, so a longer list only strips
///   *more*), but left on the refuse side anyway: the same list also drives
///   `commit_msg_hook`'s generated `sed` addresses, where position matters,
///   and a silent three-layer merge is exactly the kind of surprise
///   `refuse_split_arrays` exists to catch rather than to reason about
///   case-by-case. A repository that wants one more stripped phrase restates
///   the whole list; that restatement is visible in review, an accidental
///   concatenation would not be.
fn array_merge_policy(key: &str) -> ArrayMerge {
    match key {
        "verify.e2e" | "verify.gate" | "repos.roots" => ArrayMerge::Append,
        _ => ArrayMerge::Replace,
    }
}

impl Config {
    /// Load one file through teravars: Tera rendering, `[vars]` resolution,
    /// and the `include = [...]` directive.
    pub fn load(path: &Path) -> Result<Self> {
        Self::load_layers(&[path.to_path_buf()])
    }

    /// The Tera render context shared by every layer: `system.*` (from
    /// teravars), `env` (magi's own addition - a config that names a shared
    /// build-cache directory or a machine-specific path needs
    /// `{{ env.NAME | default(value='...') }}`), and `repo` / `repo_name`
    /// derived from the last (highest-priority) path's parent directory.
    ///
    /// Factored out so [`Config::array_provenance`] can re-render a single
    /// layer under the exact same context [`Config::load_layers`] uses for
    /// the joint render, rather than drifting from it by accident.
    fn render_ctx(paths: &[PathBuf]) -> teravars::Context {
        let mut ctx = teravars::system_context();
        let env: std::collections::BTreeMap<String, String> = std::env::vars().collect();
        ctx.insert("env", &env);
        if let Some(last) = paths.last()
            && let Some(dir) = last.parent()
        {
            ctx.insert("repo", &dir.to_string_lossy());
            ctx.insert(
                "repo_name",
                &dir.file_name().unwrap_or_default().to_string_lossy(),
            );
        }
        ctx
    }

    /// Load and deep-merge a stack of config files, later files winning.
    ///
    /// This is why the config is TOML-through-teravars rather than plain serde:
    /// the roster is a *machine* fact (which CLIs and plans you pay for) while
    /// the gate is a *repository* fact (`cargo make check` here, `pnpm test`
    /// there). Picking one file and ignoring the other would force every repo
    /// to restate the roster.
    pub fn load_layers(paths: &[PathBuf]) -> Result<Self> {
        let mut engine = teravars::Engine::default();
        let ctx = Self::render_ctx(paths);
        if paths.len() > 1 {
            Self::refuse_split_arrays(paths, &mut engine, &ctx)?;
        }
        let merged = teravars::load_merged(paths, &mut engine, &ctx).with_context(|| {
            format!(
                "rendering config via teravars: {}",
                paths
                    .iter()
                    .map(|p| p.display().to_string())
                    .collect::<Vec<_>>()
                    .join(", ")
            )
        })?;
        let mut table = merged.config;
        // `[vars]` is teravars' own input, already resolved into the render
        // context; `deny_unknown_fields` must not trip over it.
        table.remove("vars");
        toml::Value::Table(table)
            .try_into()
            .context("deserializing magi config")
    }

    /// Refuse an array that two layers both declare, unless
    /// [`array_merge_policy`] says that key is meant to accumulate.
    ///
    /// teravars **appends** arrays when it merges layers, and that is wrong for
    /// most arrays magi has: `implementers` is an ordered list of seats,
    /// `notify.command` is an argv. Concatenating two of them yields something
    /// nobody wrote - three implementers out of a machine's two and a
    /// repository's one, or an argv of `["ntfy", "publish", "curl", "-X"]`.
    ///
    /// Replacing instead would be the right merge rule for those keys, but the
    /// rule lives in teravars, which several other projects depend on;
    /// changing it there is a decision for that crate, not something to fake
    /// here by re-reading the files with different semantics and hoping the
    /// two paths agree.
    ///
    /// So magi refuses the ambiguity rather than resolving it silently, for
    /// every array key except the short, deliberate list
    /// [`array_merge_policy`] marks [`ArrayMerge::Append`] - for those, the
    /// concatenation teravars already produces *is* what both files say, so
    /// there is nothing to refuse. The cost of guessing wrong on the refused
    /// keys is a roster the operator did not ask for and is paying for by the
    /// token; the append keys carry no such risk because every element runs
    /// (or every directory is scanned) regardless of order.
    fn refuse_split_arrays(
        paths: &[PathBuf],
        engine: &mut teravars::Engine,
        ctx: &teravars::Context,
    ) -> Result<()> {
        let mut seen: std::collections::BTreeMap<String, PathBuf> = Default::default();
        for path in paths {
            let one = teravars::load_merged([path], engine, ctx)
                .with_context(|| format!("rendering {}", path.display()))?;
            for key in array_keys(&one.config, "") {
                if array_merge_policy(&key) == ArrayMerge::Append {
                    continue;
                }
                if let Some(first) = seen.get(&key) {
                    bail!(
                        "`{key}` is an array declared in two config layers:\n  \
                         {}\n  {}\nteravars appends arrays when it merges, so \
                         magi would run the concatenation of both - which is \
                         not what either file says. Declare `{key}` in exactly \
                         one of them.",
                        first.display(),
                        path.display()
                    );
                }
                seen.insert(key, path.clone());
            }
        }
        Ok(())
    }

    /// Which layers contributed to a composed, appendable array key (e.g.
    /// `"verify.gate"`), in the same low-to-high-priority order
    /// [`Config::load_layers`] concatenates them in. Layers that do not
    /// declare `key` at all are omitted.
    ///
    /// This is a **display aid for `magi doctor` only.** The command list
    /// that actually runs always comes from the one joint
    /// [`teravars::load_merged`] call in `load_layers`, never from this
    /// function - the exact hazard [`Config::refuse_split_arrays`] warns
    /// about is two merge paths that might disagree, so this function must
    /// never become a second source of the *composed* value, only of which
    /// file wrote which line in it.
    ///
    /// Re-rendering each layer alone can, in principle, resolve a
    /// `{{ vars.x }}` differently than the joint render would, if `x` is
    /// defined in one layer and referenced in another - the same caveat
    /// `refuse_split_arrays`'s structural, key-only check already lives with.
    /// None of magi's own gate commands cross that line, and a doctor listing
    /// is read by a human who can compare it against the joint one printed
    /// alongside it, so this is judged worth the simplicity of not
    /// threading provenance through the real load path.
    pub fn array_provenance(paths: &[PathBuf], key: &str) -> Vec<(PathBuf, Vec<String>)> {
        let mut engine = teravars::Engine::default();
        let ctx = Self::render_ctx(paths);
        let mut out = Vec::new();
        for path in paths {
            let Ok(one) = teravars::load_merged([path], &mut engine, &ctx) else {
                continue;
            };
            let mut cur = &one.config;
            let mut found = None;
            let parts: Vec<&str> = key.split('.').collect();
            for (i, part) in parts.iter().enumerate() {
                match cur.get(*part) {
                    Some(toml::Value::Array(a)) if i == parts.len() - 1 => {
                        found = Some(a);
                        break;
                    }
                    Some(toml::Value::Table(t)) => cur = t,
                    _ => break,
                }
            }
            let Some(values) = found else { continue };
            let strings: Vec<String> = values
                .iter()
                .filter_map(|v| v.as_str().map(str::to_owned))
                .collect();
            if !strings.is_empty() {
                out.push((path.clone(), strings));
            }
        }
        out
    }

    /// Render a composed command list for `magi doctor`: the joined command
    /// line the run actually uses, plus - only when more than one layer
    /// contributed - which layer wrote which line.
    ///
    /// A single contributing layer (the common case today) stays the plain
    /// one-line summary magi has always printed, `empty` included: that
    /// honest "(none — ...)" is what caught a real gate-composition gap
    /// before this array could compose at all, and composition should not
    /// make the common case noisier.
    pub fn describe_composed(
        paths: &[PathBuf],
        commands: &[String],
        key: &str,
        empty: &str,
    ) -> String {
        if commands.is_empty() {
            return empty.to_owned();
        }
        let joined = commands.join(" && ");
        let provenance = Self::array_provenance(paths, key);
        if provenance.len() <= 1 {
            return joined;
        }
        let mut out = joined;
        for (path, cmds) in &provenance {
            out.push_str(&format!("\n    [{}] {}", path.display(), cmds.join(" && ")));
        }
        out
    }

    /// Resolve the config for `repo`, honouring an explicit `--config` path.
    ///
    /// Returns the config and the layers it came from, empty for built-in
    /// defaults.
    pub fn discover(repo: &Path, explicit: Option<&Path>) -> Result<(Self, Vec<PathBuf>)> {
        if let Some(p) = explicit {
            let paths = vec![p.to_path_buf()];
            return Ok((Self::load_layers(&paths)?, paths));
        }
        let paths = Self::layers(repo);
        if paths.is_empty() {
            return Ok((Self::autodetected(), paths));
        }
        Ok((Self::load_layers(&paths)?, paths))
    }
    /// Environment variable that relocates the machine-wide config layer.
    ///
    /// Set it to a directory and magi reads `<dir>/magi/config.toml` instead
    /// of the one under [`dirs::config_dir`]; set it to the empty string and
    /// magi reads no machine layer at all.
    ///
    /// This exists because the machine layer is otherwise unavoidable, and a
    /// test that builds a config fixture is not asking for the operator's
    /// preferences to be merged into it. Adding `[repos] roots` to the real
    /// machine config on a development box turned two passing tests red -
    /// `repos_list_returns_name_and_path_for_every_configured_root` and
    /// `repos_list_only_rescans_within_the_ttl_when_asked_to`, whose fixtures
    /// declare `[repos] roots` of their own, which [`Config::layers`] then
    /// found in two layers and [`Config::refuse_split_arrays`] correctly
    /// refused. CI never saw it: a runner has no machine config, so the suite
    /// was green there and red only where somebody actually uses magi.
    ///
    /// An operator gets the same escape hatch for free: a second machine
    /// config, or none, without moving files about.
    pub const CONFIG_DIR_ENV: &str = "MAGI_CONFIG_DIR";

    /// Every config layer that applies to `repo`, in increasing precedence.
    ///
    /// The machine layer is whatever [`Config::machine_layer`] resolves to,
    /// which is nothing at all in a test build.
    pub fn layers(repo: &Path) -> Vec<PathBuf> {
        let mut paths = Vec::new();
        paths.extend(Self::machine_layer());
        paths.push(repo.join(".magi").join("config.toml"));
        paths.push(repo.join("magi.toml"));
        paths.retain(|p| p.is_file());
        paths
    }

    /// The machine-wide layer's path, when there is one.
    ///
    /// **A test build has none unless it names one.** A fixture is a complete
    /// statement of the config under test, and the operator's own preferences
    /// have no business being merged into it - least of all silently, on one
    /// machine, in a suite that is green everywhere else.
    #[cfg(test)]
    fn machine_layer() -> Option<PathBuf> {
        std::env::var(Self::CONFIG_DIR_ENV)
            .ok()
            .filter(|dir| !dir.trim().is_empty())
            .map(|dir| PathBuf::from(dir).join("magi").join("config.toml"))
    }

    /// The machine-wide layer's path, when there is one.
    #[cfg(not(test))]
    fn machine_layer() -> Option<PathBuf> {
        match std::env::var(Self::CONFIG_DIR_ENV) {
            // Named, and empty on purpose: no machine layer.
            Ok(dir) if dir.trim().is_empty() => None,
            Ok(dir) => Some(PathBuf::from(dir).join("magi").join("config.toml")),
            Err(_) => dirs::config_dir().map(|dir| dir.join("magi").join("config.toml")),
        }
    }

    /// Built-in config whose roster is the agent CLIs found on `PATH`.
    pub fn autodetected() -> Self {
        let mut cfg = Self::default();
        for (kind, id, model) in [
            (AgentKind::Claude, "opus", Some("opus")),
            (AgentKind::Claude, "sonnet", Some("sonnet")),
            (AgentKind::Antigravity, "antigravity", None),
            (AgentKind::Opencode, "opencode", None),
            (AgentKind::Codex, "codex", None),
            (AgentKind::Omp, "omp", None),
        ] {
            if kind.program().is_some_and(which) && !cfg.agents.iter().any(|a| a.id == id) {
                cfg.agents.push(AgentSpec {
                    id: id.to_owned(),
                    kind,
                    model: model.map(str::to_owned),
                    command: Vec::new(),
                    extra_args: Vec::new(),
                    env: BTreeMap::new(),
                    prompt_delivery: None,
                });
            }
        }
        cfg
    }

    /// The shared build cache the verify commands and the agents both build
    /// into, when the config declares one. See [`Verify::cache_dir`].
    pub fn cache_dir(&self) -> Option<PathBuf> {
        self.verify.cache_dir()
    }

    /// Look an agent up by id.
    pub fn agent(&self, id: &str) -> Result<&AgentSpec> {
        self.agents
            .iter()
            .find(|a| a.id == id)
            .with_context(|| format!("no agent with id `{id}` in the roster"))
    }

    /// Rotate `count` seats out of `ids`, or out of the whole roster at
    /// `offset` when `ids` is empty.
    ///
    /// The one rotation rule - explicit ids cycle, an empty list rotates the
    /// roster - shared by every seat count [`Config::resolve_roles`] fills in,
    /// rather than each seat reimplementing it and drifting apart.
    fn rotate(&self, ids: &[String], count: usize, offset: usize) -> Result<Vec<AgentSpec>> {
        let mut out = Vec::with_capacity(count);
        for i in 0..count {
            let spec = if ids.is_empty() {
                self.agents[(i + offset) % self.agents.len()].clone()
            } else {
                self.agent(&ids[i % ids.len()])?.clone()
            };
            out.push(spec);
        }
        Ok(out)
    }

    /// Fill the roles out to the configured widths.
    ///
    /// An empty role list rotates through the whole roster, so a three-agent
    /// roster with `candidates = 3` gives one implementation per agent, and
    /// `judges = 3` rotates the judge seats by one so that judge *i* is not the
    /// author of candidate *i* whenever the roster has more than one agent.
    pub fn resolve_roles(&self) -> Result<ResolvedRoles> {
        if self.agents.is_empty() {
            bail!(
                "agent roster is empty: no agent CLI found on PATH and no \
                 [[agents]] in the config. Run `magi init` to write a starter \
                 magi.toml."
            );
        }
        Ok(ResolvedRoles {
            implementers: self.rotate(&self.roles.implementers, self.graph.candidates, 0)?,
            judges: self.rotate(&self.roles.judges, self.graph.judges, 1)?,
            reviewers: self.rotate(&self.roles.reviewers, self.graph.reviewers, 0)?,
            fixer: self
                .roles
                .fixer
                .as_deref()
                .map(|f| self.agent(f).cloned())
                .transpose()?,
            // Role resolution validates roster shape, but deliberately does
            // not preflight a CLI. The other graph seats have always deferred
            // that failure to invocation; doing it only for the conductor
            // made otherwise usable graph commands and `doctor` fail as one.
            conductor: match self.roles.conductor.as_deref() {
                Some(id) => self.agent(id)?.clone(),
                // Keep the normal standalone-seat preference when something
                // is installed, but retain a roster fallback when it is not.
                // Invocation then reports the unavailable CLI in the same
                // place it does for every other graph role.
                None => crate::agent::pick(&self.agents, None, &crate::agent::installed)
                    .unwrap_or_else(|_| self.agents[0].clone()),
            },
        })
    }

    /// Shell prefix for [`Verify`] commands.
    pub fn shell(&self) -> Vec<String> {
        if let Some(s) = &self.verify.shell {
            return s.clone();
        }
        if which("sh") {
            vec!["sh".to_owned(), "-c".to_owned()]
        } else {
            vec!["cmd".to_owned(), "/C".to_owned()]
        }
    }

    /// Starter config, as written by `magi init`.
    pub fn starter_toml() -> String {
        let detected = Self::autodetected();
        let mut s = String::from(
            "# magi — blind multi-agent implementation competition.\n\
             # `magi run \"<task>\"` walks: implement (N parallel worktrees)\n\
             #   -> blind judging -> deliberation -> private final vote\n\
             #   -> fold losers -> review + E2E loop -> gate -> merge.\n\
             #\n\
             # Rendered by teravars: a `[vars]` table, env\n\
             # and system lookups, and `include = [...]` all work. Tera\n\
             # braces are live everywhere in this file, but comments are\n\
             # stripped before rendering (teravars >= 0.2.2), so a comment\n\
             # may quote `{{ ... }}` freely.\n\
             #\n\
             # Layers deep-merge in increasing\n\
             # precedence, so the roster can live once per machine in\n\
             # <config_dir>/magi/config.toml and each repo only states its own\n\
             # gate:\n\
             #   <config_dir>/magi/config.toml  <  .magi/config.toml  <  magi.toml\n\n\
             [vars]\n\
             # Reference it as vars.cache inside Tera braces, anywhere below.\n\
             # Single quotes inside the braces: teravars renders the raw file\n\
             # text, so TOML's own \\\" escaping never reaches Tera.\n\
             cache = \"{{ env.MAGI_CACHE | default(value='/tmp') }}\"\n\n",
        );
        if detected.agents.is_empty() {
            s.push_str(
                "# No agent CLI was found on PATH. Fill this in by hand.\n\
                 # kind = claude | opencode | antigravity | codex | command\n\
                 [[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n",
            );
        } else {
            for a in &detected.agents {
                s.push_str("[[agents]]\n");
                s.push_str(&format!("id = {:?}\n", a.id));
                s.push_str(&format!("kind = {:?}\n", a.kind.as_str()));
                if let Some(m) = &a.model {
                    s.push_str(&format!("model = {m:?}\n"));
                }
                s.push('\n');
            }
        }
        s.push_str(
            "# Leave a role list empty to rotate through the roster.\n\
             [roles]\n\
             implementers = []\n\
             judges = []\n\
             reviewers = []\n\
             # conductor = \"opus\"  # arranges the queue; unset picks a seat like chatter does\n\n\
             [graph]\n\
             candidates = 3\n\
             judges = 3\n\
             deliberate_rounds = 1\n\
             reviewers = 3\n\
             review_rounds = 6\n\
             max_parallel = 4\n\
             language = \"en\"\n\
             # One CLI conversation per seat: judges keep their own argument\n\
             # across deliberation, the fixer keeps its implementation context.\n\
             sessions = true\n\
             # Reviewer-seat timeout. When timeout_verify is omitted, E2E and\n\
             # the final gate inherit this value for compatibility.\n\
             timeout_review = 1200\n\
             # Optional independent E2E/final-gate timeout; uncomment to keep\n\
             # verification independent if timeout_review changes later.\n\
             # timeout_verify = 1200\n\n\
             [verify]\n\
             # Run once per review round in the winner's worktree; failures are\n\
             # fed back to the fixer.\n\
             e2e = []\n\
             # Final gate. Every command must exit 0 before a merge.\n\
             gate = []\n\n\
             [merge]\n\
             # none | local | pr\n\
             mode = \"none\"\n\n\
             [update]\n\
             # off | notify | install — checked in the background, throttled.\n\
             mode = \"notify\"\n\
             # interval = \"24h\"\n",
        );
        s
    }
}

/// Is `program` on `PATH`?
pub fn which(program: &str) -> bool {
    let Some(paths) = std::env::var_os("PATH") else {
        return false;
    };
    let exts: Vec<String> = std::env::var("PATHEXT")
        .map(|v| v.split(';').map(|e| e.to_lowercase()).collect())
        .unwrap_or_default();
    std::env::split_paths(&paths).any(|dir| {
        let direct = dir.join(program);
        if direct.is_file() {
            return true;
        }
        exts.iter().any(|ext| {
            let mut name = program.to_owned();
            name.push_str(ext);
            dir.join(name).is_file()
        })
    })
}

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

    fn spec(id: &str) -> AgentSpec {
        AgentSpec {
            id: id.to_owned(),
            kind: AgentKind::Command,
            model: None,
            command: vec!["true".to_owned()],
            extra_args: Vec::new(),
            env: BTreeMap::new(),
            prompt_delivery: None,
        }
    }

    #[test]
    fn timeout_verify_omitted_from_old_toml_inherits_timeout_review() {
        // `timeout_verify` used to not exist: `verify.e2e`/`verify.gate` ran
        // under `timeout_review`. A config written before this field existed
        // must run exactly as before, which means its default has to be the
        // same 1200s `timeout_review` has always defaulted to.
        let g: Graph = toml::from_str("timeout_review = 3600").expect("parse");
        assert_eq!(g.timeout_verify, None);
        assert_eq!(g.verify_timeout(), 3600);
    }

    #[test]
    fn shrinking_timeout_review_does_not_shrink_timeout_verify() {
        // The bug this field exists to close: `[graph] timeout_review = 45`
        // used to shrink the real-machine `verify.e2e`/`verify.gate` budget
        // along with the reviewer seats' own timeout, because both read the
        // same field.
        let g: Graph = toml::from_str("timeout_review = 45").expect("parse");
        assert_eq!(g.timeout_review, 45);
        assert_eq!(
            g.verify_timeout(),
            45,
            "an omitted legacy value follows review"
        );
        let explicit: Graph = toml::from_str("timeout_review = 45\ntimeout_verify = 1200")
            .expect("parse explicit override");
        assert_eq!(explicit.verify_timeout(), 1200);
    }

    #[test]
    fn a_toml_layer_written_before_these_fields_existed_still_parses() {
        // `deny_unknown_fields` cuts both ways: a config from before
        // `timeout_verify`/`e2e_every_round` existed must still parse, with
        // both defaulted rather than refused as unknown-in-reverse.
        let g: Graph =
            toml::from_str("candidates = 1\nreviewers = 3\nreview_rounds = 6\nmax_parallel = 4\n")
                .expect("an old-shaped [graph] table must still parse");
        assert_eq!(g.verify_timeout(), Graph::default().timeout_review);
        assert!(
            !g.e2e_every_round,
            "off by default, same as before this field existed"
        );
    }

    #[test]
    fn empty_roles_rotate_judges_off_their_own_candidate() {
        // Three seats, said out loud: this is a test about *rotation*, and it
        // has nothing to say about how many candidates a task buys by default.
        let cfg = Config {
            agents: vec![spec("a"), spec("b"), spec("c")],
            graph: Graph {
                candidates: 3,
                ..Graph::default()
            },
            ..Config::default()
        };
        let roles = cfg.resolve_roles().unwrap();
        let impls: Vec<&str> = roles.implementers.iter().map(|a| a.id.as_str()).collect();
        let judges: Vec<&str> = roles.judges.iter().map(|a| a.id.as_str()).collect();
        assert_eq!(impls, ["a", "b", "c"]);
        assert_eq!(judges, ["b", "c", "a"]);
        for (i, j) in judges.iter().enumerate() {
            assert_ne!(*j, impls[i], "judge {i} must not sit on its own candidate");
        }
    }

    #[test]
    fn single_agent_roster_fills_every_seat() {
        let cfg = Config {
            agents: vec![spec("solo")],
            graph: Graph {
                candidates: 3,
                ..Graph::default()
            },
            ..Config::default()
        };
        let roles = cfg.resolve_roles().unwrap();
        assert_eq!(roles.implementers.len(), 3);
        assert!(roles.judges.iter().all(|a| a.id == "solo"));
    }

    #[test]
    fn explicit_roles_win() {
        let cfg = Config {
            agents: vec![spec("a"), spec("b")],
            roles: Roles {
                implementers: vec!["b".to_owned()],
                judges: vec!["a".to_owned()],
                reviewers: Vec::new(),
                fixer: Some("a".to_owned()),
                ..Roles::default()
            },
            ..Config::default()
        };
        let roles = cfg.resolve_roles().unwrap();
        assert!(roles.implementers.iter().all(|a| a.id == "b"));
        assert!(roles.judges.iter().all(|a| a.id == "a"));
        assert_eq!(roles.fixer.unwrap().id, "a");
        assert_eq!(roles.conductor.id, "a");
    }

    #[test]
    fn unknown_agent_id_is_an_error() {
        let cfg = Config {
            agents: vec![spec("a")],
            roles: Roles {
                judges: vec!["nope".to_owned()],
                ..Roles::default()
            },
            ..Config::default()
        };
        assert!(cfg.resolve_roles().is_err());
    }

    #[test]
    fn conductor_role_is_resolved_validated_and_has_a_fallback() {
        let mut cfg = Config {
            agents: vec![spec("a"), spec("b")],
            ..Config::default()
        };
        assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "a");

        cfg.roles.conductor = Some("b".to_owned());
        assert_eq!(cfg.resolve_roles().unwrap().conductor.id, "b");

        cfg.roles.conductor = Some("missing".to_owned());
        assert!(cfg.resolve_roles().is_err());
    }

    #[test]
    fn empty_roster_is_an_error() {
        assert!(Config::default().resolve_roles().is_err());
    }

    #[test]
    fn repos_default_to_no_roots_and_a_day_of_trust() {
        assert_eq!(Config::default().repos.roots, Vec::<PathBuf>::new());
        assert_eq!(Config::default().repos.scan_ttl, 86_400);
    }

    #[test]
    fn a_config_file_with_no_repos_table_still_loads() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
        let cfg = Config::load(&path).expect("must load without [repos]");
        assert_eq!(cfg.repos.roots, Vec::<PathBuf>::new());
        assert_eq!(cfg.repos.scan_ttl, 86_400);
    }

    /// A fixture is the whole config under test.
    ///
    /// `layers` used to reach for `dirs::config_dir()` unconditionally, so on
    /// a machine where somebody had written `<config_dir>/magi/config.toml`
    /// the suite silently loaded it as the lowest layer. Adding `[repos]
    /// roots` there turned two web tests red - their fixtures declare
    /// `[repos] roots` too, and `refuse_split_arrays` rightly refuses one
    /// array key spread across two layers. CI stayed green throughout,
    /// because a runner has no such file: the suite failed only where magi is
    /// actually used.
    ///
    /// So a test build has no machine layer unless it asks for one, and this
    /// is that promise. Written against a real file at the real location so
    /// it fails if `machine_layer` starts reading it again.
    #[test]
    fn a_test_build_does_not_read_the_operators_machine_config() {
        let repo = tempfile::tempdir().unwrap();
        std::fs::write(repo.path().join("magi.toml"), "[graph]\ncandidates = 2\n").unwrap();

        let layers = Config::layers(repo.path());
        assert_eq!(
            layers,
            vec![repo.path().join("magi.toml")],
            "only the fixture's own file may be a layer"
        );
        if let Some(real) = dirs::config_dir() {
            let machine = real.join("magi").join("config.toml");
            assert!(
                !layers.contains(&machine),
                "the operator's {} must not be a layer in a test build",
                machine.display()
            );
        }
    }

    #[test]
    fn starter_toml_loads_through_teravars() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(&path, Config::starter_toml()).unwrap();
        let parsed = Config::load(&path).expect("starter config must load");
        assert_eq!(parsed.graph.candidates, 3);
        assert_eq!(parsed.merge.mode, MergeMode::None);
        assert_eq!(parsed.merge.style, MergeStyle::Merge);
        assert!(parsed.graph.sessions);
        assert_eq!(parsed.graph.timeout_review, 1200);
        assert_eq!(parsed.graph.verify_timeout(), 1200);
        assert_eq!(parsed.update.mode, UpdateMode::Notify);
    }

    #[test]
    fn starter_toml_explains_inherited_and_explicit_verify_timeouts() {
        let starter = Config::starter_toml();
        assert!(starter.contains("When timeout_verify is omitted, E2E and"));
        assert!(starter.contains("verification independent if timeout_review changes later"));
        assert!(starter.contains("# timeout_verify = 1200"));
    }

    /// A repository whose ruleset forbids merge commits declares that once,
    /// here, rather than magi asking GitHub about it on every render (see
    /// [`MergeStyle`]'s own doc for why).
    #[test]
    fn a_repository_can_declare_a_linear_history_merge_style() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(&path, "[merge]\nmode = \"none\"\nstyle = \"squash\"\n").unwrap();
        let parsed = Config::load(&path).expect("config must load");
        assert_eq!(parsed.merge.style, MergeStyle::Squash);
    }

    #[test]
    fn later_layers_win_and_vars_render() {
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let project = dir.path().join("magi.toml");
        // The machine layer owns the roster...
        std::fs::write(
            &machine,
            "[[agents]]\nid = \"opus\"\nkind = \"claude\"\nmodel = \"opus\"\n\n\
             [graph]\ncandidates = 3\nmax_parallel = 8\n",
        )
        .unwrap();
        // ...and the project layer only states what is repo-specific, plus a
        // `[vars]` value interpolated into a command.
        std::fs::write(
            &project,
            "[vars]\ncache = \"/shared\"\n\n\
             [graph]\ncandidates = 2\n\n\
             [verify]\ngate = [\"CARGO_TARGET_DIR={{ vars.cache }}/t cargo test\"]\n",
        )
        .unwrap();

        let cfg = Config::load_layers(&[machine, project]).expect("layered load");
        assert_eq!(cfg.agents.len(), 1, "roster comes from the machine layer");
        assert_eq!(cfg.graph.candidates, 2, "project layer wins");
        assert_eq!(cfg.graph.max_parallel, 8, "machine layer survives");
        assert_eq!(
            cfg.verify.gate,
            ["CARGO_TARGET_DIR=/shared/t cargo test".to_owned()]
        );
        // The rendered command is where the cache path is read back from.
        assert_eq!(cfg.cache_dir(), Some(PathBuf::from("/shared/t")));
    }

    #[test]
    fn talk_defaults_to_an_hour_and_an_unwritten_config_still_gets_it() {
        // An operator who writes no `[graph]` timeout keys at all must still
        // land on the hour, not on the five/fifteen minutes this turn used
        // to hardcode before it read from config.
        let g = Graph::default();
        assert_eq!(g.timeout_talk, 3600);

        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(&path, "[graph]\ncandidates = 2\n").unwrap();
        let cfg = Config::load(&path).expect("must load without timeout_talk set");
        assert_eq!(cfg.graph.timeout_talk, 3600);
    }

    #[test]
    fn an_overridden_talk_timeout_reaches_the_loaded_config() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(&path, "[graph]\ntimeout_talk = 120\n").unwrap();
        let cfg = Config::load(&path).expect("must load");
        assert_eq!(cfg.graph.timeout_talk, 120);
    }

    #[test]
    fn the_disk_defaults_are_the_measurements_made_up_front() {
        let cfg = Config::default();
        assert_eq!(cfg.disk.min_free_bytes, 8 * 1024 * 1024 * 1024);
        assert!(cfg.disk.auto_fold);
        assert_eq!(cfg.disk.fold_grace_secs, 6 * 60 * 60);
        assert_eq!(cfg.disk.cache_limit_bytes, 10 * 1024 * 1024 * 1024);
    }

    #[test]
    fn an_unset_disk_section_is_the_safe_default() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("magi.toml"), "[graph]\ncandidates = 1\n").unwrap();
        let cfg = Config::load(&dir.path().join("magi.toml")).expect("load");
        assert_eq!(cfg.disk, Disk::default());
    }

    #[test]
    fn env_is_available_to_templates_with_a_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        // teravars ships no `env`; magi adds it, and the `default` filter has
        // to cover the unset case or every machine would need the variable.
        //
        // Deliberately no named variable: `env` is keyed by the exact spelling
        // the OS reports, and Windows says `Path` where POSIX says `PATH`, so a
        // test asserting `env.PATH` passes on one runner and fails on another.
        // The map's non-emptiness is the platform-neutral claim.
        std::fs::write(
            &path,
            "[verify]\n\
             gate = [\"cache={{ env.MAGI_TEST_UNSET_XYZ | default(value='fallback') }}\", \
             \"populated={{ env | length > 0 }}\"]\n",
        )
        .unwrap();
        let cfg = Config::load(&path).expect("env lookup must render");
        assert_eq!(cfg.verify.gate[0], "cache=fallback");
        assert_eq!(cfg.verify.gate[1], "populated=true");
    }

    #[test]
    fn a_broken_template_names_the_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(&path, "[graph]\nlanguage = \"{{ nope.\"\n").unwrap();
        let err = Config::load(&path).expect_err("must not silently ignore");
        assert!(err.to_string().contains("teravars"), "{err}");
    }

    #[test]
    fn tera_syntax_in_comments_is_inert() {
        // teravars >= 0.2.2 strips `#` comments before Tera sees the file, so a
        // comment may quote template syntax without rendering. Before 0.2.2 this
        // load failed: the commented-out braces reached the template parser.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(
            &path,
            "# a comment may quote templates: `{{ env.NOPE | default(value='x') }}` and `{% if %}`\n\
             [graph]\ncandidates = 2\n",
        )
        .unwrap();
        let cfg = Config::load(&path).expect("comments must be inert, not rendered");
        assert_eq!(cfg.graph.candidates, 2);
    }

    #[test]
    fn opencode_defaults_to_file_delivery() {
        let mut s = spec("oc");
        s.kind = AgentKind::Opencode;
        assert_eq!(s.delivery(), Delivery::File);
        s.prompt_delivery = Some(Delivery::Argv);
        assert_eq!(s.delivery(), Delivery::Argv);
    }
    #[test]
    fn the_land_loop_is_on_but_it_cannot_merge_without_being_asked() {
        // Both default on, and that pair is the safety property: `land` takes
        // over the watching an operator was doing by hand, `land_approval`
        // keeps the irreversible step a human decision. An unattended merge
        // needs BOTH flipped, which has to be chosen deliberately twice.
        let g = Graph::default();
        assert!(
            g.land,
            "stopping at an open PR left the watching to a human"
        );
        assert!(
            g.land_approval,
            "on-by-default land is only defensible while this is also on"
        );
        assert!(g.land_rounds > 0, "a loop with no budget never terminates");
    }
    #[test]
    fn an_array_declared_in_two_layers_is_refused_instead_of_concatenated() {
        // teravars appends arrays. For an ordered list of seats, or an argv,
        // the concatenation is something neither file says - and the operator
        // pays for the extra seats by the token.
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[roles]\nimplementers = [\"a\", \"b\"]\n").unwrap();
        std::fs::write(&repo, "[roles]\nimplementers = [\"oc\"]\n").unwrap();

        let err = Config::load_layers(&[machine.clone(), repo.clone()])
            .expect_err("two layers naming one array must not merge silently")
            .to_string();
        assert!(err.contains("roles.implementers"), "{err}");
        // Both files are named: the fix is to delete one of them, and the
        // operator has to know which two to choose between.
        assert!(err.contains("machine.toml"), "{err}");
        assert!(err.contains("magi.toml"), "{err}");
    }

    #[test]
    fn a_scalar_in_one_layer_and_an_array_in_another_still_merges() {
        // The split the layering exists for: state a preference machine-wide,
        // let the repository own its own lists.
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[roles]\nchatter = \"opus\"\n").unwrap();
        std::fs::write(
            &repo,
            "[[agents]]\nid = \"oc\"\nkind = \"opencode\"\n\n\
             [roles]\nimplementers = [\"oc\"]\n",
        )
        .unwrap();

        let cfg = Config::load_layers(&[machine, repo]).expect("layers merge");
        assert_eq!(cfg.roles.chatter.as_deref(), Some("opus"));
        assert_eq!(cfg.roles.implementers, ["oc"]);
        assert_eq!(cfg.agents.len(), 1, "the roster is not doubled");
    }

    #[test]
    fn two_layers_declaring_verify_gate_run_both_in_priority_order() {
        // The `editorconfig-checker` distribution problem: a shared layer
        // wants to add a gate command without erasing the repository's own.
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
        std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();

        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
        assert_eq!(
            cfg.verify.gate,
            [
                "editorconfig-checker".to_owned(),
                "cargo make check".to_owned()
            ],
            "low-priority (machine) command first, high-priority (repo) command after"
        );
    }

    #[test]
    fn two_layers_declaring_verify_e2e_run_both_in_priority_order() {
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[verify]\ne2e = [\"shared-smoke-test\"]\n").unwrap();
        std::fs::write(&repo, "[verify]\ne2e = [\"cargo test\"]\n").unwrap();

        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
        assert_eq!(
            cfg.verify.e2e,
            ["shared-smoke-test".to_owned(), "cargo test".to_owned()]
        );
    }

    #[test]
    fn two_layers_declaring_repos_roots_are_both_scanned() {
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[repos]\nroots = [\"/machine/root\"]\n").unwrap();
        std::fs::write(&repo, "[repos]\nroots = [\"/repo/root\"]\n").unwrap();

        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
        assert_eq!(
            cfg.repos.roots,
            [PathBuf::from("/machine/root"), PathBuf::from("/repo/root")]
        );
    }

    #[test]
    fn duplicate_gate_commands_across_layers_both_run() {
        // Dropping the duplicate would be a silent surprise; the operator
        // sees a slower gate, never a missing one.
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[verify]\ngate = [\"same-command\"]\n").unwrap();
        std::fs::write(&repo, "[verify]\ngate = [\"same-command\"]\n").unwrap();

        let cfg = Config::load_layers(&[machine, repo]).expect("appendable arrays must merge");
        assert_eq!(
            cfg.verify.gate,
            ["same-command".to_owned(), "same-command".to_owned()]
        );
    }

    #[test]
    fn notify_command_is_still_refused_across_two_layers() {
        // An argv, not a set: concatenating two of them is not a program.
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[notify]\ncommand = [\"ntfy\", \"publish\"]\n").unwrap();
        std::fs::write(&repo, "[notify]\ncommand = [\"curl\", \"-X\"]\n").unwrap();

        let err = Config::load_layers(&[machine.clone(), repo.clone()])
            .expect_err("an argv split across layers must not concatenate")
            .to_string();
        assert!(err.contains("notify.command"), "{err}");
        assert!(err.contains("machine.toml"), "{err}");
        assert!(err.contains("magi.toml"), "{err}");
    }

    #[test]
    fn one_layer_declaring_verify_gate_runs_unchanged() {
        // The classification must not change behaviour for the configuration
        // this very repository has today: exactly one layer names the gate.
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("magi.toml");
        std::fs::write(&path, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();

        let cfg = Config::load(&path).expect("single layer must still load");
        assert_eq!(cfg.verify.gate, ["cargo make check".to_owned()]);
    }

    #[test]
    fn describe_composed_names_the_contributing_layers_only_when_there_are_two() {
        let dir = tempfile::tempdir().unwrap();
        let machine = dir.path().join("machine.toml");
        let repo = dir.path().join("magi.toml");
        std::fs::write(&machine, "[verify]\ngate = [\"editorconfig-checker\"]\n").unwrap();
        std::fs::write(&repo, "[verify]\ngate = [\"cargo make check\"]\n").unwrap();
        let paths = vec![machine.clone(), repo.clone()];

        let cfg = Config::load_layers(&paths).expect("appendable arrays must merge");
        let described =
            Config::describe_composed(&paths, &cfg.verify.gate, "verify.gate", "(none)");
        assert!(described.contains("editorconfig-checker && cargo make check"));
        assert!(
            described.contains(&machine.display().to_string()),
            "{described}"
        );
        assert!(
            described.contains(&repo.display().to_string()),
            "{described}"
        );

        // A single contributing layer stays the plain one-line summary.
        let single = vec![repo.clone()];
        let solo_cfg = Config::load_layers(&single).expect("single layer loads");
        let solo_described =
            Config::describe_composed(&single, &solo_cfg.verify.gate, "verify.gate", "(none)");
        assert_eq!(solo_described, "cargo make check");
    }
}