newt-core 0.8.0

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

// Model: GPT-5 | Harness: Codex | Operator: Shawn Hartsock | Time: 18:33 EDT | Date: 2026-08-12

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

use serde::{Deserialize, Serialize};

use crate::error::{NewtError, Result};
use crate::router::Tier;
pub use api_surface::{ApiSurfaceConfig, LanguagePack, SymbolRule};
pub(crate) use backend::resolve_api_key_common;
pub use backend::{
    derive_serving, set_cli_backend_override, BackendConfig, BackendDestination, BackendKind,
    BackendOverride, BackendProvenance, BackendRef, BackendRequest, BackendResolutionReceipt,
    BackendSlots, DeclaredBackend, Engine, ManagedMode, OpenAiApi, RequestMode, Serving,
};
pub use context::{
    compaction_trigger_is_session_pinned, input_percentage_ceiling, normalize_input_ceiling_pct,
    session_compaction_trigger_policy, CompactionTriggerPolicy, ContextConfig, ContextFeature,
    ContextFeatureSet, ContextFeatures, ContextManager,
};
pub use crew::{Crew, CrewBudgets, CrewPolicyConfig};
pub use dropin::*;
pub(crate) use layering::{
    expand_tilde, find_ancestor_dir, find_project_config_from, find_project_file_from, home_dir,
    merge_project_overlay, merge_toml, strip_control_plane,
};
pub use layering::{ArrayMergeStrategy, MergeConfig};
pub use loadout::{Loadout, LoadoutSettings};
pub use memory::{MemoryConfig, MemoryDisclosure, MemoryProviderKind};
pub use newt_tuner::{ModelTuning, OverflowRetry};
pub use permissions::{ModeConfig, PermissionPreset, ToolPermissions};
pub use presentation::{
    markdown_is_session_pinned, session_markdown_mode, session_spill_lines,
    set_session_spill_lines, ColorMode, EditMode, FooterMode, MarkdownMode, ThinkingMode,
};
pub use profile::{
    BundleConfig, PickVia, ProfileConfig, ProfilePick, RetryKnobs, VerifyGateKnobs,
    KNOWN_TECHNIQUES,
};
pub use semantic::{OnEmbedFailure, SemanticConfig};
pub use skills::SkillsConfig;
pub(crate) use smart_harness::{normalize_path, resolve_uncreated_path, validate_stable_anchor};
pub use smart_harness::{HarnessLaunch, SmartHarnessConfig};
pub use summarizer::SummarizerConfig;
pub use tool_exposure::{ExposureProfile, ToolExposureConfig};
pub use tools::ToolsConfig;
mod command_budget;
pub use command_budget::RunCommandBudget;

mod api_surface;
mod backend;
mod context;
mod crew;
mod dropin;
mod layering;
mod loadout;
mod memory;
mod permissions;
mod presentation;
mod profile;
mod redact;
mod semantic;
mod shell;
mod skills;
mod smart_harness;
mod summarizer;
mod tool_exposure;
mod tools;
use backend::{cli_backend_override, validate_backend_names, BackendAssembly, RecordTag};
#[cfg(test)]
use backend::{first_emission_this_process, reset_emitted_warnings_for_test};
#[cfg(test)]
use context::default_input_ceiling_pct;
use layering::{array_merge_strategy, base_is_ambient_newt_toml, mark_project_mcp_untrusted};
use presentation::{default_spill_lines, default_time_marker_secs, default_tool_output_lines};
use redact::{redact_arg_secrets, redact_url_secrets};
pub use shell::{
    confined_default_engine, full_access_default_engine, mcp_stdio_env_passthrough,
    ocap_l3_backend, resolve_shell_engine, resolve_shell_engine_choice, resolved_confined_default,
    shell_env_passthrough_default, windows_cmd_enabled, IntakeConfig, ShellConfig, ShellEngine,
};
use tools::{
    default_max_output_tokens, default_output_cap_chars_per_token, default_output_head_tokens,
};

/// Process-scoped user config root override, set by the CLI's `--config-dir`.
pub const NEWT_CONFIG_DIR_ENV: &str = "NEWT_CONFIG_DIR";

// ---------------------------------------------------------------------------
// Config types
// ---------------------------------------------------------------------------

/// `[scratch]` — the ephemeral-state location (#844). See [`Config::scratch`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScratchConfig {
    /// The scratch dir: relative (under the repo, default `.scratch`) or absolute
    /// (`/tmp`, a PVC mount) for a read-only checkout. `NEWT_SCRATCH_DIR` wins.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dir: Option<String>,
    /// The build lane's scratch base (#2604): an absolute dir under which each
    /// workspace gets its build `TMPDIR`. Unset uses a private managed base
    /// outside Git worktrees. `NEWT_BUILD_SCRATCH_DIR` wins. The Build grant
    /// covers scratch; see [`crate::scratch::build_scratch_override`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_dir: Option<String>,
}

/// Top-level Newt-Agent configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    /// Inference backends (Ollama, vLLM, etc.).
    ///
    /// Absent `[[backends]]` deserializes to **empty** (not the struct-level
    /// default's localhost fallback) so a config that defines its backends as
    /// per-file `~/.newt/backends/*.toml` drop-ins does NOT also pick up a
    /// spurious synthesized `ollama` entry. The localhost fallback is restored
    /// in [`Config::resolve`] only if backends are still empty after the disk
    /// merge (so a truly bare setup still talks to a local Ollama).
    #[serde(default = "Vec::new")]
    pub backends: Vec<BackendConfig>,

    /// Provenance marker for [`Config::is_unconfigured`]: true while the
    /// backend list is exactly the compiled-in localhost fallback — nothing
    /// operator-supplied (no inline `[[backends]]`, no drop-in, no CLI
    /// override). Maintained deterministically by [`Config::resolve`] and
    /// `Default` (serde is never trusted for it: `#[serde(skip)]`, and
    /// `resolve()` recomputes it at every backend-assembly stage). Meaningful
    /// only on a `resolve()`d config; never serialized. `pub` only because
    /// `Config`'s struct-update syntax needs every field visible — read it
    /// through [`Config::is_unconfigured`], never directly.
    #[doc(hidden)]
    #[serde(skip)]
    pub backend_fallback: bool,

    /// Which backend the session starts on when several are configured and no
    /// env/loadout pins one (#1130, epic #1126). Unset + multiple backends =
    /// today's heuristic (prefer openai). Points at a backend NAME.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_backend: Option<String>,

    /// `[discovery]` — where `newt setup` looks for live inference endpoints
    /// (#1130). Defaults cover the localhost unboxing case; add hosts for a
    /// home-lab sweep. Pure data; only `setup`/`doctor` read it.
    #[serde(default)]
    pub discovery: Discovery,

    /// `[network]` — the operator's "these hosts are mine" declaration
    /// (#1789). Colloquial trust: it shapes retry patience, never authority.
    /// It cannot widen the note exfiltration guard; see [`crate::owned_hosts`].
    #[serde(default)]
    pub network: NetworkConfig,

    /// External provider-plugin definitions.
    pub providers: Vec<ProviderConfig>,

    /// `[scratch]` — where ephemeral state (crew worktrees, the crew cargo
    /// target, per-session plans) lives (#844). `dir` may be relative (under the
    /// repo, default `.scratch`) or absolute (`/tmp`, a k8s PVC mount) for
    /// read-only checkouts. `NEWT_SCRATCH_DIR` overrides it. Applied in
    /// [`Config::apply_runtime_settings`] via [`crate::scratch::set_scratch_dir`].
    #[serde(default)]
    pub scratch: Option<ScratchConfig>,

    /// Default tier ordering used by the router when no per-backend
    /// override is specified.
    pub default_tier_order: Vec<Tier>,

    /// `[lifecycle]` — the repo's build/dev commands per lifecycle phase
    /// (`format`, `check`, `clean`, …), #880. Overrides the per-ecosystem tooling
    /// packs. Applied in [`Config::resolve`] via
    /// [`crate::tooling::set_lifecycle_override`].
    #[serde(default)]
    pub lifecycle: Option<crate::tooling::PhaseCommands>,

    /// Optional NVIDIA DGX endpoint-management config powering the
    /// `newt dgx` command suite. `None` when unconfigured — newt never
    /// dials a DGX endpoint unless this (or a `NEWT_DGX_*` env var) is set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dgx: Option<crate::dgx::DgxConfig>,

    /// TUI appearance and behaviour. `None` → built-in defaults apply.
    /// Overridable at runtime via `NEWT_CHAT_STYLE` and `NEWT_PROMPT`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tui: Option<TuiConfig>,

    /// `[shell]` — which engine runs `run_command` (ADR 0005 D2 seam). `None` /
    /// unset → the `safe-subset` default, except `--full-access` auto-upgrades to
    /// `host`. Overridable per-session by `--shell-engine`. The L3 backend
    /// (Landlock/Seatbelt/AppContainer) is a separate, auto-selected axis.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub shell: Option<ShellConfig>,

    /// `[intake]` — prompt-disposition inference overrides (#1260). `None` →
    /// the built-in [`crate::agentic::DispositionLexicon`] defaults.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub intake: Option<IntakeConfig>,

    /// `[smart_harness]` — opt-in recorded projections and narration adjudication.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub smart_harness: Option<SmartHarnessConfig>,

    /// `[context]` — context-management strategy selection (Step 24.8, #559).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context: Option<ContextConfig>,

    /// `[tools]` — tool-execution behaviour (#726). `None` → built-in defaults
    /// (notably `max_output_tokens` = 10000). See [`ToolsConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tools: Option<ToolsConfig>,

    /// `[initiative]` — how much the model looks before acting: a baseline
    /// level, per-model-family defaults, and the read-only rounds behind each
    /// level. `None` → `Measured` with the built-in rounds. An explicit
    /// `--initiative` or a persona's `initiative` supersedes it. A pre-split
    /// `[tenacity]` table is migrated here on load
    /// ([`crate::psyche_import::migrate_config_text`]). See
    /// [`crate::initiative::InitiativeConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub initiative: Option<crate::initiative::InitiativeConfig>,

    /// `[tool_exposure]` — the progressive tool-schema controller (Pass 1).
    /// `None` → [`ExposureProfile::Full`] (identity; advertise the full
    /// authorized catalog). See [`ToolExposureConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_exposure: Option<ToolExposureConfig>,

    /// Inference cost modeling. `None` → built-in rate table only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pricing: Option<crate::pricing::PricingConfig>,

    /// Memory / context-window management. `None` → RollingWindow(20).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory: Option<MemoryConfig>,

    /// Project-instruction loading (`AGENTS.md` / `CLAUDE.md`) into the system
    /// prompt. Enabled by default. Overridable via `--agents-file` /
    /// `--no-agents-file`.
    #[serde(default)]
    pub agents: AgentsConfig,

    /// newt-native MCP servers (`[[mcp_servers]]`). Merged with the servers
    /// discovered from Claude Code's config by [`crate::mcp::discover`]; these
    /// take precedence on a name clash. Empty by default.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub mcp_servers: Vec<crate::mcp::McpServerEntry>,

    /// Usage-log rotation policy. `None` → built-in defaults apply
    /// (keep last 7 sessions, no size/age limit).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub logs: Option<LogConfig>,

    /// Skill discovery search path — the ordered list of directories newt
    /// reads `SKILL.md` folders from. `None` → just `~/.newt/skills`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub skills: Option<SkillsConfig>,

    /// Per-model inference tuning overrides (`[[model_tuning]]`).
    ///
    /// Each entry locks specific parameters for a named model. Values here
    /// take precedence over empirically derived values from
    /// `model-capabilities.json` and over global `[tui]` defaults.
    ///
    /// Example `~/.newt/config.toml`:
    /// ```toml
    /// [[model_tuning]]
    /// model = "nemotron3:33b"
    /// num_ctx = 24576            # explicit Ollama context window
    /// mid_loop_trim_threshold = 12
    /// max_tool_rounds = 20
    /// ```
    ///
    /// Human-authored entries are never overwritten by the auto-tuner.
    /// Auto-tuned entries are **appended** by the harness when
    /// `tune_confidence` reaches `High`; delete or edit them freely.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub model_tuning: Vec<ModelTuning>,

    /// Durable conversation save/restore policy. `None` uses built-in defaults.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub conversations: Option<ConversationsConfig>,

    /// How a project-local `.newt/config.toml` is layered over the global
    /// config (issue #222). `None` → built-in default (arrays replace).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub merge: Option<MergeConfig>,

    /// Named permission presets (`[permission_presets.<name>]`, issue #307).
    /// Each maps onto the role-profile caveat mechanism (a
    /// [`crate::NamedPermissionPreset`]) and, when applied via `/posture`, clamps
    /// the session's authority as a hard floor. Empty by default — no preset,
    /// behavior unchanged.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub permission_presets: std::collections::BTreeMap<String, crate::NamedPermissionPreset>,

    /// Named permission-posture bindings for `/posture` (issue #307). The
    /// `[modes.<name>]` key is retained for configuration compatibility. Each
    /// binding atomically preloads a skill body, applies a permission preset as
    /// an authority floor, and adds system-prompt framing. Empty by default.
    /// See [`ModeConfig`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub modes: std::collections::BTreeMap<String, ModeConfig>,

    /// Named profiles (`[profiles.<name>]`) — a composition of harness
    /// *techniques* plus each technique's tunable knob settings (the technique
    /// library, `docs/design/technique-library.md`). A profile is selected by
    /// `--profile <name>` and tunes the harness per model family / context.
    /// Empty by default — no profile, behavior unchanged. See [`ProfileConfig`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub profiles: std::collections::BTreeMap<String, ProfileConfig>,

    /// Named bundles (`[bundles.<name>]`) — the loadable unit of the model support
    /// kit (`docs/design/model-support-kit.md`). A bundle pins which model families
    /// it applies to and which profile each resolves to. Selected by `--bundle
    /// <name>` or inferred from the model via `applies_to`. Empty by default — no
    /// bundle, behavior unchanged. See [`BundleConfig`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub bundles: std::collections::BTreeMap<String, BundleConfig>,

    /// Named loadouts (`[loadouts.<name>]` or `~/.newt/loadouts/<name>.toml`) — the
    /// top-level composition of `provider → model → kit → role → settings`
    /// (`docs/design/loadout-composition.md`). Inert until the resolver is wired
    /// (Slice 1): this carries the data model + reference validation + `/loadout`
    /// show. Empty by default. See [`Loadout`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub loadouts: std::collections::BTreeMap<String, Loadout>,

    /// Named crews (`[crews.<name>]` or `crews/<name>.toml`) — role-specialized
    /// ensembles over the backend pool (`docs/design/crew-loadout.md`). Each role
    /// names a `[loadouts.<name>]`. Empty by default. See [`Crew`].
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub crews: std::collections::BTreeMap<String, Crew>,

    /// `[crew]` — crew/team **dispatch policy** (#749). Carries the authority
    /// *clamp* every dispatched crew is met against, so a crew's effective
    /// authority is `session ⊓ clamp` — never above the session ceiling, and as
    /// tight as the operator configures. `None` (and the default clamp) is
    /// `Caveats::top()`, i.e. the meet is the identity and behavior is unchanged.
    /// This is the structural tightening point the per-subtask `team_clamp`
    /// (#749 step 8) plugs into. See [`CrewPolicyConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub crew: Option<CrewPolicyConfig>,

    /// `[plan]` — plan-authoring policy. Today: the `[plan.prune]` droppable
    /// override for the decompose prune's anti-pattern lexicon (#801/#803 →
    /// #819). `None` = compiled defaults, behavior unchanged. See
    /// [`PlanPruneConfig`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub plan: Option<PlanConfig>,
}

