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
//! #867: file-path claim verification for the cap-exit summary.
//!
//! The #867 forensic session: the round cap hit, `trim_for_summary` dropped
//! the middle of the transcript, and the tools-disabled summary confidently
//! cited `newt-tui/src/commands.rs (lines 38-40)` — a file that does not
//! exist (the model pattern-completed a *typical* Rust repo layout once its
//! real evidence was trimmed away). The phantom-reach telemetry (#717)
//! covers hallucinated *tool* names; this module is its sibling for
//! hallucinated *file* names: extract path-like claims from the final text
//! and verify each against the workspace, appending a visible refutation for
//! anything that does not resolve. The model's prose is never rewritten —
//! the check only ever *appends* a clearly-marked annotation, so the user
//! sees exactly what the model said plus what did not check out.
//!
//! Pure by construction: extraction is string processing and resolution is an
//! injected seam, so the unit tier stays fully mocked.
//! Only the [`annotate_against_workspace`] wiring (called from every final
//! (no-tool-call) answer a turn produces — cap-exit summary and normal
//! finish alike, #1964) touches the real filesystem — and it never probes
//! lexically outside the workspace root: an absolute or `..`-escaping claim
//! is unverified, not missing, and is not probed. This lexical boundary does
//! not provide symlink isolation for paths that start inside the workspace.

mod turn;
pub(crate) use turn::TurnClaims;

/// Path-like tokens in assistant prose — the same recognition rule as the
/// crew planner's claim check (`newt-cli/src/crew.rs::path_tokens`): a token
/// containing a `/` with a short alphanumeric extension. Chat-prose
/// hardening on top of that rule: markdown emphasis/backtick wrapping and
/// `path:line` suffixes fall away via the split set, trailing sentence
/// punctuation is trimmed, URL-shaped tokens are skipped, and a token with
/// no letters (`1.2/3.4`) is not a claim. Order-preserving, deduplicated —
/// precision over recall, like the crew check.
pub(crate) fn path_claims(text: &str) -> Vec<String> {
    let mut seen = std::collections::BTreeSet::new();
    let mut out = Vec::new();
    for raw in text.split(|c: char| c.is_whitespace() || "()[]{}<>,;:\"'`*".contains(c)) {
        // Trailing-only trim: a leading `.` is load-bearing (`.newt/config.toml`,
        // `./src/lib.rs`) and must survive.
        let t = raw.trim_end_matches(|c: char| ".,!?".contains(c));
        // A URL is never a workspace claim. `:` is a split character, so
        // `https://host/a.rs` arrives here as the remnant `//host/a.rs` —
        // reject the protocol-relative shape, not just a literal `://`.
        if t.is_empty() || t.starts_with("//") || t.contains("://") {
            continue;
        }
        let has_ext = t.rsplit_once('.').is_some_and(|(_, ext)| {
            (1..=4).contains(&ext.len()) && ext.chars().all(|c| c.is_ascii_alphanumeric())
        });
        if t.contains('/')
            && has_ext
            && t.chars().any(|c| c.is_ascii_alphabetic())
            && seen.insert(t.to_string())
        {
            out.push(t.to_string());
        }
    }
    out
}

/// #2718: proposed files in a remaining-work list are not existence claims.
pub(super) fn asserted_claim_lines(text: &str) -> Vec<&str> {
    let mut planning_level = None;
    let mut claims = Vec::new();
    for line in text.lines() {
        let trimmed = line.trim();
        let heading_level = trimmed.chars().take_while(|c| *c == '#').count();
        let heading = (1..=6).contains(&heading_level)
            && trimmed[heading_level..].starts_with(char::is_whitespace);
        let lower = trimmed.trim_matches('#').trim().to_ascii_lowercase();
        if lower.starts_with("remaining work") || lower.starts_with("next steps") {
            planning_level = Some(if heading { heading_level } else { 0 });
        } else if lower.starts_with("current state")
            || lower.starts_with("completed")
            || (heading && planning_level.is_some_and(|level| level == 0 || heading_level <= level))
        {
            planning_level = None;
        }
        if planning_level.is_some()
            || lower.starts_with('>')
            || lower.starts_with("planned:")
            || lower.starts_with("will ")
            || lower.starts_with("i will ")
        {
            continue;
        }
        claims.push(line);
    }
    claims
}

fn asserted_path_claims(text: &str) -> Vec<String> {
    let mut claims = Vec::new();
    for line in asserted_claim_lines(text) {
        for claim in path_claims(line) {
            if !claims.contains(&claim) {
                claims.push(claim);
            }
        }
    }
    claims
}

/// Cap on how many refuted paths the annotation lists verbatim — beyond it
/// the count is summarized, so a pathological summary can't bloat the reply.
const LISTED_CLAIMS: usize = 8;

/// Append distinct missing and unverified annotations, preserving the prose
/// exactly. `Some` is an in-workspace existence result; `None` means the
/// checker cannot inspect the claim. Both lists share the existing display cap.
#[cfg(test)]
fn annotate_path_claims(text: String, resolve: impl FnMut(&str) -> Option<bool>) -> String {
    annotate_path_claims_at(text, resolve, None)
}

fn annotate_path_claims_at(
    mut text: String,
    mut resolve: impl FnMut(&str) -> Option<bool>,
    root: Option<&std::path::Path>,
) -> String {
    let location = root
        .map(|path| {
            format!(
                " (root {})",
                crate::worktree_adoption::task_path_literal(path)
            )
        })
        .unwrap_or_default();
    let mut missing = Vec::new();
    let mut unverified = Vec::new();
    for claim in asserted_path_claims(&text) {
        match resolve(&claim) {
            Some(true) => {}
            Some(false) => missing.push(claim),
            None => unverified.push(claim),
        }
    }
    let mut remaining = LISTED_CLAIMS;
    for (claims, label) in [
        (missing, "not found in this workspace"),
        (unverified, "unverified outside workspace (not inspected)"),
    ] {
        if claims.is_empty() {
            continue;
        }
        let listed: Vec<_> = claims
            .iter()
            .take(remaining)
            .map(|p| format!("`{p}`"))
            .collect();
        remaining -= listed.len();
        let more = claims.len() - listed.len();
        let overflow = if more > 0 {
            format!(" (+{more} more)")
        } else {
            String::new()
        };
        text = format!(
            "{text}\n\n⚠ claim check (#867): cited path(s) {label}{location}: {}{overflow} \
             — verify these before acting on the summary above.",
            listed.join(", ")
        );
    }
    text
}

/// Bound on learned extra bases (below), mirroring [`OBSERVED_CAP`]'s reasoning:
/// a pathological turn must not make the resolver scan unboundedly.
const EXTRA_BASES_CAP: usize = 40;

/// The REAL-filesystem claim resolver for `workspace`: a claim resolves when
/// its lexically-normalized absolute form, joined either to the workspace
/// root OR to a "learned" extra base, stays inside the workspace AND exists
/// on disk. Claims that normalize outside the root (absolute paths
/// elsewhere, `..` escapes) are unverified without being probed. This is a
/// lexical fence, not symlink isolation. The [`ObservedPaths`] adapter below
/// records only positive existence results.
///
/// #1970: a bare relative fragment (`src/lib.rs`) cited alongside an earlier
/// claim that verified under a subdirectory (`agent-voice/agent-voice-tts/Cargo.toml`)
/// was refuted, even though the intended referent
/// (`agent-voice/agent-voice-tts/src/lib.rs`) exists — the resolver only
/// ever tried the workspace root. Every claim this closure verifies now
/// learns its parent directory as an extra base for claims checked *after*
/// it (citation order, capped at [`EXTRA_BASES_CAP`]), so a later bare
/// fragment resolves under the directory an earlier, fully-qualified claim
/// in the same text already established. A hit under more than one base is
/// still a verify, never a refutation — this checks existence, not identity.
fn workspace_claim_resolver(
    workspace: &str,
    mut exists: impl FnMut(&std::path::Path) -> bool,
) -> impl FnMut(&str) -> Option<bool> {
    let root = super::lexical_normalize(std::path::Path::new(workspace));
    let mut extra_bases: Vec<std::path::PathBuf> = Vec::new();
    move |claim: &str| {
        let p = std::path::Path::new(claim);
        if p.is_absolute() {
            let norm = super::lexical_normalize(p);
            return norm.starts_with(&root).then(|| exists(&norm));
        }
        let mut in_workspace = false;
        // An observed file parent can be deeper than the citation's base.
        // Try ancestor bases only when the resolved candidate stays inside
        // the already observed/read-authorized root.
        for base in root
            .ancestors()
            .chain(extra_bases.iter().map(|p| p.as_path()))
        {
            let norm = super::lexical_normalize(&base.join(p));
            if !norm.starts_with(&root) {
                continue;
            }
            in_workspace = true;
            if exists(&norm) {
                // #2718: a later crate-relative `agentic/mod.rs` needs the
                // earlier citation's src/ ancestor, not src/agentic/agentic/.
                for parent in norm
                    .ancestors()
                    .skip(1)
                    .take_while(|p| p.starts_with(&root))
                {
                    if extra_bases.len() < EXTRA_BASES_CAP
                        && !extra_bases.iter().any(|p| p == parent)
                    {
                        extra_bases.push(parent.to_path_buf());
                    }
                }
                return Some(true);
            }
        }
        in_workspace.then_some(false)
    }
}

/// The observed-path ledger admits only claims that actually verified.
pub(crate) fn workspace_resolver(workspace: &str) -> impl FnMut(&str) -> bool {
    let mut resolve = workspace_claim_resolver(workspace, std::path::Path::exists);
    move |claim| resolve(claim) == Some(true)
}

/// Final-answer wiring (cap-exit AND normal finish): annotate `text` against
/// the real workspace tree without treating an uninspected claim as missing.
pub(crate) fn annotate_against_workspace(text: String, workspace: &str) -> String {
    annotate_path_claims_at(
        text,
        workspace_claim_resolver(workspace, std::path::Path::exists),
        Some(std::path::Path::new(workspace)),
    )
}

