newt-core 0.8.0

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

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::collections::BTreeSet;

/// Fixed, content-free marker appended inside the protected active-prompt
/// card. Keep this stable: `prompt_read` uses it to recognize only harness
/// owned augmented cards during replacement.
pub(crate) const PROMPT_COMPREHENSION_MODEL_CARD_PREFIX: &str = "[NEWT PROMPT COMPREHENSION v1]";

const MAX_ATOMIC_ASKS: usize = 64;
const MAX_DECISIONS: usize = 16;
// Reserve one slot for an overflow lock. Silently omitting the Nth unresolved
// decision would let a reply to the first N-1 decisions unlock execution.
const MAX_CONCRETE_DECISIONS: usize = MAX_DECISIONS - 1;
const MAX_ASK_BYTES: usize = 4_096;
const MAX_CLARIFICATION_BYTES: usize = 384;
/// Adjudication is one bounded side call, never a second agent turn. A batch
/// larger than this is refused outright and every candidate stays `Pending`.
pub const MAX_ADJUDICATION_BATCH: usize = 15;
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V1: &str = "prompt_comprehension_manifest_v1";
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V2: &str = "prompt_comprehension_manifest_v2";
/// #1971: adds `atomic_ask_kinds` and, for informational clauses only, their
/// text. A v3 reader accepts v1/v2 records unchanged — the new fields are
/// optional, and an absent list is exactly equivalent to an empty one.
pub(super) const PROMPT_COMPREHENSION_SCHEMA_V3: &str = "prompt_comprehension_manifest_v3";
pub(super) const PROMPT_COMPREHENSION_SCHEMA_CURRENT: &str = PROMPT_COMPREHENSION_SCHEMA_V3;

/// Where the live [`PromptDisposition`] came from, so the model card can say
/// so truthfully (#2051 review).
///
/// The card's provenance clause once said, unconditionally, that the harness
/// inferred the disposition from the operator's words and the operator did not
/// choose it. That is false under an explicit `/mode plan` or `/mode diagnose`,
/// which reach [`PromptIntake::enforce_read_only`] and narrow the turn on the
/// operator's own standing instruction. The two arms name the two callers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispositionSource {
    /// Classified from the prompt text by the intake lexicon.
    Inferred,
    /// Narrowed after classification by a session setting the operator chose
    /// earlier (an operating mode); the prompt's own words did not decide it.
    SessionPolicy,
}

/// The harness-selected mode for one accepted prompt receipt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PromptDisposition {
    /// A decision is not locked; the harness asks a bounded batch and ends the
    /// turn before model inference.
    Ask,
    /// The task is ready for normal execution.
    Act,
    /// Answer or clarify, using tools under the session's existing authority.
    Explain,
    /// Gather evidence, using tools under the session's existing authority.
    Research,
    /// Read/recover and update only the harness-owned plan ledger; workspace,
    /// execution, network, capability-grant, and generic MCP mutation paths
    /// remain unavailable.
    Plan,
}

impl PromptDisposition {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Ask => "ask",
            Self::Act => "act",
            Self::Explain => "explain",
            Self::Research => "research",
            Self::Plan => "plan",
        }
    }

    /// Response style does not change the operator's configured allowance.
    /// `Ask` is terminal in the TUI and gets a zero-round defense in depth for
    /// headless callers.
    pub fn tool_round_limit(self, max: usize) -> usize {
        match self {
            Self::Ask => 0,
            Self::Act | Self::Explain | Self::Research | Self::Plan => max,
        }
    }
}

/// What one extracted clause *is* — an instruction to act on, or a fact the
/// operator stated (#1971).
///
/// Splitting clauses without asking this question is how a pure FYI became one
/// atomic ask with `disposition=act` and the full round budget: the intake had
/// no vocabulary for "the operator told me something" as distinct from "the
/// operator asked me to do something".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AskKind {
    /// Something to do. The historical and still-default reading.
    Instruction,
    /// Something stated. Carries content the turn must not lose, and carries
    /// **no authorization**.
    Informational,
}

impl AskKind {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Instruction => "instruction",
            Self::Informational => "informational",
        }
    }
}

/// One bounded clause extracted from a monolithic prompt. Text remains only in
/// memory, in the durable prompt receipt, and — for an informational clause
/// only — in the durable artifact; see [`PromptIntake::artifact_metadata`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AtomicAsk {
    text: String,
    kind: AskKind,
}

impl AtomicAsk {
    pub fn text(&self) -> &str {
        &self.text
    }

    /// Whether this clause instructs or merely states.
    pub fn kind(&self) -> AskKind {
        self.kind
    }

    pub fn is_informational(&self) -> bool {
        self.kind == AskKind::Informational
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionStatus {
    Pending,
    Locked,
}

impl DecisionStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Locked => "locked",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionSource {
    Operator,
    Policy,
    AuthorizedAssumption,
}

impl DecisionSource {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Operator => "operator",
            Self::Policy => "policy",
            Self::AuthorizedAssumption => "authorized_assumption",
        }
    }
}

#[derive(Debug, Clone)]
pub struct DecisionLock {
    question: String,
    status: DecisionStatus,
    source: Option<DecisionSource>,
    /// The explicit interpretation the agent will proceed under. Present iff
    /// the lock source is [`DecisionSource::AuthorizedAssumption`]: an
    /// assumption may never be silently inferred, so a lock without stated
    /// text is invalid and [`PromptIntake::validate`] rejects it.
    assumption: Option<String>,
    /// An intake-bound overflow is not a decision the operator can answer in
    /// place. The operator must split the request before execution can resume.
    overflow: bool,
    /// The operator-supplied text this lock resolved to, when one is known —
    /// a classifier proposal's summary, or the `value` half of an explicit
    /// `N: value` reply (#2515 items 5/6 round 2). `None` when the reply gave
    /// no text (a bare `1:`) or the lock has another source; display falls
    /// back to `question` in that case.
    answer: Option<String>,
}

impl DecisionLock {
    pub fn question(&self) -> &str {
        &self.question
    }

    pub fn status(&self) -> DecisionStatus {
        self.status
    }

    pub fn source(&self) -> Option<DecisionSource> {
        self.source
    }

    pub fn is_overflow(&self) -> bool {
        self.overflow
    }

    /// The recorded assumption for an [`DecisionSource::AuthorizedAssumption`]
    /// lock. `None` for pending decisions and for operator answers — auditing
    /// must be able to tell a stated operator answer from a model-adjudicated
    /// assumption, so the two never share a representation.
    pub fn assumption(&self) -> Option<&str> {
        self.assumption.as_deref()
    }

    /// The confirmed answer text for a lock, falling back to the question
    /// when no answer text is known (#2515 items 5/6 round 2).
    pub fn answer_or_question(&self) -> &str {
        self.answer.as_deref().unwrap_or(&self.question)
    }
}

/// One candidate handed to the adjudicator. `id` is the 1-based ordinal the
/// model must echo back; it indexes the CANDIDATE list, never the decision
/// vector, so a model reply can never address a decision that was not offered.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdjudicationCandidate {
    pub id: usize,
    pub question: String,
}

/// One adjudicated verdict. This is the entire authority the model has: it may
/// say "the operator delegated this, and here is the interpretation I will
/// proceed under". It cannot lock, cannot unlock, and cannot name a decision
/// that was not in the candidate batch.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct AdjudicationVerdict {
    pub decision_id: usize,
    #[serde(default)]
    pub delegated_to_agent: bool,
    #[serde(default)]
    pub assumption: String,
}

/// Why an adjudication batch was refused wholesale. Fail-closed: the candidates
/// stay `Pending` and the operator is asked.
///
/// A side call that errors, times out, or returns malformed output is NOT
/// represented here — that is handled where the call is made, by returning the
/// intake untouched. This type is only for a batch the harness refuses to send.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdjudicationRefusal {
    /// More candidates than one bounded side call may adjudicate.
    BatchTooLarge { candidates: usize, bound: usize },
}

/// Content-bearing in-memory comprehension result. Its public accessors expose
/// counts and bounded asks for UI rendering; [`PromptIntake::artifact_metadata`]
/// is the text-free persistence projection.
#[derive(Debug, Clone)]
pub struct PromptComprehensionManifest {
    atomic_asks: Vec<AtomicAsk>,
    decisions: Vec<DecisionLock>,
}

impl PromptComprehensionManifest {
    pub fn atomic_asks(&self) -> &[AtomicAsk] {
        &self.atomic_asks
    }

    /// The clauses that STATE rather than ask (#1971).
    pub fn informational_asks(&self) -> impl Iterator<Item = &AtomicAsk> {
        self.atomic_asks.iter().filter(|a| a.is_informational())
    }

    pub fn decision_count(&self) -> usize {
        self.decisions.len()
    }

    pub fn decisions(&self) -> &[DecisionLock] {
        &self.decisions
    }

    pub fn pending_decision_count(&self) -> usize {
        self.decisions
            .iter()
            .filter(|decision| decision.status == DecisionStatus::Pending)
            .count()
    }

    pub fn locked_decision_count(&self) -> usize {
        self.decisions
            .iter()
            .filter(|decision| decision.status == DecisionStatus::Locked)
            .count()
    }
}