// ---------------------------------------------------------------------------
// Durable conversation config
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ConversationsConfig {
    /// Maximum saved conversations per workspace. Default: 100. 0 = no pruning.
    #[serde(default = "default_conversations_max_per_workspace")]
    pub max_per_workspace: usize,

    /// Auto-resume this workspace's most recently active conversation at TUI
    /// session start. "Most recently active" means the highest §6 activity
    /// tick — never a wall-clock comparison.
    ///
    /// **Default: false** (#1030). Each launch starts a FRESH conversation, so
    /// running `newt` several times in one folder no longer drags every session
    /// into — and interleaves their turns onto — that folder's single latest
    /// conversation (the collision the old `true` default caused). Find and
    /// reopen a past conversation explicitly with `/resume` instead. Opt back
    /// into the old auto-resume-latest behavior with:
    ///
    /// ```toml
    /// [conversations]
    /// resume = true       # auto-resume the folder's most recent conversation
    /// ```
    ///
    /// Per-session overrides win over this key either way: `--ephemeral`
    /// (no persistence at all) and `NEWT_CONVERSATION_ID=<id>` (resume
    /// exactly that conversation).
    #[serde(default = "default_conversations_resume")]
    pub resume: bool,
}

fn default_conversations_max_per_workspace() -> usize {
    100
}

fn default_conversations_resume() -> bool {
    // #1030: fresh-on-launch. The old `true` made every launch in a folder
    // auto-resume that folder's latest conversation, so concurrent `newt`
    // processes interleaved their turns into one record. `/resume` is now the
    // explicit way back into a past conversation.
    false
}

impl Default for ConversationsConfig {
    fn default() -> Self {
        Self {
            max_per_workspace: default_conversations_max_per_workspace(),
            resume: default_conversations_resume(),
        }
    }
}

// ---------------------------------------------------------------------------
// Log rotation config
// ---------------------------------------------------------------------------

/// Rotation policy for `~/.newt/usage.jsonl`.
///
/// All limits default to the values shown. Set a field to `0` to disable
/// that particular limit. Multiple active limits compose — the most
/// restrictive one wins after each append.
///
/// Example `newt.toml`:
/// ```toml
/// [logs]
/// max_sessions = 100   # keep the last 100 turns
/// max_size_mb  = 5     # also cap at 5 MiB
/// max_age_days = 14    # and drop anything older than 2 weeks
/// keep_rotated = 2     # keep usage.jsonl.1 and .2 as backup
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LogConfig {
    /// Keep at most this many JSONL entries (most recent). Default: 7. 0 = no limit.
    #[serde(default = "default_log_max_sessions")]
    pub max_sessions: usize,

    /// Rotate when the file exceeds this size in MiB. Default: 0 (no size limit).
    #[serde(default)]
    pub max_size_mb: u64,

    /// Drop entries older than this many days. Default: 0 (no age limit).
    /// Requires a `recorded_at` field in the log entry; entries without it
    /// are kept.
    #[serde(default)]
    // INERT-CODE-RATCHET: F04 WIRE: max_age_days is parsed and defaulted but no retention decision reads it.
    pub max_age_days: u64,

    /// How many rotated copies to keep alongside the live log
    /// (`usage.jsonl.1`, `.2`, …). Default: 3. 0 = overwrite silently.
    #[serde(default = "default_log_keep_rotated")]
    pub keep_rotated: usize,
}

fn default_log_max_sessions() -> usize {
    7
}

fn default_log_keep_rotated() -> usize {
    3
}

impl Default for LogConfig {
    fn default() -> Self {
        Self {
            max_sessions: default_log_max_sessions(),
            max_size_mb: 0,
            max_age_days: 0,
            keep_rotated: default_log_keep_rotated(),
        }
    }
}

// ---------------------------------------------------------------------------
// Project-instruction (AGENTS.md / CLAUDE.md) config
// ---------------------------------------------------------------------------

/// Project-instruction loading stored under `[agents]` in `newt.toml`.
///
/// When enabled (the default), newt reads `AGENTS.md` / `CLAUDE.md` from the
/// workspace and injects them into the agent's system prompt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentsConfig {
    /// Whether to load project instructions into the system prompt. Default: true.
    pub enabled: bool,
    /// Directory to search for `AGENTS.md` / `CLAUDE.md`, or a specific
    /// instructions file. Relative paths are resolved against the workspace.
    /// Default: the workspace root.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<String>,
}

impl Default for AgentsConfig {
    fn default() -> Self {
        Self {
            enabled: true,
            path: None,
        }
    }
}

// ---------------------------------------------------------------------------
// TUI config
// ---------------------------------------------------------------------------