/// #2718: observed call directories supply context, never authority. A sibling
/// worktree is inspected only when the turn's read scope permits that root.
pub(crate) fn annotate_in_context(
    text: String,
    workspace: &str,
    directories: &[std::path::PathBuf],
    read_scope: &crate::Scope<String>,
) -> String {
    if directories.is_empty() && matches!(read_scope, crate::Scope::All) {
        return annotate_against_workspace(text, workspace);
    }
    let mut roots = directories.to_vec();
    roots.push(std::path::PathBuf::from(workspace));
    let uninspected = roots
        .iter()
        .any(|root| !crate::caveats::permits_path(read_scope, &root.to_string_lossy()));
    roots.retain(|root| crate::caveats::permits_path(read_scope, &root.to_string_lossy()));
    let mut resolvers: Vec<_> = roots
        .iter()
        .map(|root| workspace_claim_resolver(&root.to_string_lossy(), std::path::Path::exists))
        .collect();
    annotate_path_claims_at(
        text,
        |claim| {
            let mut inspected = false;
            for resolve in &mut resolvers {
                match resolve(claim) {
                    Some(true) => return Some(true),
                    Some(false) => inspected = true,
                    None => {}
                }
            }
            (inspected && !uninspected).then_some(false)
        },
        Some(std::path::Path::new(workspace)),
    )
}

/// Ledger cap: enough to name every file a real investigation touches while
/// keeping the cap-exit prompt bounded — collection stops once full.
const OBSERVED_CAP: usize = 40;

/// #867 Part A: the observed-paths ledger. Every tool-result round records
/// the path-like tokens that VERIFY against the workspace (grep's
/// `path:line:` hits, `find`/`list_dir` listings, …), deduplicated in
/// first-seen order and capped at [`OBSERVED_CAP`]. Collected as the rounds
/// happen, the ledger is immune to `trim_for_summary` — so the cap-exit
/// nudge can hand the model a manifest of REAL paths to cite even though the
/// evidence messages themselves were just trimmed away.
///
/// Only paths that verify are recorded: the ledger is a whitelist of ground
/// truth, never a channel for a tool error message (or the model's own
/// echoed hallucination) to smuggle a fake path into the prompt.
#[derive(Default)]
pub(crate) struct ObservedPaths {
    ordered: Vec<String>,
}

impl ObservedPaths {
    /// Record every claim in `text` that `exists` verifies, skipping
    /// duplicates; a no-op once the cap is reached.
    pub(crate) fn record(&mut self, text: &str, mut exists: impl FnMut(&str) -> bool) {
        for claim in path_claims(text) {
            if self.ordered.len() >= OBSERVED_CAP {
                return;
            }
            if exists(&claim) && !self.ordered.contains(&claim) {
                self.ordered.push(claim);
            }
        }
    }

    /// The recorded paths, first-seen order.
    pub(crate) fn into_vec(self) -> Vec<String> {
        self.ordered
    }
}

/// #1214/#2683: the observed local branch inventory and commit evidence,
/// handed to [`annotate_action_claims_at`] as pure data. This is not execution
/// evidence for pushes, pull requests, or tests.
pub(crate) struct TurnGitEvidence {
    /// Local branch names that exist right now.
    pub branches: Vec<String>,
    /// #2683: HEAD's sha at the START of this turn (the baseline captured
    /// before any of this turn's tool calls ran). `None` means no baseline
    /// was captured (off-repo at turn start, or the read scope refused the
    /// probe) — absence of a baseline must never manufacture a fact that was
    /// never observed. A changed task root can still report current dirt, but
    /// explicitly has no same-checkout HEAD comparison.
    pub head_before: Option<String>,
    /// HEAD's sha right now (end of turn) — always present once
    /// [`collect_git_evidence`] returns `Some` at all, since reading it is
    /// that function's own gating probe.
    pub head_now: String,
    /// #2683 round 2 (PR #2688 review): the workspace's CURRENT status
    /// snapshot, when it could be read — reported verbatim by
    /// [`annotate_action_claims_at`] as "uncommitted changes", never
    /// cross-referenced against the files the claim's own text names (round
    /// 3, PR #2688 review round 2, finding 1: that cross-reference is what
    /// produced false "not committed" verdicts on a claim that correctly
    /// scoped which file it committed — see [`commit_facts_note`]). `None`
    /// means the probe failed; it is never treated as "nothing is dirty".
    pub dirty_paths: Option<StatusSnapshot>,
}

/// Repo-relative path → two-character `git status --porcelain` code.
pub type StatusSnapshot = std::collections::BTreeMap<String, String>;

/// Parse `git status --porcelain=v1 -z` output. `-z` gives raw (unquoted) paths;
/// a rename/copy entry (`R`/`C`) is followed by its origin path, which is skipped.
fn parse_porcelain_z(out: &str) -> StatusSnapshot {
    let mut snapshot = StatusSnapshot::new();
    let mut entries = out.split('\0');
    while let Some(entry) = entries.next() {
        let (Some(code), Some(path)) = (entry.get(..2), entry.get(3..)) else {
            continue;
        };
        if code.contains(['R', 'C']) {
            entries.next();
        }
        snapshot.insert(path.to_string(), code.to_string());
    }
    snapshot
}

/// Paths in `after` that are absent from `before` or carry a different status:
/// what the run changed, ignoring dirt that was there when it started. (A file
/// already dirty and dirty in the same way stays invisible — the probe is a
/// status delta, not a content diff.)
#[must_use]
pub fn files_changed_between(before: &StatusSnapshot, after: &StatusSnapshot) -> Vec<String> {
    after
        .iter()
        .filter(|(path, code)| before.get(*path) != Some(*code))
        .map(|(path, _)| path.clone())
        .collect()
}

/// The hand-back snapshot: raw paths (`-z`) and every untracked FILE, so a stray
/// script inside a new directory is named, not hidden behind `dir/`.
const SNAPSHOT_STATUS_ARGS: [&str; 4] = ["status", "--porcelain=v1", "-z", "--untracked-files=all"];

/// One first-level subdirectory of a NON-repo workspace root that is its own
/// git repo (multi-repo recon PR2 — a bare folder of several checkouts).
/// `status` is `None` when the probe itself could not run — outside the
/// fs-read fence, or any git failure — so the caller can name the repo
/// without inventing a file list for it.
#[derive(Debug, Clone)]
pub struct NestedRepoSnapshot {
    /// The repo's directory name, relative to the workspace root (e.g.
    /// `"repoA"`) — the prefix `nested_files_changed_between` uses.
    pub repo: String,
    pub status: Option<StatusSnapshot>,
}

/// Does `dir` look like a git repo root — a `.git` entry directly inside it
/// (directory, or a worktree/submodule pointer file)? Deliberately NOT
/// `workspace_key.rs`'s `discover_git_dir`: that walks UP from a start point
/// and resolves worktree pointer files to their real gitdir, answering "is
/// this path INSIDE a repo"; this answers a narrower question — "is this
/// EXACT directory a repo root" — for filtering a directory LISTING, where
/// walking up would find the SAME repo from every subdirectory of a large
/// checkout and misclassify each as its own nested repo.
fn is_repo_root(dir: &std::path::Path) -> bool {
    dir.join(".git").exists()
}

/// Probe each first-level subdirectory of `workspace` that is its own git
/// repo — the bare-folder-of-checkouts case `snapshot_workspace` cannot see
/// (it only probes `workspace` itself). Reuses
/// [`crate::tooling::first_level_subdirs`] (never a second directory
/// lister). Only called by the caller when `workspace` itself is NOT a repo;
/// this function does not check that.
///
/// The fs-read fence is checked PER REPO via [`crate::caveats::permits_path`]
/// (prefix containment, the same check every other fs-read site uses) before
/// even attempting the probe. In practice `git_in`'s own gate
/// (`check_git_read_scope`'s "metadata" class) is coarser than this: a
/// BOUNDED `fs_read` (`Scope::Only`) refuses every metadata git read
/// uniformly, not per path — the SAME all-or-nothing rule
/// [`snapshot_workspace`] is already subject to today, verified by
/// `snapshot_nested_repos_names_every_repo_as_unprobed_under_a_bounded_scope`.
/// The `permits_path` check is kept anyway as the documented, narrower
/// intent (defense in depth against `git_in`'s gate ever becoming
/// path-aware) — either way, a repo whose probe does not succeed is
/// returned with `status: None` rather than silently skipped, so the
/// hand-back can name it as unprobed instead of making it invisible.
#[must_use]
pub fn snapshot_nested_repos(
    workspace: &str,
    read_scope: &crate::Scope<String>,
) -> Vec<NestedRepoSnapshot> {
    crate::tooling::first_level_subdirs(std::path::Path::new(workspace))
        .into_iter()
        // #2552 round 2 should-fix: `first_level_subdirs`' `is_dir()` and
        // `is_repo_root`'s `.exists()` both follow symlinks, so a first-level
        // entry that is a SYMLINK to an outside repo (`workspace/ext ->
        // /outside/repo`) would otherwise be probed and its paths/status
        // reported as `ext/…` — a leak of a repo outside the workspace
        // entirely. Filtered here (not in the shared lister, which has other
        // callers that may want symlinks) via `symlink_metadata`, which does
        // NOT follow the link, so a symlink is excluded before `is_repo_root`
        // ever resolves it.
        .filter(|dir| {
            dir.symlink_metadata()
                .is_ok_and(|meta| !meta.file_type().is_symlink())
        })
        .filter(|dir| is_repo_root(dir))
        .filter_map(|dir| {
            let repo = dir.file_name()?.to_string_lossy().into_owned();
            let dir_str = dir.to_string_lossy().into_owned();
            let status = crate::caveats::permits_path(read_scope, &dir_str)
                .then(|| git_in(&dir_str, &SNAPSHOT_STATUS_ARGS, read_scope))
                .flatten()
                .map(|out| parse_porcelain_z(&out));
            Some(NestedRepoSnapshot { repo, status })
        })
        .collect()
}

/// The nested-repo equivalent of [`files_changed_between`]: for each repo
/// present in `after` with a successful probe, diffed against the SAME repo
/// in `before` (matched by directory name) — `<repo>/<path>` for every
/// changed file, sorted. Returns `(files, unprobed)`: `unprobed` names every
/// repo excluded from `files` — a probe failure at either end, a repo
/// `after` found that `before` never saw (nothing trustworthy to diff
/// against), or a repo `before` saw that vanished by `after` (deleted or
/// renamed away mid-run — #2552 round 2 should-fix: previously silently
/// dropped instead of named) — so it is always reported, never silently
/// absorbed into an empty diff.
#[must_use]
pub fn nested_files_changed_between(
    before: &[NestedRepoSnapshot],
    after: &[NestedRepoSnapshot],
) -> (Vec<String>, Vec<String>) {
    let mut files = Vec::new();
    let mut unprobed = Vec::new();
    for repo_after in after {
        let Some(after_status) = &repo_after.status else {
            unprobed.push(repo_after.repo.clone());
            continue;
        };
        let before_status = before
            .iter()
            .find(|b| b.repo == repo_after.repo)
            .and_then(|b| b.status.as_ref());
        let Some(before_status) = before_status else {
            unprobed.push(repo_after.repo.clone());
            continue;
        };
        for path in files_changed_between(before_status, after_status) {
            files.push(format!("{}/{path}", repo_after.repo));
        }
    }
    for repo_before in before {
        if !after.iter().any(|a| a.repo == repo_before.repo) {
            unprobed.push(repo_before.repo.clone());
        }
    }
    files.sort();
    unprobed.sort();
    unprobed.dedup();
    (files, unprobed)
}

