mind-cli 0.47.0

A manager for agent tooling (skills, agents, rules, tools) that melds arbitrary git repos and links items into your agent directories.
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
//! `mind hooks run` and `mind hooks list` -- on-demand hook execution and
//! inspection, outside the meld/learn/forget/upgrade flows.
//!
//! spec: HOOK-100..105, CLI-194..196

use serde::Serialize;

use crate::catalog::CatalogItem;
use crate::cli::HookEventArg;
use crate::error::{MindError, Result};
use crate::hook_log::{HookRecord, Outcome};
use crate::manifest::{InstalledItem, Manifest};
use crate::mindfile::{HookEvent, MindToml, ResolvedHook};
use crate::paths::Paths;
use crate::resolve::{
    HookTarget, parse_hook_target, parse_item_ref, select_installed, source_matches_glob,
};
use crate::sanitize::strip_ansi;
use crate::source::{Pin, RecordedEvent, RecordedHook, RecordedSourceHook, Registry, Source};

/// Emit a result document through [`crate::commands`]'s `print_json`, which
/// records it when stdout is reserved (CLI-217) and adds the run's hook
/// results either way (HOOK-161).
fn emit_json<T: Serialize>(value: &T) -> Result<()> {
    crate::commands::print_json(value)
}

/// `mind hooks run --json`'s result document (CLI-222): the HOOK-107/HOOK-108
/// tally for the invocation, so a script can tell "nothing to do" from "ran N
/// hooks" without scraping stderr notes.
#[derive(Serialize, Debug, PartialEq, Eq)]
struct HooksRunResult {
    schema: u8,
    action: &'static str,
    target: String,
    event: &'static str,
    existed: usize,
    ran: usize,
    skipped: usize,
}

/// `mind hooks list --json`'s result document (CLI-220). Exactly one of
/// `sources`/`items` is populated, depending on whether `<target>` resolved
/// to a source or to an item ref (HOOK-104's two branches).
#[derive(Serialize, Debug, PartialEq)]
struct HooksListResult {
    schema: u8,
    action: &'static str,
    target: String,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    sources: Vec<SourceHooksJson>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    items: Vec<ItemHooksJson>,
}

/// One matched source's hooks and installed items, for `hooks list --json`'s
/// `sources` array.
#[derive(Serialize, Debug, PartialEq)]
struct SourceHooksJson {
    source: String,
    hooks: Vec<SourceHookJson>,
    items: Vec<ItemHooksJson>,
}

/// One source-level hook entry (CLI-220).
#[derive(Serialize, Debug, PartialEq)]
struct SourceHookJson {
    event: &'static str,
    required: bool,
    command: String,
    /// The CLI-196 pending/last-ran status; present only for a recorded
    /// install- or update-event hook (HOOK-124; an uninstall hook carries no
    /// recorded run state).
    #[serde(skip_serializing_if = "Option::is_none")]
    status: Option<String>,
    /// The hook's newest history record (HOOK-168); absent when the history
    /// holds none.
    #[serde(skip_serializing_if = "Option::is_none")]
    last: Option<HookRecord>,
}

/// One item's hooks -- either nested under a `SourceHooksJson` (its `source`
/// is the enclosing object, so omitted) or a top-level entry for an item-ref
/// target (its `source` is set). A list of objects, not bare strings, so a
/// later addition (e.g. an item-level install/uninstall disclosure record)
/// has somewhere to go.
#[derive(Serialize, Debug, PartialEq)]
struct ItemHooksJson {
    item: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    source: Option<String>,
    hooks: Vec<ItemHookJson>,
}

/// One item-level hook entry (CLI-220).
#[derive(Serialize, Debug, PartialEq)]
struct ItemHookJson {
    event: &'static str,
    required: bool,
    command: String,
    /// The hook's newest history record (HOOK-168).
    #[serde(skip_serializing_if = "Option::is_none")]
    last: Option<HookRecord>,
}

// ---------------------------------------------------------------------------
// Public entry points
// ---------------------------------------------------------------------------

/// `mind hooks run <target>` -- run hooks on demand.
///
/// Reuses the same disclosure + consent + run machinery as the automatic flows,
/// so it is neither more nor less guarded (HOOK-100).
// spec: HOOK-100 HOOK-101 HOOK-102 HOOK-103 CLI-194 CLI-195 HOOK-105 CLI-222
pub fn run(
    paths: &Paths,
    target: &str,
    event: HookEventArg,
    force: bool,
    accept: crate::hook::Accept,
) -> Result<()> {
    let tally = match resolve_hook_target(paths, target)? {
        HookTarget::Source(selector) => {
            // spec: HOOK-103 CLI-195 - --event build is invalid for a source target.
            if event == HookEventArg::Build {
                return Err(MindError::BuildEventRequiresItemTarget);
            }
            run_source_hooks(paths, &selector, event, force, accept)?
        }
        HookTarget::Item(item_ref) => {
            // spec: HOOK-196 -- an item target never runs an items hook.
            if event == HookEventArg::Items {
                return Err(MindError::ItemsEventRequiresSourceTarget);
            }
            run_item_hooks(paths, &item_ref, target, event, force, accept)?
        }
    };

    // spec: CLI-222 -- a successful run answers `--json` with the HOOK-107/108
    // tally rather than nothing, so a script sees "ran 2, skipped 0" instead
    // of an empty stdout.
    if crate::render::ctx().json {
        emit_json(&HooksRunResult {
            schema: 1,
            action: "hooks-run",
            target: target.to_string(),
            event: event_label(event),
            existed: tally.existed,
            ran: tally.ran,
            skipped: tally.skipped_for_consent,
        })?;
    }
    Ok(())
}

/// `mind hooks list <target>` -- report declared hooks without running any.
///
/// For a source target, lists the source's hooks (with pending/last-ran info
/// for the recorded events, install and update) and the hooks of its installed
/// items. For an item ref, lists only that item's hooks. Every event is
/// reported: `hooks list` takes no `--event` filter (CLI-196, HOOK-126).
// spec: HOOK-104 CLI-196 HOOK-105 CLI-220 HOOK-126
pub fn list(paths: &Paths, target: &str) -> Result<()> {
    match resolve_hook_target(paths, target)? {
        HookTarget::Source(selector) => list_source_hooks(paths, target, &selector),
        HookTarget::Item(item_ref) => list_item_hooks(paths, target, &item_ref),
    }
}

/// Resolve a `hooks run`/`hooks list` target string, preferring an exact match
/// against a registered source identity over [`parse_hook_target`]'s `#`-split
/// heuristic (C11).
///
/// An item-link instance's own source identity carries a `#<path>` suffix
/// (LNK-4), so the exact identity string contains `#` and would otherwise
/// parse as an item ref that matches nothing (`NotInstalled`), leaving the
/// instance's source-level hooks unreachable by name. When `target` (trimmed)
/// equals some registered source's `name` exactly, it is read as a source
/// target regardless of the `#` it carries -- UNLESS that same string, read as
/// an ordinary `<source>#<item>` ref, also names an installed item (e.g. a
/// link instance whose skill sits at a single top-level path segment, so its
/// identity `host/owner/repo#foo` is spelled identically to item `foo` in
/// source `host/owner/repo`). That is a genuine ambiguity -- which reading is
/// meant depends on registry state the caller cannot see -- so it is reported
/// as [`MindError::AmbiguousHookTarget`] rather than silently picking one
/// side. Two escapes disambiguate explicitly and always take priority over
/// both the exact-match check and the ambiguity check:
/// - A leading `source:` prefix always forces the source reading (e.g.
///   `source:host/owner/repo#foo`), stripped before the rest of resolution.
/// - A kind-qualified item ref (`<source>#<kind>:<name>`, e.g.
///   `host/owner/repo#skill:foo`) never equals a registered source's exact
///   identity (identities carry no `kind:` segment), so it always falls
///   through to [`parse_hook_target`] and resolves as that item.
///
/// Any other string -- including a plain `source#item` that does not name a
/// registered source, and the triple-`#` `<link-identity>#<item>` form for an
/// item inside a link instance -- falls back to [`parse_hook_target`] unchanged.
// spec: HOOK-105
fn resolve_hook_target(paths: &Paths, target: &str) -> Result<HookTarget> {
    let trimmed = target.trim();

    // spec: HOOK-105 -- the explicit source-targeting escape: skip both the
    // exact-match and ambiguity checks entirely.
    if let Some(rest) = trimmed.strip_prefix("source:") {
        return Ok(HookTarget::Source(rest.trim().to_string()));
    }

    let registry = Registry::load(paths)?;
    let source_match = registry.sources.iter().any(|s| s.name == trimmed);

    // spec: HOOK-105 -- only a `#`-carrying string can collide with the
    // item-ref reading; a target with no `#` is unambiguously a source
    // selector under the CLI-194 heuristic and is never read as an item here.
    if source_match
        && trimmed.contains('#')
        && let Ok(item_ref) = parse_item_ref(trimmed)
    {
        let manifest = Manifest::load(paths)?;
        let matches = select_installed(&manifest.items, &item_ref);
        if !matches.is_empty() {
            // spec: DSC-95 -- `it.source`/`it.name` are attacker-controlled (a
            // source name or a `[[items]] name`); sanitize each field BEFORE
            // composing, never the composed string (an unterminated OSC in one
            // field would otherwise swallow the fields appended after it).
            let item_forms: Vec<String> = matches
                .iter()
                .map(|it| {
                    format!(
                        "{}#{}:{}",
                        strip_ansi(&it.source),
                        it.kind.as_str(),
                        strip_ansi(&it.name)
                    )
                })
                .collect();
            return Err(MindError::AmbiguousHookTarget {
                target: trimmed.to_string(),
                item_forms,
            });
        }
    }

    if source_match {
        return Ok(HookTarget::Source(trimmed.to_string()));
    }
    parse_hook_target(target)
}