/// TUI appearance preferences stored under `[tui]` in `newt.toml`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiConfig {
    /// PS1-style prompt template.
    ///
    /// Tokens: `\w` workspace basename, `\W` full path, `\h` hostname,
    /// `\v` newt version.  Default: `"\\w $ "` (compact) / `"you $ "` (verbose).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,

    /// Skip the full-screen ANSI art splash and show a compact header instead.
    /// Equivalent to the `--no-splash` CLI flag.
    #[serde(default)]
    pub no_splash: bool,

    /// Opt into the mouse-driven spill-viewport tier (#1303): wheel/click scroll
    /// and editor-mode-aware keys on the live-spill viewport. Default **off**; it
    /// only ever activates on top of `live_spill_capable()` on an interactive
    /// stdin+stdout TTY (a strict superset gate), and the mouse code is
    /// compile-time-stripped from the lean/wyvern build.
    #[serde(default)]
    pub mouse_viewport: bool,

    /// Key binding mode for the chat input line.
    /// `"emacs"` (default), `"vi"`, or `"nano"`. Also overridable via
    /// `NEWT_EDIT_MODE`. (`nano` is emacs-style/modeless — it differs from
    /// `emacs` only in label today; the rich-tui surface honors it.)
    #[serde(default)]
    pub edit_mode: EditMode,

    /// The meta prefix chord (e.g. `"ctrl+space"`, `"ctrl+a"`): pressed before
    /// a key, it reaches newt's own operations — zoom, resize, redraw — the
    /// way tmux and herdr use `ctrl+b`. Unset means the shipped default,
    /// `ctrl+space`. Set from `/settings prefix`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prefix_key: Option<String>,

    /// Rich-tui (issue #416) input gutter width, in columns. Unset = `auto`
    /// (the responsive default: a prompt gutter when it fits under ~1/3 of the
    /// width, else a stacked prompt row). `0` turns the gutter off (prompt on
    /// its own row, input flush-left); a positive value indents the input that
    /// many columns (a value wide enough to hold the prompt renders it inline).
    /// No effect on the lean surface.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub gutter: Option<u16>,

    /// Input-footer mode: the transient multi-line `❯` input block with a
    /// status header. `"auto"` (default) shows it on a TTY and degrades to a
    /// plain scroller otherwise; `"on"` always shows it; `"off"` never does
    /// (the `--plain` CLI flag, or `NEWT_FOOTER=off`).
    #[serde(default)]
    pub footer: FooterMode,

    /// Color / theme mode (issue #527): `"auto"` (default), `"always"`,
    /// `"never"`, `"minimal"`, `"inverted"`, `"dark"`, `"light"`, `"mono"`.
    /// The `--color` CLI flag and `--mono` override this; `NO_COLOR` /
    /// `TERM=dumb` force it off unless an explicit `--color` is given. This is
    /// the on/off + theme *mode*; `[tui.colors]` below is the palette.
    #[serde(default)]
    pub color: ColorMode,

    /// How a thinking model's streamed reasoning is shown: `"stream"` (default
    /// — dim reasoning + a cargo-style spinner, TTY only) or `"off"`.
    #[serde(default)]
    pub thinking: ThinkingMode,

    /// Legacy line limit retained for pre-execution previews such as
    /// `write_file`. Completed tool results use `[tui].spill_lines` instead.
    /// Default: 20. Set to 0 to show the full preview.
    #[serde(default = "default_tool_output_lines")]
    pub tool_output_lines: usize,

    /// #1235: collapsed height of every tool's SPILL VIEW — the tail-biased,
    /// gutter-glyphed result block following its `⚙` audit line. It bounds both
    /// the live viewport and canonical completed result. An active TTY viewport
    /// may expand retained output up to safe terminal capacity. Default: 3.
    /// Set to 0 for unbounded completed output and no live viewport. The legacy
    /// `tool_output_lines` preview setting does not override it.
    #[serde(default = "default_spill_lines")]
    pub spill_lines: usize,

    /// Seconds between dim `[HH:MM]` markers committed above tool calls, so a
    /// long transcript can be read for WHEN as well as what. At most one marker
    /// per interval however long the gap — a turn that blocks for an hour emits
    /// one line when it returns, not twelve.
    ///
    /// Default: 300 (five minutes), and it applies to the INTERACTIVE surface
    /// only. Piped, headless and `newt solve` runs commit no markers whatever
    /// this says, because a wall clock in stdout makes byte-exact capture
    /// unstable and those are the paths where that matters. Set to 0 to turn
    /// them off interactively too.
    #[serde(default = "default_time_marker_secs")]
    pub time_marker_secs: u64,

    /// Initial tool-call round allowance per turn. Default: 40. With nonzero
    /// `workflow_grace_rounds`, fresh evidence or actual workspace changes
    /// renew this allowance automatically. Explicit call/cost budgets and
    /// cancellation still apply. Without recent progress, Newt requests one
    /// final tools-disabled answer; set grace to zero for a hard round cap.
    #[serde(default = "default_max_tool_rounds")]
    pub max_tool_rounds: usize,

    /// Rounds granted at each progress-backed renewal of `max_tool_rounds`.
    /// Default: 5. Each renewal requires fresh evidence since the last; repeated
    /// reads, passing checks on unchanged work, and plan updates do not renew.
    /// Set to 0 for a hard round cap. Applies equally to every provider and to
    /// Smart Harness, without model-facing continuation prompts.
    #[serde(default = "default_workflow_grace_rounds")]
    pub workflow_grace_rounds: usize,

    /// Maximum "you narrated intent but called no tool" auto-continue nudges
    /// per turn — the narrate-then-stop rescue. Once spent, the next no-tool
    /// narration is accepted as the turn's final answer and the turn ends.
    /// Default: 1. Weak local models that chronically announce actions in
    /// prose instead of calling tools benefit from 2–3; the second and later
    /// nudges escalate (they name the active plan step and demand a bare tool
    /// call). `0` disables the rescue entirely — every no-tool narration is
    /// accepted as the final answer. See docs/design/next-loop-levers.md,
    /// lever L3.
    #[serde(default = "default_narration_nudge_cap")]
    pub narration_nudge_cap: usize,

    /// Append one sentence to the system prompt asking the model to state, in
    /// one short sentence, what it is about to do before each tool call.
    /// Default: false. Off, nothing changes; on, the sentence is appended at
    /// prompt build time, never baked into the identity text.
    #[serde(default = "default_narration_intent_line")]
    pub narration_intent_line: bool,

    /// Tool-call permission policy for the interactive TUI: which tools the
    /// model may invoke and over which targets. This is a *preset that selects
    /// an attenuation* — the host (`newt-identity`) lowers it into a signed,
    /// attenuation-only capability that enforcement consults. Default:
    /// `WorkspaceDev`.
    #[serde(default)]
    pub permissions: ToolPermissions,

    /// Enable per-round agent-loop diagnostics printed to the TUI. Shows each
    /// round's content excerpt, tool-call count, token usage, and flags empty
    /// model responses before they become silent failures. Also set via the
    /// `NEWT_DEBUG=1` environment variable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub debug: Option<bool>,

    /// Enable deep backend/inference diagnostics. Intended for issue reports
    /// and compatibility debugging; also set via `NEWT_TRACE=1`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trace: Option<bool>,

    /// Shell command to run after every successful file write or edit, to
    /// give the agent immediate ground-truth feedback on whether its change
    /// compiled / passed basic checks. Output is appended to the tool result
    /// so the model sees it without needing to ask.
    ///
    /// Set this per-workspace in `.newt/config.toml` — not globally — because
    /// the right command depends on the project's build system:
    ///
    /// ```toml
    /// [tui]
    /// build_check_cmd = "cargo check -q --workspace"  # Rust
    /// # build_check_cmd = "npm run build --silent"    # Node
    /// # build_check_cmd = "python -m py_compile"      # Python
    /// ```
    ///
    /// `None` (default) disables auto-checking — no extra command is run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_check_cmd: Option<String>,

    // -----------------------------------------------------------------------
    // DGX / inference endpoint resource management
    // -----------------------------------------------------------------------
    /// Ollama context-window cap sent as `options.num_ctx` on every request.
    /// Limits the KV-cache allocation so a large model can't exhaust VRAM
    /// mid-session. `None` → newt trusts the model's declared context window
    /// from Ollama `/api/show` and sends ~80 % of it as `num_ctx` (see
    /// `real_context_discovery`). Set an explicit cap here (e.g. 8192 / 16384)
    /// on VRAM-constrained hosts; this always takes precedence over the
    /// declared window.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub num_ctx: Option<u32>,

    /// Opt into **empirical** context-window discovery (the conserve-for-tiny-
    /// hardware mode). When `false`/unset (the default), newt **trusts the
    /// declared** `/api/show` window: `safe_context` tracks ~80 % of it and is
    /// raised back to it each session, so a model is never permanently capped
    /// by a past overflow. When `true`, newt instead keeps the conservative
    /// behaviour — bootstrap `safe_context` once, never auto-raise it, and let
    /// runtime context-window 400s ratchet it down and persist (for hardware
    /// that genuinely can't serve the full declared window). Overridable
    /// per-model via `[[model_tuning]] real_context_discovery`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub real_context_discovery: Option<bool>,

    /// TCP connect timeout in seconds for inference requests (default: 5).
    /// A fast failure here means the endpoint is down (connection refused),
    /// distinguishing it from a slow-but-alive endpoint that needs the full
    /// `inference_timeout_secs` to respond. Keep this short.
    #[serde(default = "default_connect_timeout_secs")]
    pub connect_timeout_secs: u64,

    /// Inference timeout in seconds (default: 120). Single-response requests
    /// use it as a total bound; streamed generation uses it as the maximum
    /// idle gap between chunks.
    #[serde(default = "default_inference_timeout_secs")]
    pub inference_timeout_secs: u64,

    /// How long Ollama keeps a model resident in VRAM after the last request,
    /// as an Ollama duration string (e.g. `"5m"`, `"0"`, `"-1"`).
    /// Default: `"5m"`. Use `"0"` to unload immediately after each turn
    /// (maximum headroom for multi-model or multi-agent workloads at the cost
    /// of a reload on each turn). Use `"-1"` to keep forever.
    #[serde(default = "default_keep_alive")]
    pub keep_alive: String,

    // Summarizer knobs (timeout / retries / fallback model) moved to the
    // dedicated `~/.newt/summarizer.toml` ([`SummarizerConfig`]) in Step 24.10
    // (#559), so the summarizer can run on its own backend. Old `[tui]` keys
    // (`summarizer_timeout_secs` / `summarizer_retries` / `summarizer_model`)
    // are no longer read — `#[serde(default)]` ignores them in stale configs.
    /// Markdown rendering of RichTUI text output (Step 25.4, #568), including
    /// assistant replies and built-in documents such as `/help`. `auto`
    /// (default) renders whenever color is active; `on`/`off` force it. The
    /// `/markdown [on|off]` command overrides this for the session.
    #[serde(default)]
    pub markdown: MarkdownMode,

    /// Maximum number of messages in the in-progress tool-call message list
    /// before the agent trims the middle to prevent context overflow.
    /// Default: 40 (≈ 20 tool-call rounds). Set lower on memory-constrained
    /// endpoints or when `num_ctx` is small.
    #[serde(default = "default_mid_loop_trim_threshold")]
    pub mid_loop_trim_threshold: usize,

    /// Estimated-token threshold that triggers a mid-loop context trim,
    /// independent of `mid_loop_trim_threshold` (which counts *messages*).
    /// A single tool round can return a multi-KB file listing or JSON payload
    /// that adds hundreds of thousands of tokens in one message — far below the
    /// message-count threshold but well past the model's context window. When
    /// set, trimming fires as soon as the estimated token count (chars / 4)
    /// exceeds this value. `None` disables token-based trimming.
    /// Default: `None` (message-count trimming only). See issue #223.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mid_loop_trim_tokens: Option<usize>,

    /// Normalise hyphens to underscores in MCP server names when advertising
    /// tool definitions and routing tool calls.  Some API proxies (e.g. those
    /// that wrap the Anthropic backend) replace hyphens with underscores in
    /// tool names; advertising the sanitised form ensures the model's tool
    /// calls round-trip back unchanged and routes correctly.  Default: `true`.
    /// Set to `false` only when every connected MCP server is behind a proxy
    /// that preserves hyphens verbatim.
    #[serde(default = "default_sanitize_mcp_server_names")]
    pub sanitize_mcp_server_names: bool,

    /// Hosts (IP or hostname) for which newt may send an MCP OAuth Bearer token
    /// over an UNENCRYPTED (non-`https`) connection. Empty by default: a stored
    /// Bearer is sent only over `https` or to loopback (`localhost`/`127.0.0.1`/
    /// `::1`). newt WARNs on every non-loopback unencrypted MCP connection
    /// regardless; an allow-listed host still warns but the token is sent.
    /// This is the explicit opt-out of the secure-by-default transport policy —
    /// see `docs/decisions/mcp_transport_security.md`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub mcp_allow_insecure_hosts: Vec<String>,

    /// Whether the interactive `!` bang-escape is available — typing `! <cmd>`
    /// at the prompt runs a host command with the user's own authority (not the
    /// agent's OCAP leash). Default: `true`. Set `false` for locked-down or
    /// shared deployments where the human at the keyboard should not have an
    /// unconfined host shell-out. The model can never invoke `!` either way;
    /// this only governs the human. See `docs/decisions/plain_scroller_tui.md`.
    #[serde(default = "default_allow_bang_escape")]
    pub allow_bang_escape: bool,
    /// `tui-shell-commands`: human-typed navigation/inspection commands
    /// (`cd`/`pwd`/`ls`/`env`/`date`) handled by the TUI itself, managing a
    /// session working directory shown in the prompt — distinct from the agent's
    /// `run_command`. Default `true`. The model never sees these; this governs
    /// only the human at the keyboard.
    #[serde(default = "default_allow_shell_commands")]
    // INERT-CODE-RATCHET: F06 WIRE: shell admission and mutation knobs are parsed but neither shell path consults them.
    pub allow_shell_commands: bool,
    /// Whether the `tui-shell-commands` suite may MUTATE the filesystem
    /// (`mkdir`/`mv`, and `rm` via a recoverable graveyard). Default `false` —
    /// navigation + inspection only until the operator opts in.
    #[serde(default = "default_allow_shell_mutations")]
    pub allow_shell_mutations: bool,
}

fn default_max_tool_rounds() -> usize {
    40
}

fn default_workflow_grace_rounds() -> usize {
    5
}

fn default_narration_nudge_cap() -> usize {
    1
}

fn default_narration_intent_line() -> bool {
    false
}

fn default_connect_timeout_secs() -> u64 {
    5
}

fn default_inference_timeout_secs() -> u64 {
    120
}

fn default_keep_alive() -> String {
    "5m".to_string()
}

fn default_mid_loop_trim_threshold() -> usize {
    40
}

fn default_sanitize_mcp_server_names() -> bool {
    true
}

fn default_allow_bang_escape() -> bool {
    true
}

fn default_allow_shell_commands() -> bool {
    true
}

fn default_allow_shell_mutations() -> bool {
    false
}

// ---------------------------------------------------------------------------
// Per-model tuning helpers
// ---------------------------------------------------------------------------

impl Config {
    /// Find the first `[[model_tuning]]` entry whose `model` field matches
    /// `name` exactly. Returns `None` when no entry exists.
    #[must_use]
    pub fn find_model_tuning(&self, name: &str) -> Option<&ModelTuning> {
        self.model_tuning.iter().find(|t| t.model == name)
    }
}

/// `[plan]` — plan-authoring policy (#819).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanConfig {
    /// `[plan.prune]` — the decompose-prune lexicon override.
    #[serde(default)]
    pub prune: PlanPruneConfig,
}