/// The nested-repo equivalent of a single repo's CURRENT dirty set (not a
/// delta) — every path in each successfully-probed repo's own status
/// snapshot, prefixed `<repo>/`. Returns `(files, unprobed)`, same shape and
/// same reason as [`nested_files_changed_between`]: a repo whose probe
/// failed is named, never silently absorbed into an empty list.
#[must_use]
pub fn nested_current_paths(snapshots: &[NestedRepoSnapshot]) -> (Vec<String>, Vec<String>) {
    let mut files = Vec::new();
    let mut unprobed = Vec::new();
    for repo in snapshots {
        match &repo.status {
            Some(status) => {
                for path in status.keys() {
                    files.push(format!("{}/{path}", repo.repo));
                }
            }
            None => unprobed.push(repo.repo.clone()),
        }
    }
    files.sort();
    (files, unprobed)
}

/// F26 v4 (#2552 round 3): the ONE decision every hand-back git source must
/// obey — where does `workspace` sit relative to a repo? `git rev-parse
/// --show-toplevel`, CANONICALIZED, compared against a CANONICALIZED
/// `workspace` (a symlinked spelling of the real root — macOS `/tmp` →
/// `/private/tmp`, or a symlinked checkout — must still read as
/// [`OwnRoot`](WorkspaceRepoLocation::OwnRoot)). Three-way, not the round-2
/// bool: round 2's `false` collapsed "not a repo at all" and "a subdirectory
/// of a larger repo" into one case and dropped subtree-scoped status
/// entirely for the second — round 3's review: that re-creates the exact
/// "nothing changed" bug this PR fixes for the everyday `--cwd repo/crate`
/// invocation, where `crate` has no nested repos of its own to fall back on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkspaceRepoLocation {
    /// `workspace` IS the repo toplevel — today's behaviour, unchanged.
    OwnRoot,
    /// `workspace` is a SUBDIRECTORY of a larger repo. `prefix` is `git
    /// rev-parse --show-prefix` (e.g. `"crate/"`) — the string every path
    /// reported for this case is stripped of, so it stays workspace-relative.
    InsideRepo { prefix: String },
    /// No repo found at all (or the read scope refused the probe).
    NotARepo,
}

/// See [`WorkspaceRepoLocation`]. Every caller (the shelled `snapshot_workspace`
/// / `snapshot_workspace_subtree` below, `newt_git::GitEngine` opened by
/// `headless.rs`, its commits/HEAD baseline, and the nested-repo probe's
/// gate) makes this SAME call, rather than independent (and, in #2552,
/// inconsistent) tests.
#[must_use]
pub fn locate_workspace_repo(
    workspace: &str,
    read_scope: &crate::Scope<String>,
) -> WorkspaceRepoLocation {
    let Some(toplevel) = git_in(workspace, &["rev-parse", "--show-toplevel"], read_scope) else {
        return WorkspaceRepoLocation::NotARepo;
    };
    let (Ok(toplevel), Ok(root)) = (
        std::fs::canonicalize(toplevel.trim()),
        std::fs::canonicalize(workspace),
    ) else {
        return WorkspaceRepoLocation::NotARepo;
    };
    if toplevel == root {
        return WorkspaceRepoLocation::OwnRoot;
    }
    let Some(prefix) = git_in(workspace, &["rev-parse", "--show-prefix"], read_scope) else {
        return WorkspaceRepoLocation::NotARepo;
    };
    WorkspaceRepoLocation::InsideRepo {
        prefix: prefix.trim().to_string(),
    }
}

/// Convenience for a caller that only needs the `OwnRoot` yes/no (e.g.
/// deciding whether the embedded `GitEngine`'s STATUS methods, never its
/// commits, are safe to use — see [`WorkspaceRepoLocation`]'s doc).
#[must_use]
pub fn is_workspace_repo_root(workspace: &str, read_scope: &crate::Scope<String>) -> bool {
    locate_workspace_repo(workspace, read_scope) == WorkspaceRepoLocation::OwnRoot
}

/// The workspace's changed-path snapshot when it IS a repo toplevel
/// ([`WorkspaceRepoLocation::OwnRoot`]); `None` otherwise (off-repo, or a
/// subdirectory of a larger repo — see [`snapshot_workspace_subtree`] for
/// that case instead, never an unscoped view of the enclosing repo).
#[must_use]
pub fn snapshot_workspace(
    workspace: &str,
    read_scope: &crate::Scope<String>,
) -> Option<StatusSnapshot> {
    if !is_workspace_repo_root(workspace, read_scope) {
        return None;
    }
    // Status alone: a repo with no commit yet has no HEAD, and must still be probed.
    let out = git_in(workspace, &SNAPSHOT_STATUS_ARGS, read_scope)?;
    Some(parse_porcelain_z(&out))
}

/// The workspace's changed-path snapshot when `workspace` is a SUBDIRECTORY
/// of a larger repo ([`WorkspaceRepoLocation::InsideRepo`]) — F26 v2's
/// scoping, restored in round 3 as a real (not dropped) third case: status
/// scoped with pathspec `.` (nothing outside the workspace subtree is even
/// considered) and every path stripped of `prefix` (`git rev-parse
/// --show-prefix`, from [`locate_workspace_repo`]) so it stays
/// workspace-relative. A nested git repo INSIDE the workspace collapses to a
/// single opaque `<dir>/` placeholder in that scoped status (git never
/// descends into an embedded repo) — dropped here, since
/// [`snapshot_nested_repos`] is the real way to see inside one.
#[must_use]
pub fn snapshot_workspace_subtree(
    workspace: &str,
    prefix: &str,
    read_scope: &crate::Scope<String>,
) -> Option<StatusSnapshot> {
    let mut args: Vec<&str> = SNAPSHOT_STATUS_ARGS.to_vec();
    args.extend(["--", "."]);
    let out = git_in(workspace, &args, read_scope)?;
    Some(
        parse_porcelain_z(&out)
            .into_iter()
            .filter_map(|(path, code)| {
                let rel = path.strip_prefix(prefix)?;
                (!rel.ends_with('/')).then(|| (rel.to_string(), code))
            })
            .collect(),
    )
}

/// Branch names the summary claims: the first ref-looking token within a few
/// words of a `branch`/`branches` mention — the live transcripts say both
/// "branch `X`" and "Branch is clean on X". Ref-looking is strict (must
/// contain `/` or a digit, ref charset only), so hyphenated prose like
/// "tools-disabled" near the word "branch" is never mistaken for a claim —
/// precision over recall, like [`path_claims`]. Backtick/quote wrapping and
/// trailing punctuation fall away.
pub(crate) fn claimed_branches(text: &str) -> Vec<String> {
    const WINDOW: usize = 4;
    let mut out = Vec::new();
    for line in text
        .lines()
        .filter(|line| !line.trim_start().starts_with('>'))
    {
        let toks: Vec<&str> = line.split_whitespace().collect();
        for (i, tok) in toks.iter().enumerate() {
            let key = tok
                .trim_matches(|c: char| !c.is_ascii_alphanumeric())
                .to_lowercase();
            if key != "branch" && key != "branches" {
                continue;
            }
            for (offset, cand) in toks.iter().skip(i + 1).take(WINDOW).enumerate() {
                if offset > 0 && ends_a_clause(toks[i + offset]) {
                    break;
                }
                if matches!(*cand, "☐" | "✓" | "→" | "•") {
                    break;
                }
                let wrapped = cand.starts_with(['`', '\'', '"']);
                let numbered = cand.ends_with('.')
                    && cand
                        .trim_end_matches('.')
                        .chars()
                        .all(|c| c.is_ascii_digit());
                let cand = cand
                    .trim_end_matches('.')
                    .trim_matches(|c: char| "`'\"()[],;:!?*".contains(c));
                let cand = cand.trim_end_matches('.');
                let refy = cand.contains('/') || cand.chars().any(|c| c.is_ascii_digit());
                if !cand.is_empty()
                    && (!numbered || wrapped)
                    && refy
                    && cand
                        .chars()
                        .all(|c| c.is_ascii_alphanumeric() || "/-_.".contains(c))
                {
                    if !out.contains(&cand.to_string()) {
                        out.push(cand.to_string());
                    }
                    break; // one claim per mention; keep scanning after it
                }
            }
        }
    }
    out
}

/// #2683: does `text` claim, somewhere, that a commit was just made? The
/// verb form "committed" is the trigger (never the noun "commit" — "one
/// commit ahead", "the existing commit", "pushed the existing commit" are
/// never claims of having just performed a commit). Conservative, like
/// [`claimed_branches`]: a negation, a future-tense auxiliary, a
/// temporal-distancing word ("previously"/"earlier"), or the
/// future/incomplete phrase "yet to" voids the match — scanned across the
/// WHOLE clause (back to the nearest real sentence boundary, or the start of
/// the line), never a fixed token count: "I have not, for safety reasons,
/// been committed" has its negation 4 tokens before the verb, well outside a
/// short window, and "Not staged; committed." has a negation that belongs to
/// the PRIOR clause and must not bleed across the `;`. A quoted line (starts
/// with `>`, markdown blockquote — someone else's words, not the model's
/// own claim) is skipped entirely. Precision over recall: a spurious
/// annotation on an honest "I have not committed" is worse than missing a
/// real false claim — "already" is deliberately NOT a void word: "I have
/// already committed the fix" is ordinary language for a just-finished
/// action in THIS turn, not a claim about an earlier session.
///
/// #2683 round 3 (PR #2688 review round 2, finding 2): a clause boundary is
/// a token that ENDS with `.`/`!`/`?`/`;`, never merely one that CONTAINS
/// one — a period embedded mid-token (`src/a.rs`'s extension dot) is not a
/// sentence boundary. Treating it as one discarded the negation preceding it
/// in "I have not changed src/a.rs or committed anything", turning an
/// honest non-claim into a false positive. [`CLAUSE_ABBREVIATIONS`] closes
/// the matching gap at the OTHER end of a token: "e.g."/"etc." genuinely end
/// with a period without ending the sentence.
const ACTION_VOID_WORDS: [&str; 13] = [
    "not",
    "no",
    "never",
    "will",
    "would",
    "should",
    "could",
    "must",
    "pending",
    "if",
    "until",
    "previously",
    "earlier",
];
const CLAUSE_BOUNDARY: [char; 4] = ['.', '!', '?', ';'];