// ---------------------------------------------------------------------------
// Source-level hook runner (HOOK-101)
// ---------------------------------------------------------------------------

/// Run a source's hooks for the given event (`install`, `update`, or
/// `uninstall`; `build` is an item-only event and is refused by the caller).
/// Returns the HOOK-107 tally on success (CLI-222).
// spec: HOOK-101 HOOK-126
fn run_source_hooks(
    paths: &Paths,
    selector: &str,
    event: HookEventArg,
    force: bool,
    accept: crate::hook::Accept,
) -> Result<HookTally> {
    if event == HookEventArg::Items {
        return run_items_hooks(paths, selector, force, accept);
    }
    let mut registry = Registry::load(paths)?;

    let indices: Vec<usize> = registry
        .sources
        .iter()
        .enumerate()
        .filter(|(_, s)| source_matches_glob(&s.name, selector))
        .map(|(i, _)| i)
        .collect();

    if indices.is_empty() {
        return Err(MindError::SourceNotFound {
            name: selector.to_string(),
        });
    }

    let mut registry_dirty = false;

    // spec: HOOK-107 -- count hooks that existed (were actually considered: not
    // already-up-to-date and not "no hooks declared"), how many of those ran,
    // and how many were skipped specifically for want of consent (a non-TTY
    // run, not an interactive decline). A `hooks run` that had work to do but
    // could not get consent for any of it is reported as an error rather than
    // a silent exit 0 (U43).
    let mut existed: usize = 0;
    let mut ran: usize = 0;
    let mut skipped_for_consent: usize = 0;
    // spec: HOOK-106 HOOK-107 -- every source that contributed at least one
    // considered hook, for the HooksNotRun remedy (paste-able only when this
    // holds exactly one entry; see `hooks_not_run_message`).
    let mut contributors: Vec<String> = Vec::new();

    for idx in indices {
        let source = &registry.sources[idx];
        let clone_dir = source.clone_dir(paths);
        let toml_path = clone_dir.join("mind.toml");

        let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
        let resolved = mindfile
            .as_ref()
            .map(|m| m.resolved_hooks(&toml_path))
            .transpose()?
            .unwrap_or_default();

        let hook_event = match event {
            HookEventArg::Install => HookEvent::Install,
            // spec: HOOK-126 -- an update run selects the update hooks and is
            // otherwise identical to an install run (pending filter, recording).
            HookEventArg::Update => HookEvent::Update,
            HookEventArg::Uninstall => HookEvent::Uninstall,
            HookEventArg::Build => unreachable!("caller guards build for source targets"),
            HookEventArg::Items => unreachable!("items runs through run_items_hooks"),
        };
        let event_name = event_label(event);

        // spec: HOOK-111 HOOK-56 -- the consumer's recorded override replaces
        // the declared install hooks, and the declared update hooks when there
        // are any, exactly as `upgrade` applies it.
        let override_cmd = source.override_command().map(str::to_string);
        let (resolved, replaced) = match (&override_cmd, hook_event) {
            (Some(cmd), HookEvent::Install) => {
                crate::hook::apply_hook_override(resolved, Some(cmd), hook_event)
            }
            (Some(cmd), HookEvent::Update)
                if resolved.iter().any(|h| h.event == HookEvent::Update) =>
            {
                crate::hook::apply_hook_override(resolved, Some(cmd), hook_event)
            }
            _ => (resolved, None),
        };
        let replaced = replaced.unwrap_or_default();

        let hooks: Vec<&ResolvedHook> = resolved.iter().filter(|h| h.event == hook_event).collect();

        if hooks.is_empty() {
            // spec: CLI-217, DSC-95 -- `source.name` is source-influenced.
            crate::render::note(format!(
                "note: no {event_name} hooks declared for source {}",
                strip_ansi(&source.name)
            ));
            continue;
        }

        let source_name = source.name.clone();
        let pin_desc = pin_description(&source.pin);
        let commit = source.commit.clone().unwrap_or_default();
        let current = source.commit.clone();
        let clone_path = clone_dir.display().to_string();
        let browse_url = source.browse_url(&commit);
        let mut source_contributed = false;
        // spec: HOOK-126 -- hooks the pending filter held back (each of which
        // really ran at this commit; a HOOK-121 baseline does not filter here),
        // so a run that considered nothing can say WHY instead of exiting 0 in
        // silence.
        let mut settled: Vec<String> = Vec::new();

        for h in &hooks {
            // spec: HOOK-101 -- for a recorded event (install or update), skip
            // hooks already run at the current commit unless --force overrides.
            if let Some(rec_event) = recorded_event(hook_event)
                && !force
                && hook_already_ran(
                    &registry.sources[idx],
                    &h.run,
                    rec_event,
                    current.as_deref(),
                )
            {
                settled.push(strip_ansi(h.label()));
                continue;
            }
            existed += 1;
            source_contributed = true;

            // spec: HOOK-111 -- only the override itself replaced anything.
            let replaced_here: &[String] = if override_cmd.as_deref() == Some(h.run.as_str()) {
                &replaced
            } else {
                &[]
            };
            let disclosure = crate::hook::hook_disclosure_text(
                h.label(),
                h.event.as_str(),
                h.optional,
                &source_name,
                &pin_desc,
                &commit,
                &clone_path,
                &h.run,
                (!replaced_here.is_empty())
                    .then(|| replaced_here.join("; "))
                    .as_deref(),
                browse_url.as_deref(),
            );

            let site = crate::hook_log::Site {
                item: None,
                commit: current.as_deref(),
                replaced: replaced_here,
            };
            // `hooks run` is outside the unified plan (HOOK-180): no plan ever
            // lists these, so each is prompted on its own (HOOK-185).
            let key = crate::consent::HookKey::source_hook(&source_name, h.event.as_str(), &h.run);
            match crate::hook::decide(&key, &disclosure, h.optional, accept.event(h.event))? {
                crate::hook::HookAct::Run => {
                    ran += 1;
                    // Unlike the `note:`-prefixed lines around it this is a
                    // progress announcement, so it stays a `println!`: stdout in
                    // text mode, unreachable from `--json` stdout because the
                    // run's fd 1 points at stderr (main.rs's `json_stdout`).
                    // Same for install.rs's `running build hook for ...` and
                    // commands.rs's `running install hook '...' for ...`. See
                    // `tests/cli_hooks.rs::hooks_run_running_hook_note_is_one_document`.
                    // spec: CLI-217
                    println!(
                        "running {event_name} hook '{}' for {}",
                        h.label(),
                        source_name
                    );
                    // spec: HOOK-53 -- a non-zero exit is a hard stop; propagate
                    // the error after saving whatever was recorded so far.
                    // spec: HOOK-141 -- `hooks run` sees the recorded set.
                    let hook_env = crate::commands::source_hook_env(&registry.sources[idx]);
                    if let Err(e) = crate::hook::run_hook(
                        &h.run,
                        &clone_dir,
                        &source_name,
                        event_name,
                        h.label(),
                        &hook_env,
                        site,
                    ) {
                        if registry_dirty {
                            let _ = registry.save(paths);
                        }
                        return Err(e);
                    }
                    // spec: HOOK-101 HOOK-124 -- record the run-commit for
                    // install and update hooks, under this event's key.
                    if let Some(rec_event) = recorded_event(hook_event) {
                        record_hook_run(
                            &mut registry.sources[idx],
                            &h.run,
                            rec_event,
                            current.clone(),
                        );
                        registry_dirty = true;
                    }
                }
                crate::hook::HookAct::Skip => {
                    // spec: HOOK-160
                    crate::hook_log::not_run(&source_name, site, event_name, h.label(), &h.run);
                    // spec: HOOK-106 -- when the skip was for want of consent (a
                    // non-TTY run), name the cause and print the exact,
                    // copy-pasteable command to re-run it unattended, instead of
                    // a bare note that says nothing about why or what to do.
                    if !crate::hook::is_tty() {
                        skipped_for_consent += 1;
                        // The remedy re-selects the event that was run:
                        // `--event` defaults to `install`, so an uninstall
                        // skip whose remedy omitted it would silently suggest
                        // running different code.
                        // spec: CLI-217 HOOK-106
                        crate::render::note(source_skip_note(event_name, h.label(), &source_name));
                    } else {
                        // spec: CLI-217, DSC-95 -- the label and the identity are
                        // both source-controlled and `render::note` does not
                        // sanitize, so strip each field before composing.
                        crate::render::note(format!(
                            "note: skipped {event_name} hook '{}' for {}",
                            strip_ansi(h.label()),
                            strip_ansi(&source_name)
                        ));
                    }
                    // spec: HOOK-101 -- even a skipped install (or update)
                    // hook is recorded (with ran_at = None) so repeat runs know
                    // it was offered.
                    if let Some(rec_event) = recorded_event(hook_event) {
                        record_hook_run(&mut registry.sources[idx], &h.run, rec_event, None);
                        registry_dirty = true;
                    }
                }
                crate::hook::HookAct::Abort => {
                    crate::hook_log::aborted(&source_name, site, event_name, h.label(), &h.run);
                    // spec: HOOK-100 -- a required hook's abort is a non-zero exit.
                    if registry_dirty {
                        let _ = registry.save(paths);
                    }
                    return Err(MindError::HookAborted {
                        label: h.label().to_string(),
                    });
                }
            }
        }
        if source_contributed {
            contributors.push(source_name.clone());
        } else if !settled.is_empty() {
            // spec: HOOK-126 CLI-217 -- every declared hook was held back by the
            // pending filter. Without this the run exits 0 having done nothing
            // and said nothing, which reads exactly like "this source has no
            // hooks". Name the commit they are settled at and the way to run
            // them anyway.
            crate::render::note(nothing_pending_note(
                event_name,
                &settled,
                &source_name,
                current.as_deref(),
            ));
        }
    }

    if registry_dirty {
        registry.save(paths)?;
    }

    // spec: HOOK-107 -- there was work to do, and none of it could get consent
    // (every hook that existed was skipped for want of a terminal). A run with
    // nothing to do (no hooks declared, or every install hook already ran at
    // the current commit) stays exit 0; only this specific "had work, no
    // consent available" case is an error.
    if existed > 0 && ran == 0 && skipped_for_consent > 0 {
        return Err(MindError::HooksNotRun {
            target: selector.to_string(),
            event: event_label(event).to_string(),
            skipped: skipped_for_consent,
            resolved: contributors,
        });
    }

    Ok(HookTally {
        existed,
        ran,
        skipped_for_consent,
    })
}