/// `[plan.prune]` — droppable, three-Cs override for the #801/#803 planner-
/// decomposition prune. The compiled `ACTION_MARKERS` lexicon is the default;
/// this table composes with it (remove first, then additions), so a new
/// anti-pattern — or un-marking a verb your domain uses for real work — is
/// CONFIG, not code. The prune itself stays grade-neutral either way: it only
/// removes no-diff leaves before any authority grant (#803's n=5 A/B measured
/// no grade lift; it removes a failure *mechanism*).
///
/// ```toml
/// [plan.prune]
/// disabled = false
/// add_inspect = ["scrutinize"]   # pruned wherever they lead an instruction
/// add_gate = ["smoke"]           # pruned only when terminal
/// remove = ["review"]            # e.g. a repo where "Review X" edits docs
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanPruneConfig {
    /// Turn the prune off entirely (the pre-#803 behavior).
    #[serde(default)]
    pub disabled: bool,
    /// Extra leading verbs classified Inspect (case-insensitive).
    #[serde(default)]
    pub add_inspect: Vec<String>,
    /// Extra leading verbs classified Gate (case-insensitive).
    #[serde(default)]
    pub add_gate: Vec<String>,
    /// Verbs to REMOVE from the effective lexicon (builtin or added).
    #[serde(default)]
    pub remove: Vec<String>,
}

impl Default for TuiConfig {
    fn default() -> Self {
        Self {
            prompt: None,
            no_splash: false,
            mouse_viewport: false,
            edit_mode: EditMode::Nano,
            prefix_key: None,
            gutter: None,
            footer: FooterMode::Auto,
            color: ColorMode::Auto,
            thinking: ThinkingMode::Stream,
            tool_output_lines: default_tool_output_lines(),
            spill_lines: default_spill_lines(),
            time_marker_secs: default_time_marker_secs(),
            max_tool_rounds: default_max_tool_rounds(),
            workflow_grace_rounds: default_workflow_grace_rounds(),
            narration_nudge_cap: default_narration_nudge_cap(),
            narration_intent_line: default_narration_intent_line(),
            permissions: ToolPermissions::default(),
            debug: None,
            trace: None,
            build_check_cmd: None,
            num_ctx: None,
            real_context_discovery: None,
            connect_timeout_secs: default_connect_timeout_secs(),
            inference_timeout_secs: default_inference_timeout_secs(),
            keep_alive: default_keep_alive(),
            markdown: MarkdownMode::default(),
            mid_loop_trim_threshold: default_mid_loop_trim_threshold(),
            mid_loop_trim_tokens: None,
            sanitize_mcp_server_names: default_sanitize_mcp_server_names(),
            mcp_allow_insecure_hosts: Vec::new(),
            allow_bang_escape: default_allow_bang_escape(),
            allow_shell_commands: default_allow_shell_commands(),
            allow_shell_mutations: default_allow_shell_mutations(),
        }
    }
}

/// Endpoint-discovery configuration (`[discovery]`, #1130): the hosts and
/// ports `newt setup` probes for live inference servers. Each host is tried
/// on every listed Ollama port (`/api/tags`) and vLLM/OpenAI port
/// (`/v1/models`); each hit becomes a `~/.newt/backends/<name>.toml`. Pure
/// data — the three Cs: adding a lab host is config, not code.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Discovery {
    /// Hostnames to probe. Default: just localhost (the unboxing case).
    pub hosts: Vec<String>,
    /// Ports probed with the Ollama protocol (`/api/tags`).
    pub ollama_ports: Vec<u16>,
    /// Ports probed as OpenAI-compatible (`/v1/models`) — the vLLM range;
    /// several ports = several single-model instances (space multiplexing).
    pub vllm_ports: Vec<u16>,
}

/// `[network]` — which DNS suffixes the operator calls theirs.
///
/// Pure data, Configuration over hardcoded knowledge: the built-in private
/// suffixes stay compiled in as the floor, and this only ever *adds*. Declaring
/// `owned_suffixes = [".corp"]` makes `infer.corp` eligible for the patient
/// local-inference retry policy. It grants no authority and does not touch the
/// hardcoded exfiltration guard.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkConfig {
    /// DNS suffixes the operator owns (`.corp`, `.home.arpa`). A leading dot is
    /// optional; matching is case-insensitive.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub owned_suffixes: Vec<String>,
}

impl Default for Discovery {
    fn default() -> Self {
        Self {
            hosts: vec!["localhost".into()],
            ollama_ports: vec![11434],
            // vLLM convention first, then llama.cpp router mode; the remaining
            // adjacent ports cover operators hosting one vLLM instance per model.
            vllm_ports: vec![8000, 8080, 8001, 8002, 8003],
        }
    }
}

/// Shared provider-identity validation: nonempty and unique names. The
/// unified selection contract ([`Config::select_backend`]) name-addresses
/// providers, so a duplicate can hand the session a different provider
/// than the one the operator meant. Cross-namespace ties (a backend and a
/// provider sharing one name) are DELIBERATE and documented: a ROUTABLE
/// backend wins the tie; a destination-less backend loses it to the
/// provider — pinned by tests, not rejected.
fn validate_provider_names(providers: &[ProviderConfig]) -> std::result::Result<(), String> {
    let mut seen = std::collections::BTreeSet::new();
    for (i, p) in providers.iter().enumerate() {
        if p.name.trim().is_empty() {
            return Err(format!(
                "provider #{} has no name — every [[providers]] entry needs a unique \
                 `name` (selection is name-based)",
                i + 1
            ));
        }
        if !seen.insert(p.name.clone()) {
            return Err(format!(
                "two providers share the name `{}` — provider selection is name-based \
                 ($NEWT_PROVIDER, default_backend), so a duplicate can run the wrong \
                 one; rename one",
                p.name
            ));
        }
    }
    Ok(())
}

/// A backend with a COHERENT wire destination — a nonempty HTTP `endpoint`,
/// or a nonempty embedded `model_path` on a backend whose kind is embedded
/// (or unset — composition pins it to Embedded). The ONE usability
/// predicate selection runs on: an embedded backend is exactly as
/// selectable as an HTTP one, but a `model_path` on an HTTP-kind backend is
/// an incoherent declaration, not a route.
fn backend_is_routable(b: &BackendConfig) -> bool {
    if !b.endpoint.is_empty() {
        return true;
    }
    b.model_path.as_deref().is_some_and(|p| !p.is_empty())
        && matches!(b.kind, None | Some(BackendKind::Embedded))
}

/// The typed outcome of the shared slot selector — kept distinct so an
/// explicit selector naming a DESTINATION-LESS backend cannot collapse into
/// either "selected" (routing nowhere) or "nothing selected" (silently
/// running something else).
enum SlotSelection {
    /// The precedence picked this slot (routable by construction).
    Slot(usize),
    /// An explicit selector (`$NEWT_PROVIDER` / `default_backend`) named a
    /// configured backend with neither endpoint nor model_path. Surfaced,
    /// never silently skipped: consumers turn this into a hard error
    /// ([`SelectionOutcome::UnroutableNamed`], the assembly's field-only
    /// targeting error) or a documented `None`
    /// ([`Config::select_configured_backend`]).
    ExplicitlyUnroutable { name: String },
    /// An explicit selector names NOTHING among these backends — a typo, or
    /// a provider's name (this selector only knows `[[backends]]`; the
    /// caller that knows providers decides). Surfaced the same way:
    /// selection must not silently desert an explicit selector for the
    /// preference rules.
    ExplicitlyUnmatched { name: String },
    /// Nothing explicitly selected and nothing routable configured.
    None,
}

/// The ONE config-backend slot selector — shared by
/// [`Config::select_configured_backend`], [`Config::select_backend`],
/// [`ResolvedConfig::selected_backend`], and the assembly's unnamed
/// field-only CLI targeting, so "which backend is selected" has exactly one
/// answer. Precedence, most-specific first: `$NEWT_PROVIDER` names a
/// backend > `default_backend` > a sole routable backend > prefer an
/// OpenAI-kind routable entry, else the first routable one. An EXPLICIT
/// selector (env/default) naming a configured but destination-less backend
/// is [`SlotSelection::ExplicitlyUnroutable`] — never a silent pick of it,
/// never a silent fall-through past it.
fn select_backend_slot(
    backends: &[&BackendConfig],
    default_backend: Option<&str>,
) -> SlotSelection {
    // 1. Operator / live override: $NEWT_PROVIDER names a backend.
    if let Ok(name) = std::env::var("NEWT_PROVIDER") {
        if !name.is_empty() {
            return match backends.iter().position(|b| b.name == name) {
                Some(i) if backend_is_routable(backends[i]) => SlotSelection::Slot(i),
                Some(_) => SlotSelection::ExplicitlyUnroutable { name },
                None => SlotSelection::ExplicitlyUnmatched { name },
            };
        }
    }
    // 2. The configured default. An EMPTY string is absent, exactly as
    //    `select_backend` treats it — never an authoritative selector for a
    //    backend named "".
    if let Some(name) = default_backend.filter(|n| !n.is_empty()) {
        return match backends.iter().position(|b| b.name == name) {
            Some(i) if backend_is_routable(backends[i]) => SlotSelection::Slot(i),
            Some(_) => SlotSelection::ExplicitlyUnroutable {
                name: name.to_string(),
            },
            None => SlotSelection::ExplicitlyUnmatched {
                name: name.to_string(),
            },
        };
    }
    // 3. A sole backend is the obvious choice.
    if backends.len() == 1 {
        return match backends.first().filter(|b| backend_is_routable(b)) {
            Some(_) => SlotSelection::Slot(0),
            None => SlotSelection::None,
        };
    }
    // 4. Prefer an OpenAI-kind entry, else the first routable one.
    backends
        .iter()
        .position(|b| b.kind == Some(BackendKind::Openai) && backend_is_routable(b))
        .or_else(|| backends.iter().position(|b| backend_is_routable(b)))
        .map_or(SlotSelection::None, SlotSelection::Slot)
}

/// Selection follows the request in EVERY composer: a destination request
/// or a NAMED field-only edit makes its target the config-level selection
/// (`default_backend`), so an exclusive request can never leave a stale
/// default naming a discarded backend — with no CLI-installed
/// `$NEWT_PROVIDER`, typed/receipt selection would otherwise resolve
/// Unknown/None against a config that plainly contains the requested
/// backend. An UNNAMED field-only edit already targeted the selected
/// backend — nothing to move.
fn pin_requested_selection(
    cfg: &mut Config,
    over: Option<&BackendOverride>,
    requested_slot: Option<usize>,
) {
    let Some(over) = over.filter(|o| !o.is_empty()) else {
        return;
    };
    if over.endpoint.is_some() || over.model_path.is_some() || over.name.is_some() {
        if let Some(target) = requested_slot.and_then(|i| cfg.backends.get(i)) {
            cfg.default_backend = Some(target.name.clone());
        }
    }
}

/// A runtime-resolved configuration: the flattened [`Config`] plus the
/// per-backend provenance receipts, aligned **1:1 by slot** with
/// `config.backends` — receipt `i` is about backend `i`, full stop. Never
/// looked up by name or by `&BackendConfig` (both were how a receipt could
/// end up describing the wrong backend). Immutably derefs to [`Config`];
/// there is deliberately NO `DerefMut` — mutating the config would silently
/// invalidate the receipts.
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
    config: Config,
    receipts: Vec<BackendResolutionReceipt>,
}

/// One backend of a [`ResolvedConfig`]: the slot index, the effective
/// backend, and its provenance receipt — the three always travel together.
#[derive(Debug, Clone, Copy)]
pub struct ResolvedBackend<'a> {
    /// The slot index into `config.backends` / the receipts.
    pub slot: usize,
    /// The effective (flattened) backend at that slot.
    pub backend: &'a BackendConfig,
    /// The provenance receipt for that slot.
    pub receipt: &'a BackendResolutionReceipt,
}

impl std::ops::Deref for ResolvedConfig {
    type Target = Config;
    fn deref(&self) -> &Config {
        &self.config
    }
}

impl ResolvedConfig {
    /// Discard the receipts and keep the flattened config — the
    /// compatibility exit for consumers that predate receipts.
    #[must_use]
    pub fn into_config(self) -> Config {
        self.config
    }