/// The validated intake passed through the harness for one turn.
#[derive(Debug, Clone)]
pub struct PromptIntake {
    manifest: PromptComprehensionManifest,
    disposition: PromptDisposition,
    /// The disposition selected from the task itself. An unresolved decision
    /// temporarily changes the live disposition to `Ask`; an explicit answer
    /// restores this value once every decision is locked.
    post_lock_disposition: PromptDisposition,
    /// Whether the live disposition is the lexicon's reading of the prompt or
    /// a session policy's narrowing of it. Read by the model card only.
    source: DispositionSource,
    /// Why the LAST clarification reply failed to lock the batch, if it did.
    ///
    /// #1689 item 1. Set only by [`Self::resolve_with_operator_answer`]; a
    /// freshly analyzed intake has none. The harness reads it to say what was
    /// wrong instead of re-emitting the identical block, which is the whole
    /// difference between a blocked session and one that looks hung.
    last_rejection: Option<ClarificationRejection>,
    /// Free text the operator wants to talk through before locking anything,
    /// set by a `/discuss …` reply to a pending clarification. Not a decision
    /// answer: no ordinal is resolved and no state locks. The harness reads
    /// this to run one bounded, tool-less side call and show the operator the
    /// model's answer, then clears it — the batch stays pending underneath.
    pending_discussion: Option<String>,
    /// A classifier's read of an operator reply that carried no explicit
    /// ordinal, offered but not yet acted on (#2517 — "confirm-then-lock").
    ///
    /// Single-shot: [`Self::resolve_with_operator_answer`] either locks it
    /// (the very next reply is a bare affirmation) or discards it (anything
    /// else — including a reply that looks like a NEW attempt at an answer).
    /// It never survives a second turn and never locks on its own; only an
    /// operator confirmation following it can. This is what keeps a
    /// classifier's guess from becoming the same silent inference the
    /// harness has always refused to do from a bare "continue" — the guess
    /// is shown, named, and requires its own explicit yes.
    proposed_answer: Option<ProposedAnswer>,
    /// The proposal that was outstanding when the LAST refusal happened, if
    /// any (#2517 follow-up, addendum item 5). A classifier's proposal is
    /// consumed unconditionally by the next reply (see
    /// [`Self::resolve_with_operator_answer`]), so a reply that fails to
    /// parse as an ordinal — `NoOrdinals` / `ReadsAsQuestion` — silently drops
    /// it with nothing said. This mirrors it into the refusal text instead of
    /// letting the operator lose track of whether it is still on the table.
    last_rejection_proposal: Option<ProposedAnswer>,
}

/// A single classifier-proposed decision, named for display in
/// [`PromptIntake::proposed_answer_notice`] and resolved back to a `decisions`
/// index in [`PromptIntake::resolve_with_operator_answer`].
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProposedAnswer {
    /// Absolute index into `manifest.decisions` — NOT the displayed ordinal,
    /// which shifts as other decisions lock (same reasoning as
    /// `pending_indices`).
    index: usize,
    /// A short, human-readable label for what the classifier read the reply
    /// as choosing, shown back to the operator so a wrong guess is obvious
    /// before it can be confirmed.
    summary: String,
}

/// Exact-match affirmations that confirm a pending classifier proposal.
/// Deliberately a closed, exact-match list rather than a substring scan —
/// the classifier already did the semantic work; this step is a narrow,
/// auditable lexical check, not a second round of inference.
const CONFIRMATION_WORDS: &[&str] = &[
    "y",
    "yes",
    "yes.",
    "yep",
    "yeah",
    "yup",
    "correct",
    "right",
    "confirm",
    "confirmed",
    "ok",
    "okay",
    "sure",
    "do it",
    "go ahead",
    "proceed",
    "that one",
    "that's right",
    "sounds right",
];

impl PromptIntake {
    /// Analyze a new operator prompt before any model-visible work begins,
    /// classifying its disposition against `lexicon` (#1260 — the operator's
    /// `[intake]` overrides; [`DispositionLexicon::default`] = built-ins).
    pub fn analyze_with(prompt: &str, lexicon: &DispositionLexicon) -> Self {
        let mut intake = Self::analyze(prompt);
        if prompt.trim().is_empty() {
            return intake; // the empty-prompt Ask terminal is not lexicon-driven
        }
        // Re-derive the lexicon-driven part. Ask KINDS are lexicon-driven too
        // (#1971), so the asks are re-extracted rather than reused; decisions
        // depend only on ask text, which the re-extraction reproduces.
        let (asks, _) = extract_atomic_asks_with(prompt, lexicon);
        intake.post_lock_disposition = infer_disposition_with(prompt, &asks, lexicon);
        intake.manifest.atomic_asks = asks;
        if intake.disposition != PromptDisposition::Ask {
            intake.disposition = intake.post_lock_disposition;
        }
        debug_assert!(intake.validate().is_ok());
        intake
    }

    /// Comprehend a bare continuation that resumes an accepted objective
    /// (#2332): the nudge's own asks, with the objective's recorded
    /// `disposition`, so a one-word nudge neither widens nor narrows the task.
    /// `Ask` is never an accepted objective's authority; given one, or a nudge
    /// that itself needs a decision, the nudge's own reading stands.
    pub fn resume_with(
        nudge: &str,
        disposition: PromptDisposition,
        lexicon: &DispositionLexicon,
    ) -> Self {
        let mut intake = Self::analyze_with(nudge, lexicon);
        if disposition != PromptDisposition::Ask && intake.disposition != PromptDisposition::Ask {
            intake.disposition = disposition;
            intake.post_lock_disposition = disposition;
        }
        debug_assert!(intake.validate().is_ok());
        intake
    }

    /// Analyze a new operator prompt before any model-visible work begins.
    pub fn analyze(prompt: &str) -> Self {
        if prompt.trim().is_empty() {
            let intake = Self {
                manifest: PromptComprehensionManifest {
                    atomic_asks: vec![AtomicAsk {
                        text: "(empty operator prompt)".to_string(),
                        kind: AskKind::Instruction,
                    }],
                    decisions: vec![DecisionLock {
                        question: "Provide a non-empty task before execution.".to_string(),
                        status: DecisionStatus::Pending,
                        source: None,
                        assumption: None,
                        overflow: false,
                        answer: None,
                    }],
                },
                disposition: PromptDisposition::Ask,
                post_lock_disposition: PromptDisposition::Explain,
                source: DispositionSource::Inferred,
                last_rejection: None,
                pending_discussion: None,
                proposed_answer: None,
                last_rejection_proposal: None,
            };
            debug_assert!(intake.validate().is_ok());
            return intake;
        }
        let (atomic_asks, atomic_overflow) = extract_atomic_asks(prompt);
        let post_lock_disposition = infer_disposition(prompt, &atomic_asks);
        let (mut decisions, decision_overflow) = extract_decisions(&atomic_asks);
        if atomic_overflow || decision_overflow {
            decisions.push(overflow_decision());
        }
        let disposition = if decisions
            .iter()
            .any(|decision| decision.status == DecisionStatus::Pending)
        {
            PromptDisposition::Ask
        } else {
            post_lock_disposition
        };
        let intake = Self {
            manifest: PromptComprehensionManifest {
                atomic_asks,
                decisions,
            },
            disposition,
            post_lock_disposition,
            source: DispositionSource::Inferred,
            last_rejection: None,
            pending_discussion: None,
            proposed_answer: None,
            last_rejection_proposal: None,
        };
        debug_assert!(intake.validate().is_ok());
        intake
    }

    pub fn disposition(&self) -> PromptDisposition {
        self.disposition
    }

    /// Select an explicit non-action disposition for this accepted intake.
    ///
    /// Operating modes are applied after deterministic prompt intake. They may
    /// choose `Explain`, `Research`, or `Plan`, but must never turn a pending
    /// `Ask` into executable work or select `Act`. Updating both live and
    /// post-lock state keeps the model card, durable artifact, advertised
    /// catalog, and dispatcher on one effective disposition.
    pub fn enforce_read_only(&mut self, disposition: PromptDisposition) {
        if !matches!(
            disposition,
            PromptDisposition::Explain | PromptDisposition::Research | PromptDisposition::Plan
        ) {
            debug_assert!(false, "read-only disposition required");
            return;
        }
        if self.disposition != PromptDisposition::Ask {
            self.disposition = disposition;
            self.post_lock_disposition = disposition;
            // The narrowing came from a session setting, not the prompt; the
            // card must not claim the operator had no hand in it.
            self.source = DispositionSource::SessionPolicy;
        }
        debug_assert!(self.validate().is_ok());
    }

    /// Whether the live disposition was inferred from the prompt or narrowed
    /// by a session policy the operator set.
    pub fn disposition_source(&self) -> DispositionSource {
        self.source
    }

    pub fn atomic_asks(&self) -> &[AtomicAsk] {
        self.manifest.atomic_asks()
    }

    pub fn manifest(&self) -> &PromptComprehensionManifest {
        &self.manifest
    }

    /// Render the complete bounded clarification batch for the operator. This
    /// content intentionally never enters model/system context or artifact
    /// metadata; it is presented directly by the harness and the turn ends.
    pub fn clarification_batch(&self) -> String {
        let pending = self.pending_indices();
        if pending.is_empty() {
            return String::new();
        }

        // #1689 item 3: singular phrasing for a single item. "these decisions"
        // and "every item" over a one-item list is what made a COMPLETE batch
        // read as a truncated one, and sent an operator hunting for output that
        // had never been withheld.
        let mut rendered = String::from(if pending.len() == 1 {
            "I need this decision locked before I can execute. Reply with an explicit ordinal, for example `1: …`:\n"
        } else {
            "I need these decisions locked before I can execute. Reply using an explicit ordinal for every item, for example `1: …`:\n"
        });
        // #1689 item 4: ordinals come from the SAME mapping the resolver reads
        // (`pending_indices`), so a displayed "3." is always the "3:" that
        // resolves. The old code enumerated BEFORE filtering, numbering by
        // absolute position in `decisions` while the resolver indexed into the
        // pending-only slice. Unreachable today only because locking is
        // all-or-nothing; the moment anything locks a decision on its own — the
        // policy resolver the comment below anticipates — the two diverge, and
        // the gate starts displaying "3." while accepting only "1:".
        for (ordinal, index) in pending.iter().enumerate() {
            let decision = &self.manifest.decisions[*index];
            let question = truncate_chars(&decision.question, MAX_CLARIFICATION_BYTES);
            rendered.push_str(&format!("{}. {}\n", ordinal + 1, question));
        }
        // #2517: named on every batch, not only after a rejection — the
        // operator's first reaction to a locked-format prompt is often to
        // want to talk about it, not to retype it correctly.
        rendered.push_str(
            "(`/discuss <what's on your mind>` talks it through first — nothing here is lost.)\n",
        );
        rendered.trim_end().to_string()
    }