/// Trailing-period abbreviations common enough in operator prose that a
/// token ending in one must not be mistaken for the end of a clause —
/// `ends_with` alone would still misread these the same way `contains` used
/// to misread a path's embedded extension dot.
const CLAUSE_ABBREVIATIONS: [&str; 6] = ["e.g.", "i.e.", "etc.", "vs.", "mr.", "dr."];

/// Does `tok` genuinely end a clause: a trailing `.`/`!`/`?`/`;` that is not
/// a known abbreviation? Checking the token's END (not merely whether it
/// CONTAINS a boundary char anywhere) already excludes a path's embedded
/// extension dot, since that dot is not the token's last character.
pub(super) fn ends_a_clause(tok: &str) -> bool {
    tok.ends_with(CLAUSE_BOUNDARY)
        && !CLAUSE_ABBREVIATIONS.contains(&tok.to_ascii_lowercase().as_str())
}

pub(crate) fn claims_committed(text: &str) -> bool {
    claims_completed_action(text, "committed")
}

/// Shared completed-action classifier for commit and push claim checks.
pub(crate) fn claims_completed_action(text: &str, action: &str) -> bool {
    let normalize = |t: &str| {
        t.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '\'')
            .to_ascii_lowercase()
    };
    text.lines().any(|line| {
        if line.trim_start().starts_with('>') {
            return false; // a quoted line is not the model's own claim
        }
        let toks: Vec<&str> = line.split_whitespace().collect();
        toks.iter().enumerate().any(|(i, tok)| {
            if normalize(tok) != action {
                return false;
            }
            let clause_start = toks[..i]
                .iter()
                .rposition(|t| ends_a_clause(t))
                .map_or(0, |p| p + 1);
            let clause: Vec<String> = toks[clause_start..i].iter().map(|t| normalize(t)).collect();
            let voided = clause
                .iter()
                .any(|k| ACTION_VOID_WORDS.contains(&k.as_str()) || k.contains("n't"))
                || clause
                    .windows(2)
                    .any(|w| matches!(w[0].as_str(), "yet" | "ready") && w[1] == "to");
            !voided
        })
    })
}

/// #2683 round 3 (PR #2688 review round 2, finding 1): the git FACTS for a
/// claimed commit, with NO attempt to attribute them to any specific file
/// the claim names. The prior design matched a commit claim anywhere in the
/// text against EVERY path [`path_claims`] extracted from the SAME text and
/// refuted the claim when one of them was still dirty — but "Committed
/// src/a.rs. src/b.rs remains uncommitted." names exactly which file it
/// committed, and that per-file check could not parse the distinction: it
/// saw a commit claim and a dirty `src/b.rs` both present in the text and
/// refuted the WHOLE claim, even though the claim never said b.rs was
/// committed. Reporting the raw HEAD delta and the current `git status
/// --porcelain` listing side by side, with no claim-to-file mapping at all,
/// can never manufacture that false refutation — the operator reads the
/// facts against their own claim text.
fn commit_facts_note(before: Option<&str>, now: &str, dirty: Option<&StatusSnapshot>) -> String {
    let head = match before {
        None => format!(
            "HEAD is `{}`; no same-checkout baseline (workspace changed during turn)",
            short_sha(now)
        ),
        Some(before) if before == now => "HEAD did not move".to_string(),
        Some(before) => format!(
            "HEAD moved from `{}` to `{}`",
            short_sha(before),
            short_sha(now)
        ),
    };
    let changes = match dirty {
        None => "not probed".to_string(),
        Some(map) if map.is_empty() => "none".to_string(),
        Some(map) => {
            // Already lexicographic: `StatusSnapshot` is a `BTreeMap`.
            let listed: Vec<_> = map
                .keys()
                .take(LISTED_CLAIMS)
                .map(|p| format!("`{p}`"))
                .collect();
            let more = map.len() - listed.len();
            if more > 0 {
                format!("{} (+{more} more)", listed.join(", "))
            } else {
                listed.join(", ")
            }
        }
    };
    format!(
        "repository state at the end of this turn: {head}; uncommitted changes: {changes} \
         — verify the workspace state before trusting the summary above."
    )
}

/// Short enough for prose; a sha already shorter than this (a test
/// fixture's placeholder) passes through unchanged.
fn short_sha(sha: &str) -> &str {
    &sha[..sha.len().min(12)]
}

/// #1214/#2683: append a refutation for a claimed local branch absent from
/// the observed inventory, and — when the text claims a commit and a HEAD
/// baseline was captured for this turn — a neutral commit-FACTS note (round
/// 3: no per-file verdict; see [`commit_facts_note`]), preserving the
/// model's prose as an exact prefix. Git state still cannot establish
/// whether tests passed, an existing commit was pushed, or a pull request
/// was opened — do not turn a keyword in prose into an accusation about
/// those.
#[cfg(test)]
fn annotate_action_claims(text: String, evidence: Option<&TurnGitEvidence>) -> String {
    annotate_action_claims_at(text, evidence, None, false)
}

fn annotate_action_claims_at(
    text: String,
    evidence: Option<&TurnGitEvidence>,
    root: Option<&std::path::Path>,
    root_changed: bool,
) -> String {
    let Some(ev) = evidence else { return text };
    let mut notes: Vec<String> = Vec::new();
    for b in claimed_branches(&text) {
        if !ev.branches.iter().any(|have| have == &b) {
            notes.push(format!("claimed branch `{b}` does not exist"));
        }
    }
    // Recognize the commit claim against the model's ORIGINAL prose, never
    // against a block this function itself appended.
    let commit_claimed = claims_committed(&text);
    let mut text = text;
    if !notes.is_empty() {
        text = format!(
            "{text}\n\n⚠ claim check (#1214): {} — verify the workspace state before \
             trusting the summary above.",
            notes.join("; ")
        );
    }
    if commit_claimed && (ev.head_before.is_some() || root_changed) {
        let location = root
            .map(|path| format!("in {}: ", crate::worktree_adoption::task_path_literal(path)))
            .unwrap_or_default();
        text = format!(
            "{text}\n\n⚠ claim check (#2683): {location}{}",
            commit_facts_note(
                ev.head_before.as_deref(),
                &ev.head_now,
                ev.dirty_paths.as_ref()
            )
        );
    }
    text
}

/// Observe the branch and commit evidence used by final-answer annotations.
/// Preserve the existing no-evidence behavior for unborn HEADs and failed
/// Git reads; no evidence means no annotation. `head_before` is the HEAD sha
/// captured once at the start of this turn ([`git_head`], before any of this
/// turn's tool calls ran) — `None` when no baseline was captured, which
/// leaves [`TurnGitEvidence::head_before`] `None` too rather than guessing.
/// Also probes the CURRENT status snapshot (reusing [`SNAPSHOT_STATUS_ARGS`]
/// / [`parse_porcelain_z`], the same machinery [`snapshot_workspace`] uses)
/// for [`TurnGitEvidence::dirty_paths`] — a failed probe is `None`, never an
/// implied "clean".
pub(crate) fn collect_git_evidence(
    workspace: &str,
    read_scope: &crate::Scope<String>,
    head_before: Option<&str>,
) -> Option<TurnGitEvidence> {
    let head_now = git_head(workspace, read_scope)?;
    let branches = git_in(
        workspace,
        &["branch", "--format=%(refname:short)"],
        read_scope,
    )?
    .lines()
    .map(|l| l.trim().to_string())
    .filter(|l| !l.is_empty())
    .collect();
    let dirty_paths =
        git_in(workspace, &SNAPSHOT_STATUS_ARGS, read_scope).map(|out| parse_porcelain_z(&out));
    Some(TurnGitEvidence {
        branches,
        head_before: head_before.map(str::to_string),
        head_now,
        dirty_paths,
    })
}

/// Current HEAD sha of `workspace`, or `None` off-repo / on failure.
pub(crate) fn git_head(workspace: &str, read_scope: &crate::Scope<String>) -> Option<String> {
    git_in(workspace, &["rev-parse", "HEAD"], read_scope).map(|s| s.trim().to_string())
}