    /// A resolution of `config` AS-IS: pure declarations — no disk merge,
    /// no CLI request, receipts minted 1:1 from each backend's own
    /// declaration. The INFALLIBLE last-resort constructor for surfaces
    /// that must render something even when resolution proper failed (the
    /// TUI's `unwrap_or_default` lane); it validates nothing, exactly like
    /// the bare `Config` it wraps.
    #[must_use]
    pub fn unrequested(config: Config) -> Self {
        let receipts = config
            .backends
            .iter()
            .map(|b| BackendResolutionReceipt {
                declaration: DeclaredBackend::of(b),
                request: None,
                observation: None,
                binding: crate::model_card::CardBindingSeed::from_backend(b),
            })
            .collect();
        Self { config, receipts }
    }

    /// Test-support (doc-hidden, the `test_guard` precedent): run the
    /// backend assembly over `config` + explicit drop-in `dirs` + an
    /// explicit request, exactly as `resolve_runtime_unpublished` does but
    /// without candidate-path/env IO — so dependent crates' tests can
    /// build receipt-bearing resolutions deterministically.
    ///
    /// # Errors
    /// The assembly's identity/destination/request errors.
    #[doc(hidden)]
    pub fn assemble_for_test(
        mut config: Config,
        dirs: &[&Path],
        over: Option<BackendOverride>,
    ) -> std::result::Result<Self, String> {
        let mut assembly = BackendAssembly::new(std::mem::take(&mut config.backends))?;
        for dir in dirs {
            assembly.merge_dir(dir)?;
        }
        let default_backend = config.default_backend.clone();
        let _slot = assembly.apply_request(over, default_backend.as_deref())?;
        let (backends, receipts) = assembly.finish();
        config.backends = backends;
        Ok(Self { config, receipts })
    }

    /// Publish this resolution's process-global settings —
    /// [`Config::publish_runtime_settings`] without giving up the receipts:
    /// validate first, publish explicitly, keep selecting through the
    /// receipt-bearing view.
    pub fn publish_runtime_settings(&self) {
        self.config.publish_runtime_settings();
    }

    /// The receipts, slot-aligned with `backends`.
    #[must_use]
    pub fn receipts(&self) -> &[BackendResolutionReceipt] {
        &self.receipts
    }

    /// The backend at `slot`, with its receipt.
    #[must_use]
    pub fn backend(&self, slot: usize) -> Option<ResolvedBackend<'_>> {
        match (self.config.backends.get(slot), self.receipts.get(slot)) {
            (Some(backend), Some(receipt)) => Some(ResolvedBackend {
                slot,
                backend,
                receipt,
            }),
            _ => None,
        }
    }

    /// Every backend, zipped with its receipt, in slot order.
    pub fn backends(&self) -> impl Iterator<Item = ResolvedBackend<'_>> {
        self.config
            .backends
            .iter()
            .zip(self.receipts.iter())
            .enumerate()
            .map(|(slot, (backend, receipt))| ResolvedBackend {
                slot,
                backend,
                receipt,
            })
    }

    /// The backend [`Config::select_configured_backend`] would pick, WITH
    /// its receipt — the same shared index selector, so the two can never
    /// disagree about which backend was selected.
    #[must_use]
    pub fn selected_backend(&self) -> Option<ResolvedBackend<'_>> {
        self.config
            .selected_configured_slot()
            .and_then(|slot| self.backend(slot))
    }
}

/// A subprocess provider-plugin entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
    pub name: String,
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default)]
    pub env_pass: Vec<String>,
    pub tiers: Vec<Tier>,
}

/// The backend the shared precedence selected — a configured `[[backends]]`
/// entry or a `[[providers]]` plugin. Returned inside [`SelectionOutcome`] by
/// [`Config::select_backend`] so every surface resolves ONE backend, then
/// instantiates exactly that one.
///
/// Not `PartialEq`/`Eq`: it borrows [`BackendConfig`] (which carries an `f64`
/// field, so it cannot be `Eq`) and [`ProviderConfig`]. Compare on an owned
/// projection (a name/endpoint), not on the borrow.
#[derive(Debug, Clone)]
pub enum SelectedBackend<'a> {
    Configured(&'a BackendConfig),
    Provider(&'a ProviderConfig),
}

/// The outcome of the shared backend-selection contract
/// ([`Config::select_backend`]). Three cases — kept distinct so the caller
/// cannot collapse an operator error into a silent fallback:
///
/// - [`Selected`](Self::Selected): the precedence picked a concrete backend
///   or provider — instantiate exactly that one.
/// - [`UnknownNamed`](Self::UnknownNamed): an *explicit* selector
///   (`$NEWT_PROVIDER` or `default_backend`) named an entry that matches **no**
///   configured backend or provider. This is an operator error (a typo in the
///   selector), NOT a cue to run some other backend. The caller MUST surface it
///   rather than fall back — otherwise a mistyped `$NEWT_PROVIDER` silently
///   runs the wrong model (invariant: an explicitly selected backend is
///   authoritative; no silent fallback).
/// - [`Unset`](Self::Unset): nothing was explicitly selected and nothing
///   configured qualified. Only here may the caller fall back to local
///   discovery.
///
/// Not `PartialEq`/`Eq` for the same reason as [`SelectedBackend`]; match on it
/// or compare an owned projection.
#[derive(Debug, Clone)]
pub enum SelectionOutcome<'a> {
    /// The precedence selected this backend/provider.
    Selected(SelectedBackend<'a>),
    /// An explicit selector named something that matches no configured entry.
    UnknownNamed(String),
    /// An explicit selector (`$NEWT_PROVIDER` / `default_backend`) named a
    /// configured backend that has NEITHER an endpoint NOR an embedded
    /// `model_path` — there is nothing to route to, and an explicit
    /// selection is authoritative, so silently running some other backend
    /// is as wrong as it is for `UnknownNamed`. The caller MUST surface it:
    /// fix the backend (give it a destination) or the selector.
    UnroutableNamed(String),
    /// Nothing explicitly selected and nothing configured qualified.
    Unset,
}

// ---------------------------------------------------------------------------
// Default
// ---------------------------------------------------------------------------

/// The last-resort localhost Ollama backend: used both as `Config::default()`'s
/// sole backend (no config file at all) and as the [`Config::resolve`] fallback
/// when neither inline `[[backends]]` nor per-file drop-ins supply any, so a
/// bare install still talks to a local Ollama.
///
/// An explicit `$OLLAMA_HOST` names THAT Ollama (#2665). Ollama's own CLI and
/// `newt worker` already honour it; the chat path resolves its backend from
/// this config and used to pin `127.0.0.1:11434` regardless, so a session
/// started with `OLLAMA_HOST` pointing elsewhere still probed the developer's
/// live Ollama. Verbatim, like `LocalOllamaBackend::discover`: the operator's
/// endpoint is not second-guessed.
fn fallback_localhost_backend() -> BackendConfig {
    let endpoint = std::env::var("OLLAMA_HOST")
        .ok()
        .filter(|host| !host.is_empty())
        .unwrap_or_else(|| "http://127.0.0.1:11434".into());
    BackendConfig {
        name: "ollama".into(),
        endpoint,
        model: Some("llama3.1:8b".into()),
        tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
        kind: Some(BackendKind::Ollama),
        ..Default::default()
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            backends: vec![fallback_localhost_backend()],
            backend_fallback: true,
            default_backend: None,
            discovery: Discovery::default(),
            network: NetworkConfig::default(),
            providers: Vec::new(),
            scratch: None,
            default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
            lifecycle: None,
            dgx: None,
            tui: None,
            shell: None,
            intake: None,
            smart_harness: None,
            context: None,
            tools: None,
            initiative: None,
            tool_exposure: None,
            pricing: None,
            memory: None,
            agents: AgentsConfig::default(),
            mcp_servers: Vec::new(),
            logs: None,
            skills: None,
            model_tuning: Vec::new(),
            conversations: None,
            merge: None,
            permission_presets: std::collections::BTreeMap::new(),
            modes: std::collections::BTreeMap::new(),
            profiles: std::collections::BTreeMap::new(),
            bundles: std::collections::BTreeMap::new(),
            loadouts: std::collections::BTreeMap::new(),
            crews: std::collections::BTreeMap::new(),
            crew: None,
            plan: None,
        }
    }
}

// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------