    /// Why the last clarification reply was refused, if it was (#1689 item 1).
    ///
    /// `None` on a freshly analyzed intake and after a reply that locked the
    /// batch. The harness prints [`ClarificationRejection::explain`] above the
    /// re-emitted batch so a second identical block is never the whole
    /// response to a rejected answer.
    pub fn last_rejection(&self) -> Option<&ClarificationRejection> {
        self.last_rejection.as_ref()
    }

    /// Take the pending `/discuss` text, if a reply asked to talk it through
    /// instead of answering. Clears it: the harness runs one bounded side
    /// call in response, and a stale discussion must never replay itself on
    /// the next unrelated rejection.
    pub fn take_pending_discussion(&mut self) -> Option<String> {
        self.pending_discussion.take()
    }

    /// Offer a classifier's read of a non-explicit reply, naming which
    /// PENDING decision (displayed ordinal, 1-based) it believes the operator
    /// meant and a short label for what that choice was. Ignored — returns an
    /// unchanged clone — if `ordinal` is not currently pending: a stale or
    /// out-of-range proposal must never silently attach to the wrong item.
    ///
    /// This only OFFERS; nothing locks until [`Self::resolve_with_operator_answer`]
    /// sees the operator's own next reply confirm it (#2517).
    pub fn propose_answer(&self, ordinal: usize, summary: impl Into<String>) -> Self {
        let mut proposed = self.clone();
        let pending = proposed.pending_indices();
        let Some(index) = ordinal.checked_sub(1).and_then(|i| pending.get(i).copied()) else {
            return proposed;
        };
        proposed.proposed_answer = Some(ProposedAnswer {
            index,
            summary: summary.into(),
        });
        proposed
    }

    /// One line naming the classifier's proposal and how to confirm or
    /// override it, or `None` if nothing is proposed. Shown in place of the
    /// ordinary rejection — a proposal is progress, not a refusal.
    pub fn proposed_answer_notice(&self) -> Option<String> {
        let proposal = self.proposed_answer.as_ref()?;
        let ordinal = self.proposal_ordinal(proposal);
        Some(format!(
            "I read that as choosing {ordinal}: \"{}\" — reply `yes` to lock it, \
             or answer explicitly (`{ordinal}: …`) if that's not right.",
            proposal.summary
        ))
    }

    /// The displayed ordinal (1-based) a proposal's absolute `decisions`
    /// index currently maps to. Shared by [`Self::proposed_answer_notice`]
    /// and [`Self::last_rejection_explanation`] so the two can never disagree
    /// about which numbered item a proposal names.
    fn proposal_ordinal(&self, proposal: &ProposedAnswer) -> usize {
        self.pending_indices()
            .iter()
            .position(|&i| i == proposal.index)
            .map_or(0, |p| p + 1)
    }

    /// Why the last clarification reply was refused, PLUS a reminder that a
    /// classifier's proposal is still on the table when one was outstanding
    /// at the time (addendum item 5) — `None` when nothing was refused.
    ///
    /// This is the one place refusal text is assembled; the harness prints
    /// this instead of calling [`ClarificationRejection::explain`] directly,
    /// so the proposal reminder can never be added at one call site and
    /// missed at another.
    pub fn last_rejection_explanation(&self) -> Option<String> {
        let rejection = self.last_rejection.as_ref()?;
        let mut text = rejection.explain();
        if let Some(proposal) = self.last_rejection_proposal.as_ref() {
            let ordinal = self.proposal_ordinal(proposal);
            text.push_str(&format!(
                "\n(the reading I proposed is still waiting — reply `yes` to take it: \
                 {ordinal}: \"{}\".)",
                proposal.summary
            ));
        }
        Some(text)
    }

    /// Addendum item 6: one "locked N: answer" line per decision that was
    /// `Pending` in `previous` and is `Locked` in `self` — the confirmation
    /// printed for the one moment that changes state, so a wrong lock is
    /// visible before the turn proceeds. The ordinal is `previous`'s own
    /// displayed numbering (the same mapping [`Self::clarification_batch`]
    /// rendered when the operator answered it), never renumbered against
    /// `self`, where a lock has already removed the item from "pending".
    ///
    /// #2515 items 5/6 round 2: names the ANSWER a proposal or an explicit
    /// `N: value` supplied, not the question, so the confirmation reads as
    /// "what you got" rather than "what was asked" — falling back to the
    /// question only when no answer text is known ([`DecisionLock::answer_or_question`]).
    pub fn newly_locked_lines(&self, previous: &Self) -> Vec<String> {
        previous
            .pending_indices()
            .iter()
            .enumerate()
            .filter_map(|(position, &index)| {
                let decision = self.manifest.decisions.get(index)?;
                (decision.status == DecisionStatus::Locked)
                    .then(|| format!("locked {}: {}", position + 1, decision.answer_or_question()))
            })
            .collect()
    }

    /// The absolute `decisions` indices that are still pending, in order.
    ///
    /// #1689 item 4. This is the ONE mapping between a displayed ordinal and a
    /// decision: position `n` here is the ordinal `n + 1` the operator types,
    /// for both [`Self::clarification_batch`] and `explicit_answer_indices`.
    /// Keeping render and resolve on one function is what makes them unable to
    /// disagree, rather than merely observed to agree.
    fn pending_indices(&self) -> Vec<usize> {
        self.manifest
            .decisions
            .iter()
            .enumerate()
            .filter_map(|(index, decision)| {
                (decision.status == DecisionStatus::Pending).then_some(index)
            })
            .collect()
    }

    /// Resolve only explicit operator answers against this pending manifest.
    ///
    /// Every pending decision requires an explicit ordinal (`1: …`, `2: …`).
    /// This deliberately rejects acknowledgements such as `continue`: an LLM
    /// must never infer a decision value merely because the operator resumed
    /// the conversation.
    pub fn resolve_with_operator_answer(&self, answer: &str) -> Self {
        // An empty first receipt has no task semantics to preserve. Its direct
        // clarification answer is therefore a new operator task under the
        // same prompt root and must receive a fresh, fail-closed intake.
        if self
            .manifest
            .atomic_asks
            .first()
            .is_some_and(|ask| ask.text == "(empty operator prompt)")
        {
            return Self::analyze(answer);
        }
        // Overflow means the original request exceeded the bounded intake
        // representation. No answer can safely prove every omitted ask is
        // resolved, so retain Ask until the operator starts a smaller task.
        if self
            .manifest
            .decisions
            .iter()
            .any(DecisionLock::is_overflow)
        {
            return self.clone();
        }
        let mut resolved = self.clone();
        // Addendum item 5: cleared up front so a stale proposal from an
        // EARLIER rejection's explanation never survives into this one's.
        resolved.last_rejection_proposal = None;
        // #2515 items 5/6 round 2: a classifier's proposal now survives a
        // refusal — it is cleared ONLY on lock, on an explicit reply that
        // gives real ordinals (a "new attempt", Ok or Incomplete/OutOfRange),
        // or by `propose_answer` replacing it outright. A bare confirmation
        // word locks it on ANY turn it is still outstanding, not only the
        // very next one.
        let outstanding_proposal = resolved.proposed_answer.clone();
        if let Some(proposal) = outstanding_proposal.clone() {
            let normalized = answer.trim().to_ascii_lowercase();
            if CONFIRMATION_WORDS.contains(&normalized.as_str()) {
                resolved.proposed_answer = None;
                if let Some(decision) = resolved.manifest.decisions.get_mut(proposal.index) {
                    decision.status = DecisionStatus::Locked;
                    decision.source = Some(DecisionSource::Operator);
                    decision.answer = Some(proposal.summary);
                }
                resolved.last_rejection = None;
                resolved.disposition = if resolved.manifest.pending_decision_count() == 0 {
                    resolved.post_lock_disposition
                } else {
                    PromptDisposition::Ask
                };
                debug_assert!(resolved.validate().is_ok());
                return resolved;
            }
        }
        // #2517: the escape hatch. A `/discuss …` reply is never an answer —
        // it asks to talk the batch through before committing to one. This is
        // checked before the ordinal parser so a discussion request can never
        // itself be misread as a malformed answer and rejected; nothing locks
        // and the batch stays exactly as pending as it was, proposal included.
        if let Some(text) = discussion_request(answer) {
            resolved.last_rejection = None;
            resolved.pending_discussion = Some(text);
            debug_assert!(resolved.validate().is_ok());
            return resolved;
        }
        // #1689 item 4: the same mapping `clarification_batch` renders from.
        let pending = resolved.pending_indices();

        match explicit_answer_outcome(answer, &pending) {
            Ok(indices) => {
                // A real ordinal reply is a new attempt — whatever it names,
                // an outstanding proposal is no longer the operator's most
                // recent word on the batch.
                resolved.proposed_answer = None;
                for (index, value) in indices {
                    let decision = &mut resolved.manifest.decisions[index];
                    decision.status = DecisionStatus::Locked;
                    decision.source = Some(DecisionSource::Operator);
                    decision.answer = value;
                }
                resolved.last_rejection = None;
            }
            // #1689 item 1: carry WHY, so the harness can say something other
            // than the identical block a second time.
            Err(rejection) => {
                // Addendum item 5: only the two kinds a bare, non-ordinal
                // reply produces (`NoOrdinals`/`ReadsAsQuestion`) are the ones
                // a classifier proposal was offered against — `Incomplete`
                // and `OutOfRange` mean the operator DID give ordinals (a new
                // attempt), so the proposal is discarded same as the Ok arm;
                // there is nothing ambiguous for it to still apply to.
                if matches!(
                    rejection,
                    ClarificationRejection::NoOrdinals | ClarificationRejection::ReadsAsQuestion
                ) {
                    // The proposal is a non-ordinal reply's refusal too, so it
                    // stays in `resolved.proposed_answer` (never taken above)
                    // for a later `yes` to confirm; mirrored here only for
                    // THIS turn's refusal text.
                    resolved.last_rejection_proposal = outstanding_proposal;
                } else {
                    resolved.proposed_answer = None;
                }
                resolved.last_rejection = Some(rejection);
            }
        }

        resolved.disposition = if resolved.manifest.pending_decision_count() == 0 {
            resolved.post_lock_disposition
        } else {
            PromptDisposition::Ask
        };
        debug_assert!(resolved.validate().is_ok());
        resolved
    }