/// `hooks run <source> --event items` (HOOK-196): offer each matched source's
/// pending items hooks, or every one under `--force`, with `command` set to
/// `hooks run`. The HOOK-107 accounting applies as for the other events.
// spec: HOOK-196
fn run_items_hooks(
    paths: &Paths,
    selector: &str,
    force: bool,
    accept: crate::hook::Accept,
) -> Result<HookTally> {
    let registry = Registry::load(paths)?;
    let names: Vec<String> = registry
        .sources
        .iter()
        .filter(|s| source_matches_glob(&s.name, selector))
        .map(|s| s.name.clone())
        .collect();
    if names.is_empty() {
        return Err(MindError::SourceNotFound {
            name: selector.to_string(),
        });
    }
    let mut tally = HookTally::default();
    let mut contributors: Vec<String> = Vec::new();
    for name in &names {
        let source = registry.find(name).expect("matched above");
        if crate::items_hook::effective_hooks(paths, source).is_empty() {
            // spec: CLI-217, DSC-95
            crate::render::note(format!(
                "note: no items hooks declared for source {}",
                strip_ansi(name)
            ));
            continue;
        }
        let t = crate::items_hook::offer(paths, name, "hooks run", accept, force)?;
        if t.existed > 0 {
            contributors.push(name.clone());
        } else if t.settled > 0 {
            // spec: HOOK-126 CLI-217 -- say why nothing ran.
            let quoted = crate::error::shell_quote(&strip_ansi(name));
            crate::render::note(format!(
                "note: nothing pending for {}: its items hooks already applied the installed \
                 set; use --force to run them anyway:\n  mind hooks run {quoted} --event items --force",
                strip_ansi(name)
            ));
        }
        tally.existed += t.existed;
        tally.ran += t.ran;
        tally.skipped_for_consent += t.skipped_for_consent;
    }
    // spec: HOOK-107
    if tally.existed > 0 && tally.ran == 0 && tally.skipped_for_consent > 0 {
        return Err(MindError::HooksNotRun {
            target: selector.to_string(),
            event: "items".to_string(),
            skipped: tally.skipped_for_consent,
            resolved: contributors,
        });
    }
    Ok(tally)
}

// ---------------------------------------------------------------------------
// Item-level hook runner (HOOK-102 / HOOK-103)
// ---------------------------------------------------------------------------

/// The HOOK-107/HOOK-108 accounting for one `hooks run` invocation: how many
/// hooks for the selected event were actually considered, how many of those
/// ran, and how many were skipped specifically for want of consent (a non-TTY
/// run, not an interactive decline).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct HookTally {
    existed: usize,
    ran: usize,
    skipped_for_consent: usize,
}

impl HookTally {
    /// Record `count` item hooks offered for the selected event, resolving how
    /// they will be decided (HOOK-108).
    ///
    /// Every item lifecycle hook goes through the same ladder as a source hook
    /// (`install.rs::run_item_hook`), mirrored here in the same order: the
    /// bypass flag runs it unattended (HOOK-83), a non-TTY run skips it
    /// outright (HOOK-22), and only a terminal prompts. So the outcome for the
    /// whole batch is known before the batch runs. An interactive run is the
    /// user's own decision per hook and is counted as neither a run nor a
    /// consent failure, which is what keeps an interactive decline at exit 0.
    fn offered(&mut self, count: usize, accepted: bool) {
        self.existed += count;
        if accepted {
            self.ran += count;
        } else if !crate::hook::is_tty() {
            self.skipped_for_consent += count;
        }
    }

    /// The HOOK-107 predicate: there was work to do, none of it ran, and at
    /// least one hook was skipped for want of consent.
    fn nothing_could_consent(self) -> bool {
        self.existed > 0 && self.ran == 0 && self.skipped_for_consent > 0
    }
}

/// Run an item's hooks in place (install/uninstall) or re-install it (build).
///
/// `target` is the raw target string the user typed; it names the run in the
/// HOOK-108 error so the printed remedy is the command they actually invoked.
/// `force` mirrors `run_source_hooks`'s `--force`: for the install event, it
/// re-runs a hook already recorded as run at the item's current commit
/// (HOOK-110) instead of filtering it out as pending-only. Returns the
/// HOOK-107/108 tally on success (CLI-222); always zero for `--event build`,
/// which has no hook-by-hook count of its own (and does not consult `force`:
/// a build hook carries no HOOK-110 recorded-run state to override).
// spec: HOOK-102 HOOK-103 HOOK-108 HOOK-110
fn run_item_hooks(
    paths: &Paths,
    item_ref: &crate::resolve::ItemRef,
    target: &str,
    event: HookEventArg,
    force: bool,
    accept: crate::hook::Accept,
) -> Result<HookTally> {
    let manifest = Manifest::load(paths)?;
    let matches = select_installed(&manifest.items, item_ref);

    if matches.is_empty() {
        // spec: HOOK-102 -- not-installed item is an error naming the item.
        return Err(MindError::NotInstalled {
            name: item_ref.name.clone(),
        });
    }

    // spec: HOOK-108 -- the HOOK-107 accounting applies to item targets too,
    // accumulated across every item the ref matched.
    let mut tally = HookTally::default();
    // spec: HOOK-106 HOOK-107 HOOK-108 -- every item that contributed at least
    // one considered hook, for the HooksNotRun remedy (see `run_source_hooks`'s
    // `contributors` for the same reasoning on the source side).
    let mut contributors: Vec<String> = Vec::new();

    // spec: CLI-194 -- a ref matching several items runs each in turn.
    for installed in matches {
        match event {
            HookEventArg::Build => {
                run_item_build(paths, installed, accept.build)?;
            }
            HookEventArg::Items => unreachable!("caller refuses items for item targets"),
            HookEventArg::Install | HookEventArg::Update | HookEventArg::Uninstall => {
                run_item_lifecycle_hooks(
                    paths,
                    installed,
                    event,
                    force,
                    accept,
                    &mut tally,
                    &mut contributors,
                )?;
            }
        }
    }

    // spec: HOOK-108 -- an item target that had hooks to offer but no way to
    // consent to any of them is an error, exactly as for a source target
    // (HOOK-107), instead of a silent exit 0.
    if tally.nothing_could_consent() {
        // spec: HOOK-106 HOOK-107 -- when the ref matched exactly one item AND
        // the typed target carries no glob metacharacter, the target as typed
        // already resolves back to that same item deterministically (whatever
        // form it was spelled in -- bare, a kind-qualified escape, or an
        // abbreviated source selector), so it is kept verbatim rather than
        // rewritten into a normalized form (that would risk re-entering the
        // HOOK-105 source/item ambiguity a kind-qualified escape was written
        // specifically to avoid). A target that DOES carry a glob metacharacter
        // (e.g. `agents#skill:sc*`) is echoed back only when it matched
        // SEVERAL items; when it happens to match exactly one, the glob text
        // itself is still not safe to paste into a shell (it would expand
        // against the caller's cwd, not necessarily naming the item at all),
        // so it is replaced with the resolved concrete identity instead --
        // `contributors` already holds exactly that (`item_hook_target`,
        // pushed by `run_item_lifecycle_hooks` for the one item that
        // contributed), which is what makes this branch able to reuse it
        // rather than re-deriving it from a separately captured `InstalledItem`.
        let resolved = if contributors.len() == 1 {
            if crate::resolve::is_glob(target) {
                contributors
            } else {
                vec![target.to_string()]
            }
        } else {
            contributors
        };
        return Err(MindError::HooksNotRun {
            target: target.to_string(),
            event: event_label(event).to_string(),
            skipped: tally.skipped_for_consent,
            resolved,
        });
    }
    Ok(tally)
}