impl Config {
    /// Load configuration from an explicit file path.
    pub fn load(path: &Path, report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> Result<Self> {
        let text = Self::read_text(path, report)?;
        toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
    }

    /// Read a config file's text for decoding: the ONE file-read chokepoint
    /// ([`Self::load`] and every layered path through `load_value`). It runs
    /// the psyche importer first, so a pre-split `[tenacity]` table reaches
    /// the typed decode as `[initiative]`. Only the operator's own config
    /// (under [`Self::user_config_dir`]) is rewritten on disk; a project,
    /// ambient, `/etc` or explicitly named file is shared or untrusted, so it
    /// is translated in memory with a warning.
    fn read_text(
        path: &Path,
        report: &mut dyn FnMut(crate::tty::Notice<'static>),
    ) -> Result<String> {
        let own = Self::user_config_dir().is_some_and(|dir| path.starts_with(dir));
        Ok(crate::psyche_import::read_config_file(path, own, report)?)
    }

    /// Resolve configuration by searching well-known locations, then layering a
    /// project-local override on top.
    ///
    /// Base search order (first match wins):
    /// 1. `$NEWT_CONFIG` environment variable
    /// 2. `./newt.toml`
    /// 3. `$NEWT_CONFIG_DIR/config.toml` or `~/.newt/config.toml`
    /// 4. `/etc/newt/config.toml`
    ///
    /// Then, if a project-local `.newt/config.toml` is found by walking up from
    /// the current directory (see [`Config::project_config_path`]), it is
    /// deep-merged **over** the base so a repo can pin its own models, endpoints,
    /// rules, and local stdio MCP services without copying the whole global
    /// config. Tables merge recursively (project keys win) and scalars are
    /// replaced by the project value. Arrays follow `[merge] arrays` —
    /// `"replace"` (default) or `"append"` (see [`ArrayMergeStrategy`]). The
    /// project config's `[merge]` setting takes precedence, then the base's.
    /// See issue #222.
    ///
    /// When no project override exists this is byte-for-byte the legacy
    /// first-match behavior. Returns `Config::default()` if nothing is found.
    /// True when no operator-supplied inference backend exists anywhere: no
    /// inline `[[backends]]` in any config file, no `backends/*.toml`
    /// drop-in, no `[[providers]]`, no `--backend-*` CLI override — the
    /// backend list is exactly the compiled-in localhost fallback (which is
    /// synthesized ONLY in that fully-bare case; a provider-only config is
    /// configured and gets no synthetic backend). This is the first-run wizard's
    /// "nothing configured" predicate: [`Config::resolve`] otherwise silently
    /// invents a localhost Ollama, so a missing config was never observable
    /// as a state. Meaningful only on a config produced by `resolve()`.
    #[must_use]
    pub fn is_unconfigured(&self) -> bool {
        self.backend_fallback
    }

    pub fn resolve(report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> Result<Self> {
        Self::resolve_runtime(report).map(ResolvedConfig::into_config)
    }

    /// [`Config::resolve`] WITHOUT the process-global publication. For code
    /// that only needs a config VALUE (a tool, a memory window): resolving is
    /// a read, and a read must not rewrite the runtime settings a running turn
    /// has already captured, nor race with whoever owns them.
    ///
    /// # Errors
    /// Any config-load error `resolve` itself would surface.
    pub fn resolve_unpublished(
        report: &mut dyn FnMut(crate::tty::Notice<'static>),
    ) -> Result<Self> {
        Self::resolve_runtime_unpublished(report).map(ResolvedConfig::into_config)
    }

    /// [`Config::resolve_runtime_unpublished`] plus the process-global
    /// publication — the full runtime resolution, receipts kept.
    ///
    /// # Errors
    /// Any config-load error `resolve` itself would surface.
    pub fn resolve_runtime(
        report: &mut dyn FnMut(crate::tty::Notice<'static>),
    ) -> Result<ResolvedConfig> {
        let resolved = Self::resolve_runtime_unpublished(report)?;
        resolved.publish_runtime_settings();
        Ok(resolved)
    }

    /// The full disk resolution WITH per-backend provenance receipts and
    /// WITHOUT the process-global publication: file layering, then the
    /// backend assembly (identity validation → operator drop-in replacement
    /// → exact probe observation → CLI request), receipts aligned 1:1 with
    /// `backends`. For consumers that must resolve and VALIDATE (backend
    /// pick, card binding, principal decision) before anything touches
    /// process-global state — publish explicitly afterwards.
    ///
    /// # Errors
    /// Any config-load error `resolve` itself would surface.
    pub fn resolve_runtime_unpublished(
        report: &mut dyn FnMut(crate::tty::Notice<'static>),
    ) -> Result<ResolvedConfig> {
        let base_path = Self::candidate_paths().into_iter().find(|p| p.is_file());
        // #1301 trust boundary: is the chosen base the AMBIENT cwd-relative
        // `./newt.toml` fallthrough (a freshly cloned repo can ship one at its
        // root — `cd repo && newt` → same host-RCE class as the walk-up) rather
        // than an operator-explicit base? `$NEWT_CONFIG` pins a base at this
        // resolution layer (interactive `--config` publishes that env;
        // explicit-profile consumers may instead call `Config::load`). If it
        // points AT `./newt.toml`, that is the operator's explicit choice
        // (Trusted). Every other `./newt.toml` base is ambient → Untrusted.
        let base_ambient = base_is_ambient_newt_toml(base_path.as_deref());
        // A project-local config that *is* the base (e.g. cwd is the project and
        // its `.newt/config.toml` already matched) must not be merged onto itself.
        let project_path =
            Self::project_config_path().filter(|p| Some(p.as_path()) != base_path.as_deref());

        let mut cfg = match (&base_path, &project_path) {
            // Fast path: no project override.
            (Some(p), None) => {
                if base_ambient {
                    // config-plane-provenance: an AMBIENT `./newt.toml` base is
                    // attacker-reachable (a cloned repo ships one at its root), so
                    // strip its control-plane keys before deserialize — the same
                    // fail-closed treatment the walk-up overlay already gets, and
                    // the base vector the convergence audit surfaced. A
                    // `$NEWT_CONFIG`-pinned / user-home / `/etc` base is
                    // operator-explicit (Trusted) and loaded verbatim.
                    let mut base_val = Self::load_value(p, report)?;
                    strip_control_plane(&mut base_val);
                    base_val
                        .try_into()
                        .map_err(|e| NewtError::Config(e.to_string()))?
                } else {
                    Self::load(p, report)?
                }
            }
            (None, None) => Self::default(),
            // Project override present → layer it over the base (or the default
            // config when there is no base file).
            (base, Some(proj)) => {
                let mut merged = match base {
                    Some(p) => Self::load_value(p, report)?,
                    None => toml::Value::try_from(Self::default())
                        .map_err(|e| NewtError::Config(e.to_string()))?,
                };
                // config-plane-provenance: an ambient `./newt.toml` base is
                // untrusted, so strip its control-plane keys too (the overlay is
                // stripped separately by `merge_project_overlay` below).
                if base_ambient {
                    strip_control_plane(&mut merged);
                }
                let project_val = Self::load_value(proj, report)?;
                // The merge strategy is itself config: the project declares how
                // it wants to be merged (`[merge] arrays = ...`), else the global
                // config's setting, else the built-in default (Replace).
                let strategy = array_merge_strategy(&project_val, &merged);
                // #1301 trust boundary: a project-local `.newt/config.toml` is
                // found by walking UP from cwd, so a freshly cloned repo can ship
                // one — its `[[mcp_servers]]` are attacker-reachable and must be
                // UNTRUSTED (literals verbatim, `${cmd:…}` never runs, refs
                // rejected), exactly like a `.mcp.json` overlay. The merge below
                // folds those entries into `mcp_servers`, indistinguishable from
                // the trusted base's; capture how many project entries there are
                // BEFORE the merge so provenance can be reconstructed after.
                let project_mcp_count = project_val
                    .get("mcp_servers")
                    .and_then(toml::Value::as_array)
                    .map(Vec::len);
                // config-plane-provenance: the walked-up project overlay is
                // attacker-reachable, so strip its control-plane keys (exec /
                // endpoint authority) BEFORE folding it in — a hostile repo
                // cannot run a command or redirect endpoints via config. Its
                // `[[mcp_servers]]` are left to fold (the count above), then
                // stamped Untrusted by `mark_project_mcp_untrusted` below.
                merge_project_overlay(&mut merged, project_val, strategy);
                let mut cfg: Self = merged
                    .try_into()
                    .map_err(|e| NewtError::Config(e.to_string()))?;
                // `trust` is `#[serde(skip)]`, so every merged entry deserialized
                // to the `Trusted` default; stamp the project-origin ones back to
                // UNTRUSTED before anything can resolve their secrets.
                mark_project_mcp_untrusted(&mut cfg.mcp_servers, strategy, project_mcp_count);
                cfg
            }
        };
        // #1301 trust boundary (ambient `./newt.toml` base): when the base is the
        // cwd-relative `./newt.toml` fallthrough it is itself attacker-reachable,
        // so EVERY entry it contributed — plus any ambient project overlay already
        // merged on top — is UNTRUSTED. This only ever downgrades; a trusted base
        // (user home config, `/etc`, or an explicit `$NEWT_CONFIG`) is untouched
        // and its project overlay was handled by `mark_project_mcp_untrusted`.
        if base_ambient {
            for entry in &mut cfg.mcp_servers {
                entry.trust = crate::mcp::McpTrust::Untrusted;
            }
        }
        // `backend_fallback` provenance: serde never round-trips the skipped
        // flag reliably, so recompute it at the file boundary. A config file
        // that supplied inline `[[backends]]` is operator-configured; a file
        // with none is (so far) as bare as no file at all. The `(None, None)
        // => Self::default()` arm keeps the flag `Default` set (true).
        if base_path.is_some() || project_path.is_some() {
            cfg.backend_fallback = cfg.backends.is_empty();
        }
        // Per-file backends (the endpoint control surface): drop a
        // `~/.newt/backends/<name>.toml` to add/override a backend — no
        // `config.toml` edit, and no overlapping inline `[[backends]]` to
        // hand-deconflict. Runs first so disk loadouts/crews can name a disk
        // backend's provider.
        // The backend assembly: identity validation FIRST (name-keyed
        // machinery is unsound on duplicate/empty names), then per-file
        // drop-ins from the `backends/` dirs next to the config —
        // `~/.newt/backends/*.toml` first, then the project
        // `.newt/backends/` (so project overrides home overrides inline
        // `[[backends]]`).
        let mut assembly =
            BackendAssembly::new(std::mem::take(&mut cfg.backends)).map_err(NewtError::Config)?;
        validate_provider_names(&cfg.providers).map_err(NewtError::Config)?;
        if let Some(dir) = Self::user_config_dir() {
            assembly
                .merge_dir(&dir.join("backends"))
                .map_err(NewtError::Config)?;
        }
        if let Some(proj) = Self::project_config_path() {
            if let Some(parent) = proj.parent() {
                assembly
                    .merge_dir(&parent.join("backends"))
                    .map_err(NewtError::Config)?;
            }
        }
        // A successfully merged operator drop-in is operator-supplied
        // configuration — the resolved backend list is no longer the bare
        // compiled-in fallback (see `is_unconfigured`).
        if assembly.operator_configured() {
            cfg.backend_fallback = false;
        }
        // Localhost fallback: a config that declared no inline `[[backends]]`
        // deserializes to empty (see the field doc); if no drop-in supplied one
        // either — AND no `[[providers]]` exist — restore the bare-install
        // localhost Ollama so newt still has a backend to talk to. A
        // provider-only config is CONFIGURED: synthesizing a backend here
        // would outrank the provider (a Configured pick precedes
        // `providers.first`), silently deserting the operator's provider on
        // the normal path while the profile path selected it.
        if assembly.is_empty() {
            if cfg.providers.is_empty() {
                cfg.backend_fallback = true;
                assembly.push_fallback(fallback_localhost_backend());
            } else {
                cfg.backend_fallback = false;
            }
        }
        // Per-file bundles (the model-support-kit control surface): drop a
        // `~/.newt/bundles/<name>.toml` to add a bundle — no `config.toml` edit.
        cfg.merge_disk_bundles();
        // Per-file loadouts (the shareable composition control surface): drop a
        // `~/.newt/loadouts/<name>.toml` to add a loadout — no `config.toml` edit.
        // Runs after bundles so a disk loadout may name a disk bundle.
        cfg.merge_disk_loadouts();
        // Per-file crews (the role-ensemble control surface): drop a
        // `~/.newt/crews/<name>.toml` to add a crew — no `config.toml` edit.
        // Runs after loadouts so a disk crew may name a disk loadout.
        cfg.merge_disk_crews();
        // Per-file DGX nodes (the per-host control surface): drop a
        // `~/.newt/dgx/<name>.toml` to add/override a DGX node — each host its
        // own file, no inline `[[dgx.nodes]]`. The active selection
        // (active_node/active_endpoint/active_model) stays in `[dgx]`.
        cfg.merge_disk_dgx_nodes();
        // The explicit per-invocation CLI `--backend-*` request — the single
        // owner of that precedence, so an explicit config file cannot defeat
        // a per-invocation backend pin. Recorded AS a request on the target
        // slot's receipt, never blended into the declaration.
        let over = cli_backend_override();
        let requested_slot = assembly
            .apply_request(over.clone(), cfg.default_backend.as_deref())
            .map_err(NewtError::Config)?;
        if assembly.requested() {
            cfg.backend_fallback = false;
        }
        let (backends, receipts) = assembly.finish();
        cfg.backends = backends;
        pin_requested_selection(&mut cfg, over.as_ref(), requested_slot);
        Ok(ResolvedConfig {
            config: cfg,
            receipts,
        })
    }

    /// Apply one final resolved configuration to the runtime: the CLI
    /// `--backend-*` override (through [`BackendOverride::apply`] — the
    /// invariant-owning composer, which warns and leaves the config
    /// untouched on a refused request), then the process-global
    /// publications. Compatibility surface for consumers that hold a bare
    /// [`Config`] (e.g. [`Config::load`] of an explicit profile) and do not
    /// need provenance receipts. Consumers that must validate before any
    /// process-global mutation use [`Config::prepare_runtime`] /
    /// [`Config::resolve_runtime_unpublished`] and publish explicitly.
    pub fn apply_runtime_settings(&mut self) {
        if let Some(over) = cli_backend_override().filter(|o| !o.is_empty()) {
            over.apply(self);
        }
        self.publish_runtime_settings();
    }

    /// Prepare an already-loaded config (an explicit `--profile` file, a
    /// constructed config) for the runtime WITHOUT touching process-global
    /// state: run the backend assembly over its backends — identity
    /// validation, then the CLI `--backend-*` request — and return the
    /// receipt-bearing [`ResolvedConfig`]. No disk drop-ins are merged and
    /// no localhost fallback is invented: the profile IS the whole config.
    /// Publish explicitly afterwards.
    ///
    /// # Errors
    /// Duplicate or empty backend names; a CLI request naming an ambiguous
    /// backend.
    pub fn prepare_runtime(mut self) -> Result<ResolvedConfig> {
        let mut assembly =
            BackendAssembly::new(std::mem::take(&mut self.backends)).map_err(NewtError::Config)?;
        validate_provider_names(&self.providers).map_err(NewtError::Config)?;
        let default_backend = self.default_backend.clone();
        let over = cli_backend_override();
        let requested_slot = assembly
            .apply_request(over.clone(), default_backend.as_deref())
            .map_err(NewtError::Config)?;
        if assembly.requested() {
            self.backend_fallback = false;
        }
        let (backends, receipts) = assembly.finish();
        self.backends = backends;
        pin_requested_selection(&mut self, over.as_ref(), requested_slot);
        Ok(ResolvedConfig {
            config: self,
            receipts,
        })
    }

    /// Publish the resolved configuration's process-global settings. Keep
    /// this AFTER any validation that should be able to fail without leaving
    /// half-published globals behind. Reads only (`&self`): publication
    /// copies resolved values into process-global slots, it never edits the
    /// config.
    pub fn publish_runtime_settings(&self) {
        shell::set_windows_cmd(
            self.shell
                .as_ref()
                .and_then(|s| s.windows_cmd)
                .unwrap_or(false),
        );
        // #726: push the resolved `[tools] max_output_tokens` into the
        // process-wide model-facing output budget without threading a new
        // `usize` through `ChatCtx` + `execute_tool` + every call site.
        crate::agentic::set_max_output_tokens(self.max_output_tokens());
        crate::agentic::set_output_head_tokens(self.output_head_tokens());
        crate::agentic::set_output_cap_chars_per_token(self.output_cap_chars_per_token());
        // Publish the resolved `[initiative]` config so `effective_initiative`
        // can pick the per-family default for the active model and every
        // level reads its configured round count. An explicit `--initiative`
        // or persona still supersedes the level.
        crate::initiative::set_initiative_config(self.initiative.clone().unwrap_or_default());
        // #880: publish the repo `[lifecycle]` overrides so the crew's normalize
        // (and future phase consumers) honor them.
        if let Some(lc) = &self.lifecycle {
            crate::tooling::set_lifecycle_override(lc.clone());
        }
        // #844: publish `[scratch] dir` so crew worktrees / the crew target /
        // session plans honor it. `NEWT_SCRATCH_DIR` still overrides.
        if let Some(dir) = self.scratch.as_ref().and_then(|s| s.dir.as_deref()) {
            crate::scratch::set_scratch_dir(dir);
        }
        // #2604: the build lane's scratch base; `NEWT_BUILD_SCRATCH_DIR` wins.
        if let Some(dir) = self.scratch.as_ref().and_then(|s| s.build_dir.as_deref()) {
            crate::scratch::set_build_scratch_dir(dir);
        }
        // #1789: publish `[network] owned_suffixes` so retry policy can treat
        // operator-owned inference hosts as patiently as loopback ones.
        if !self.network.owned_suffixes.is_empty() {
            crate::owned_hosts::set_owned_suffixes(self.network.owned_suffixes.clone());
        }
    }

    /// The configured model-facing output token budget (`[tools]
    /// max_output_tokens`), or the built-in default when `[tools]` is absent
    /// (#726). `0` means "no cap". See [`ToolsConfig`].
    pub fn max_output_tokens(&self) -> usize {
        self.tools
            .as_ref()
            .map(|t| t.max_output_tokens)
            .unwrap_or_else(default_max_output_tokens)
    }

    /// The resolved `[tool_exposure]` policy, or the identity (`Full`) default
    /// when the section is absent. See [`ToolExposureConfig`].
    pub fn tool_exposure(&self) -> ToolExposureConfig {
        self.tool_exposure.unwrap_or_default()
    }

    /// The configured head allocation for oversized `run_command` output
    /// (`[tools] output_head_tokens`), or the built-in tail-biased default.
    pub fn output_head_tokens(&self) -> usize {
        self.tools
            .as_ref()
            .map(|t| t.output_head_tokens)
            .unwrap_or_else(default_output_head_tokens)
    }

    /// The resolved `[tools] output_cap_chars_per_token` — the conservative
    /// chars/token used to size the model-facing output cap — or the built-in
    /// default (3) when `[tools]` is absent. See [`ToolsConfig`].
    pub fn output_cap_chars_per_token(&self) -> usize {
        self.tools
            .as_ref()
            .map(|t| t.output_cap_chars_per_token)
            .unwrap_or_else(default_output_cap_chars_per_token)
    }

    /// Validate backend IDENTITY before any name-keyed machinery runs:
    /// every backend needs a nonempty name, and no two may share one.
    /// Selection (`default_backend`, `$NEWT_PROVIDER`), CLI overrides,
    /// drop-in merging, and the provenance receipts are all name-keyed —
    /// with a duplicate, receipts collapse last-wins while selection takes
    /// the first match, which can hand backend A the card binding declared
    /// for backend B and activate the wrong card. Hard, actionable error
    /// instead.
    pub fn validate_backend_identities(&self) -> std::result::Result<(), String> {
        validate_backend_names(self.backends.iter())
    }

    /// Merge per-file DGX nodes from the `dgx/` dirs next to the config:
    /// `~/.newt/dgx/*.toml` first, then the project `.newt/dgx/` (so project
    /// overrides home overrides inline `[[dgx.nodes]]`). Filename stem = node
    /// name. A malformed drop-in is skipped with a warning; it must not break
    /// startup.
    fn merge_disk_dgx_nodes(&mut self) {
        if let Some(dir) = Self::user_config_dir() {
            self.merge_dgx_nodes_from_dir(&dir.join("dgx"));
        }
        if let Some(proj) = Self::project_config_path() {
            if let Some(parent) = proj.parent() {
                self.merge_dgx_nodes_from_dir(&parent.join("dgx"));
            }
        }
    }

    /// Load `<dir>/*.toml` as DGX nodes (filename stem = name) into
    /// `self.dgx.nodes`. A drop-in **replaces** an existing node of the same
    /// name (last-wins), else it is appended — so a `dgx1.toml` file supersedes
    /// an inline `[[dgx.nodes]]` named `dgx1` without a duplicate. The `[dgx]`
    /// table is created (default selection) if it was absent. A malformed file
    /// is skipped with a warning.
    fn merge_dgx_nodes_from_dir(&mut self, dir: &Path) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return; // no dgx dir — fine
        };
        let mut paths: Vec<PathBuf> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "toml"))
            .collect();
        paths.sort();
        for path in paths {
            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
                continue;
            };
            match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::dgx::DgxNode>(&t))
            {
                Ok(Ok(mut node)) => {
                    // The filename is authoritative for the name (collision-free).
                    node.name = stem.to_string();
                    let dgx = self.dgx.get_or_insert_with(Default::default);
                    match dgx.nodes.iter_mut().find(|n| n.name == node.name) {
                        Some(existing) => *existing = node,
                        None => dgx.nodes.push(node),
                    }
                }
                Ok(Err(e)) => {
                    tracing::warn!(path = %path.display(), error = %e, "skipping malformed dgx node file");
                }
                Err(_) => {}
            }
        }
    }