    /// The pending, non-overflow decisions offered to the adjudicator, in
    /// order. The heuristic alone decides what appears here; adjudication is
    /// strictly a filter over this list (#1749).
    pub fn adjudication_candidates(&self) -> Vec<AdjudicationCandidate> {
        self.candidate_indices()
            .into_iter()
            .enumerate()
            .map(|(ordinal, index)| AdjudicationCandidate {
                id: ordinal + 1,
                question: self.manifest.decisions[index].question.clone(),
            })
            .collect()
    }

    /// Absolute `decisions` indices eligible for adjudication.
    fn candidate_indices(&self) -> Vec<usize> {
        self.manifest
            .decisions
            .iter()
            .enumerate()
            .filter_map(|(index, decision)| {
                (decision.status == DecisionStatus::Pending && !decision.overflow).then_some(index)
            })
            .collect()
    }

    /// Apply model verdicts under harness-owned rules. The model proposes; this
    /// function is the only thing that may move state, and the ONLY transition
    /// it can perform is `Pending -> Locked(AuthorizedAssumption)`:
    ///
    /// * a `decision_id` outside the candidate batch is discarded;
    /// * `delegated_to_agent: false` leaves the decision pending;
    /// * an empty/whitespace assumption is refused — an assumption is never
    ///   silently inferred (#1749);
    /// * an already-locked decision is never touched, so a model cannot
    ///   overwrite or re-source an operator answer;
    /// * a duplicate `decision_id` after the first is discarded.
    ///
    /// An oversized batch refuses wholesale rather than adjudicating a prefix.
    pub fn apply_adjudications(
        &self,
        verdicts: &[AdjudicationVerdict],
    ) -> Result<Self, AdjudicationRefusal> {
        let candidates = self.candidate_indices();
        if candidates.len() > MAX_ADJUDICATION_BATCH {
            return Err(AdjudicationRefusal::BatchTooLarge {
                candidates: candidates.len(),
                bound: MAX_ADJUDICATION_BATCH,
            });
        }

        let mut resolved = self.clone();
        let mut seen = BTreeSet::new();
        for verdict in verdicts {
            if !verdict.delegated_to_agent {
                continue;
            }
            let assumption = verdict.assumption.trim();
            if assumption.is_empty() {
                continue;
            }
            let Some(index) = verdict
                .decision_id
                .checked_sub(1)
                .and_then(|ordinal| candidates.get(ordinal).copied())
            else {
                continue;
            };
            if !seen.insert(index) {
                continue;
            }
            let decision = &mut resolved.manifest.decisions[index];
            debug_assert_eq!(decision.status, DecisionStatus::Pending);
            decision.status = DecisionStatus::Locked;
            decision.source = Some(DecisionSource::AuthorizedAssumption);
            decision.assumption = Some(
                truncate_chars(assumption, MAX_CLARIFICATION_BYTES)
                    .trim()
                    .to_string(),
            );
        }

        resolved.disposition = if resolved.manifest.pending_decision_count() == 0 {
            resolved.post_lock_disposition
        } else {
            PromptDisposition::Ask
        };
        debug_assert!(resolved.validate().is_ok());
        Ok(resolved)
    }

    /// Absolute indices of model-authorized locks, in order. Position `n` here
    /// is the ordinal `n + 1` the operator types to `/undo-lock`. It is its own
    /// numbering — deliberately NOT shared with the clarification ordinals,
    /// which the resolver reads (#1689 item 4).
    fn assumption_indices(&self) -> Vec<usize> {
        self.manifest
            .decisions
            .iter()
            .enumerate()
            .filter_map(|(index, decision)| {
                (decision.source == Some(DecisionSource::AuthorizedAssumption)).then_some(index)
            })
            .collect()
    }

    /// One operator-facing line per model-authorized assumption. An assumption
    /// the operator never sees is indistinguishable from a silent guess, so
    /// every lock states its interpretation and how to reopen it.
    pub fn authorized_assumption_notices(&self) -> Vec<String> {
        self.assumption_indices()
            .into_iter()
            .enumerate()
            .filter_map(|(ordinal, index)| {
                let assumption = self.manifest.decisions[index].assumption.as_deref()?;
                Some(format!(
                    "Assuming: {assumption} — `/undo-lock {}` to reopen",
                    ordinal + 1
                ))
            })
            .collect()
    }

    /// Reopen a model-authorized assumption by its `/undo-lock` ordinal.
    /// Operator answers are NOT reversible this way: `/undo-lock` exists to
    /// undo the harness's own inference, not to discard what the operator said.
    pub fn undo_lock(&self, ordinal: usize) -> Option<Self> {
        let index = *self.assumption_indices().get(ordinal.checked_sub(1)?)?;
        let mut reopened = self.clone();
        let decision = &mut reopened.manifest.decisions[index];
        decision.status = DecisionStatus::Pending;
        decision.source = None;
        decision.assumption = None;
        reopened.disposition = PromptDisposition::Ask;
        debug_assert!(reopened.validate().is_ok());
        Some(reopened)
    }

    /// Test-only: append `extra` pending candidates beyond what the parser can
    /// produce. `MAX_CONCRETE_DECISIONS` already caps `analyze` at
    /// `MAX_ADJUDICATION_BATCH`, so the adjudication bound is defense in depth
    /// against a future parser bound raise — unreachable through parsing, and
    /// therefore only provable through a direct constructor.
    #[cfg(test)]
    pub(super) fn with_extra_pending_candidates(&self, extra: usize) -> Self {
        let mut widened = self.clone();
        for index in 0..extra {
            widened.manifest.decisions.push(DecisionLock {
                question: format!("Choose the smallest fix for extra-module-{index}."),
                status: DecisionStatus::Pending,
                source: None,
                assumption: None,
                overflow: false,
                answer: None,
            });
        }
        widened.disposition = PromptDisposition::Ask;
        widened
    }

    /// Count of locks whose authority is model adjudication rather than an
    /// operator answer. Auditing must be able to tell the two apart.
    pub fn authorized_assumption_count(&self) -> usize {
        self.assumption_indices().len()
    }

    /// Content-free model projection placed inside the protected active-prompt
    /// card. It contains no prompt, decision, or clarification text.
    pub fn model_card(&self) -> String {
        let pending = self.manifest.pending_decision_count();
        let locked = self.manifest.locked_decision_count();
        // #2051: one owner for every sentence the harness says about the
        // disposition. The block carries the action line plus the provenance
        // and privacy clauses — without them a small model reads the action
        // line as an operator-imposed rule and reports its compliance.
        let instruction =
            super::DispositionVoices::default().card_block(self.disposition, self.source);
        let prompt = self
            .manifest
            .atomic_asks
            .iter()
            .map(AtomicAsk::text)
            .collect::<Vec<_>>()
            .join("\n");
        let refinement = request_refinement_model_card(&prompt);
        let mut card = format!(
            "{PROMPT_COMPREHENSION_MODEL_CARD_PREFIX}\n\
             disposition: {}\n\
             atomic_ask_count: {}\n\
             decision_count: {}\n\
             pending_decision_count: {pending}\n\
             locked_decision_count: {locked}\n\
             {instruction}",
            self.disposition.as_str(),
            self.manifest.atomic_asks.len(),
            self.manifest.decisions.len(),
        );
        if !refinement.is_empty() {
            card.push('\n');
            card.push_str(&refinement);
        }
        // #1971: the stated facts, VERBATIM and named as facts.
        //
        // This is the one place the card is not content-free, and the exception
        // is the whole point of the fix. A clause the harness has decided
        // carries no authorization is exactly the clause most likely to be
        // dropped — the evidenced session turned a 92-byte statement into
        // `atomic_ask_count: 1` and nothing else, and the fact it stated was
        // never acted on or mentioned again. A count cannot be read back; the
        // text can.
        let noted: Vec<&str> = self
            .manifest
            .informational_asks()
            .map(AtomicAsk::text)
            .collect();
        if !noted.is_empty() {
            card.push_str(
                "\nnoted_facts: the operator STATED the following; they carry no request to act\n",
            );
            for fact in noted {
                card.push_str("  - ");
                card.push_str(fact);
                card.push('\n');
            }
            card.push_str(
                "noted_instruction: acknowledge these in your reply and carry them forward; \
                 do not treat them as authorization to mutate anything",
            );
        }
        card
    }