/// Run a read-only git plumbing command in `workspace`; `None` on any failure.
fn git_in(workspace: &str, args: &[&str], read_scope: &crate::Scope<String>) -> Option<String> {
    // Confused-deputy-safe: `workspace` may be a hostile repo whose `.git/config`
    // could turn a raw `git` read into out-of-fence code (core.fsmonitor, hooks,
    // diff.external, …). Metadata also requires explicit read authority.
    let out = crate::git_hardening::metadata_git(std::path::Path::new(workspace), args, read_scope)
        .ok()?
        .output()
        .ok()?;
    out.status
        .success()
        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}

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

    /// The hand-back snapshot must enumerate individual untracked files.
    #[test]
    fn snapshot_lists_individual_untracked_files() {
        assert!(SNAPSHOT_STATUS_ARGS.contains(&"--untracked-files=all"));
    }

    #[test]
    fn porcelain_z_parses_spaces_untracked_and_renames() {
        let out = " M src/a b.rs\0?? new.txt\0R  moved.rs\0old.rs\0";
        let s = parse_porcelain_z(out);
        assert_eq!(s.get("src/a b.rs").map(String::as_str), Some(" M"));
        assert_eq!(s.get("new.txt").map(String::as_str), Some("??"));
        assert_eq!(s.get("moved.rs").map(String::as_str), Some("R "));
        assert!(
            !s.contains_key("old.rs"),
            "a rename origin is not a path of its own"
        );
        assert!(parse_porcelain_z("").is_empty());
    }

    /// U7: the delta ignores dirt that predates the run, but reports a path whose
    /// status changed, and a path that appeared.
    #[test]
    fn files_changed_is_the_delta_not_the_dirt() {
        let snap = |v: &[(&str, &str)]| {
            v.iter()
                .map(|(p, c)| (p.to_string(), c.to_string()))
                .collect::<StatusSnapshot>()
        };
        let before = snap(&[("dirty.txt", " M"), ("staged.txt", "A ")]);
        let after = snap(&[
            ("dirty.txt", " M"),
            ("staged.txt", "AM"),
            ("stray.sh", "??"),
        ]);
        assert_eq!(
            files_changed_between(&before, &after),
            ["staged.txt", "stray.sh"]
        );
        assert!(files_changed_between(&after, &after).is_empty());
    }

    /// The #867 transcript shapes: bold-wrapped path with a parenthesized
    /// line hint, a backticked `path:line`, a URL (never a claim), and a
    /// letterless version-ish token (never a claim).
    #[test]
    fn path_claims_extracts_slash_ext_tokens_from_chat_prose() {
        let text = "Defined in **newt-tui/src/commands.rs** (lines 38-40); \
                    handler at `session/mod.rs:567`. See \
                    https://github.com/x/y/blob/main/z.rs and spec 1.2/3.4.";
        assert_eq!(
            path_claims(text),
            vec!["newt-tui/src/commands.rs", "session/mod.rs"]
        );
    }

    #[test]
    fn path_claims_keeps_leading_dots_dedupes_and_skips_prose() {
        let text = "check .newt/config.toml then ./src/lib.rs, then .newt/config.toml again. \
                    A plain sentence, an edit/shell mention, and lib.rs alone are not claims.";
        assert_eq!(path_claims(text), vec![".newt/config.toml", "./src/lib.rs"]);
        assert!(path_claims("no paths here at all").is_empty());
    }

    #[test]
    fn annotate_is_a_noop_when_claims_verify_or_are_absent() {
        let clean = "all good, nothing cited".to_string();
        assert_eq!(annotate_path_claims(clean.clone(), |_| Some(false)), clean);
        let cited = "the fix is in a/b.rs and c/d.rs".to_string();
        assert_eq!(annotate_path_claims(cited.clone(), |_| Some(true)), cited);
    }

    #[test]
    fn annotate_appends_refutation_and_preserves_the_prose_prefix() {
        let cited = "the fix is in a/b.rs and c/d.rs".to_string();
        let out = annotate_path_claims(cited.clone(), |c| Some(c == "a/b.rs"));
        assert!(out.starts_with(&cited), "prose must be an exact prefix");
        assert!(out.contains("⚠ claim check (#867)"), "got: {out}");
        assert!(out.contains("`c/d.rs`"), "the missing path is named");
        assert!(!out.contains("`a/b.rs`"), "verified paths are not listed");
    }

    #[test]
    fn annotate_caps_the_listed_paths() {
        let cited: String = (0..12)
            .map(|i| format!("see dir{i}/f{i}.rs "))
            .collect::<String>();
        let out = annotate_path_claims(cited, |_| Some(false));
        assert!(out.contains("`dir0/f0.rs`"));
        assert!(out.contains("`dir7/f7.rs`"));
        assert!(!out.contains("`dir8/f8.rs`"), "capped at {LISTED_CLAIMS}");
        assert!(out.contains("(+4 more)"), "got: {out}");
    }

    /// #867 Part A: the ledger records only verified paths, dedupes in
    /// first-seen order, and stops at the cap — an error message citing a
    /// fake path can never enter the manifest.
    #[test]
    fn observed_paths_records_verified_dedupes_and_caps() {
        let mut led = ObservedPaths::default();
        led.record("src/a.rs:12: hit and src/b.rs:9: hit", |c| c != "src/b.rs");
        led.record("src/a.rs:44: again, plus docs/x.md", |_| true);
        assert_eq!(led.into_vec(), vec!["src/a.rs", "docs/x.md"]);

        let mut full = ObservedPaths::default();
        let many: String = (0..50).map(|i| format!("d/f{i}.rs ")).collect();
        full.record(&many, |_| true);
        let v = full.into_vec();
        assert_eq!(v.len(), 40, "capped at OBSERVED_CAP");
        assert_eq!(v[0], "d/f0.rs");
        assert_eq!(v[39], "d/f39.rs");
    }

    fn evidence(branches: &[&str]) -> TurnGitEvidence {
        TurnGitEvidence {
            branches: branches.iter().map(|s| s.to_string()).collect(),
            head_before: None,
            head_now: "0000000000000000000000000000000000000000".to_string(),
            dirty_paths: None,
        }
    }

    /// Like [`evidence`], but with an explicit #2683 HEAD baseline/now pair
    /// — `before == now` is the "did not move" case.
    fn evidence_with_head(branches: &[&str], before: &str, now: &str) -> TurnGitEvidence {
        TurnGitEvidence {
            branches: branches.iter().map(|s| s.to_string()).collect(),
            head_before: Some(before.to_string()),
            head_now: now.to_string(),
            dirty_paths: None,
        }
    }

    /// Like [`evidence_with_head`], but also carrying a CURRENT dirty-status
    /// snapshot — reported verbatim by the round-3 commit-facts note, never
    /// cross-referenced against the claim's own text. The status code is
    /// irrelevant to the check, so every dirty path shares a placeholder.
    fn evidence_with_dirty(
        branches: &[&str],
        before: &str,
        now: &str,
        dirty: &[&str],
    ) -> TurnGitEvidence {
        TurnGitEvidence {
            branches: branches.iter().map(|s| s.to_string()).collect(),
            head_before: Some(before.to_string()),
            head_now: now.to_string(),
            dirty_paths: Some(
                dirty
                    .iter()
                    .map(|p| (p.to_string(), " M".to_string()))
                    .collect(),
            ),
        }
    }

    /// #1214, from the live Ornith transcript: "Branch is clean on
    /// step-09.Help-rollup-for-the-548 (only my single commit ahead of
    /// bench/548-base)" — the nonexistent branch is refuted without making
    /// unrelated assertions about commit creation or passing tests.
    #[test]
    fn refutes_only_the_phantom_branch_from_the_live_transcript() {
        let text = "Branch is clean on step-09.Help-rollup-for-the-548 \
                    (only my single commit ahead of bench/548-base). \
                    All existing tests pass."
            .to_string();
        let ev = evidence(&["bench/548-base", "main"]);
        let out = annotate_action_claims(text.clone(), Some(&ev));
        assert!(out.starts_with(&text), "prose is an exact prefix");
        assert!(out.contains("⚠ claim check (#1214)"), "got: {out}");
        assert!(
            out.contains("`step-09.Help-rollup-for-the-548` does not exist"),
            "phantom branch refuted: {out}"
        );
        assert!(!out.contains("no commit was created"), "{out}");
        // The REAL branch is not refuted.
        assert!(!out.contains("`bench/548-base` does not exist"), "{out}");
    }

    /// Existing branches and prose without branch claims remain untouched.
    #[test]
    fn honest_summaries_pass_untouched() {
        let honest = "committed the fix on branch fix/x-1; tests pass".to_string();
        let ev = evidence(&["fix/x-1", "main"]);
        assert_eq!(annotate_action_claims(honest.clone(), Some(&ev)), honest);

        let no_claims = "I explored the code and here is my analysis".to_string();
        let ev = evidence(&["main"]);
        assert_eq!(
            annotate_action_claims(no_claims.clone(), Some(&ev)),
            no_claims
        );
        // No evidence (not a git workspace) → never annotate.
        let claimy = "committed and pushed".to_string();
        assert_eq!(annotate_action_claims(claimy.clone(), None), claimy);
    }

    /// The same fixtures that must never get a commit-facts note must ALSO
    /// never get one manufactured by an unmoved-HEAD baseline, which is the
    /// one evidence state where a careless recognizer would misfire on the
    /// negated, future-tense, retrospective, or quoted "commit(ted)" mentions
    /// below.
    #[test]
    fn git_state_does_not_refute_negated_or_unrelated_action_claims() {
        for text in [
            "These are all __pycache__ .pyc files generated by running the CI tests. \
             They're not staged or committed — just sitting untracked in the working tree.",
            "All tests passed.",
            "I pushed the existing commit to origin.",
            "I opened a pull request for the existing commit.",
            "I have not committed these changes.",
            "After approval, these files will be committed.",
            "Previously, I committed the change.",
            "> committed the change",
            "The existing branch is one commit ahead of origin/main.",
            "These changes are yet to be committed.",
            "The files have not, for safety reasons, been committed.",
            // #2683 round 3 (PR #2688 review round 2, finding 2): a path's
            // own embedded extension dot is not a sentence boundary —
            // treating it as one would strip the "not" preceding it.
            "I have not changed src/a.rs or committed anything",
        ] {
            let ev = evidence(&["main"]);
            assert_eq!(
                annotate_action_claims(text.to_string(), Some(&ev)),
                text,
                "Git metadata does not contradict this statement: {text}"
            );
            let ev = evidence_with_head(&["main"], "aaa111", "aaa111");
            assert_eq!(
                annotate_action_claims(text.to_string(), Some(&ev)),
                text,
                "not a direct, present-tense commit claim, even with an unmoved-HEAD \
                 baseline on hand: {text}"
            );
        }
    }

    /// A branch inventory ALONE (no commit/HEAD evidence collected this
    /// turn) cannot establish whether a commit was created — absence of
    /// evidence is not evidence of absence (#2683).
    #[test]
    fn branch_inventory_is_not_commit_execution_evidence() {
        let text = "I committed the change".to_string();
        let ev = evidence(&["main"]);
        assert_eq!(annotate_action_claims(text.clone(), Some(&ev)), text);
    }

    /// #2683, the measured retest bug (COMPARE.md, 2026-10-02): the model
    /// reported "committed locally" when the change was only staged — HEAD
    /// never moved. Round 3: this is now a neutral FACTS note, not a
    /// verdict — the operator reads "HEAD did not move" against their own
    /// judgment of the claim, appended after the model's prose, without
    /// disturbing the branch-claim (#1214) channel.
    #[test]
    fn commit_claim_gets_a_facts_note_when_head_did_not_move() {
        let text = "I committed the change locally. cargo check passes.".to_string();
        let ev = evidence_with_head(&["main"], "aaa111", "aaa111");
        let out = annotate_action_claims(text.clone(), Some(&ev));
        assert!(out.starts_with(&text), "prose is an exact prefix: {out}");
        assert!(out.contains("⚠ claim check (#2683)"), "got: {out}");
        assert!(out.contains("HEAD did not move"), "got: {out}");
    }

    /// #2683 round 3 (PR #2688 review round 2): HEAD unmoved PLUS a staged
    /// change reported in the SAME facts note — "uncommitted changes" is
    /// read straight out of the current status snapshot, never matched
    /// against the claim's own wording.
    #[test]
    fn head_unmoved_with_staged_changes_gets_the_facts_note() {
        let text = "I committed the change locally.".to_string();
        let ev = evidence_with_dirty(&["main"], "aaa111", "aaa111", &["src/a.rs"]);
        let out = annotate_action_claims(text.clone(), Some(&ev));
        assert!(out.contains("⚠ claim check (#2683)"), "got: {out}");
        assert!(out.contains("HEAD did not move"), "got: {out}");
        assert!(out.contains("`src/a.rs`"), "got: {out}");
    }

    /// Round 3: a TRUE commit claim, with evidence that HEAD actually moved,
    /// now ALSO gets the facts note — it is informational, never an
    /// accusation, so a true claim is not exempted from it; the note simply
    /// corroborates what the model already said.
    #[test]
    fn a_true_committed_claim_gets_a_facts_note_showing_head_moved() {
        let text = "I committed the change locally.".to_string();
        let ev = evidence_with_head(&["main"], "aaa111", "bbb222");
        let out = annotate_action_claims(text.clone(), Some(&ev));
        assert!(out.starts_with(&text), "prose is an exact prefix: {out}");
        assert!(out.contains("⚠ claim check (#2683)"), "got: {out}");
        assert!(
            out.contains("HEAD moved from `aaa111` to `bbb222`"),
            "got: {out}"
        );
        assert!(
            out.contains("uncommitted changes: not probed"),
            "got: {out}"
        );
    }

    /// #2683 round 3 (PR #2688 review round 2, finding 1): the fix
    /// direction in full — stop mapping claim text to files. "Committed
    /// src/a.rs. src/b.rs remains uncommitted." names exactly which file it
    /// committed; the OLD per-file check could not parse that distinction
    /// and refuted the WHOLE claim because b.rs (correctly named as
    /// still-dirty, by the model's OWN prose) showed up in the dirty
    /// snapshot. The facts note reports src/b.rs as a FACT, never as a
    /// verdict against the claim — no "not committed" wording of ours
    /// appears anywhere in the output.
    #[test]
    fn an_honest_partial_commit_claim_gets_facts_never_a_false_not_committed_verdict() {
        let text = "Committed src/a.rs. src/b.rs remains uncommitted.".to_string();
        let ev = evidence_with_dirty(&["main"], "aaa111", "bbb222", &["src/b.rs"]);
        let out = annotate_action_claims(text.clone(), Some(&ev));
        assert!(out.starts_with(&text), "prose is an exact prefix: {out}");
        assert!(out.contains("⚠ claim check (#2683)"), "got: {out}");
        assert!(
            out.contains("HEAD moved from `aaa111` to `bbb222`"),
            "got: {out}"
        );
        assert!(
            out.contains("`src/b.rs`"),
            "the dirty file is a FACT: {out}"
        );
        let annotation = out.strip_prefix(&text).expect("exact prefix");
        assert!(
            !annotation.to_lowercase().contains("not committed"),
            "no claim-to-file verdict of ours: {out}"
        );
    }

    /// Direct coverage of the recognizer: the trigger is the verb
    /// "committed", never the noun "commit"; negation, future tense, a
    /// temporal-distancing word, and a quoted line all void a match.
    #[test]
    fn claims_committed_recognizes_the_verb_but_not_voided_or_noun_forms() {
        assert!(claims_committed(
            "committed the fix on branch fix/x-1; tests pass"
        ));
        assert!(claims_committed("Committed the fix locally."));
        assert!(!claims_committed("I have not committed these changes."));
        assert!(!claims_committed(
            "After approval, these files will be committed."
        ));
        assert!(!claims_committed("Previously, I committed the change."));
        assert!(!claims_committed("> committed the change"));
        assert!(!claims_committed("I pushed the existing commit to origin."));
        assert!(!claims_committed(
            "The existing branch is one commit ahead of origin/main."
        ));
    }

    /// #2683 round 2 (PR #2688 review, finding 2): "ordinary language" cases
    /// the fixed-token-window recognizer got wrong in both directions —
    /// false negatives (a real claim wrongly voided) and a false positive (a
    /// non-claim wrongly flagged).
    #[test]
    fn claims_committed_is_clause_aware_not_window_bounded() {
        // False negative, precision gap: "already" is ordinary language for
        // a just-finished action THIS turn, not a claim about an earlier
        // session — must be recognized, not voided.
        assert!(claims_committed("I have already committed the fix"));
        // False negative, recall gap: the negation ("Not") belongs to the
        // PRIOR clause ("staged") and must not bleed across the `;` to void
        // the real claim that follows it.
        assert!(claims_committed("Not staged; committed."));
        // False positive: a future/incomplete construction, not a claim of
        // having committed.
        assert!(!claims_committed("These changes are yet to be committed."));
        // Recall gap: the negation is 4 tokens before the verb — beyond any
        // fixed short window — but still in the SAME clause as "committed".
        assert!(!claims_committed(
            "The files have not, for safety reasons, been committed."
        ));
        // #2683 round 3 (PR #2688 review round 2, finding 2): a path's own
        // embedded period ("src/a.rs") is not a sentence boundary —
        // treating it as one discarded the "not" preceding it and turned
        // this honest non-claim into a false positive.
        assert!(!claims_committed(
            "I have not changed src/a.rs or committed anything"
        ));
        // The fix must not overcorrect: a path that genuinely ENDS a
        // sentence (a real trailing period after the extension) still lets
        // the NEXT clause claim a commit.
        assert!(claims_committed("Verified src/a.rs. I committed the fix."));
        // A trailing-period abbreviation mid-clause ("e.g.") must not be
        // mistaken for the sentence boundary either — the same false-split
        // shape as the path case, at the token's own end rather than its
        // middle.
        assert!(!claims_committed(
            "The patch has not, e.g. for safety reasons, been committed."
        ));
    }

    /// The clause-aware recognizer fix must reach the full annotation
    /// pipeline (`annotate_action_claims`), not just the pure recognizer.
    #[test]
    fn ordinary_language_and_clause_boundary_commit_claims_are_also_refuted() {
        for text in ["I have already committed the fix", "Not staged; committed."] {
            let ev = evidence_with_head(&["main"], "aaa111", "aaa111");
            let out = annotate_action_claims(text.to_string(), Some(&ev));
            assert!(out.contains("⚠ claim check (#2683)"), "{text} -> {out}");
        }
    }

    /// Prose after "branch" is not a ref; backticked refs unwrap.
    #[test]
    fn branch_claim_detection_is_conservative() {
        assert_eq!(
            claimed_branches("on branch `fix/a-1` and branch main stays; the branch is fine"),
            vec!["fix/a-1"],
            "prose words and bare names without ref-chars are not claims"
        );
    }

    /// The workspace wiring honors the fence: a `..` escape and an absolute
    /// path outside the root are refuted without a stat; a real in-tree file
    /// verifies. Uses this crate's own source tree read-only — no tempdirs,
    /// no writes.
    #[test]
    fn workspace_wiring_fences_and_resolves() {
        let ws = env!("CARGO_MANIFEST_DIR");
        let ok = annotate_against_workspace("see src/lib.rs".to_string(), ws);
        assert!(!ok.contains("⚠ claim check"), "real file verifies: {ok}");
        let bad = annotate_against_workspace(
            "see src/nope.rs and ../escape/x.rs and /etc/hosts.d/y.rs".to_string(),
            ws,
        );
        assert!(bad.contains("`src/nope.rs`"), "got: {bad}");
        assert!(bad.contains("`../escape/x.rs`"), "escape refuted: {bad}");
        assert!(
            bad.contains("`/etc/hosts.d/y.rs`"),
            "outside refuted: {bad}"
        );
    }

    /// Real existing and absent external files ground the annotation boundary:
    /// neither can be declared missing or verified by a workspace-only checker.
    #[test]
    fn external_path_claims_are_unverified_not_missing() {
        let tree = tempfile::tempdir().unwrap();
        let workspace = tree.path().join("workspace");
        let reports = tree.path().join("reports");
        std::fs::create_dir_all(&workspace).unwrap();
        std::fs::create_dir_all(&reports).unwrap();
        let existing = reports.join("report.md");
        std::fs::write(&existing, "observed report").unwrap();
        // The existing prose tokenizer recognizes slash paths, not Windows
        // drive prefixes. Resolver boundary coverage below is platform-neutral.
        let absolute = cfg!(unix).then(|| existing.to_string_lossy().into_owned());
        for claim in absolute
            .as_deref()
            .into_iter()
            .chain(["../reports/report.md", "../reports/absent.md"])
        {
            let text = format!("See `{claim}`.");
            let out = annotate_against_workspace(text.clone(), workspace.to_str().unwrap());
            let annotation = out.strip_prefix(&text).expect("preserve original prose");
            assert!(
                annotation.contains("unverified outside workspace"),
                "external existence is not known to this checker: {out}"
            );
            assert!(!annotation.contains("not found"), "{out}");
            assert!(annotation.contains(&format!("`{claim}`")), "{out}");
        }
    }

    #[test]
    fn mixed_path_claims_keep_missing_and_unverified_separate() {
        let workspace = env!("CARGO_MANIFEST_DIR");
        let text = "See src/lib.rs, src/absent-file.rs and ../../reports/report.md.".to_string();
        let out = annotate_against_workspace(text.clone(), workspace);
        let annotation = out.strip_prefix(&text).expect("preserve original prose");
        let (missing, unverified) = annotation
            .split_once("unverified outside workspace")
            .expect("external claims have a distinct annotation");
        assert!(missing.contains("not found in this workspace"), "{out}");
        assert!(missing.contains("`src/absent-file.rs`"), "{out}");
        assert!(!missing.contains("`../../reports/report.md`"), "{out}");
        assert!(unverified.contains("`../../reports/report.md`"), "{out}");
        assert!(!annotation.contains("`src/lib.rs`"), "{out}");
    }

    #[test]
    fn external_path_annotation_keeps_the_existing_list_bound() {
        let text: String = (0..12)
            .map(|i| format!("See ../reports/report{i}.md. "))
            .collect();
        let out = annotate_against_workspace(text.clone(), env!("CARGO_MANIFEST_DIR"));
        let annotation = out.strip_prefix(&text).expect("preserve original prose");
        assert!(annotation.contains("unverified outside workspace"), "{out}");
        assert!(annotation.contains("`../reports/report0.md`"), "{out}");
        assert!(annotation.contains("`../reports/report7.md`"), "{out}");
        assert!(!annotation.contains("`../reports/report8.md`"), "{out}");
        assert!(annotation.contains("(+4 more)"), "{out}");
    }

    #[test]
    fn unverified_external_claims_do_not_enter_observed_paths() {
        let mut observed = ObservedPaths::default();
        observed.record(
            "See src/lib.rs and ../../reports/report.md.",
            workspace_resolver(env!("CARGO_MANIFEST_DIR")),
        );
        assert_eq!(observed.into_vec(), vec!["src/lib.rs"]);
    }

    #[test]
    fn workspace_claim_resolver_never_probes_lexical_escapes() {
        let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let outside = workspace.parent().unwrap().join("external-report.md");
        let mut probes = Vec::new();
        {
            let mut resolve = workspace_claim_resolver(workspace.to_str().unwrap(), |path| {
                probes.push(path.to_path_buf());
                true
            });
            assert_eq!(resolve(outside.to_str().unwrap()), None);
            assert_eq!(resolve("../../reports/report.md"), None);
            assert_eq!(resolve("src/lib.rs"), Some(true));
            // Learning src/ must not permit an escape beyond that base either.
            assert_eq!(resolve("../../reports/report.md"), None);
        }
        assert_eq!(probes, vec![workspace.join("src/lib.rs")]);
    }

    #[test]
    fn missing_and_unverified_claims_share_one_display_cap() {
        let text: String = (0..12).map(|i| format!("See dir/file{i}.md. ")).collect();
        let out = annotate_path_claims(text.clone(), |claim| {
            claim.ends_with("0.md").then_some(false)
        });
        let annotation = out.strip_prefix(&text).unwrap();
        assert_eq!(annotation.matches('`').count(), LISTED_CLAIMS * 2);
        assert!(annotation.contains("not found in this workspace"));
        assert!(annotation.contains("unverified outside workspace"));
        assert!(annotation.contains("(+4 more)"));
    }

    /// #1970 regression, reproduced against this repo's own workspace root
    /// (the parent of `CARGO_MANIFEST_DIR`, which contains sibling crates —
    /// the same shape as the reported bug's `agent-voice/agent-voice-tts/`
    /// subproject under `Gilamonster-Foundation`): `newt-core/Cargo.toml`
    /// verifies directly at root and its directory is learned; the bare
    /// fragment `src/lib.rs` cited right after it must now resolve there
    /// too, instead of being refuted as absent at the workspace root.
    #[test]
    fn bare_fragment_resolves_under_an_earlier_verified_claims_directory() {
        let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("newt-core has a workspace root parent");
        let ws = ws.to_str().expect("workspace root is valid UTF-8");
        let text = "see newt-core/Cargo.toml and also src/lib.rs".to_string();
        let out = annotate_against_workspace(text.clone(), ws);
        assert_eq!(out, text, "both claims verify, no annotation: {out}");
    }

    /// Twin of the above: a fragment that is genuinely absent everywhere —
    /// root and every learned base — still refutes. A false positive would
    /// be worse than the false refutation this fix closes (#1970's own
    /// framing: it "teaches operators to ignore refutations").
    #[test]
    fn a_genuinely_absent_fragment_still_refutes_even_with_a_learned_base() {
        let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("newt-core has a workspace root parent");
        let ws = ws.to_str().expect("workspace root is valid UTF-8");
        let text = "see newt-core/Cargo.toml and also nope/nope.rs".to_string();
        let out = annotate_against_workspace(text, ws);
        assert!(out.contains("⚠ claim check (#867)"), "got: {out}");
        assert!(out.contains("`nope/nope.rs`"), "got: {out}");
    }

    /// A claim resolving under more than one base (root AND a learned base)
    /// is still a verify — ambiguity is never treated as a refutation.
    #[test]
    fn a_claim_resolvable_under_multiple_bases_still_verifies() {
        let ws = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("newt-core has a workspace root parent");
        let ws = ws.to_str().expect("workspace root is valid UTF-8");
        // `newt-core/Cargo.toml` verifies at root and learns `newt-core/` as
        // a base; `Cargo.toml` alone then resolves at BOTH the root (the
        // workspace's own Cargo.toml) and the learned `newt-core/` base.
        let text = "see newt-core/Cargo.toml and also Cargo.toml".to_string();
        let out = annotate_against_workspace(text.clone(), ws);
        assert_eq!(out, text, "ambiguous-but-real is still a verify: {out}");
    }

    /// Multi-repo recon PR2 fixtures: a `git()` runner scoped to `dir`, and a
    /// bare tempdir root holding two independent git repos (`repoA`,
    /// `repoB`), one with a real uncommitted edit — the exact r9-r12 shape
    /// (a bare folder of several checkouts) `snapshot_workspace` alone
    /// cannot see into.
    fn git_in_dir(dir: &std::path::Path, args: &[&str]) {
        assert!(
            std::process::Command::new("git")
                .args(args)
                .current_dir(dir)
                .output()
                .expect("git")
                .status
                .success(),
            "git {args:?} in {}",
            dir.display()
        );
    }

    fn init_repo(dir: &std::path::Path) {
        std::fs::create_dir_all(dir).unwrap();
        git_in_dir(dir, &["init", "-q"]);
        git_in_dir(dir, &["config", "user.email", "t@example.com"]);
        git_in_dir(dir, &["config", "user.name", "t"]);
    }

    /// A bare root holding `repoA` (committed file `a.txt`, then edited
    /// uncommitted) and `repoB` (clean).
    fn bare_multi_repo_fixture() -> tempfile::TempDir {
        let root = tempfile::tempdir().expect("bare multi-repo root");
        let repo_a = root.path().join("repoA");
        init_repo(&repo_a);
        std::fs::write(repo_a.join("a.txt"), "one\n").unwrap();
        git_in_dir(&repo_a, &["add", "a.txt"]);
        git_in_dir(&repo_a, &["commit", "-q", "-m", "init"]);
        std::fs::write(repo_a.join("a.txt"), "one\ntwo\n").unwrap(); // uncommitted edit
        init_repo(&root.path().join("repoB"));
        root
    }

    /// F24-recon row 5 / PR2 (red first): the bare-root shape reports EACH
    /// nested repo, never a bare "unavailable". `repoA`'s uncommitted edit
    /// is visible in its own status snapshot; `repoB` (clean) is present
    /// with an empty one.
    #[test]
    fn snapshot_nested_repos_probes_each_first_level_git_child() {
        let root = bare_multi_repo_fixture();
        assert!(
            snapshot_workspace(&root.path().to_string_lossy(), &crate::Scope::All).is_none(),
            "the bare root itself is not a repo"
        );
        let snapshots = snapshot_nested_repos(&root.path().to_string_lossy(), &crate::Scope::All);
        assert_eq!(snapshots.len(), 2, "{snapshots:?}");
        let repo_a = snapshots
            .iter()
            .find(|s| s.repo == "repoA")
            .expect("repoA probed");
        let status_a = repo_a.status.as_ref().expect("repoA's probe must succeed");
        assert_eq!(status_a.get("a.txt").map(String::as_str), Some(" M"));
        let repo_b = snapshots
            .iter()
            .find(|s| s.repo == "repoB")
            .expect("repoB probed");
        assert_eq!(
            repo_b
                .status
                .as_ref()
                .expect("repoB's probe must succeed")
                .len(),
            0,
            "repoB is clean"
        );
    }

    /// A plain (non-git) first-level subdirectory is not a candidate at all
    /// — never listed, never reported as unprobed. Only a `.git`-bearing
    /// directory counts as a nested repo.
    #[test]
    fn snapshot_nested_repos_ignores_non_repo_subdirs() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(root.path().join("just_a_folder")).unwrap();
        let snapshots = snapshot_nested_repos(&root.path().to_string_lossy(), &crate::Scope::All);
        assert!(snapshots.is_empty(), "{snapshots:?}");
    }

    /// A nested repo that fails to probe (outside the fs-read fence, or any
    /// other `git_in` failure) is named, not silently skipped — `status:
    /// None`, distinguishable from "not a repo at all" (which never appears
    /// in the list). Measured: `check_git_read_scope`'s "metadata" class
    /// (what `git_in` uses) is all-or-nothing on `Scope::Only` — the SAME
    /// gate `snapshot_workspace` is already subject to today — so a bounded
    /// scope refuses every nested repo's probe uniformly, not per-path; this
    /// pins that behaviour is inherited, not silently different, for the
    /// nested case.
    #[test]
    fn snapshot_nested_repos_names_every_repo_as_unprobed_under_a_bounded_scope() {
        let root = bare_multi_repo_fixture();
        let scope = crate::Scope::only([root.path().to_string_lossy().into_owned()]);
        let snapshots = snapshot_nested_repos(&root.path().to_string_lossy(), &scope);
        assert_eq!(snapshots.len(), 2, "{snapshots:?}");
        for repo in &snapshots {
            assert!(
                repo.status.is_none(),
                "{}: a bounded fs_read scope refuses every metadata git read, \
                 the same as snapshot_workspace — named, not probed",
                repo.repo
            );
        }
        // Confirms the SAME gate the single-repo path is already subject to,
        // not a nested-only regression.
        assert!(snapshot_workspace(&root.path().join("repoA").to_string_lossy(), &scope).is_none());
    }

    /// `nested_files_changed_between` (the delta): repoA's edit, made AFTER
    /// `before` was captured, is reported prefixed `repoA/`; repoB (clean at
    /// both ends) contributes nothing.
    #[test]
    fn nested_files_changed_between_prefixes_by_repo_and_ignores_clean_repos() {
        let root = tempfile::tempdir().unwrap();
        let repo_a = root.path().join("repoA");
        init_repo(&repo_a);
        std::fs::write(repo_a.join("a.txt"), "one\n").unwrap();
        git_in_dir(&repo_a, &["add", "a.txt"]);
        git_in_dir(&repo_a, &["commit", "-q", "-m", "init"]);
        init_repo(&root.path().join("repoB"));

        let workspace = root.path().to_string_lossy().into_owned();
        let before = snapshot_nested_repos(&workspace, &crate::Scope::All);
        std::fs::write(repo_a.join("a.txt"), "one\ntwo\n").unwrap();
        let after = snapshot_nested_repos(&workspace, &crate::Scope::All);
        let (files, unprobed) = nested_files_changed_between(&before, &after);
        assert_eq!(files, vec!["repoA/a.txt".to_string()], "{files:?}");
        assert!(unprobed.is_empty(), "{unprobed:?}");
    }

    /// `nested_current_paths` (the CURRENT set, not a delta): repoA's
    /// uncommitted edit is visible with no "before" needed at all — this is
    /// what feeds `uncommitted_files` in the nested case.
    #[test]
    fn nested_current_paths_lists_every_probed_repos_dirty_files() {
        let root = bare_multi_repo_fixture();
        let snapshots = snapshot_nested_repos(&root.path().to_string_lossy(), &crate::Scope::All);
        let (files, unprobed) = nested_current_paths(&snapshots);
        assert_eq!(files, vec!["repoA/a.txt".to_string()], "{files:?}");
        assert!(unprobed.is_empty(), "{unprobed:?}");
    }

    /// A root that IS a repo takes the existing `snapshot_workspace` path
    /// untouched — `snapshot_nested_repos` is simply never reached by the
    /// caller in that case (the byte-identical-to-today claim is pinned at
    /// the `headless_contract`/`headless_cli` level, where the actual
    /// `files_changed_source` string is asserted); this test only confirms
    /// the pure claim_check-level behaviour is unaffected: `snapshot_workspace`
    /// still succeeds on a repo root exactly as before this PR touched
    /// anything.
    #[test]
    fn a_repo_root_still_probes_via_snapshot_workspace_unaffected_by_nesting() {
        let root = tempfile::tempdir().unwrap();
        init_repo(root.path());
        assert!(snapshot_workspace(&root.path().to_string_lossy(), &crate::Scope::All).is_some());
    }

    /// F26 v3 / #2552 round 2 (red first): a plain folder with no `.git` of
    /// its own, sitting INSIDE a larger git repo, is NOT its own repo root —
    /// `snapshot_workspace` must return `None` (never the enclosing repo's
    /// status, scoped or otherwise: `is_workspace_repo_root` is the single
    /// decision every hand-back source now obeys), and `snapshot_nested_repos`
    /// is the mechanism that actually reports the nested repo it contains.
    #[test]
    fn snapshot_workspace_never_discovers_an_enclosing_repo_above_the_workspace() {
        let outer = tempfile::tempdir().expect("outer root");
        init_repo(outer.path());
        std::fs::write(outer.path().join("outer.txt"), "outer\n").unwrap();
        git_in_dir(outer.path(), &["add", "outer.txt"]);
        git_in_dir(outer.path(), &["commit", "-q", "-m", "outer init"]);

        // `workspace`: a plain subfolder of the outer repo, itself not a
        // repo, holding one nested repo (`inner`).
        let workspace = outer.path().join("workspace");
        std::fs::create_dir_all(&workspace).unwrap();
        let inner = workspace.join("inner");
        init_repo(&inner);
        std::fs::write(inner.join("i.txt"), "one\n").unwrap();
        git_in_dir(&inner, &["add", "i.txt"]);
        git_in_dir(&inner, &["commit", "-q", "-m", "inner init"]);
        std::fs::write(inner.join("i.txt"), "one\ntwo\n").unwrap(); // uncommitted

        let workspace_str = workspace.to_string_lossy().into_owned();
        assert!(
            !is_workspace_repo_root(&workspace_str, &crate::Scope::All),
            "a subfolder of a larger repo is not its own repo root"
        );
        assert!(
            snapshot_workspace(&workspace_str, &crate::Scope::All).is_none(),
            "must never resolve to the enclosing repo's status"
        );
        let snapshots = snapshot_nested_repos(&workspace_str, &crate::Scope::All);
        assert_eq!(snapshots.len(), 1, "{snapshots:?}");
        assert_eq!(snapshots[0].repo, "inner");
        let status = snapshots[0]
            .status
            .as_ref()
            .expect("inner's probe must succeed");
        assert_eq!(status.get("i.txt").map(String::as_str), Some(" M"));
    }

    /// #2552 round 2: the symlinked-root nit — `is_workspace_repo_root`
    /// canonicalizes BOTH sides, so a symlinked spelling of a real repo root
    /// still reads as "own repo", not as "a subdirectory of the same
    /// toplevel" (which `git rev-parse --show-toplevel`'s always-resolved
    /// answer would otherwise make a lexical comparison conclude).
    #[test]
    #[cfg(unix)]
    fn is_workspace_repo_root_true_through_a_symlinked_spelling_of_the_root() {
        let real = tempfile::tempdir().expect("real root");
        init_repo(real.path());
        let parent = real.path().parent().expect("tempdir has a parent");
        let link = parent.join(format!(
            "symlink-{}",
            real.path().file_name().unwrap().to_string_lossy()
        ));
        std::os::unix::fs::symlink(real.path(), &link).expect("symlink");
        assert!(
            is_workspace_repo_root(&link.to_string_lossy(), &crate::Scope::All),
            "a symlinked spelling of the real root must still count as its own repo"
        );
        std::fs::remove_file(&link).ok();
    }

    /// #2552 round 2 should-fix (red first): a first-level entry that is a
    /// SYMLINK to a repo OUTSIDE the workspace must never be probed —
    /// `first_level_subdirs`' `is_dir()` and `is_repo_root`'s `.exists()`
    /// both follow symlinks, so without an explicit skip this would report
    /// an outside repo's files under `ext/…`.
    #[test]
    #[cfg(unix)]
    fn snapshot_nested_repos_skips_a_symlink_to_an_outside_repo() {
        let workspace = tempfile::tempdir().expect("workspace");
        let outside = tempfile::tempdir().expect("outside repo");
        init_repo(outside.path());
        std::fs::write(outside.path().join("secret.txt"), "leak\n").unwrap();
        git_in_dir(outside.path(), &["add", "secret.txt"]);
        git_in_dir(outside.path(), &["commit", "-q", "-m", "init"]);
        std::fs::write(outside.path().join("secret.txt"), "leak\nmore\n").unwrap();

        let link = workspace.path().join("ext");
        std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");

        let snapshots =
            snapshot_nested_repos(&workspace.path().to_string_lossy(), &crate::Scope::All);
        assert!(
            snapshots.is_empty(),
            "a symlinked first-level entry must never be probed: {snapshots:?}"
        );
    }

    /// #2552 round 2 should-fix (red first): a repo present at `before` but
    /// gone by `after` (deleted or renamed away mid-run) must be NAMED in
    /// `unprobed`, never silently dropped from the diff.
    #[test]
    fn nested_files_changed_between_names_a_repo_deleted_during_the_run() {
        let before = vec![NestedRepoSnapshot {
            repo: "gone".to_string(),
            status: Some(StatusSnapshot::new()),
        }];
        let after: Vec<NestedRepoSnapshot> = Vec::new();
        let (files, unprobed) = nested_files_changed_between(&before, &after);
        assert!(files.is_empty(), "{files:?}");
        assert_eq!(unprobed, vec!["gone".to_string()], "{unprobed:?}");
    }
}

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

    /// #2718: the retest's plan numbers 1 and 7 are not branch names.
    #[test]
    fn plan_steps_do_not_become_branches() {
        assert!(claimed_branches("Worktree + branch created. Extraction 1 done.\n6. Push the branch to origin ☐ 7. Open PR").is_empty());
        assert_eq!(claimed_branches("Created branch `123`."), ["123"]);
        assert_eq!(claimed_branches("Branch is clean on fix/a-1"), ["fix/a-1"]);
    }

    /// #2718: a verified full path supplies the base for a shorter crate-relative citation.
    #[test]
    fn shortened_path_and_future_path_are_not_refuted() {
        let mut resolve = workspace_claim_resolver("/repo", |p| {
            p == std::path::Path::new("/repo/newt-core/src/agentic/mod.rs")
        });
        assert_eq!(resolve("newt-core/src/agentic/mod.rs"), Some(true));
        assert_eq!(resolve("agentic/mod.rs"), Some(true));
        let text = "Remaining work:\n1. Extract newt-core/src/agentic/loop_infer.rs\n\nCurrent state:\nUpdated src/missing.rs";
        let got = annotate_path_claims(text.into(), |_| Some(false));
        let note = got.strip_prefix(text).unwrap();
        assert!(!note.contains("loop_infer"), "{got}");
        assert!(note.contains("src/missing.rs"), "{got}");
    }

    /// #2718 round 2: planning ends at the next unrelated section heading.
    #[test]
    fn round2_plan_section_does_not_hide_validation_claims() {
        let text = "## Next steps\nCreate src/future.rs\n## Validation\nUpdated src/missing.rs";
        let got = annotate_path_claims(text.into(), |_| Some(false));
        let note = got.strip_prefix(text).unwrap();
        assert!(!note.contains("src/future.rs"), "{got}");
        assert!(note.contains("src/missing.rs"), "{got}");
    }

    /// #2718: actual sibling worktree paths resolve only with observed context
    /// and read authority. Grounds the pure resolver in a real filesystem.
    #[test]
    fn observed_worktree_context_stays_inside_read_authority() {
        let root = tempfile::tempdir().unwrap();
        let launch = root.path().join("launch");
        let tree = root.path().join("tree");
        std::fs::create_dir_all(&launch).unwrap();
        std::fs::create_dir_all(tree.join("src")).unwrap();
        std::fs::write(tree.join("src/new.rs"), "// present").unwrap();
        let text = "Updated src/new.rs";
        assert_eq!(
            annotate_in_context(
                text.into(),
                launch.to_str().unwrap(),
                std::slice::from_ref(&tree),
                &crate::Scope::All
            ),
            text
        );
        let denied = annotate_in_context(
            text.into(),
            launch.to_str().unwrap(),
            &[tree],
            &crate::Scope::only([launch.to_string_lossy().into_owned()]),
        );
        assert!(denied.contains("unverified"));
        assert!(!denied.contains("not found"));
    }
}

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

    /// #2741: observing an absolute file path supplies its containing directory,
    /// so a later repository-relative citation must not double that prefix.
    #[test]
    fn issue_2741_repository_relative_citation_from_observed_file_parent() {
        let mut resolve = workspace_claim_resolver("/task/newt-core/src", |p| {
            p == std::path::Path::new("/task/newt-core/src/loop.rs")
        });
        assert_eq!(resolve("newt-core/src/loop.rs"), Some(true));
        assert_eq!(resolve("src/loop.rs"), Some(true));
        assert_ne!(resolve("newt-tui/src/loop.rs"), Some(true));
    }
}