    /// Load a config file as a raw `toml::Value` (for layered merging).
    fn load_value(
        path: &Path,
        report: &mut dyn FnMut(crate::tty::Notice<'static>),
    ) -> Result<toml::Value> {
        let text = Self::read_text(path, report)?;
        toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
    }

    /// Locate a project-local `.newt/config.toml` by walking up from the current
    /// directory toward the filesystem root, stopping before `$HOME` so the
    /// global `~/.newt/config.toml` is never mistaken for a project override.
    /// Returns the nearest match (innermost project wins). See issue #222.
    pub fn project_config_path() -> Option<PathBuf> {
        let cwd = std::env::current_dir().ok()?;
        find_project_config_from(&cwd, home_dir().as_deref())
    }

    /// The user-writable config root: `$NEWT_CONFIG_DIR` or `~/.newt`.
    /// The operator-PINNED config path (`$NEWT_CONFIG`), when set — the one
    /// base whose sibling `models/` is a legitimate card source. Pure env
    /// mirror of `candidate_paths`' first entry; deliberately NOT "whatever
    /// base resolve happened to pick", because an ambient `./newt.toml` base
    /// is attacker-reachable (#1301) and its sibling `./models/` must never
    /// satisfy a trusted backend's card pointer.
    #[must_use]
    pub fn pinned_config_path() -> Option<PathBuf> {
        std::env::var("NEWT_CONFIG")
            .ok()
            .filter(|v| !v.is_empty())
            .map(PathBuf::from)
            // A set-but-missing NEWT_CONFIG falls through to the next
            // candidate at load — so it must not redirect the card catalog
            // either, or cards would resolve relative to a config that was
            // never actually selected.
            .filter(|p| p.is_file())
    }

    pub fn user_config_dir() -> Option<PathBuf> {
        if let Some(path) = std::env::var_os(NEWT_CONFIG_DIR_ENV)
            .filter(|value| !value.is_empty())
            .map(PathBuf::from)
        {
            return Some(path);
        }
        home_dir().map(|h| h.join(".newt"))
    }

    /// The user-writable config path: `$NEWT_CONFIG_DIR/config.toml` or
    /// `~/.newt/config.toml`.
    /// This is the first path `resolve()` reads and the target for `save()`.
    pub fn user_config_path() -> Option<PathBuf> {
        Self::user_config_dir().map(|dir| dir.join("config.toml"))
    }

    /// The shared config-based backend-selection precedence (#1320, PR-3) — the
    /// single definition used by chat's `resolve_backend_choice` config rungs, by
    /// `solve`, and by the ACP worker, so every entry point agrees on which
    /// configured backend the operator named. Most-specific first: `$NEWT_PROVIDER`
    /// names a backend > `default_backend` > a sole backend > prefer an
    /// OpenAI-kind entry, else the first routable one — where routable means a
    /// nonempty endpoint OR an embedded `model_path` ([`backend_is_routable`]).
    ///
    /// `None` in exactly three cases: no configured backend has a
    /// destination; an EXPLICIT selector (`$NEWT_PROVIDER` /
    /// `default_backend`) names a configured but destination-less backend;
    /// or an explicit selector names NOTHING configured. This `Option`
    /// surface cannot carry those errors, so it selects NOTHING rather than
    /// silently selecting the unroutable backend or silently running some
    /// other one (both pre-#1819 behaviors); the typed errors are
    /// [`SelectionOutcome::UnroutableNamed`] /
    /// [`SelectionOutcome::UnknownNamed`] on [`Config::select_backend`].
    /// Env-synthesized fallbacks (codex, legacy dgx, localhost) stay in chat's
    /// `resolve_backend_choice`, layered around this.
    #[must_use]
    pub fn select_configured_backend(&self) -> Option<&BackendConfig> {
        self.selected_configured_slot().map(|i| &self.backends[i])
    }

    /// The index behind [`Config::select_configured_backend`] and
    /// [`ResolvedConfig::selected_backend`] — the shared
    /// [`select_backend_slot`], so the borrowed pick, the receipt-bearing
    /// pick, and the CLI's unnamed field-only targeting can never disagree.
    fn selected_configured_slot(&self) -> Option<usize> {
        let backends: Vec<&BackendConfig> = self.backends.iter().collect();
        match select_backend_slot(&backends, self.default_backend.as_deref()) {
            SlotSelection::Slot(i) => Some(i),
            // The Option surfaces select NOTHING here — see the
            // `select_configured_backend` doc; `select_backend` carries the
            // typed `UnknownNamed` / `UnroutableNamed` errors.
            SlotSelection::ExplicitlyUnroutable { .. }
            | SlotSelection::ExplicitlyUnmatched { .. }
            | SlotSelection::None => None,
        }
    }

    /// The ONE backend-selection contract, unified across `[[backends]]` and
    /// `[[providers]]`, so an explicitly named backend is authoritative on every
    /// surface (chat / solve / worker). Precedence, most-specific first:
    /// `$NEWT_PROVIDER` (names a backend OR a provider) > `default_backend`
    /// (either) > a sole configured backend > the preference rules of
    /// [`Self::select_configured_backend`] > a sole/first provider.
    ///
    /// Returns a [`SelectionOutcome`]: `Selected` when the precedence picks a
    /// concrete backend/provider; `UnknownNamed` when an *explicit* selector
    /// names something that matches nothing configured (an operator error — the
    /// caller must NOT fall back to a different backend); `UnroutableNamed`
    /// when an explicit selector names a configured backend with neither
    /// endpoint nor model_path (equally an operator error — nothing to route
    /// to, and no silent fallback); `Unset` only when nothing is explicitly
    /// selected and nothing configured qualifies, at which point the caller
    /// may fall back to local discovery.
    ///
    /// A provider is chosen only when the precedence selects it: a bare
    /// `providers.first()` never bypasses `$NEWT_PROVIDER` / `default_backend`
    /// (the ACP-worker bug this closes).
    pub fn select_backend(&self) -> SelectionOutcome<'_> {
        // The most-specific PRESENT selector decides — `$NEWT_PROVIDER` if set,
        // else `default_backend`. Only that one selector is consulted: if it is
        // set but names nothing, we must NOT fall through to the next selector or
        // to preference (either would be a silent fallback). A mistyped
        // `$NEWT_PROVIDER` is an error, not permission to run `default_backend`.
        let explicit_selector = std::env::var("NEWT_PROVIDER")
            .ok()
            .filter(|n| !n.is_empty())
            .or_else(|| self.default_backend.clone().filter(|n| !n.is_empty()));
        if let Some(name) = explicit_selector {
            // A routable backend claims this name → fall through to the shared
            // precedence below (which re-checks `$NEWT_PROVIDER` / `default_backend`
            // and selects exactly that backend). Backends win a name tie.
            let routable_backend = self
                .backends
                .iter()
                .any(|b| b.name == name && backend_is_routable(b));
            if !routable_backend {
                // A provider claims this name → select it (an unroutable
                // backend does not win a name tie against a provider).
                if let Some(provider) = self.providers.iter().find(|p| p.name == name) {
                    return SelectionOutcome::Selected(SelectedBackend::Provider(provider));
                }
                // A destination-less backend claims it → an operator error:
                // the explicit selection is authoritative, there is nothing
                // to route to, and silently running another backend is the
                // exact failure mode `UnknownNamed` exists for.
                if self.backends.iter().any(|b| b.name == name) {
                    return SelectionOutcome::UnroutableNamed(name);
                }
                // Nothing configured claims it at all.
                return SelectionOutcome::UnknownNamed(name);
            }
        }
        // The shared backend precedence (sole > prefer-openai > first usable).
        if let Some(backend) = self.select_configured_backend() {
            return SelectionOutcome::Selected(SelectedBackend::Configured(backend));
        }
        // Nothing in [[backends]] qualified: a sole/first provider, else Unset.
        match self.providers.first() {
            Some(provider) => SelectionOutcome::Selected(SelectedBackend::Provider(provider)),
            None => SelectionOutcome::Unset,
        }
    }