/// Re-install an item through the transactional path so a failed build leaves
/// the existing copy untouched (HOOK-103 / LIFE-1 / LIFE-4).
// spec: HOOK-103
fn run_item_build(paths: &Paths, installed: &InstalledItem, accept_build: bool) -> Result<()> {
    let registry = Registry::load(paths)?;
    let source = registry
        .sources
        .iter()
        .find(|s| s.name == installed.source)
        .ok_or_else(|| MindError::SourceNotFound {
            name: installed.source.clone(),
        })?;

    let mut catalog_items = Vec::new();
    crate::catalog::scan_source(paths, source, &mut catalog_items)?;

    let catalog_item = catalog_items
        .iter()
        .find(|c| c.kind == installed.kind && c.name == installed.bare_name)
        .ok_or_else(|| MindError::NotInstalled {
            name: installed.name.clone(),
        })?;

    let commit = installed.commit.clone();
    // A progress line, not an aside, so it stays a `println!`: stdout in text
    // mode, and unreachable from `--json` stdout because the run's fd 1 points
    // at stderr (main.rs's `json_stdout`). See
    // `tests/cli_hooks.rs::hooks_run_build_rebuilding_note_is_one_document`.
    // spec: CLI-217
    println!(
        "rebuilding {} via transactional reinstall",
        installed.display_key()
    );
    // spec: HOOK-141 -- the recorded set and the item's recorded reasons.
    let hook_env = crate::hook::HookEnv {
        item_profiles: installed.profiles.clone(),
        ..crate::commands::source_hook_env(source)
    };
    let mut new_installed = crate::install::install(
        paths,
        catalog_item,
        &commit,
        &catalog_items,
        false,
        accept_build,
        &hook_env,
    )?;
    // Same commit, so the same release (VER-60).
    new_installed.version = installed.version.clone();
    // Persist the updated InstalledItem (hash may change after a build hook).
    let mut manifest = Manifest::load(paths)?;
    manifest.insert(new_installed);
    manifest.save(paths)?;
    Ok(())
}