    /// Exact persistence projection for a bodyless `Decision` artifact. The
    /// values are scalar counts or BLAKE3 digests; raw prompt-derived text is
    /// intentionally absent.
    pub fn artifact_metadata(&self) -> Value {
        let mut status_counts = serde_json::Map::new();
        status_counts.insert(
            DecisionStatus::Pending.as_str().to_string(),
            Value::from(self.manifest.pending_decision_count() as u64),
        );
        status_counts.insert(
            DecisionStatus::Locked.as_str().to_string(),
            Value::from(self.manifest.locked_decision_count() as u64),
        );

        let mut source_counts = serde_json::Map::new();
        for source in [
            DecisionSource::Operator,
            DecisionSource::Policy,
            DecisionSource::AuthorizedAssumption,
        ] {
            let count = self
                .manifest
                .decisions
                .iter()
                .filter(|decision| decision.source == Some(source))
                .count();
            source_counts.insert(source.as_str().to_string(), Value::from(count as u64));
        }

        json!({
            "schema": PROMPT_COMPREHENSION_SCHEMA_CURRENT,
            "disposition": self.disposition.as_str(),
            "atomic_ask_count": self.manifest.atomic_asks.len() as u64,
            "clarification_count": self.manifest.pending_decision_count() as u64,
            "decision_count": self.manifest.decisions.len() as u64,
            "decision_status_counts": Value::Object(status_counts),
            "decision_source_counts": Value::Object(source_counts),
            "informational_ask_count": self.manifest.informational_asks().count() as u64,
            // #1971. Every other field here is a count or a digest, and that
            // rule is kept for everything an operator DECIDED. Stated facts are
            // the deliberate exception, for three reasons:
            //
            // * a digest cannot be read back, so the durable record of a
            //   dropped fact could not say what was dropped — the artifact that
            //   evidenced this bug recorded `atomic_ask_count=1, bytes=92` and
            //   the fact itself was unrecoverable;
            // * this duplicates nothing: `prompt_receipts.raw_text` already
            //   persists the entire prompt verbatim in the same database, so
            //   the content-free rule was never a privacy boundary here;
            // * it is bounded — informational clauses only, already truncated
            //   to MAX_ASK_BYTES by extraction.
            "informational_asks": self
                .manifest
                .informational_asks()
                .map(|ask| Value::from(ask.text()))
                .collect::<Vec<_>>(),
            "atomic_ask_kinds": self
                .manifest
                .atomic_asks
                .iter()
                .map(|ask| Value::from(ask.kind().as_str()))
                .collect::<Vec<_>>(),
            "atomic_ask_digests": self
                .manifest
                .atomic_asks
                .iter()
                .map(|ask| digest_metadata(&ask.text))
                .collect::<Vec<_>>(),
            "clarification_digests": self
                .manifest
                .decisions
                .iter()
                .filter(|decision| decision.status == DecisionStatus::Pending)
                .map(|decision| digest_metadata(&decision.question))
                .collect::<Vec<_>>(),
            "authorized_assumption_digests": self
                .manifest
                .decisions
                .iter()
                .filter_map(|decision| decision.assumption.as_deref())
                .map(digest_metadata)
                .collect::<Vec<_>>(),
        })
    }

    /// Validate the bounded invariants relied upon by the model-card and
    /// artifact writers. This is public for tests and alternate harnesses.
    pub fn validate(&self) -> Result<(), String> {
        if self.manifest.atomic_asks.len() > MAX_ATOMIC_ASKS {
            return Err("atomic ask count exceeds the intake bound".to_string());
        }
        if self.manifest.decisions.len() > MAX_DECISIONS {
            return Err("decision count exceeds the intake bound".to_string());
        }
        if self.post_lock_disposition == PromptDisposition::Ask {
            return Err("post-lock disposition cannot be ask".to_string());
        }
        for ask in &self.manifest.atomic_asks {
            if ask.text.is_empty() || ask.text.len() > MAX_ASK_BYTES {
                return Err("atomic ask is empty or exceeds the intake bound".to_string());
            }
        }
        for decision in &self.manifest.decisions {
            if decision.question.is_empty() || decision.question.len() > MAX_ASK_BYTES {
                return Err("decision text is empty or exceeds the intake bound".to_string());
            }
            match (decision.status, decision.source) {
                (DecisionStatus::Pending, None) | (DecisionStatus::Locked, Some(_)) => {}
                (DecisionStatus::Pending, Some(_)) => {
                    return Err("pending decision has a lock source".to_string())
                }
                (DecisionStatus::Locked, None) => {
                    return Err("locked decision lacks a source".to_string())
                }
            }
            match (decision.source, decision.assumption.as_deref()) {
                (Some(DecisionSource::AuthorizedAssumption), None | Some("")) => {
                    return Err("authorized assumption lock lacks a stated assumption".to_string())
                }
                (Some(DecisionSource::AuthorizedAssumption), Some(_)) => {}
                (_, Some(_)) => {
                    return Err(
                        "only an authorized assumption may carry assumption text".to_string()
                    )
                }
                (_, None) => {}
            }
        }
        let pending = self.manifest.pending_decision_count();
        if (pending > 0) != (self.disposition == PromptDisposition::Ask) {
            return Err("disposition does not match pending decisions".to_string());
        }
        if pending == 0 && self.disposition != self.post_lock_disposition {
            return Err("resolved disposition does not match task disposition".to_string());
        }
        Ok(())
    }
}

/// Classify one clause as an instruction or a stated fact (#1971).
///
/// # Why this only ever *narrows*
///
/// Measured before it was written: of 22 ordinary operator imperatives — "add
/// a test for the parser", "update the docs", "rebase onto main", "continue",
/// "land it" — **22 reach `Act` solely through the no-match fallback**,
/// because the action lexicon is 17 needles wide. Inverting that fallback, or
/// treating "no recognised imperative" as informational, would silently strip
/// authorization from every one of them.
///
/// So this fires only on POSITIVE evidence that a clause states rather than
/// asks, in two shapes, both pure data:
///
/// 1. an explicit marker at the clause's start (`fyi`, `note that`,
///    `i'll want`) — self-announcing, no further evidence needed;
/// 2. a declarative subject lead (`the`, `it`, `we`, `there`) **and** a
///    stative marker (` is `, ` are `, `'s `) — the "X is now at Y" shape the
///    issue names. Both halves are required: `the` alone would swallow "the
///    tests need updating", and ` is ` alone would swallow "make sure it is
///    green".
///
/// Everything else stays an instruction, which keeps today's behaviour for
/// every clause this cannot positively read. The failure mode of a gap here is
/// therefore an FYI that is still treated as an instruction — the status quo —
/// never an instruction demoted by a lexicon miss.
fn classify_clause(clause: &str, lexicon: &DispositionLexicon) -> AskKind {
    let lower = clause.trim().to_ascii_lowercase();
    let opens_with = |needles: &[String]| {
        needles
            .iter()
            .any(|n| !n.is_empty() && lower.starts_with(n.as_str()))
    };
    if opens_with(&lexicon.informational_markers) {
        return AskKind::Informational;
    }
    // A stative marker is padded on both sides so it matches as a WORD: bare
    // "is" would fire inside "revise", and " is" alone inside "this ".
    let padded = format!(" {lower} ");
    let stative = lexicon
        .stative_markers
        .iter()
        .any(|m| !m.is_empty() && padded.contains(m.as_str()));
    if stative && opens_with(&lexicon.declarative_leads) {
        return AskKind::Informational;
    }
    AskKind::Instruction
}

fn extract_atomic_asks(prompt: &str) -> (Vec<AtomicAsk>, bool) {
    extract_atomic_asks_with(prompt, &DispositionLexicon::default())
}

fn extract_atomic_asks_with(prompt: &str, lexicon: &DispositionLexicon) -> (Vec<AtomicAsk>, bool) {
    let mut asks = Vec::new();
    for line in prompt.lines() {
        let line = strip_list_marker(line.trim());
        if line.is_empty() {
            continue;
        }
        for semicolon_clause in line.split(';') {
            // Treat a period followed by a space as a bounded sentence
            // separator too. This is intentionally shallow rather than a
            // natural-language parser, but it covers the common monolithic
            // "choose X. then choose Y" prompt shape without splitting URLs.
            for clause in semicolon_clause.split(". ") {
                let clause = clause.trim();
                if clause.is_empty() {
                    continue;
                }
                if asks.len() == MAX_ATOMIC_ASKS {
                    return (asks, true);
                }
                asks.push(AtomicAsk {
                    text: truncate_chars(clause, MAX_ASK_BYTES),
                    kind: classify_clause(clause, lexicon),
                });
            }
        }
    }
    if asks.is_empty() {
        asks.push(AtomicAsk {
            text: "(empty operator prompt)".to_string(),
            kind: AskKind::Instruction,
        });
    }
    (asks, false)
}

fn strip_list_marker(line: &str) -> &str {
    let line = line.trim_start_matches(['-', '*', '•', ' ']);
    let digits = line.bytes().take_while(u8::is_ascii_digit).count();
    if digits > 0 {
        let rest = &line[digits..];
        if let Some(rest) = rest.strip_prefix(['.', ')']) {
            return rest.trim_start();
        }
    }
    line
}

/// Prompt-specific refinements layered over the standing response/repository
/// policy in the protected active-prompt card. Keep this narrow: general
/// Markdown shape and source-first evidence belong to the harness policy, not
/// an incident-derived prompt lexicon.
fn request_refinement_model_card(prompt: &str) -> String {
    let lower = prompt.to_ascii_lowercase();
    let packs = crate::api_surface::builtin_packs();
    let language = crate::api_surface::detect_source_language(prompt, &packs);
    let contains = |needle| crate::api_surface::contains_bounded_ascii(&lower, needle);
    let names_source_files = ["code file", "code files", "source file", "source files"]
        .iter()
        .any(|needle| contains(needle));
    let names_language_files = language.is_some()
        && ["file", "files", "script", "scripts"]
            .iter()
            .any(|needle| contains(needle));
    let source_files = names_source_files || names_language_files;

    let mut lines = Vec::new();
    if contains("table") {
        lines.push("response_shape: table".to_string());
    }

    if source_files {
        lines.push("evidence_scope: source_files".to_string());
        match language {
            Some(pack) => {
                if let Ok(extensions) =
                    crate::api_surface::source_extensions_for(&packs, Some(&pack.name))
                {
                    lines.push(format!("source_extensions: {}", extensions.join(",")));
                }
                lines.push(format!(
                    "source_filter: category=source language={}",
                    pack.name
                ));
            }
            None => lines.push("source_filter: category=source".to_string()),
        }
        lines.push(
            "scope_instruction: code/source means registered language source files only; \
             exclude documentation, manifests, lockfiles, and other repository metadata \
             from the primary evidence set"
                .to_string(),
        );
    }
    lines.join("\n")
}