    /// Serialize the config to pretty TOML for **audit**, with inline secret
    /// material redacted. Every `[[mcp_servers]]` `env` / `headers` **literal**
    /// value is replaced with [`Self::REDACTED`] — a literal is the only place
    /// `Config` can carry a raw secret inline (e.g. an `Authorization: Bearer …`
    /// header, an `API_KEY=…` child env var, or a `Bearer ${cmd:…}`
    /// interpolation string that could embed literal secret text). Keys are kept
    /// so an auditor sees *which* variables/headers are set without the values.
    ///
    /// Secret *references* — a `{ env | file | cmd }` [`SecretValue::Ref`], or an
    /// `api_key_file` / `api_key_env` — are left as-is: they name *where* a
    /// secret lives, never the secret itself, so `newt config` can show the
    /// operator their wiring without ever printing a resolved value.
    ///
    /// # Errors
    /// A TOML serialization failure (should not happen for a valid `Config`).
    pub fn to_redacted_toml(&self) -> Result<String> {
        use crate::mcp::SecretValue;
        let mut redacted = self.clone();
        let redact = |v: &mut SecretValue| {
            if matches!(v, SecretValue::Literal(_)) {
                *v = SecretValue::Literal(Self::REDACTED.to_string());
            }
        };
        for server in &mut redacted.mcp_servers {
            server.env.values_mut().for_each(redact);
            server.headers.values_mut().for_each(redact);
            // A `url` can embed userinfo (`user:pass@`) or a `?api_key=…` param,
            // and `args` can carry a `--token …` — none of which are `SecretValue`s,
            // so they'd otherwise pass through verbatim into an audit dump (#1301).
            if let Some(url) = &server.url {
                server.url = Some(redact_url_secrets(url));
            }
            server.args = redact_arg_secrets(&server.args);
        }
        toml::to_string_pretty(&redacted).map_err(|e| NewtError::Config(e.to_string()))
    }

    /// Placeholder substituted for redacted secret values in [`Self::to_redacted_toml`].
    pub const REDACTED: &'static str = "<redacted>";

    /// The personas directory: sibling of `~/.newt/config.toml`, i.e.
    /// `~/.newt/personas`. Falls back to a relative `./personas` only when
    /// `$HOME` can't be resolved. The same default `newt-tui`'s
    /// `PersonaStore::default_dir()` uses; a headless caller (#1021 PR 5.2)
    /// resolves it the same way without depending on `newt-tui`.
    #[must_use]
    pub fn personas_dir() -> PathBuf {
        Self::user_config_path()
            .map(|p| p.with_file_name("personas"))
            .unwrap_or_else(|| PathBuf::from("personas"))
    }

    /// Serialize this config and write it to `path`, creating parent dirs if needed.
    pub fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
        }
        let destination = crate::atomic_fs::ResolvedPath::resolve(path).map_err(|error| {
            NewtError::Config(format!(
                "resolve config destination for {}: {error:#}",
                path.display()
            ))
        })?;
        let _lock = crate::atomic_fs::acquire_lock(&destination.lock_path())
            .map_err(|error| NewtError::Config(format!("lock {}: {error:#}", path.display())))?;
        let text = toml::to_string_pretty(self).map_err(|e| NewtError::Config(e.to_string()))?;
        destination
            .atomic_write(text.as_bytes())
            .map_err(|error| NewtError::Config(format!("write {}: {error:#}", path.display())))
    }

    /// Set the top-level `default_backend` key while preserving the rest of the
    /// TOML document, including comments and formatting. Pure: the caller owns
    /// any filesystem write.
    pub fn with_default_backend(text: &str, name: &str) -> Result<String> {
        if name.trim().is_empty() {
            return Err(NewtError::Config(
                "default backend name cannot be empty".to_string(),
            ));
        }

        let mut doc = text
            .parse::<toml_edit::DocumentMut>()
            .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
        let root = doc.as_table_mut();
        if let Some(item) = root.get_mut("default_backend") {
            let existing = item.as_str().ok_or_else(|| {
                NewtError::Config("`default_backend` is not a string".to_string())
            })?;
            if existing == name {
                return Ok(doc.to_string());
            }

            let decor = item
                .as_value()
                .expect("a string item is always a value")
                .decor()
                .clone();
            *item = toml_edit::value(name);
            *item
                .as_value_mut()
                .expect("toml_edit::value always creates a value")
                .decor_mut() = decor;
        } else {
            root.insert("default_backend", toml_edit::value(name));
        }
        Ok(doc.to_string())
    }

    /// Set one scalar `key` in the `[tui]` table, creating the table if it is
    /// absent and preserving the rest of the document — comments, formatting,
    /// and any `[tui.*]` subtables (#2569 is what happens when a subtable is
    /// treated as the table). PURE: the caller owns the filesystem write.
    pub fn with_tui_key(text: &str, key: &str, value: &str) -> Result<String> {
        let mut doc = text
            .parse::<toml_edit::DocumentMut>()
            .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
        let tui = doc
            .as_table_mut()
            .entry("tui")
            .or_insert_with(|| {
                let mut table = toml_edit::Table::new();
                table.set_implicit(false);
                toml_edit::Item::Table(table)
            })
            .as_table_mut()
            .ok_or_else(|| NewtError::Config("`tui` is not a table".to_string()))?;
        tui.insert(key, toml_edit::value(value));
        Ok(doc.to_string())
    }

    /// Set `enabled` on the named `[[mcp_servers]]` entry, preserving comments
    /// and formatting (`/mcp enable|disable`, #1149). PURE (no I/O) like
    /// [`with_net_host`](Self::with_net_host); the caller does the read/write.
    /// Errors when the server name isn't present.
    pub fn with_mcp_enabled(text: &str, name: &str, enabled: bool) -> Result<String> {
        let mut doc = text
            .parse::<toml_edit::DocumentMut>()
            .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
        let servers =
            doc.as_table_mut()
                .entry("mcp_servers")
                .or_insert(toml_edit::Item::ArrayOfTables(
                    toml_edit::ArrayOfTables::new(),
                ));
        let arr = servers.as_array_of_tables_mut().ok_or_else(|| {
            NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
        })?;
        let entry = arr
            .iter_mut()
            .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name))
            .ok_or_else(|| NewtError::Config(format!("no [[mcp_servers]] entry named `{name}`")))?;
        if enabled {
            // Default is enabled: remove the key so the file stays minimal.
            entry.remove("enabled");
        } else {
            entry["enabled"] = toml_edit::value(false);
        }
        Ok(doc.to_string())
    }

    /// Append a `[[mcp_servers]]` entry to the TOML `text`, preserving comments
    /// and formatting (`newt mcp add|install`). PURE (no I/O) like
    /// [`with_mcp_enabled`](Self::with_mcp_enabled); the caller does the
    /// read/write. Defaults stay implicit (no `enabled = true`, no
    /// `type = "stdio"`) so the file stays minimal. Errors on an empty name, a
    /// duplicate name, or an entry whose transport is missing its required
    /// field ([`crate::mcp::McpServerEntry::is_valid`]) — an unconnectable
    /// server never lands in the file.
    pub fn with_mcp_server_added(text: &str, entry: &crate::mcp::McpServerEntry) -> Result<String> {
        crate::mcp::validate_entry_for_write(entry)?;
        let mut doc = text
            .parse::<toml_edit::DocumentMut>()
            .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
        let servers =
            doc.as_table_mut()
                .entry("mcp_servers")
                .or_insert(toml_edit::Item::ArrayOfTables(
                    toml_edit::ArrayOfTables::new(),
                ));
        let arr = servers.as_array_of_tables_mut().ok_or_else(|| {
            NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
        })?;
        if arr
            .iter()
            .any(|t| t.get("name").and_then(|v| v.as_str()) == Some(entry.name.as_str()))
        {
            return Err(NewtError::Config(format!(
                "an [[mcp_servers]] entry named `{}` already exists (remove it first, \
                 or toggle it with `/mcp enable|disable`)",
                entry.name
            )));
        }
        arr.push(crate::mcp::entry_to_toml_table(entry, None)?);
        Ok(doc.to_string())
    }

    /// Remove the named `[[mcp_servers]]` entry from the TOML `text`, preserving
    /// comments and formatting (`newt mcp remove`). PURE (no I/O); the caller
    /// does the read/write. Errors when the server name isn't present.
    pub fn with_mcp_server_removed(text: &str, name: &str) -> Result<String> {
        let mut doc = text
            .parse::<toml_edit::DocumentMut>()
            .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
        // Distinguish "no section" (→ the entry is absent) from "a section this
        // writer cannot edit" (e.g. the inline-array form, which the serde
        // reader accepts) — misreporting the latter as absent would be a lie.
        let item = doc
            .as_table_mut()
            .get_mut("mcp_servers")
            .ok_or_else(|| NewtError::Config(format!("no [[mcp_servers]] entry named `{name}`")))?;
        let arr = item.as_array_of_tables_mut().ok_or_else(|| {
            NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
        })?;
        let before = arr.len();
        arr.retain(|t| t.get("name").and_then(|v| v.as_str()) != Some(name));
        if arr.len() == before {
            return Err(NewtError::Config(format!(
                "no [[mcp_servers]] entry named `{name}`"
            )));
        }
        Ok(doc.to_string())
    }

    /// Build the ordered list of candidate config file paths.
    fn candidate_paths() -> Vec<PathBuf> {
        let mut paths = Vec::new();

        if let Ok(p) = std::env::var("NEWT_CONFIG") {
            paths.push(PathBuf::from(p));
        }

        paths.push(PathBuf::from("./newt.toml"));

        if let Some(path) = Self::user_config_path() {
            paths.push(path);
        }

        paths.push(PathBuf::from("/etc/newt/config.toml"));
        paths
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
#[path = "config_tests/tests.rs"]
mod tests;

/// W1 (unified backend resolution) — the authoritative selection suite for
/// [`Config::select_backend`], covering all eight precedence scenarios the
/// corrective spec names. The companion `newt-acp-worker` suite proves the
/// *destination* (transport + URL) each selected backend instantiates to; this
/// suite proves *which* entry the ONE contract selects.
///
/// Every test is serialized on `newt_provider_env`: `select_backend` reads the
/// process-global `$NEWT_PROVIDER`, so these must never run concurrently with
/// one another (the env-mutating cases would otherwise leak into the env-free
/// ones). Each `$NEWT_PROVIDER` case restores the prior value *before* asserting
/// so a failed assert cannot pollute the next test in the lane.
#[cfg(test)]
#[path = "config_tests/select_backend_tests.rs"]
mod select_backend_tests;

// Model: GPT-5 | Harness: Codex | Operator: Shawn Hartsock | Time: 21:30 EDT | Date: 2026-08-12