/// Run an item's install or uninstall hooks in place at its store location,
/// adding what it considered to `tally` (HOOK-108) and, if it contributed at
/// least one considered hook, its key to `contributors` (HOOK-106/107, for the
/// `HooksNotRun` remedy). `force` (HOOK-110) re-offers an install hook already
/// recorded as run at the item's current commit, mirroring `run_source_hooks`'s
/// `!force` guard on the source side; it has no effect on the uninstall event,
/// which carries no recorded run-state to override.
// spec: HOOK-102 HOOK-108 HOOK-110
fn run_item_lifecycle_hooks(
    paths: &Paths,
    installed: &InstalledItem,
    event: HookEventArg,
    force: bool,
    accept: crate::hook::Accept,
    tally: &mut HookTally,
    contributors: &mut Vec<String>,
) -> Result<()> {
    let registry = Registry::load(paths)?;
    let source = registry
        .sources
        .iter()
        .find(|s| s.name == installed.source)
        .ok_or_else(|| MindError::SourceNotFound {
            name: installed.source.clone(),
        })?;

    let mut catalog_items: Vec<CatalogItem> = Vec::new();
    crate::catalog::scan_source(paths, source, &mut catalog_items)?;

    let catalog_item = catalog_items
        .iter()
        .find(|c| c.kind == installed.kind && c.name == installed.bare_name)
        .ok_or_else(|| MindError::NotInstalled {
            name: installed.name.clone(),
        })?;

    let store = paths.mind_home.join(&installed.store);
    let commit = &installed.commit;
    // spec: HOOK-141 -- the recorded set and the item's recorded reasons.
    let hook_env = crate::hook::HookEnv {
        item_profiles: installed.profiles.clone(),
        ..crate::commands::source_hook_env(source)
    };

    match event {
        // spec: HOOK-126 -- an update run selects the item's update hooks and is
        // otherwise identical to an install run: same pending filter (HOOK-110),
        // same recording, same in-place execution against the store copy.
        HookEventArg::Install | HookEventArg::Update => {
            let event_name = event_label(event);
            let all_hooks = if event == HookEventArg::Update {
                catalog_item.update_hooks()
            } else {
                catalog_item.install_hooks()
            };
            if all_hooks.is_empty() {
                // spec: CLI-217
                crate::render::note(format!(
                    "note: no {event_name} hooks declared for {}",
                    installed.display_key()
                ));
                return Ok(());
            }
            // spec: HOOK-110 -- a hook already recorded as run at the item's
            // current commit is filtered out before the HOOK-108 tally, exactly
            // as `hook_already_ran` filters an already-ran source install hook
            // (HOOK-101). This is what makes a repeat `hooks run` after the hook
            // ran see `existed == 0` and settle to exit 0 instead of
            // `HooksNotRun`. Filtered-out hooks print nothing, mirroring the
            // silent `continue` in `run_source_hooks`. `--force` skips this
            // filter entirely (offering every declared install hook regardless
            // of its recorded commit), mirroring `run_source_hooks`'s
            // `!force && hook_already_ran(...)` guard on the source side.
            let current = Some(commit.as_str());
            let hooks: Vec<&crate::mindfile::ResolvedHook> = all_hooks
                .into_iter()
                .filter(|h| force || !item_hook_already_ran(installed, &h.run, current))
                .collect();
            if hooks.is_empty() {
                return Ok(());
            }
            // spec: HOOK-108
            tally.offered(hooks.len(), accept.event_str(event_name));
            contributors.push(item_hook_target(installed));
            let (recorded, outcome) = crate::install::run_item_install_hooks_partial(
                catalog_item,
                &hooks,
                &store,
                commit,
                accept,
                &hook_env,
            );
            // spec: HOOK-110 -- persist whatever ran or was offered before an
            // abort too, exactly as `run_source_hooks` saves the registry
            // before propagating a hook's error (HOOK-53): unlike the
            // `learn`/`upgrade` path, this item stays installed regardless of
            // the outcome, so a later hook's failure must not discard the
            // record of an earlier hook that already ran its side effect (it
            // would otherwise be offered, and its side effect re-applied, on
            // retry).
            record_item_hooks_run(paths, installed.key().as_str(), &recorded)?;
            outcome?;
        }
        HookEventArg::Uninstall => {
            let hooks = catalog_item.uninstall_hooks();
            if hooks.is_empty() {
                // spec: CLI-217
                crate::render::note(format!(
                    "note: no uninstall hooks declared for {}",
                    installed.display_key()
                ));
                return Ok(());
            }
            // spec: HOOK-108
            tally.offered(hooks.len(), accept.uninstall);
            contributors.push(item_hook_target(installed));
            crate::install::run_item_uninstall_hooks(
                installed, &hooks, &store, commit, accept, &hook_env,
            )?;
        }
        HookEventArg::Build => unreachable!("build handled by run_item_build"),
        HookEventArg::Items => unreachable!("items is refused for item targets"),
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Source-level hook lister (HOOK-104)
// ---------------------------------------------------------------------------

/// List hooks for matching sources (and their installed items) without running.
// spec: HOOK-104 CLI-196 CLI-220
fn list_source_hooks(paths: &Paths, target: &str, selector: &str) -> Result<()> {
    let registry = Registry::load(paths)?;
    let manifest = Manifest::load(paths)?;
    let history = load_history(paths);

    let matching: Vec<&Source> = registry
        .sources
        .iter()
        .filter(|s| source_matches_glob(&s.name, selector))
        .collect();

    if matching.is_empty() {
        return Err(MindError::SourceNotFound {
            name: selector.to_string(),
        });
    }

    let mut sources_json: Vec<SourceHooksJson> = Vec::new();

    for source in matching {
        let clone_dir = source.clone_dir(paths);
        let toml_path = clone_dir.join("mind.toml");
        let mindfile = MindToml::load(&clone_dir).unwrap_or_default();
        let mut resolved = mindfile
            .as_ref()
            .map(|m| m.resolved_hooks(&toml_path))
            .transpose()?
            .unwrap_or_default();
        // spec: HOOK-190 HOOK-196 -- a curator's items hooks are recorded on the
        // source, which has no `mind.toml` to declare them.
        if mindfile.is_none() {
            resolved.extend(
                crate::items_hook::effective_hooks(paths, source)
                    .into_iter()
                    .map(|h| ResolvedHook {
                        name: Some(h.label),
                        run: h.run,
                        optional: h.optional,
                        event: HookEvent::Items,
                    }),
            );
        }

        // spec: DSC-95 -- `source.name` is attacker-controlled (a local-path
        // meld derives it from directory names).
        println!("source: {}", strip_ansi(&source.name));
        let current = source.commit.as_deref();

        let mut hooks_json: Vec<SourceHookJson> = Vec::new();
        if resolved.is_empty() {
            println!("  (no source-level hooks declared)");
        } else {
            for h in &resolved {
                let kind_str = if h.optional { "optional" } else { "required" };
                let event_str = h.event.as_str();
                // spec: HOOK-124 -- an update hook is recorded in the same
                // set as an install hook, so it carries the same
                // pending/last-ran status; an uninstall hook records nothing.
                // spec: HOOK-196 -- an items hook's status is its applied set.
                let status = if h.event == HookEvent::Items {
                    Some(crate::items_hook::status(paths, &manifest, source, &h.run))
                } else {
                    recorded_event(h.event)
                        .map(|rec_event| install_hook_status(source, &h.run, rec_event, current))
                };
                println!(
                    "  [{event_str}] {kind_str}  {:?}  {}",
                    h.run,
                    status.as_deref().unwrap_or("(not recorded)")
                );
                // spec: HOOK-168
                let last = last_result(&history, &source.name, None, event_str, &h.run);
                if let Some(r) = last {
                    println!("      {}", last_result_text(r, &h.run));
                }
                hooks_json.push(SourceHookJson {
                    event: event_str,
                    required: !h.optional,
                    command: h.run.clone(),
                    status,
                    last: last.cloned(),
                });
            }
        }

        // Also list the installed items of this source and their hooks.
        let source_items: Vec<&InstalledItem> = manifest
            .items
            .values()
            .filter(|it| it.source == source.name)
            .collect();

        let mut items_json: Vec<ItemHooksJson> = Vec::new();
        if !source_items.is_empty() {
            let mut catalog_items = Vec::new();
            let _ = crate::catalog::scan_source(paths, source, &mut catalog_items);

            for installed in source_items {
                let item_hooks: Vec<_> = if let Some(c) = catalog_items
                    .iter()
                    .find(|c| c.kind == installed.kind && c.name == installed.bare_name)
                {
                    c.hooks.iter().collect()
                } else {
                    vec![]
                };

                if !item_hooks.is_empty() {
                    println!("  item: {}", installed.display_key());
                    let mut item_hooks_json: Vec<ItemHookJson> = Vec::new();
                    for h in item_hooks {
                        let kind_str = if h.optional { "optional" } else { "required" };
                        let event_str = h.event.as_str();
                        println!("    [{event_str}] {kind_str}  {:?}", h.run);
                        // spec: HOOK-168
                        let key = installed.display_key();
                        let last =
                            last_result(&history, &source.name, Some(&key), event_str, &h.run);
                        if let Some(r) = last {
                            println!("        {}", last_result_text(r, &h.run));
                        }
                        item_hooks_json.push(ItemHookJson {
                            event: event_str,
                            required: !h.optional,
                            command: h.run.clone(),
                            last: last.cloned(),
                        });
                    }
                    items_json.push(ItemHooksJson {
                        // spec: DSC-95 -- `--json` field.
                        item: installed.display_key(),
                        source: None,
                        hooks: item_hooks_json,
                    });
                }
            }
        }

        sources_json.push(SourceHooksJson {
            // spec: DSC-95 -- `--json` field.
            source: strip_ansi(&source.name),
            hooks: hooks_json,
            items: items_json,
        });
    }

    if crate::render::ctx().json {
        emit_json(&HooksListResult {
            schema: 1,
            action: "hooks-list",
            target: target.to_string(),
            sources: sources_json,
            items: Vec::new(),
        })?;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Item-level hook lister (HOOK-104)
// ---------------------------------------------------------------------------

/// List hooks for matching installed items without running any.
// spec: HOOK-104 CLI-196 CLI-220
fn list_item_hooks(paths: &Paths, target: &str, item_ref: &crate::resolve::ItemRef) -> Result<()> {
    let manifest = Manifest::load(paths)?;
    let matches = select_installed(&manifest.items, item_ref);

    if matches.is_empty() {
        return Err(MindError::NotInstalled {
            name: item_ref.name.clone(),
        });
    }

    let registry = Registry::load(paths)?;
    let history = load_history(paths);

    let mut items_json: Vec<ItemHooksJson> = Vec::new();

    for installed in matches {
        // spec: DSC-95 -- `installed.source` is attacker-controlled.
        println!(
            "item: {} (source {})",
            installed.display_key(),
            strip_ansi(&installed.source)
        );

        let source = registry.sources.iter().find(|s| s.name == installed.source);
        let hooks: Vec<ResolvedHook> = if let Some(src) = source {
            let mut catalog_items = Vec::new();
            let _ = crate::catalog::scan_source(paths, src, &mut catalog_items);
            catalog_items
                .iter()
                .find(|c| c.kind == installed.kind && c.name == installed.bare_name)
                .map(|c| c.hooks.clone())
                .unwrap_or_default()
        } else {
            vec![]
        };

        let mut hooks_json: Vec<ItemHookJson> = Vec::new();
        if hooks.is_empty() {
            println!("  (no hooks declared)");
        } else {
            for h in &hooks {
                let kind_str = if h.optional { "optional" } else { "required" };
                let event_str = h.event.as_str();
                println!("  [{event_str}] {kind_str}  {:?}", h.run);
                // spec: HOOK-168
                let key = installed.display_key();
                let last = last_result(&history, &installed.source, Some(&key), event_str, &h.run);
                if let Some(r) = last {
                    println!("      {}", last_result_text(r, &h.run));
                }
                hooks_json.push(ItemHookJson {
                    event: event_str,
                    required: !h.optional,
                    command: h.run.clone(),
                    last: last.cloned(),
                });
            }
        }

        items_json.push(ItemHooksJson {
            // spec: DSC-95 -- `--json` field.
            item: installed.display_key(),
            source: Some(strip_ansi(&installed.source)),
            hooks: hooks_json,
        });
    }

    if crate::render::ctx().json {
        emit_json(&HooksListResult {
            schema: 1,
            action: "hooks-list",
            target: target.to_string(),
            sources: Vec::new(),
            items: items_json,
        })?;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Hook history (HOOK-167)
// ---------------------------------------------------------------------------

/// `mind hooks history --json`'s result document (CLI-253).
#[derive(Serialize, Debug)]
struct HooksHistoryResult {
    schema: u8,
    action: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    target: Option<String>,
    records: Vec<HistoryRecordJson>,
}

/// One `hooks history --json` record: the HOOK-160 object, plus the saved
/// output under `--show` (HOOK-169).
#[derive(Serialize, Debug)]
struct HistoryRecordJson {
    #[serde(flatten)]
    record: HookRecord,
    /// The log's text; `null` when the record names a log that is gone.
    /// Absent without `--show` or when no log was saved.
    #[serde(skip_serializing_if = "Option::is_none")]
    output: Option<Option<String>>,
}

/// What `mind hooks history` was asked for (CLI-253, CLI-254, HOOK-169).
#[derive(Debug, Default)]
pub struct HistoryQuery {
    pub target: Option<String>,
    pub failed: bool,
    pub outcomes: Vec<Outcome>,
    pub events: Vec<String>,
    pub since: Option<String>,
    pub limit: Option<usize>,
    pub show: bool,
}

impl HistoryQuery {
    /// Whether anything narrows the history beyond its own contents.
    fn filters(&self) -> bool {
        self.target.is_some()
            || self.failed
            || !self.outcomes.is_empty()
            || !self.events.is_empty()
            || self.since.is_some()
            // `--limit 0` empties a non-empty history, so it counts too.
            || self.limit == Some(0)
    }
}

/// `mind hooks history [target] [filters] [--limit N] [--show]` -- show recent
/// hook results from the history file, oldest first (HOOK-167).
// spec: HOOK-167 HOOK-169 CLI-253 CLI-254
pub fn history(paths: &Paths, q: &HistoryQuery) -> Result<()> {
    let path = crate::hook_log::history_file(&paths.mind_home);
    let all = crate::hook_log::read_history(&path).map_err(|e| MindError::io(&path, e))?;
    let filter = q
        .target
        .as_deref()
        .map(|t| history_target(t, &all))
        .transpose()?;
    let since = q.since.as_deref().map(since_cutoff).transpose()?;
    // `--failed` is shorthand for `--outcome failed`; given both, either matches.
    let mut outcomes = q.outcomes.clone();
    if q.failed {
        outcomes.push(Outcome::Failed);
    }
    let mut records: Vec<HookRecord> = all
        .into_iter()
        .filter(|r| outcomes.is_empty() || outcomes.contains(&r.outcome))
        .filter(|r| q.events.is_empty() || q.events.contains(&r.event))
        .filter(|r| since.as_deref().is_none_or(|s| r.at.as_str() >= s))
        .filter(|r| filter.as_ref().is_none_or(|f| record_matches(r, f)))
        .collect();
    if let Some(n) = q.limit {
        let drop = records.len().saturating_sub(n);
        records.drain(..drop);
    }

    let cap = crate::config::Config::load(paths)
        .ok()
        .and_then(|c| c.hook_history)
        .unwrap_or(crate::hook_log::DEFAULT_HISTORY);
    // spec: HOOK-164 -- say the history is off even when an older file still
    // holds records, so they are not read as current. Under `--json` the note
    // goes to stderr, outside the document.
    if cap == 0 {
        crate::render::note(
            "note: hook history is off (hook-history = 0 in config.toml); \
             nothing new is recorded",
        );
    }

    if crate::render::ctx().json {
        return emit_json(&HooksHistoryResult {
            schema: 1,
            action: "hooks-history",
            target: q.target.clone(),
            records: records
                .into_iter()
                .map(|r| HistoryRecordJson {
                    output: (q.show && r.log.is_some()).then(|| read_log(&r)),
                    record: r,
                })
                .collect(),
        });
    }

    if records.is_empty() {
        if cap > 0 && q.filters() {
            println!("no recorded hook results match");
        } else if cap > 0 {
            println!("no hook results recorded");
        }
        return Ok(());
    }
    for r in &records {
        println!("{}", history_line(r));
        let Some(log) = &r.log else {
            continue;
        };
        if !q.show {
            println!("    log: {log}");
            continue;
        }
        // spec: HOOK-169 -- the log itself, verbatim (it is already tagged
        // and headed), or a note that it is gone.
        match std::fs::File::open(log) {
            Ok(file) => {
                println!("    log: {log}");
                print_log(file);
            }
            Err(_) => println!("    log: {log} (no longer on disk)"),
        }
    }
    Ok(())
}

/// The text of `r`'s saved log, sanitized line by line (DSC-95: it is the
/// hook's own output), or `None` when it cannot be read.
fn read_log(r: &HookRecord) -> Option<String> {
    let file = std::fs::File::open(r.log.as_deref()?).ok()?;
    let mut text = String::new();
    for_each_log_line(file, |l| {
        text.push_str(l);
        text.push('\n');
        true
    });
    Some(text)
}

/// The longest line [`for_each_log_line`] reads at once: a log's own lines are
/// at most 64 KiB plus a tag (HOOK-165), so only a file that is not a hook log
/// is ever cut here.
const SHOW_LINE_MAX: u64 = 128 * 1024;

/// Call `f` with each line of a saved log, sanitized (DSC-95) one line at a
/// time: `strip_ansi` folds a newline into a space, so the file is split
/// first, and a large log is never held in memory whole (HOOK-169). A read
/// error ends the lines where it happened.
fn for_each_log_line(file: std::fs::File, mut f: impl FnMut(&str) -> bool) {
    use std::io::{BufRead, Read};
    let mut reader = std::io::BufReader::new(file);
    let mut line = Vec::new();
    loop {
        line.clear();
        match reader
            .by_ref()
            .take(SHOW_LINE_MAX)
            .read_until(b'\n', &mut line)
        {
            Ok(0) | Err(_) => break,
            Ok(_) => {}
        }
        if line.last() == Some(&b'\n') {
            line.pop();
        }
        if !f(&strip_ansi(&String::from_utf8_lossy(&line))) {
            break;
        }
    }
}

/// Print a saved log under `--show` (HOOK-169), streamed.
fn print_log(file: std::fs::File) {
    use std::io::Write;
    let mut out = std::io::stdout().lock();
    for_each_log_line(file, |l| writeln!(out, "{l}").is_ok());
}

/// The earliest `at` a `--since` value keeps (CLI-254), as an RFC 3339 prefix
/// that compares as text against the records' `at`: a duration counts back
/// from now; a date or time is taken as UTC.
fn since_cutoff(value: &str) -> Result<String> {
    let v = value.trim();
    if let Ok(secs) = crate::auto_update::parse_duration(v) {
        let now = crate::auto_update::now();
        return Ok(crate::hook_log::rfc3339(now.saturating_sub(secs)));
    }
    let bad = || MindError::BadHistorySince {
        value: value.to_string(),
    };
    // YYYY-MM-DD, then optionally THH:MM, :SS, and a trailing Z.
    let body = v.strip_suffix('Z').unwrap_or(v);
    let shape_ok = |s: &str, pattern: &str| {
        s.len() == pattern.len()
            && s.bytes().zip(pattern.bytes()).all(|(c, p)| match p {
                b'9' => c.is_ascii_digit(),
                _ => c == p,
            })
    };
    let ok = ["9999-99-99", "9999-99-99T99:99", "9999-99-99T99:99:99"]
        .iter()
        .any(|p| shape_ok(body, p));
    if !ok {
        return Err(bad());
    }
    // The fields in range too, so a typo is refused rather than silently
    // matching nothing (or everything). Offsets per the patterns above.
    let field = |at: usize| body.get(at..at + 2).and_then(|s| s.parse::<u32>().ok());
    let in_range = |at: usize, lo: u32, hi: u32| field(at).is_none_or(|n| (lo..=hi).contains(&n));
    if !(in_range(5, 1, 12)
        && in_range(8, 1, 31)
        && in_range(11, 0, 23)
        && in_range(14, 0, 59)
        && in_range(17, 0, 59))
    {
        return Err(bad());
    }
    Ok(body.to_string())
}

/// One `hooks history` text line: when, outcome, event, target, label, and
/// status, plus the verb that ran it. An item hook's label is its event
/// (HOOK-160), already in the line, so only a source hook's label is shown.
fn history_line(r: &HookRecord) -> String {
    let (target, label) = match &r.item {
        Some(item) => (format!("{}#{item}", r.source), String::new()),
        None => (r.source.clone(), format!(" '{}'", r.label)),
    };
    let status = if r.outcome.ran() {
        format!(" ({})", r.status_text())
    } else {
        String::new()
    };
    let commit = r
        .commit
        .as_deref()
        .map(|c| format!(" at {}", short_commit(c)))
        .unwrap_or_default();
    // The outcome column fits the longest outcome, `interrupted`.
    format!(
        "{}  {:<11} {:<9} {target}{label}{status}{commit}  [{}]",
        r.at,
        r.outcome.as_str(),
        r.event,
        r.verb
    )
}

fn short_commit(c: &str) -> &str {
    c.get(..12).unwrap_or(c)
}

/// Read a `hooks history` target the way [`resolve_hook_target`] reads a
/// `hooks run`/`hooks list` one, against the recorded identities instead of
/// the registry (CLI-253): a `source:` prefix forces the source reading, and
/// a target equal to a recorded source identity is that source even when it
/// carries `#` (an item-link instance, LNK-4). Anything else falls back to
/// the `#`-split heuristic.
// spec: CLI-253 HOOK-105
fn history_target(target: &str, records: &[HookRecord]) -> Result<HookTarget> {
    let trimmed = target.trim();
    if let Some(rest) = trimmed.strip_prefix("source:") {
        return Ok(HookTarget::Source(rest.trim().to_string()));
    }
    if records.iter().any(|r| r.source == trimmed) {
        return Ok(HookTarget::Source(trimmed.to_string()));
    }
    parse_hook_target(trimmed)
}

/// Whether a history record belongs to a `hooks history` target: a source
/// filter matches the record's source (an item hook of a matched source
/// included); an item ref matches only that item's hooks.
fn record_matches(r: &HookRecord, target: &HookTarget) -> bool {
    match target {
        HookTarget::Source(selector) => source_matches_glob(&r.source, selector),
        HookTarget::Item(item_ref) => {
            let Some(item) = r.item.as_deref() else {
                return false;
            };
            let Some((kind, name)) = item.split_once(':') else {
                return false;
            };
            item_ref
                .source
                .as_deref()
                .is_none_or(|s| source_matches_glob(&r.source, s))
                && item_ref.kind.is_none_or(|k| k.as_str() == kind)
                && name_matches(name, &item_ref.name)
        }
    }
}

fn name_matches(name: &str, pattern: &str) -> bool {
    if crate::resolve::is_glob(pattern) {
        glob::Pattern::new(pattern).is_ok_and(|p| p.matches(name))
    } else {
        name == pattern
    }
}

/// The newest history record for one hook, for `hooks list` (HOOK-168):
/// matched on source, item, event, and command, where the command is the one
/// that ran or one it replaced (a consumer's override, HOOK-56).
fn last_result<'a>(
    history: &'a [HookRecord],
    source: &str,
    item: Option<&str>,
    event: &str,
    command: &str,
) -> Option<&'a HookRecord> {
    let source = strip_ansi(source);
    let command = strip_ansi(command);
    history.iter().rev().find(|r| {
        r.source == source
            && r.item.as_deref() == item
            && r.event == event
            && (r.command == command || r.replaced.contains(&command))
    })
}

/// The `hooks list` text for a hook's last result (HOOK-168), naming the
/// command that ran when it was an override of `command`.
fn last_result_text(r: &HookRecord, command: &str) -> String {
    let mut s = format!("last: {} ", r.outcome.as_str());
    if r.outcome.ran() {
        s.push_str(&format!("({}) ", r.status_text()));
    }
    s.push_str(&r.at);
    if r.command != strip_ansi(command) {
        s.push_str(&format!(", ran in its place: {}", r.command));
    }
    if let Some(log) = &r.log {
        s.push_str(&format!(", log: {log}"));
    }
    s
}

/// The history for `hooks list`, empty when it cannot be read.
fn load_history(paths: &Paths) -> Vec<HookRecord> {
    crate::hook_log::read_history(&crate::hook_log::history_file(&paths.mind_home))
        .unwrap_or_default()
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------

/// A short description of a `Pin` for the hook disclosure, the same wording
/// meld and upgrade use.
fn pin_description(pin: &Pin) -> String {
    crate::commands::pin_description(pin)
}

/// The `<source>#<kind>:<name>` form that re-resolves `installed` as a
/// `hooks run` item target (HOOK-106/107/108): unlike `InstalledItem::key()`
/// (the manifest key, `kind:name`, which `hooks run` does not accept as a
/// target at all), this is what a `HooksNotRun` remedy must name for the
/// command it prints to actually be runnable. Mirrors the `item_forms`
/// construction in `resolve_hook_target`'s `AmbiguousHookTarget` path.
fn item_hook_target(installed: &InstalledItem) -> String {
    // spec: DSC-95 -- `installed.source`/`installed.name` are attacker-controlled;
    // sanitize each field BEFORE composing (never the composed string), same
    // reasoning as `resolve_hook_target`'s `item_forms`.
    format!(
        "{}#{}:{}",
        strip_ansi(&installed.source),
        installed.kind.as_str(),
        strip_ansi(&installed.name)
    )
}

/// Whether a source hook has already RUN at `current` for this event (mirrors
/// the private `hook_ran_at` in commands.rs).
///
/// The event is half the key (HOOK-124). Without it, a source declaring the
/// same command for `install` and `update` has its update hook considered
/// already-run the moment the install hook ran, and `hooks run <src> --event
/// update` exits 0 having run nothing.
///
/// A HOOK-121 meld baseline is NOT a run: it holds back the automatic `upgrade`
/// pass on an unmoved source, not an on-demand `hooks run` where the user named
/// the target and the event themselves (HOOK-126).
// spec: HOOK-121 HOOK-124
fn hook_already_ran(
    source: &Source,
    command: &str,
    event: RecordedEvent,
    current: Option<&str>,
) -> bool {
    current.is_some()
        && source
            .install_hooks
            .iter()
            .any(|r| r.is(command, event) && !r.baseline && r.ran_at.as_deref() == current)
}

/// Whether an item's install hook has already run at `current` (HOOK-110).
/// Mirrors `hook_already_ran` above, but reads the per-item record
/// (`InstalledItem::install_hooks`) instead of the source's.
fn item_hook_already_ran(installed: &InstalledItem, command: &str, current: Option<&str>) -> bool {
    current.is_some()
        && installed
            .install_hooks
            .iter()
            .any(|r| r.command == command && r.ran_at.as_deref() == current)
}

/// Upsert `recorded`'s outcomes into the manifest entry keyed `key`'s
/// `install_hooks` (HOOK-110), mirroring `record_hook_run`'s upsert-by-command
/// for the source-level record. A no-op if the item vanished from the manifest
/// between load and this call (nothing to persist against).
fn record_item_hooks_run(paths: &Paths, key: &str, recorded: &[RecordedHook]) -> Result<()> {
    if recorded.is_empty() {
        return Ok(());
    }
    let mut manifest = Manifest::load(paths)?;
    if let Some(item) = manifest.items.get_mut(key) {
        for r in recorded {
            if let Some(existing) = item
                .install_hooks
                .iter_mut()
                .find(|e| e.command == r.command)
            {
                existing.ran_at = r.ran_at.clone();
            } else {
                item.install_hooks.push(r.clone());
            }
        }
        manifest.save(paths)?;
    }
    Ok(())
}

/// Upsert a hook's run state in `source.install_hooks`, keyed by `(command,
/// event)` (mirrors the private `record_install_hook` in commands.rs). A run
/// clears the HOOK-121 baseline flag: the record now describes a real run.
// spec: HOOK-124
fn record_hook_run(
    source: &mut Source,
    command: &str,
    event: RecordedEvent,
    ran_at: Option<String>,
) {
    if let Some(r) = source
        .install_hooks
        .iter_mut()
        .find(|r| r.is(command, event))
    {
        r.ran_at = ran_at;
        r.baseline = false;
    } else {
        let mut rec = RecordedSourceHook::install(command, ran_at);
        rec.event = Some(event);
        source.install_hooks.push(rec);
    }
}

/// The HOOK-106 skip NOTE for one source hook skipped for want of consent in a
/// non-TTY run: it names the cause and prints the exact, copy-pasteable
/// `mind hooks run <id> ...` command to re-run the hook unattended. This is the
/// note counterpart of the [`MindError::HooksNotRun`] error's remedy, and it
/// carries the SAME injection surface: `source_name` is attacker-influenced (a
/// source name, marketplace alias, or item-link path segment, none restricted
/// against shell metacharacters), so the identity that lands inside the runnable
/// command is passed through [`crate::error::shell_quote`] (HOOK-106) exactly as
/// the error's remedy is. The runnable command is printed on its own indented
/// line with no surrounding shell-quote character, exactly like
/// `error::hooks_not_run_message`'s single-match arm: a DOUBLE-quote
/// presentation frame around the (already single-quoted) identity is copyable
/// in a way that re-exposes `$`/backtick inside it when the frame's own quotes
/// are pasted along with the command, so this note avoids a shell-quote
/// character as its frame entirely. `source_name` also appears once as bare
/// prose ("for <name>"), which is not a shell command and needs no quoting.
fn source_skip_note(event_name: &str, label: &str, source_name: &str) -> String {
    // spec: DSC-95 -- `render::note` does not sanitize, and both the label and
    // the identity are source-controlled: a label of cursor-control escapes can
    // scroll away or overwrite the region where the NEXT hook's consent
    // disclosure is about to be drawn. Strip each field before composing.
    let label = strip_ansi(label);
    let source_name = strip_ansi(source_name);
    let quoted = crate::error::shell_quote(&source_name);
    format!(
        "note: skipped {event_name} hook '{label}' for {source_name} (not a terminal); \
         re-run unattended with:\n  mind hooks run {quoted} --event {event_name} \
         --accept-hooks={event_name}"
    )
}

/// The HOOK-126 "nothing pending" note: `hooks run` found hooks for the event
/// but the pending filter held every one of them back, so the run had nothing
/// to consider.
///
/// `labels` and `source_name` are both source-controlled and land in
/// `render::note`, which does not sanitize, so each is stripped before it is
/// composed in (DSC-95). The identity also appears inside a runnable command,
/// where it is shell-quoted for the same reason `source_skip_note` quotes it.
fn nothing_pending_note(
    event_name: &str,
    settled: &[String],
    source_name: &str,
    current: Option<&str>,
) -> String {
    let safe_name = strip_ansi(source_name);
    let quoted = crate::error::shell_quote(&safe_name);
    let at = match current {
        Some(c) => format!("already ran at {}", strip_ansi(c)),
        None => "already ran".to_string(),
    };
    let which: Vec<String> = settled.iter().map(|l| format!("'{l}'")).collect();
    format!(
        "note: nothing pending for {safe_name}: {event_name} hook(s) {} {at}; \
         use --force to run them anyway:\n  mind hooks run {quoted} --event {event_name} --force",
        which.join(", ")
    )
}

/// The recorded counterpart of a lifecycle event (HOOK-55, HOOK-124), or `None`
/// for an event whose runs are not recorded and so does not participate in the
/// pending filter: install and update are recorded, uninstall is not (it only
/// ever fires at `unmeld` or on demand).
// spec: HOOK-124
fn recorded_event(event: HookEvent) -> Option<RecordedEvent> {
    RecordedEvent::of(event)
}

/// The label for a `--event` value, as it appears in notes and disclosures.
fn event_label(event: HookEventArg) -> &'static str {
    match event {
        HookEventArg::Install => "install",
        HookEventArg::Update => "update",
        HookEventArg::Uninstall => "uninstall",
        HookEventArg::Build => "build",
        HookEventArg::Items => "items",
    }
}

/// Status string for a recorded source hook of `event` shown by `hooks list`.
/// Returns "pending (never ran)", "pending (last ran at <commit>)", "ran at
/// <commit>", or, for a HOOK-121 baseline, "not pending (recorded at meld
/// <commit>)".
///
/// The event is part of the lookup key (HOOK-124): a command declared for both
/// events has two records, and reporting one event's status under the other
/// would tell the user a hook had run when it had not.
// spec: HOOK-124
fn install_hook_status(
    source: &Source,
    command: &str,
    event: RecordedEvent,
    current: Option<&str>,
) -> String {
    match source.install_hooks.iter().find(|r| r.is(command, event)) {
        None => "pending (never ran)".to_string(),
        Some(rec) => match &rec.ran_at {
            None => "pending (never ran)".to_string(),
            Some(ran) if rec.baseline => {
                // HOOK-121: recorded at the meld commit without running, so the
                // hook is not pending, but it never ran either. Saying "ran at"
                // here would be a plain untruth on an informational surface.
                if current.is_some_and(|c| c == ran) {
                    format!("not pending (recorded at meld {ran})")
                } else {
                    format!("pending (never ran; melded at {ran})")
                }
            }
            Some(ran) => {
                if current.is_some_and(|c| c == ran) {
                    format!("ran at {ran}")
                } else {
                    format!("pending (last ran at {ran})")
                }
            }
        },
    }
}

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

    /// A `--since` date or time is refused when a field is out of range, not
    /// only when its shape is wrong.
    // spec: CLI-254
    #[test]
    fn since_refuses_out_of_range_fields() {
        for good in [
            "2026-10-28",
            "2026-10-28T14:00",
            "2026-10-28T23:59:59Z",
            "2026-01-01T00:00:00",
        ] {
            assert!(since_cutoff(good).is_ok(), "{good}");
        }
        for bad in [
            "2026-13-01",
            "2026-00-10",
            "2026-10-32",
            "2026-10-00",
            "2026-10-28T24:00",
            "2026-10-28T14:60",
            "2026-10-28T14:00:60",
            "2026-10-28T14",
        ] {
            assert!(since_cutoff(bad).is_err(), "{bad}");
        }
    }

    /// Every outcome, `interrupted` included, pads to the same width, so the
    /// event column lines up across `hooks history` lines.
    // spec: HOOK-167
    #[test]
    fn history_lines_align_for_every_outcome() {
        let rec = |outcome| HookRecord {
            at: "2026-10-28T14:03:09Z".into(),
            run: "r".into(),
            verb: "meld".into(),
            event: "install".into(),
            label: "setup".into(),
            source: "local/a/b".into(),
            item: None,
            command: "true".into(),
            replaced: Vec::new(),
            commit: None,
            outcome,
            exit_code: None,
            signal: None,
            duration_ms: None,
            error: None,
            log: None,
        };
        let columns: Vec<usize> = [
            Outcome::Ok,
            Outcome::Failed,
            Outcome::Skipped,
            Outcome::Declined,
            Outcome::Aborted,
            Outcome::Interrupted,
        ]
        .into_iter()
        .map(|o| history_line(&rec(o)).find("install").unwrap())
        .collect();
        assert!(columns.windows(2).all(|w| w[0] == w[1]), "{columns:?}");
    }

    /// The HOOK-107 predicate, one axis at a time. Each of the three clauses
    /// has to be able to veto on its own, or the error fires on a run that
    /// either had nothing to do or actually did something.
    // spec: HOOK-107 HOOK-108
    #[test]
    fn nothing_could_consent_needs_work_no_runs_and_a_consent_failure() {
        let t = |existed, ran, skipped_for_consent| {
            HookTally {
                existed,
                ran,
                skipped_for_consent,
            }
            .nothing_could_consent()
        };
        // The one true case: there was work, none of it ran, consent failed.
        assert!(t(1, 0, 1));
        assert!(t(5, 0, 5));
        // Nothing existed: a run with nothing to do is never an error, even if
        // the other counters were somehow non-zero.
        assert!(!t(0, 0, 0));
        assert!(!t(0, 0, 1));
        // Something ran: partial progress is not "the run did nothing".
        assert!(!t(2, 1, 1));
        // Nothing was skipped for want of consent: an interactive decline (or
        // an already-satisfied run) leaves this at zero and stays exit 0.
        assert!(!t(1, 0, 0));
    }

    /// `offered` records HOOKS, not calls: a single call for an item with three
    /// hooks contributes three, and repeated calls accumulate across the items
    /// an item glob matched (CLI-194).
    // spec: HOOK-108
    #[test]
    fn offered_accumulates_hook_counts_across_items() {
        let mut tally = HookTally::default();
        tally.offered(3, true);
        tally.offered(2, true);
        assert_eq!(
            tally,
            HookTally {
                existed: 5,
                ran: 5,
                skipped_for_consent: 0,
            }
        );
        assert!(!tally.nothing_could_consent());
    }

    /// A zero-hook offer moves nothing. The callers guard `hooks.is_empty()`
    /// before calling, so this pins that the guard is belt-and-braces rather
    /// than the only thing keeping an empty batch out of the accounting.
    // spec: HOOK-108
    #[test]
    fn offered_zero_hooks_is_a_no_op() {
        let mut tally = HookTally::default();
        tally.offered(0, true);
        tally.offered(0, false);
        assert_eq!(tally, HookTally::default());
        assert!(!tally.nothing_could_consent());
    }

    /// The bypass arm of `offered` short-circuits ahead of the TTY test, so it
    /// predicts "ran" without consulting the environment at all. That is what
    /// makes `--accept-hooks` unable to produce a
    /// `HooksNotRun` no matter how the process was launched.
    // spec: HOOK-108 HOOK-83
    #[test]
    fn offered_under_the_bypass_predicts_ran_regardless_of_environment() {
        let mut tally = HookTally::default();
        tally.offered(4, true);
        assert_eq!(tally.ran, 4);
        assert_eq!(tally.skipped_for_consent, 0);
        assert!(!tally.nothing_could_consent());
    }

    /// The event labels are the literal `--event` values (CLI-195), because the
    /// HOOK-106 remedy interpolates one straight into the command it tells the
    /// user to run. A label that is not a valid flag value would print a
    /// command that fails to parse.
    // spec: HOOK-106 CLI-195
    #[test]
    fn event_label_matches_the_cli_event_values() {
        assert_eq!(event_label(HookEventArg::Install), "install");
        assert_eq!(event_label(HookEventArg::Uninstall), "uninstall");
        assert_eq!(event_label(HookEventArg::Build), "build");
        for arg in [
            HookEventArg::Install,
            HookEventArg::Uninstall,
            HookEventArg::Build,
        ] {
            let label = event_label(arg);
            assert!(
                !label.is_empty() && label.chars().all(|c| c.is_ascii_lowercase()),
                "{label:?} must be a bare lowercase flag value"
            );
        }
    }

    /// HOOK-106 (P1 injection): the source skip NOTE's runnable command must
    /// shell-quote the resolved identity, exactly as the `HooksNotRun` error's
    /// remedy does. A source name is not restricted against shell metacharacters
    /// (`validate_prefix`/`is_safe_manifest_path` allow them), and the note
    /// interpolates it into a "copy this and run it" `mind hooks run <id> ...`
    /// command. On the UNFIXED note (`'mind hooks run {source_name} ...'`, framed
    /// in single quotes with the raw name inside) an identity carrying a single
    /// quote breaks out of the frame; this pins that it no longer can.
    // spec: HOOK-106
    #[test]
    fn source_skip_note_shell_quotes_the_identity_in_the_runnable_command() {
        let evil = "x'; touch /tmp/mind-skip-note-pwned; echo '";
        let note = source_skip_note("install", "setup", evil);
        // The runnable command carries the shell-quoted identity, never the bare
        // injected text spliced straight into the command.
        assert!(
            note.contains(&crate::error::shell_quote(evil)),
            "the note's command must carry the shell-quoted identity: {note}"
        );
        assert!(
            !note.contains("mind hooks run x'; touch"),
            "the note must never splice the raw identity into the command: {note}"
        );
    }

    /// HOOK-106 (P1 injection), proved by execution rather than string match:
    /// extract the `mind hooks run ...` command the note prints and run it
    /// through a real `sh -c`. The injected `touch` must never fire, and the
    /// shell must see the identity back as a single literal argument. This is
    /// the note-side counterpart of `error::tests::
    /// shell_quote_round_trips_a_malicious_identity_through_a_real_shell`.
    // spec: HOOK-106
    #[test]
    fn source_skip_note_command_is_inert_when_pasted_into_a_real_shell() {
        use std::process::Command;
        if Command::new("sh").arg("-c").arg("true").status().is_err() {
            // No `sh` on PATH (mirrors selfupdate.rs's skip): nothing to prove.
            return;
        }
        let sentinel = std::path::Path::new("/tmp/mind-skip-note-rt-pwned");
        let _ = std::fs::remove_file(sentinel);
        // No balancing quote in the payload, so on the UNFIXED note (bare
        // identity spliced into the command) the `;` terminates `mind hooks run`
        // and `touch` fires -- making the sentinel check below load-bearing, not
        // merely the stdout comparison.
        let evil = "x; touch /tmp/mind-skip-note-rt-pwned; echo hi";
        let note = source_skip_note("install", "setup", evil);

        // Pull out the runnable command from its own indented line (no
        // surrounding shell-quote character, HOOK-106 Fix 2).
        let lead = "re-run unattended with:\n";
        let start = note.find(lead).expect("note has a runnable command") + lead.len();
        let command = note[start..].lines().next().unwrap().trim();
        assert!(
            command.starts_with("mind hooks run "),
            "extracted command must be the mind invocation: {command:?}"
        );

        // Replace the `mind` binary name with `printf '%s\n'` so the arguments
        // the shell would hand `mind` are instead echoed: an injection would run
        // `touch` (and drop the sentinel) rather than being passed as data.
        let probe = command.replacen("mind hooks run", "printf '%s\\n'", 1);
        let out = Command::new("sh")
            .arg("-c")
            .arg(&probe)
            .output()
            .expect("sh -c must run");
        assert!(
            !sentinel.exists(),
            "the injected 'touch' must not have executed via the note's command: {probe:?}"
        );
        let _ = std::fs::remove_file(sentinel);
        // The identity must survive as one literal argument, unexecuted.
        assert!(
            String::from_utf8_lossy(&out.stdout).contains(evil),
            "the shell must see the identity back literally, proving the quote \
             was not interpreted: stdout {:?}",
            String::from_utf8_lossy(&out.stdout)
        );
    }

    /// HOOK-106 Fix 2: pasting the WHOLE framed remedy -- the "re-run
    /// unattended with:" line and the indented command line beneath it, frame
    /// included -- into a real shell must not execute an embedded `$(...)`
    /// command substitution. On the OLD double-quote presentation frame
    /// (`re-run with "mind hooks run '...' ..."`), copying the surrounding `"`
    /// characters along with the already-single-quoted identity re-exposed
    /// `$`/backtick inside it, because a double-quoted context keeps `$` and a
    /// backtick special even around an inner single-quoted segment. The new
    /// frame carries no shell-quote character at all, so a verbatim paste of
    /// the frame is inert regardless of what a reader includes.
    // spec: HOOK-106
    #[test]
    fn source_skip_note_whole_framed_remedy_is_inert_when_pasted_with_the_frame() {
        use std::process::Command;
        if Command::new("sh").arg("-c").arg("true").status().is_err() {
            return;
        }
        let sentinel = std::path::Path::new("/tmp/mind-skip-note-whole-frame-pwned");
        let _ = std::fs::remove_file(sentinel);
        let evil = "$(touch /tmp/mind-skip-note-whole-frame-pwned)`touch /tmp/mind-skip-note-whole-frame-pwned`";
        let note = source_skip_note("install", "setup", evil);

        // Take the remedy exactly as a reader would copy it: from "re-run
        // unattended with:" through the end of the note, frame and all.
        let lead_at = note
            .find("re-run unattended with:")
            .expect("note has a remedy frame");
        let framed_remedy = &note[lead_at..];

        let _ = Command::new("sh").arg("-c").arg(framed_remedy).output();
        assert!(
            !sentinel.exists(),
            "pasting the whole framed remedy (frame included) must not execute \
             the embedded command substitution: {framed_remedy:?}"
        );
        let _ = std::fs::remove_file(sentinel);
    }
}