/// The pure-data needle table driving [`infer_disposition`] (#1260, three-Cs):
/// the English phrase lists and the trailing-`?` fallback are LANGUAGE
/// knowledge, so they live in droppable/overridable data — the lexicon
/// convention (`api_surface.rs` language packs) — never hardcoded in logic.
/// Built-in defaults via [`Default`]; the `[intake]` config table overrides any
/// list wholesale and retargets the `?` fallback.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DispositionLexicon {
    /// Needles that force **Act** (checked first; any match wins).
    pub action: Vec<String>,
    /// Needles classifying **Research** (checked before explain).
    pub research: Vec<String>,
    /// Needles classifying **Explain**.
    pub explain: Vec<String>,
    /// Where a prompt that matches NO list but ends with `?` lands — the
    /// fallback cliff made visible and tunable (#1257: "What are the 10 largest
    /// Rust files…?" classified Explain SOLELY through this).
    pub question_mark_disposition: PromptDisposition,
    /// Clause openings that make a question a request (#2332): "Can you install
    /// …?" asks for the install, not for an explanation of it. A `?` prompt with
    /// a clause opening this way skips the `?` fallback and routes like its
    /// imperative form. The needle lists still decide first.
    pub request_openers: Vec<String>,
    /// Clause openings that propose work rather than order it ("let's ",
    /// "we should ", "shall we "): a prompt whose EVERY ask opens with one
    /// reads as **Plan**, checked before the action needles. Matched at the
    /// START of each extracted ask, never anywhere in the prompt — "fix the
    /// bug we should have caught" is an order. A bare nudge behind the opener
    /// ("let's go", "let's continue") proposes nothing and falls through.
    pub collaborative: Vec<String>,
    /// Verbs that keep a collaborative opener an order: "let's commit this",
    /// "let's run the tests", "lets push" ask for one bounded act, not a
    /// plan. Matched against the first word after the opener.
    pub direct_verbs: Vec<String>,
    /// Clause openings that announce a stated fact outright (#1971). Matched at
    /// the START of a clause; no further evidence is required.
    pub informational_markers: Vec<String>,
    /// Declarative subject leads. Informational only when a
    /// [`stative_markers`](Self::stative_markers) entry also appears — see
    /// [`classify_clause`].
    pub declarative_leads: Vec<String>,
    /// Copulas and stative auxiliaries: the "X **is** Y" half of a statement.
    /// Matched as whole words.
    pub stative_markers: Vec<String>,
}

impl Default for DispositionLexicon {
    fn default() -> Self {
        Self {
            action: [
                "implement",
                "modify",
                "change",
                "create",
                "write",
                "edit",
                "delete",
                "fix",
                // Checked before research, so "refactor the largest file"
                // acts instead of falling to the research needle "largest".
                "refactor",
                "build",
                "run ",
                "execute",
                // A quoted command after `test` is an execution request even
                // when phrased as a question. Keep this narrower than bare
                // `test ` so "explain how the test harness works" remains an
                // Explain deliverable.
                // Leading space is a word boundary. `infer_disposition_with`
                // pads the prompt so these also match at byte zero, without
                // widening `latest \"release\"` or `greatest 'risk'` to Act.
                " test \"",
                " test '",
                "commit",
                "push",
                "open a pr",
                "open pr",
                "merge",
                // #2562 round 2 (PR-2562 review, "data not stemming" ruling,
                // part 2): whole-word matching (#2553 finding 3) means a bare
                // gerund/participle ("fixing", "refactoring", "implemented")
                // is NOT added here — those are description words as often
                // as commands ("explain refactoring", "how is caching
                // implemented?"), and alone they already reach Act through
                // the terminal fallback (measured: 22/22 ordinary
                // imperatives). What whole-word matching broke instead is
                // the MIXED prompt whose only action cue is one of these
                // unambiguous REQUEST PHRASES, which used to win only by
                // accidentally substring-matching a bare verb needle
                // elsewhere in the same clause — restore those explicitly:
                "needs fixing",
                "need fixing",
                "needs refactoring",
                "need refactoring",
                "get it fixed",
                "get them fixed",
                "get it committed",
                // #2562 round 3 (PR-2562 review round 2): bare "rerun" /
                // "re-run" GAINED authority on an explanatory prompt that
                // merely NAMES the word ("explain why the rerun flag
                // exists"). "run " no longer matches inside "rerun the
                // tests" either way ("re" is alphanumeric-adjacent, so the
                // left edge fails the whole-word check — correctly: "rerun"
                // is one word, not "run" plus a prefix) — but the fix is
                // request PHRASES, not the bare word, same asymmetry as
                // "needs fixing" above.
                "rerun the",
                "re-run the",
                "please rerun",
                "please re-run",
            ]
            .map(str::to_string)
            .to_vec(),
            research: [
                "research",
                "investigate",
                "look up",
                "find out",
                "analyze",
                "diagnose",
                "audit",
                "explore",
                "compare",
                // #1260: evidence-gathering phrasings from the diagnosed #1257
                // session ("the 10 largest Rust files") — data additions, so
                // such prompts classify by CONTENT, not the `?` cliff.
                "largest",
                "biggest",
                "smallest",
                // #1387: line count is a first-class evidence question, answered
                // read-only by `find` (sort=lines/show_lines) — NOT an Act that
                // needs `wc -l`, and NOT a bytesize fallback. Keeping it in
                // Research and giving Research the capability is the fix for
                // "Research is too strict".
                "line count",
                // Whole-word matching (#2553 finding 3) no longer lets
                // "line count" match inside "line counts" — add the plural
                // as its own entry rather than reopen substring matching.
                "line counts",
                "most lines",
                "fewest lines",
                "longest file",
                "shortest file",
                // #2562 round 2 (PR-2562 review, "data not stemming" ruling,
                // part 1, BLOCKING): whole-word matching stopped these
                // needles from matching their own inflections, which
                // silently fell through to the Act fallback — a read-only
                // gerund GAINING mutation authority ("auditing the
                // permission table" → Act). GERUNDS ONLY (round 3, PR-2562
                // review round 2): a gerund names an ACTIVITY, and a request
                // built on one is read-only — safe to over-match, since at
                // worst an ambiguous prompt loses authority rather than
                // gaining it. The matching NOUN forms (`investigation`,
                // `analysis`, `diagnosis`, `comparison`, `audits`) were
                // tried and reverted: a noun names a THING, and ordinary
                // mutation requests routinely name things ("update the
                // diagnosis message", "add a comparison test") — those nouns
                // cost Act on everyday edits, exactly #2558 test 1.
                "researching",
                "auditing",
                "investigating",
                "analyzing",
                "diagnosing",
                "exploring",
                "comparing",
            ]
            .map(str::to_string)
            .to_vec(),
            explain: [
                "explain",
                "summarize",
                "describe",
                "what is",
                "why ",
                "how does",
                "how do",
                // #1260: plural/interrogative forms the old list missed ("what
                // is" ≠ "what are" was half the #1257 cliff).
                "what are",
                "which are",
                // #2332: keeps "Could you tell me what X does?" an answer once
                // `could you ` makes a question a request.
                "tell me",
                // #2562 round 2/round 3: same "data not stemming" ruling —
                // see the research list's matching comment above. GERUNDS
                // ONLY: `summary`/`description`/`explanation` are nouns and
                // were reverted (round 3) after they cost Act on
                // "update the description field", "add a summary line".
                "explaining",
                "summarizing",
                "describing",
            ]
            .map(str::to_string)
            .to_vec(),
            question_mark_disposition: PromptDisposition::Explain,
            // "try now?" / "retry?" are the recorded retry forms (#2283).
            request_openers: [
                "can you ",
                "could you ",
                "would you ",
                "will you ",
                "try ",
                "retry",
            ]
            .map(str::to_string)
            .to_vec(),
            // Both apostrophes are data: `to_ascii_lowercase` leaves U+2019
            // (the smart quote macOS types) alone, so "Let’s" needs its own
            // entry or it silently stays on the ordinary ladder.
            collaborative: [
                "let's ",
                "lets ",
                "let’s ",
                "we should ",
                "how about we ",
                "i'm thinking ",
                "i’m thinking ",
                "i am thinking ",
                "shall we ",
            ]
            .map(str::to_string)
            .to_vec(),
            direct_verbs: [
                "commit", "push", "pull", "merge", "rebase", "run", "rerun", "test", "build",
                "deploy", "release", "tag", "install", "lint", "format", "check", "land", "ship",
                "publish", "bump", "revert", "retry", "start", "stop", "restart", "open", "close",
            ]
            .map(str::to_string)
            .to_vec(),
            // Self-announcing statements. `i'll want` / `i'm going to` are the
            // future-tense forms only: bare `i want` is routinely an
            // instruction ("I want you to fix the parser") and is deliberately
            // absent.
            informational_markers: [
                "fyi",
                "btw",
                "by the way",
                "just so you know",
                "heads up",
                "note that",
                "for reference",
                "for context",
                "i'll want",
                "i will want",
                "i'm going to",
                "i am going to",
                "we're going to",
                "we are going to",
            ]
            .map(str::to_string)
            .to_vec(),
            // Subject leads. An English imperative does not open with one of
            // these, which is what keeps the 22 measured fallback imperatives
            // ("add…", "update…", "continue") out of this branch entirely.
            declarative_leads: [
                "the ", "a ", "an ", "this ", "that ", "these ", "those ", "it ", "its ", "it's ",
                "i ", "i'm ", "i've ", "i'll ", "we ", "we're ", "we've ", "we'll ", "you ",
                "your ", "there ", "my ", "our ", "their ", "his ", "her ",
            ]
            .map(str::to_string)
            .to_vec(),
            // Padded on both sides by `classify_clause`, so each matches as a
            // whole word rather than inside `revise` or `this`.
            stative_markers: [
                " is ",
                " are ",
                " was ",
                " were ",
                " isn't ",
                " aren't ",
                " will be ",
                " has ",
                " have ",
                " had ",
                "'s ",
                "'re ",
                "'ve ",
            ]
            .map(str::to_string)
            .to_vec(),
        }
    }
}

/// Whole-word substring match (#2553 finding 3): `needle` matches `haystack`
/// only where its outer edges land on a word boundary. Checked only on edges
/// that are themselves word characters — a needle already padded with a space
/// or quote (`" test \""`, `"run "`) supplies its own boundary on that side
/// and needs no further check. This is what keeps `"refactor"` off
/// `"refactoring"` (suffix, no trailing boundary) while still matching
/// `"refactor the largest file"` (#2553's original case), and applies to
/// every lexicon entry, not a special case for one word.
///
/// `_` counts as a word character (#2562 round 3, PR-2562 review round 2):
/// `char::is_alphanumeric` alone treats `_` as a boundary, so an identifier
/// leaked a needle into authority — `explain what fix_path does` hit `fix`,
/// `describe the build_graph function` hit `build`, `summary_stats` hit
/// `summary` — granting or stealing Act on a purely explanatory question
/// about code. The old substring matcher had the same flaw, so this closes
/// a pre-existing gap in finding 3's own class, not a fresh regression.
fn contains_word(haystack: &str, needle: &str) -> bool {
    if needle.is_empty() {
        return false;
    }
    let is_word = |c: char| c.is_alphanumeric() || c == '_';
    let mut start = 0;
    while let Some(pos) = haystack[start..].find(needle) {
        let abs = start + pos;
        let end = abs + needle.len();
        let left_ok = !needle.chars().next().is_some_and(is_word)
            || !haystack[..abs].chars().next_back().is_some_and(is_word);
        let right_ok = !needle.chars().next_back().is_some_and(is_word)
            || !haystack[end..].chars().next().is_some_and(is_word);
        if left_ok && right_ok {
            return true;
        }
        start = abs + 1;
    }
    false
}

fn infer_disposition(prompt: &str, asks: &[AtomicAsk]) -> PromptDisposition {
    infer_disposition_with(prompt, asks, &DispositionLexicon::default())
}

/// Classify a prompt's disposition against `lexicon` (#1260) — pure, no I/O.
/// Precedence: a collaborative opener on EVERY ask reads as `Plan` first.
/// "let's refactor the largest file" proposes the refactor rather than
/// ordering it, so the opener must be read before the `refactor` action
/// needle (added for "refactor the largest file") can claim the prompt. The
/// opener is matched at the start of each ask, never mid-clause ("fix the bug
/// we should have caught" keeps `Act`), and a bare nudge behind it ("let's
/// go", "let's continue") is handed back to the ordinary ladder through
/// [`crate::classifiers::is_bare_continuation`] on the remainder. Like the
/// statement arm below, this narrows on positive evidence, never on silence.
/// After it, unchanged from the historical logic: an action needle wins
/// outright; else research; else explain; else the `?` fallback; else the
/// terminal fallback.
///
/// # The terminal fallback no longer grants on silence (#1971)
///
/// It used to be `Act` unconditionally, which made *absence of evidence* the
/// most permissive disposition: a 92-byte statement of fact matched no needle,
/// fell through, and was handed the full execution authority and round budget
/// while the fact it stated was never acted on at all. That is the shape
/// #1908 named — "no match" and "matched as Act" are different facts, and
/// conflating them grants authority on silence.
///
/// It is now `Act` **unless every clause positively reads as a statement**, in
/// which case the turn is [`Explain`](PromptDisposition::Explain): answer,
/// acknowledge, read if useful, mutate nothing.
///
/// **`Act` remains the fallback for silence, and that is deliberate.** Of 22
/// ordinary imperatives measured against this lexicon — "add a test for the
/// parser", "update the docs", "rebase onto main", "continue", "land it" —
/// **all 22 reach `Act` only through this fallback**. Inverting it would strip
/// authorization from the ordinary case to fix the exceptional one. So the
/// narrowing is driven by evidence FOR a statement, never by the absence of
/// evidence for an instruction: silence still means Act, but a clause that
/// says "X is Y" no longer does.
///
/// A mixed prompt keeps `Act`. One instruction among five statements is still
/// an instruction, and `all()` over an empty ask list cannot arise —
/// [`extract_atomic_asks_with`] always yields at least one.
fn infer_disposition_with(
    prompt: &str,
    asks: &[AtomicAsk],
    lexicon: &DispositionLexicon,
) -> PromptDisposition {
    let proposes = |ask: &AtomicAsk| {
        let text = ask.text.trim_start().to_ascii_lowercase();
        lexicon
            .collaborative
            .iter()
            .filter(|n| !n.is_empty())
            .find_map(|n| text.strip_prefix(n.as_str()))
            .is_some_and(|rest| {
                let verb = rest
                    .split_whitespace()
                    .next()
                    .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
                    .unwrap_or("");
                !crate::classifiers::is_bare_continuation(rest)
                    && !lexicon.direct_verbs.iter().any(|v| v == verb)
            })
    };
    if !asks.is_empty() && asks.iter().all(proposes) {
        return PromptDisposition::Plan;
    }
    let lower = prompt.to_ascii_lowercase();
    // Padding makes a lexicon entry with a leading-space word boundary match at
    // the beginning of a prompt without losing that boundary inside prose.
    let padded = format!(" {lower}");
    let hit = |needles: &[String]| {
        needles
            .iter()
            .any(|n| !n.is_empty() && contains_word(&padded, n))
    };
    if hit(&lexicon.action) {
        return PromptDisposition::Act;
    }
    if hit(&lexicon.research) {
        return PromptDisposition::Research;
    }
    if hit(&lexicon.explain) {
        return PromptDisposition::Explain;
    }
    let requests = |ask: &AtomicAsk| {
        let text = ask.text.trim_start().to_ascii_lowercase();
        lexicon
            .request_openers
            .iter()
            .any(|n| !n.is_empty() && text.starts_with(n.as_str()))
    };
    if lower.trim_end().ends_with('?') && !asks.iter().any(requests) {
        return lexicon.question_mark_disposition;
    }
    if !asks.is_empty() && asks.iter().all(AtomicAsk::is_informational) {
        return PromptDisposition::Explain;
    }
    PromptDisposition::Act
}

fn extract_decisions(asks: &[AtomicAsk]) -> (Vec<DecisionLock>, bool) {
    let mut decisions = Vec::new();
    for ask in asks {
        let lower = ask.text.to_ascii_lowercase();
        // A claim such as "per policy" inside a prompt is not a verified
        // harness policy. This intake has no external policy resolver, so it
        // deliberately creates only unresolved decisions here. The only
        // automatic lock path in this MVP is a later explicit operator answer;
        // policy and authorized-assumption sources remain represented in the
        // durable schema for a future verified resolver.
        let decision =
            if needs_operator_decision(&lower) || has_ambiguous_destructive_target(&lower) {
                Some((DecisionStatus::Pending, None))
            } else {
                None
            };
        if let Some((status, source)) = decision {
            if decisions.len() == MAX_CONCRETE_DECISIONS {
                return (decisions, true);
            }
            decisions.push(DecisionLock {
                question: ask.text.clone(),
                status,
                source,
                assumption: None,
                overflow: false,
                answer: None,
            });
        }
    }
    (decisions, false)
}

fn overflow_decision() -> DecisionLock {
    DecisionLock {
        question: format!(
            "This request exceeds Newt's bounded intake capacity ({MAX_CONCRETE_DECISIONS} decisions or {MAX_ATOMIC_ASKS} asks). Use /new, then start a smaller task before execution."
        ),
        status: DecisionStatus::Pending,
        source: None,
        assumption: None,
        overflow: true,
        answer: None,
    }
}

/// Cues that mark a clause as a directive negation — "do not do X",
/// commanding the operator — rather than a descriptive one. Deliberately
/// excludes `"does not "` / `"doesn't "`: those are third-person indicative
/// ("the shim does not use SQLite or Postgres" describes behavior, it does
/// not command anything), and folding them into a "prohibition" cue list
/// would claim a mood this classifier cannot actually detect (#1708).
///
/// `"never "` and `"don't "` are kept even though English also permits an
/// indicative reading of both ("it never does X", "they don't do X"). This
/// classifier does not attempt that disambiguation beyond the interrogative
/// check in [`is_directive_prohibition`] — a rare indicative "never"/"don't"
/// clause that also contains " or " + a trigger word can still be misread
/// as a prohibition. #1689 item 6 tracks the general problem; #1689 item 5
/// tracks moving this and the sibling needle lists into the droppable
/// `DispositionLexicon`. Both are out of scope here — kept as a plain list
/// to keep this fix narrow.
const NEGATION_CUES: &[&str] = &[
    "do not ",
    "don't ",
    "must not ",
    "must never ",
    "should not ",
    "shouldn't ",
    "never ",
    "avoid ",
];

/// Leading words that can precede a directive without changing its mood: a
/// politeness filler or an explicit second-person subject. Stripped before
/// testing for a negation cue, so "Please do not X" and "You must not X"
/// are recognized the same as "Do not X" (#1708).
const DIRECTIVE_SUBJECT_PREFIXES: &[&str] = &["please ", "you "];

/// Strip a leading single-word "Label: " prefix — "Constraint:", "Rule:",
/// "Note:" — before testing for a negation cue (#1708). Generic on purpose:
/// instruction labels are free-form operator vocabulary, not a fixed phrase
/// list to keep in sync. Deliberately requires a single alphabetic word (no
/// spaces) immediately before the colon so an unrelated mid-clause colon —
/// "the old value was: never mind" — is never mistaken for a label.
fn strip_leading_label(s: &str) -> &str {
    if let Some(colon) = s.find(':') {
        let (label, rest) = s.split_at(colon);
        if (2..=20).contains(&label.len())
            && label.chars().all(|c| c.is_ascii_alphabetic())
            && rest.starts_with(": ")
        {
            return rest[": ".len()..].trim_start();
        }
    }
    s
}

/// Is `lower` (an already-lowercased atomic ask) a directive prohibition —
/// a negated command to the operator — rather than a question or a
/// descriptive statement?
///
/// A question mark is decisive either way: "Shouldn't we use X or Y?" opens
/// with a negated auxiliary but is asking, not commanding, so a `?`
/// anywhere in the clause vetoes prohibition classification outright. This
/// is intentionally cruder than parsing subject-auxiliary inversion — it
/// will also (wrongly) veto a genuine prohibition that ends in a
/// rhetorical "...understood?", which is an accepted, narrow limitation
/// (#1708) rather than a claim this function does not make.
fn is_directive_prohibition(lower: &str) -> bool {
    if lower.contains('?') {
        return false;
    }
    let mut clause = strip_leading_label(lower.trim_start());
    while let Some(rest) = DIRECTIVE_SUBJECT_PREFIXES
        .iter()
        .find_map(|prefix| clause.strip_prefix(prefix))
    {
        clause = rest;
    }
    NEGATION_CUES.iter().any(|cue| clause.starts_with(cue))
}

/// A clause needs an explicit operator decision only when it poses a real
/// choice. "Do NOT implement A, B, or C" and "Never select A or B" name a
/// prohibition, not a question — [`is_directive_prohibition`] gates both
/// the `choose`/`select`/`pick` needles and the `" or "` + trigger-word
/// heuristic below so a prohibited clause cannot trip either (#1707,
/// #1708). It intentionally does NOT gate `"either "`/`"tbd"`/`"which
/// ..."`: a negated form of those is rare and out of scope for this fix.
fn needs_operator_decision(lower: &str) -> bool {
    let prohibition = is_directive_prohibition(lower);
    // The detector stays conservative: it generates CANDIDATES. Whether the
    // operator delegated a candidate is decided by bounded adjudication, not by
    // making these needles cleverer (#1749).
    (!prohibition && contains_any(lower, &["choose ", "select ", "pick "]))
        || contains_any(
            lower,
            &[
                "either ",
                "tbd",
                "to be decided",
                "which option",
                "which backend",
                "which provider",
            ],
        )
        || (!prohibition
            && lower.contains(" or ")
            && contains_any(lower, &["should", "use", "implement"]))
}

/// A destructive verb with a bare demonstrative/pronoun has no grounded
/// target. Treat it as a blocking decision rather than inheriting whatever
/// object happened to be salient in the model's transient context.
fn has_ambiguous_destructive_target(lower: &str) -> bool {
    [
        "delete it",
        "delete this",
        "delete that",
        "remove it",
        "remove this",
        "remove that",
        "drop it",
        "drop this",
        "drop that",
        "destroy it",
        "destroy this",
        "destroy that",
        "wipe it",
        "wipe this",
        "wipe that",
        "purge it",
        "purge this",
        "purge that",
    ]
    .iter()
    .any(|phrase| lower.contains(phrase))
}

/// Why an operator's clarification reply did not lock the batch.
///
/// #1689 item 1. The gate used to re-emit a byte-identical block on every
/// rejection, which is what made a blocked session indistinguishable from a
/// hung one: nothing said what was wrong, and nothing named the way out. The
/// model is never called on this path, so if the harness does not explain the
/// refusal, nothing will.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClarificationRejection {
    /// Nothing that parsed as `N: value` appeared anywhere in the reply.
    NoOrdinals,
    /// Ordinals parsed, but not for every pending decision.
    Incomplete { answered: usize, expected: usize },
    /// An ordinal outside the pending range was used.
    OutOfRange { ordinal: usize, expected: usize },
    /// The reply read as a question rather than an answer, and carried no
    /// ordinals to override that reading.
    ReadsAsQuestion,
}

impl ClarificationRejection {
    /// One line stating what was wrong and how to get out — always naming the
    /// escape hatch, because the operator's real problem is usually that they
    /// disagree with the gate rather than that they mistyped.
    pub fn explain(&self) -> String {
        let detail = match self {
            Self::NoOrdinals => {
                "no `N: value` line was found — each answer needs its ordinal, like `1: use the second option`".to_string()
            }
            Self::Incomplete { answered, expected } => format!(
                "{answered} of {expected} decisions were answered — locking is all-or-nothing, so answer every ordinal in one reply"
            ),
            Self::OutOfRange { ordinal, expected } => format!(
                "ordinal {ordinal} is outside this batch — the pending items are numbered 1..={expected}"
            ),
            Self::ReadsAsQuestion => {
                "the reply read as a question rather than an answer — prefix each answer with its ordinal (`1: …`) and it will be accepted even if it contains a `?`".to_string()
            }
        };
        format!(
            "that reply did not lock the batch: {detail}.\n\
             (`/discuss <what's on your mind>` talks it through without losing the batch; \
             `/new` abandons this prompt and starts a fresh conversation.)"
        )
    }
}

/// Punctuation a human uses to number a choice, tried in this order. `:` is
/// tried first because it can never be mistaken for a decimal point or a
/// parenthetical close, so it stays the unambiguous default when a line could
/// match more than one.
const ORDINAL_SEPARATORS: [char; 3] = [':', '.', ')'];

/// Resolve explicit operator answers, or say why the reply was refused.
/// The `Option<String>` alongside each index is the trimmed `value` text
/// after the ordinal's separator, `None` when the reply gave none (a bare
/// `1:`) — used to name the locked ANSWER rather than just the question
/// (#2515 items 5/6 round 2).
///
/// #1689 items 1 and 2.
fn explicit_answer_outcome(
    answer: &str,
    pending: &[usize],
) -> Result<Vec<(usize, Option<String>)>, ClarificationRejection> {
    let answer = answer.trim();
    if answer.is_empty() {
        return Err(ClarificationRejection::NoOrdinals);
    }
    let mut resolved = BTreeMap::new();
    let mut saw_ordinal = false;
    let mut out_of_range = None;
    for line in answer.lines() {
        let line = line.trim();
        let line = line.strip_prefix("decision ").unwrap_or(line);
        // #2517 follow-up: a human numbering a choice reaches for `.` or `)`
        // at least as often as `:` — "1. Widen the local surface", "1) …".
        // Try each separator the operator might reasonably use rather than
        // rejecting a perfectly explicit ordinal over punctuation choice.
        // `.` is guarded against a decimal number ("3.14"): a digit
        // immediately after the dot means it was never a list marker.
        let Some((ordinal, value)) = ORDINAL_SEPARATORS.iter().find_map(|sep| {
            let (ordinal, value) = line.split_once(*sep)?;
            if *sep == '.' && value.starts_with(|c: char| c.is_ascii_digit()) {
                return None;
            }
            Some((ordinal, value))
        }) else {
            continue;
        };
        let Ok(ordinal) = ordinal.trim().parse::<usize>() else {
            continue;
        };
        // An empty value is fine now that the separator itself (not the text
        // after it) is what makes this a real ordinal rather than free text:
        // "1." and "1)" alone are explicit enough to lock, the same way a
        // bare `1:` was already accepted — the pending item's own question is
        // the value, not the reply's text.
        saw_ordinal = true;
        let value = value.trim();
        let value = (!value.is_empty()).then(|| value.to_string());
        let Some(pending_ordinal) = ordinal.checked_sub(1) else {
            out_of_range = Some(ordinal);
            continue;
        };
        match pending.get(pending_ordinal) {
            Some(index) => {
                resolved.insert(*index, value);
            }
            None => out_of_range = Some(ordinal),
        }
    }

    // #1689 item 2: the question heuristic applies ONLY when the reply carried
    // no usable ordinals. It used to reject the WHOLE answer for a `?` anywhere
    // in it, so `1: drain before rotation — sound right?` was refused: a
    // perfectly good answer with a thought attached. An explicit `N: value` is
    // the operator stating a decision, and that outranks a punctuation guess.
    if !saw_ordinal {
        if looks_like_unresolved_question(answer) {
            return Err(ClarificationRejection::ReadsAsQuestion);
        }
        return Err(ClarificationRejection::NoOrdinals);
    }
    if let Some(ordinal) = out_of_range {
        if resolved.len() != pending.len() {
            return Err(ClarificationRejection::OutOfRange {
                ordinal,
                expected: pending.len(),
            });
        }
    }
    if resolved.len() != pending.len() {
        return Err(ClarificationRejection::Incomplete {
            answered: resolved.len(),
            expected: pending.len(),
        });
    }
    Ok(resolved.into_iter().collect())
}

/// The pre-#1689 shape, kept for the existing parser tests: outcome without a
/// reason. Production reads `explicit_answer_outcome` so a refusal can be
/// explained instead of silently repeated.
#[cfg(test)]
fn explicit_answer_indices(answer: &str, pending: &[usize]) -> Option<Vec<usize>> {
    explicit_answer_outcome(answer, pending)
        .ok()
        .map(|resolved| resolved.into_iter().map(|(index, _value)| index).collect())
}

/// Recognize the `/discuss` escape hatch and return the text to discuss, if
/// any. `/discuss` alone (or with only whitespace after it) still counts —
/// the operator may simply want the batch re-explained.
///
/// #2517: the strict `N: value` gate has no answer for an operator who
/// disagrees with the question rather than merely mistyping the reply; the
/// only prior way out was `/new`, which throws the whole prompt away. This
/// gives "I want to talk about this first" its own recognized shape instead
/// of forcing it through the ordinal parser, where it always read as
/// [`ClarificationRejection::NoOrdinals`] or [`ClarificationRejection::ReadsAsQuestion`].
pub fn discussion_request(answer: &str) -> Option<String> {
    let rest = answer
        .strip_prefix("/discuss")
        .or_else(|| answer.strip_prefix("/chat"))?;
    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
        // `/discussion-of-x` or `/chatty` — not the command.
        return None;
    }
    Some(rest.trim().to_string())
}

fn looks_like_unresolved_question(answer: &str) -> bool {
    let lower = answer.to_ascii_lowercase();
    answer.contains('?')
        || lower.starts_with("what ")
        || lower.starts_with("which ")
        || lower.starts_with("can ")
        || lower.starts_with("could ")
        || lower.starts_with("should ")
}

fn contains_any(text: &str, needles: &[&str]) -> bool {
    needles.iter().any(|needle| text.contains(needle))
}

fn truncate_chars(text: &str, byte_limit: usize) -> String {
    if text.len() <= byte_limit {
        return text.to_string();
    }
    let mut end = byte_limit;
    while !text.is_char_boundary(end) {
        end -= 1;
    }
    text[..end].to_string()
}

fn digest_metadata(text: &str) -> Value {
    json!({
        "digest": blake3::hash(text.as_bytes()).to_hex().to_string(),
        "bytes": text.len() as u64,
    })
}

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