-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhooks.json
More file actions
1213 lines (1213 loc) · 109 KB
/
Copy pathhooks.json
File metadata and controls
1213 lines (1213 loc) · 109 KB
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
{
"_comment": [
"Native Claude Code plugin-hooks schema (hooks = object keyed by event).",
"This file is dual-purpose:",
" 1. The plugin loader reads it directly when the ai-config plugin is",
" enabled, so each entry's `command` uses ${CLAUDE_PLUGIN_ROOT}.",
" 2. scripts/install-hooks.py reads it to register the same hooks into",
" ~/.claude/settings.json for the opt-in per-machine path; it rebuilds",
" its own $HOME/.claude command from the preserved `script` key and",
" ignores the `command`/`why`/`_note_*` fields.",
"",
"Extra keys (`script`, `why`, `_note_*`) are metadata the plugin schema",
"tolerates and ignores. `event` is the object key; `matcher` sits at the",
"group level; `timeout` and an optional `if` sit on the hook entry."
],
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/inject-local-time.sh\"",
"timeout": 10,
"script": "inject-local-time.sh",
"why": "CLAUDE.md 'Timestamp recaps in local time' -- supplies the real local time each turn, because a fabricated timestamp is indistinguishable from a remembered one."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/ensure-open-pr-monitor.py\"",
"timeout": 10,
"script": "ensure-open-pr-monitor.py",
"why": "Ensures one detached timer continuously reconciles every open GitHub PR and GitLab merge request authored by the authenticated user, including ones opened outside this session."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/inject-pr-monitor-status.py\"",
"timeout": 10,
"script": "inject-pr-monitor-status.py",
"why": "Surfaces changed state from the detached pollers on the next prompt: the per-PR watchers no-unmonitored-pr.py starts (GitHub only) and the all-open-PR monitor, which also polls GitLab merge requests. A poller observes while the session is absent but cannot wake a terminated model."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-ums-after-error.py\"",
"timeout": 10,
"script": "remind-ums-after-error.py",
"why": "CLAUDE.md 'Run UMS proactively' -- discovering you were wrong is a trigger with no artifact, so nothing else prompts the pass; this injects a reminder naming the admitted error when no memory/skill write followed it.",
"_note_not_a_stop_hook": [
"Deliberately UserPromptSubmit rather than Stop, which is the shape",
"the Stop guards use. A Stop guard suppresses a message that is",
"wrong to send. An error admission is RIGHT to send: blocking it would",
"suppress the honest correction, delay it reaching the user, and remove",
"the very signal that surfaces the mistake. So this reminds on the next",
"prompt and can only ever ADD context -- there is no code path in it",
"that suppresses, delays, or alters a message."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-mistake-without-a-hook.py\"",
"timeout": 10,
"script": "no-mistake-without-a-hook.py",
"why": "cai 2026-08-02 'every time you make a mistake, write a hook to prevent it recurring'. Injects rather than blocks, because an error admission is RIGHT to send -- only the follow-up is owed. Complements remind-ums-after-error.py: that one wants a recorded learning, this one wants a mechanism."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-deserialize-before-binary-claim.py\"",
"timeout": 10,
"script": "remind-deserialize-before-binary-claim.py",
"why": "ucdavis/bcs#579, 2026-08-05: `gh pr diff --name-only` listed three .rds artifacts, that path list was read as a claim about their CONTENTS, and a boxed BLOCKER asked the maintainer to authorize ~1.5h of serial compute plus a 500-task SLURM array -- from the path list alone. Measured afterward against d1dd05e3^, two of the three carried values equal to their predecessors and differed only in freshly stamped provenance, while msm-validation-accuracy-results.rds had genuinely moved (bias 121%, rmse 18%). The escalation was not baseless; it was unmeasured, and nothing in the path list separated the one real change from the two re-serializations. Injects rather than blocks: an escalation about a binary artifact's size, path, or tracking is right to send, and a guard that misfires on those gets switched off.",
"_note_discharge_is_path_scoped": [
"The discharge matches a deserialization and the artifact path in",
"the SAME tool_use blob. A readRDS of file B must not discharge a",
"claim about file A -- an over-broad discharge produces silence,",
"which is indistinguishable from compliance (algorithmatize-checks,",
"'A reminder guard's discharge condition is a second matcher').",
"It also requires a command that RAN: prose asserting the check",
"happened is the claim under suspicion, not evidence for it",
"(fail-fast, 'A guard's discharge fires on positive success')."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-learn-from-review.py\"",
"timeout": 10,
"script": "remind-learn-from-review.py",
"why": "ai-config#1065 'any time a reviewer brings up valid concerns, learn from that'. Injects, never blocks, when an accepted reviewer finding has no recorded learning or mechanism after it -- the external-correction sibling of remind-ums-after-error.py, which fires only on first-person admissions.",
"_note_covers_external_correction": [
"UserPromptSubmit, inject-only, for the same reason as the two",
"reminders above: accepting a reviewer's finding and fixing it is",
"RIGHT, so this can only ADD context and never blocks. It fills the",
"hole those two leave -- both key on a FIRST-PERSON admission and",
"remind-ums-after-error.py explicitly excludes correcting someone",
"else, so agreeing with a reviewer triggers neither. That is the",
"commonest way a mistake surfaces and the class ai-config#1065 is",
"about.",
"",
"Activation follows this file's uniform model (see the top-level",
"_comment and README): the plugin loads it on enable, and",
"install-hooks.py --fix registers it for a non-plugin install."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-ums-on-scrutiny.py\"",
"timeout": 10,
"script": "remind-ums-on-scrutiny.py",
"why": "ai-config#2261 'run UMS when work is scrutinized'. Injects, never blocks, when a review of your work was read, or a questioned claim was then corrected, with no explicit UMS after it -- the scrutiny sibling of remind-ums-after-error.py (first-person admission) and remind-learn-from-review.py (accepted finding).",
"_note_not_a_stop_hook": [
"UserPromptSubmit, inject-only, for the same reason as",
"remind-ums-after-error.py and remind-learn-from-review.py:",
"reading a review and correcting a claim are RIGHT, so this can",
"only ADD context and never blocks. It covers a review-read",
"before Address, and a closed Q&A whose correction never said",
"'I was wrong'. Those overlap the admission sibling when the",
"correction also matches ADMISSION; that overlap is deliberate.",
"Critical feedback that is not lexically decidable stays a",
"written rule, not a matcher.",
"",
"Activation follows this file's uniform model (see the top-level",
"_comment and README): the plugin loads it on enable, and",
"install-hooks.py --fix registers it for a non-plugin install."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-retry-before-declaring-blocked.py\"",
"timeout": 10,
"script": "remind-retry-before-declaring-blocked.py",
"why": "ai-config#2994, measured 2026-09-02: an `ALLOW_UNREVIEWED_PUSH=1 git push`, and the attempt to grant it a permission rule, were denied by the auto-mode permission classifier three times over; the session reported the path as permanently closed -- to the user, in a memory entry, and on this repo's own tracker -- and the byte-identical command then succeeded, with no settings change and no permission rule added. (#2994 is not self-consistent about how many of those three denials were the push itself rather than the `update-config` edit that followed it, so every claim here is worded to hold under both readings; #3008 tracks settling it.) Injects, never blocks, when a classifier denial has no later re-attempt of the same command.",
"_note_not_a_stop_hook": [
"UserPromptSubmit, inject-only, for the same reason as the",
"reminders above: reporting a denial is RIGHT to send. The",
"classifier's own message says to stop and explain when a",
"capability is essential, and a user waiting on a stalled task",
"needs to hear it. What is premature is the PERMANENCE, not the",
"report, so this can only ADD context and never suppresses,",
"delays, or alters a message.",
"",
"Scoped to the classifier's own denial and nothing else. A",
"user's own rejection is a decision to respect rather than a",
"sample to re-draw, and a deterministic permission-rule or hook",
"refusal returns the same answer by construction. All four",
"denial shapes were measured from real transcripts; the hook's",
"docstring carries the counts.",
"",
"Activation follows this file's uniform model (see the top-level",
"_comment and README): the plugin loads it on enable, and",
"install-hooks.py --fix registers it for a non-plugin install."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-both-sides-from-git.py\"",
"timeout": 10,
"script": "remind-both-sides-from-git.py",
"why": "ai-config#1185, an incident of 2026-08-05. A ucdavis/bcs session ran `git show d1dd05e3^:inst/extdata/ett-validation-true-effects.rds > /tmp/te-old.rds` and compared it against the WORKING TREE copy of that same path. The branch did not contain the merge in question, so both operands were the same blob, `identical()` returned TRUE, and the session concluded the opposite of the truth -- then published it to an issue, retracted a correct earlier finding on it, and closed a second issue as invalid. All three had to be corrected. Only one operand of that comparison names a revision; the other is whatever the checkout happens to hold.",
"_note_warns_never_blocks": [
"UserPromptSubmit, inject-only, for the same reason as the",
"reminders above: it can only ADD context, and no code path in it",
"suppresses, delays, or alters a message.",
"",
"Blocking was rejected on the merits rather than by analogy.",
"Comparing a committed blob against the working tree is",
"frequently CORRECT -- checking whether your own uncommitted edit",
"changed something is exactly that comparison. What separates the",
"hazard from the correct use is what the comparison is used to",
"CLAIM, which is not observable from the commands. A blocking",
"guard would therefore misfire on every legitimate",
"did-my-edit-change-anything check, and per README a hook that",
"misfires is worse than a missing one.",
"",
"Sibling of the deserialize-before-binary-claim guard proposed",
"in ai-config#1181 -- not on main yet, and nothing here depends",
"on it landing. It covers a hole in that one rather than",
"overlapping it. That guard",
"fires when NOTHING deserialized the artifact; its discharge is a",
"deserialization naming that path. The broken comparison above",
"SATISFIES that discharge, because a readRDS of the path did run.",
"Verified rather than assumed: that hook, run against a transcript",
"of this incident, produces no output. That one asks whether the",
"check happened; this one asks whether it compared the right two",
"things."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-ci-crosscheck-sim-verdict.py\"",
"timeout": 10,
"script": "remind-ci-crosscheck-sim-verdict.py",
"why": "Lacaedemon/sparta incident of 2026-08-22 -- a local simulation run reading FAIL was used to publish a decision, while CI transcript on the same clip/seed read PASS. Injects a reminder on the prompt following a local sim/transcript check when used to assert a verdict, prompting a CI-side reading."
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/require-gh-repo-flag.py\"",
"timeout": 10,
"script": "require-gh-repo-flag.py",
"why": "Blocks mutating repo-scoped gh commands lacking -R, so a write never silently targets whatever repo the cwd happens to be.",
"_note_no_if": [
"Deliberately carries no `if` prefilter. `Bash(gh *)` was tried and",
"verified working in the live harness -- it fires on `cd /tmp && gh ...`",
"and on `GH_TOKEN=x gh ...`, so it is not a strict prefix glob today.",
"It is still removed, because the asymmetry is bad: if that matching",
"ever tightens to a prefix check, the guard stops firing on exactly the",
"compound and env-prefixed shapes it exists to catch, and it stops",
"SILENTLY -- a disabled guard and a guard with nothing to block look",
"identical. The prefilter buys one Python startup (~30ms) per gh-less",
"Bash call; that is not worth a silent-failure mode."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-unauthorized-merge.py\"",
"timeout": 10,
"script": "no-unauthorized-merge.py",
"why": "cai 2026-08-04 'I want a mechanistic prohibition, not just a guideline' -- mechanistically blocks unauthorized PR/MR merge commands (gh pr merge, glab mr merge, gh api .../merge) at the PreToolUse boundary unless an explicit ALLOW_MERGE=1 flag is provided."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-whole-file-punct-replace.py\"",
"timeout": 10,
"script": "no-whole-file-punct-replace.py",
"why": "shared/coding/ascii-punctuation-in-source.md's over-application warning. A whole-file glyph replace converts pre-existing glyphs on untouched lines, and the resulting mechanical diff HIDES real changes -- on 2026-08-02 it concealed two dropped paragraphs. Blocks only when a glyph replace, a write-back, and no scoping token all co-occur under an interpreter."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unchained-branch-switch.py\"",
"timeout": 10,
"script": "flag-unchained-branch-switch.py",
"why": "One Bash call ran `git checkout feat/some-branch -q && git merge --ff-only ...` on line 1 and `git merge origin/main` on line 2. The checkout failed -- the branch was already checked out in another worktree -- and the `&&` protected only line 1, so line 2's merge ran against `main`. Nothing errored: the failing checkout wrote to stderr and the call still exited 0. That time it fast-forwarded onto an already-published commit; in general it commits or merges onto the wrong branch, which is the failure shared/workflow/incidents-dont-repeal-decisions.md records costing a commit on the wrong PR's branch and a history rewrite to separate.",
"_note_warns_never_blocks": [
"Emits `additionalContext` and a `systemMessage` and nothing else --",
"no `permissionDecision` key at all, so the normal permission flow is",
"untouched. Naming `allow` there would BYPASS a prompt the user would",
"otherwise have seen.",
"",
"Deny was rejected on the merits, not by analogy. A multi-line git",
"script whose later lines mutate is frequently CORRECT: the author may",
"know the checkout cannot fail, may have run it already, or may intend",
"the mutation on the current branch regardless. That is a fact about",
"intent, which the command does not carry -- so a blocking guard would",
"refuse a large class of legitimate scripts, and per README a hook that",
"misfires is worse than a missing one.",
"",
"Measured against 15184 real Bash tool_use records in this machine's",
"transcripts: 4396 mention `git `, and this fires on 66 of them --",
"0.43% of all Bash calls, 1.50% of the git-bearing ones. Two clauses",
"were added BECAUSE that measurement found them: a switch used as an",
"`if` condition, and a cascade where a second chained checkout",
"supersedes the first."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-stale-branch-mutation.py\"",
"timeout": 10,
"script": "flag-stale-branch-mutation.py",
"why": "2026-09-04 incident: a session ran `git checkout -b fix/909-...`, then an unisolated adversarial-reviewer subagent's own git commands left the checkout on a different branch (fix/892-...), then the session's `git commit` and `git push -u origin fix/909-...` both ran silently against the wrong branch -- the push even printed the usual \"Create a pull request\" hint. Warns, never blocks, when a MUTATING git command (or a `git push` naming the branch) runs while the actually-checked-out branch has drifted from what THIS session most recently, explicitly selected via `git checkout`/`git switch`, tracked in a small per-session per-repository state file across separate Bash calls (ai-config#3204).",
"_note_warns_never_blocks": [
"Emits `additionalContext` and a `systemMessage` and nothing else --",
"no `permissionDecision` key at all, so the normal permission flow is",
"untouched. A drifted checkout can be exactly what is wanted, so a",
"blocking guard would refuse legitimate work whenever the answer is",
"'yes, work on whatever is actually checked out'."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-heavy-work-on-head-node.py\"",
"timeout": 10,
"script": "no-heavy-work-on-head-node.py",
"why": "On 2026-07-31 a shiva sysadmin reported login-node CPU-load alerts from two R devtools::test() processes at 262 and 90 CPU-minutes. The rule they broke was already written down twice, and a passive prose rule only fires if you happen to read it as you type the command -- so the check has to run at the command. Whether this host is a SLURM compute node needs no judgment: sinfo answers it exactly.",
"_note_safe_off_cluster": [
"Belongs in the shared registry rather than under dotfiles/shiva/,",
"even though its subject is one cluster, because the cost of",
"carrying it everywhere is ~50ms of Python startup -- the same",
"shape as the two PreToolUse guards above -- and dotfiles/ has no",
"hook plumbing at all, so scoping it there would mean building a",
"second registration mechanism for a single hook.",
"",
"Measured 2026-08-04 on shiva: a non-Bash call and an ordinary",
"Bash call each cost ~52ms and never reach sinfo, because the",
"regex prefilter returns first. Only a command that would",
"actually be blocked pays the sinfo subprocess.",
"",
"Fails OPEN in three separate ways, each verified: sinfo missing",
"(OSError), sinfo present but erroring (non-zero return), and",
"already-on-a-compute-node. Off a cluster the first branch is the",
"one taken, so the guard is inert rather than merely quiet."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-add-a-outside-pathspec.py\"",
"timeout": 10,
"script": "flag-add-a-outside-pathspec.py",
"why": "ucdavis/bcs's CLAUDE.md bans a bare `git add -A` and prescribes `git add -A ':!inst/extdata'` as the sanctioned replacement, after a 2026-07-30 incident where an untracked-and-unignored directory leaked a credential and participant identifiers. On 2026-08-09 that sanctioned form was followed verbatim and still swept in two unrelated untracked scratch files at the repo root, because the exclusion pathspec protects only the path it names -- nothing checks the LIVE working tree for what else is untracked. Warns rather than blocks, per README's 'A hook that misfires is worse than a missing one': a bare `git add -A` is frequently exactly right, and this hook cannot tell intended untracked content from accidental.",
"_note_reads_live_git_status": [
"Unlike most hooks here, this one shells out to `git status`",
"at match time to compute what is actually untracked -- a",
"pure text match on the command string cannot answer 'does the",
"exclusion pathspec actually cover everything untracked',",
"since that depends on the live working tree, not the command.",
"Fails open on any git failure, timeout, or non-repo cwd.",
"",
"Emits only `additionalContext` and a `systemMessage`; no",
"`permissionDecision`, so the normal permission flow is",
"untouched."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-reset-hard-uncommitted-work.py\"",
"timeout": 10,
"script": "flag-reset-hard-uncommitted-work.py",
"why": "On 2026-08-09, in the same session as the git-add incident above, a `git reset --hard <ref>` run to drop a throwaway probe commit also discarded UNRELATED uncommitted edits to .Rbuildignore and NEWS.md sitting in the same working tree -- both had to be redone. `reset --hard` resetting the entire working tree (not just the commit being undone) is exactly its documented behaviour; the mistake was reaching for it in a tree with unrelated live edits, for an experiment that never needed to touch that tree. Warns rather than blocks: discarding a commit is frequently exactly the point, and this hook cannot tell 'this dirty state is unrelated' from 'this dirty state is what I meant to discard'. Extended in ai-config#2560 to `git checkout <pathspec>` and `git restore <pathspec>`: those forms revert the named paths to the INDEX rather than to the pre-edit tree, so an edit made since the last `git add` is discarded silently at exit 0. A plain branch switch (`git checkout <ref>`) stays out of scope because git refuses one that would clobber local changes and otherwise carries them across, and `git restore --staged` without `--worktree` stays out of scope because it rewrites only the index. The FORCED `git checkout` forms discard tracked working-tree changes too, and ai-config#2524 closed that gap rather than justifying it. Measured 2026-09-04 on git 2.43.0: `git checkout -f other` over a modified tracked file printed only 'Switched to branch' and exited 0 with the edit gone, `git checkout --force -b feature` did the same, and `git checkout -f` with no operand at all reverted every tracked file to HEAD with no output whatsoever -- so a forced checkout that resolves to no pathspec is now scoped to the whole tracked tree, exactly like `reset --hard`. That needed no new command parsing: `git checkout` was already read and `-f` was already a known flag. `git switch -f` / `--discard-changes` stays uncovered, and that one IS a scope decision: `switch` is a fourth command this guard does not read at all, and reading it would widen the guard past the three it was built for. The catalogs name that remaining gap.",
"_note_reads_live_git_status": [
"Also reads live `git status`, same reasoning as the sibling",
"hook above: whether uncommitted tracked-file changes exist is",
"a fact about the working tree, not about the command text.",
"Untracked files are deliberately out of scope -- `reset",
"--hard` does not touch them, `git clean` does -- so an",
"untracked scratch file alone does not trigger this.",
"",
"Emits only `additionalContext` and a `systemMessage`; no",
"`permissionDecision`."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-handrolled-verdict-parse.py\"",
"timeout": 10,
"script": "no-handrolled-verdict-parse.py",
"why": "shared/workflow/ardi.md mandates check-pr-fully-clean.py as verdict authority. PreToolUse guard that refuses hand-rolled regex/grep verdict parses of review comments without running the script or setting ALLOW_HANDROLLED_VERDICT_PARSE=1."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-pr-create-without-dupe-check.py\"",
"timeout": 10,
"script": "warn-pr-create-without-dupe-check.py",
"why": "pr-on-claim.md makes an issue's cross-referenced open PRs the authoritative in-flight signal; issue-first.md and report-mistakes-proactively.md require the same search before gh issue create. Both rules are consulted at read time while the omission happens at composition time. PR half measured on Morrison-Lab/wai 2026-08-19/20: five branches fixed the same CI failure across five hours, two reaching duplicate PRs (#77, #78). Issue half measured on Morrison-Lab/ai-config 2026-08: one cp1252 decode crash had four open issues (#1984, #2040, #2048, #2049) before #2086 closed them. Issue discharge requires --state all --search (or gh search issues / MCP search_issues), not --state open, per check-open-prs-before-duplicating.md. Warns rather than blocks -- a duplicate is cheap to close and a blocked creation is expensive, since creating the PR or filing the issue is the act that makes work visible. Anchored on a command position with heredoc bodies stripped. Fails open. Tracked as #1748 (PR) and #2088 (issue)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-unlabelled-agent-issue.py\"",
"timeout": 10,
"script": "warn-unlabelled-agent-issue.py",
"why": "shared/workflow/issue-first.md, 'Label an agent-filed issue with its authorship and its model'. disclose-agent-authorship.md scopes its marker line to comment BODIES and excludes an issue body, so an agent-filed issue is the one artifact that discloses nothing -- it goes out under the account holder's login as type: User. The labels close that, and the rule is consulted at read time while the omission happens at composition time. WARNS, never blocks: an unlabelled issue is fixed by one `gh issue edit --add-label` while a blocked create loses the act that makes work visible (report-mistakes-proactively.md, 'filing is not gated on approval'), the rule is scoped to repos we administrate and this hook cannot tell which repo is ours (sup files upstream with neither label), and a repo whose labels cannot be created still gets the issue. Anchored on a command position with heredoc bodies stripped, for the reason warn-pr-create-without-dupe-check.py records: this corpus quotes `gh issue create` constantly. Fails open. Tracked as #3111."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-deferred-closing-keyword.py\"",
"timeout": 10,
"script": "warn-deferred-closing-keyword.py",
"why": "shared/workflow/issue-first.md, 'A closing keyword plus #N closes #N even when the sentence negates it'. GitHub's parser is purely lexical, so a body saying a LATER PR will close #N, or that #N is NOT being closed, closes it on merge. Scoped to a PR/issue DESCRIPTION and a commit message, since GitHub never scans a plain comment. Warns when a closing keyword sits next to an issue reference mid-line AND its sentence carries a deferral or negation cue; a bare `Closes #N` line never fires. Three measured occurrences: ai-config#1718/#1717, Morrison-Lab/gha#460/#322, ucdavis/bcs#982/#923. Warns rather than blocks -- the guard cannot read intent and the remedy costs one word. Fails open. Tracked as #3512."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-stale-adjacent-comment.py\"",
"timeout": 15,
"script": "flag-stale-adjacent-comment.py",
"why": "shared/workflow/learn-from-review-findings.md, from ucdavis/bcs#679. Warns, never blocks, when a `git commit` changes a literal value while an unchanged comment within ten lines still asserts the old one."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-delete-branch-under-stacked-pr.py\"",
"timeout": 25,
"script": "no-delete-branch-under-stacked-pr.py",
"why": "Measured on ucdavis/bcs 2026-08-27. Covers `gh pr close --delete-branch` as well as `gh pr merge`: both carry the same -d flag, and the branch deletion is the hazard rather than the merge. Merging #749 with --delete-branch removed the branch that was #750's base, and #750 went to CLOSED rather than being retargeted to main, while gh reported the merge as successful and said nothing about it. Retargeting is GitHub's DOCUMENTED behaviour for exactly this sequence and usually happens; why it failed here is not established, and this hook does not claim to know -- it warns that the flag MAY close a stacked child, which is the part that is certain. Recovery deadlocks: a closed PR cannot be retargeted, and cannot be reopened while its base branch is gone, so the only way out is to push the deleted branch back, reopen, retarget, then delete again -- possible only while a copy survives. WARNS, never blocks: the condition needs a network query, so blocking would fail closed whenever gh is unavailable, and the flag is the tidy habit on every unstacked merge. Silent when nothing is stacked, when the query fails or returns an unexpected shape, when gh is absent, when the command carries no -R or PR target, and when the delete flag is explicitly disabled (--delete-branch=false)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-clobbering-push.py\"",
"timeout": 20,
"script": "no-clobbering-push.py",
"why": "shared/workflow/check-before-pushing.md. A branch you cut and whose PR you opened is the one you are least likely to check before pushing to, and claim-pr.md records three ways it gains another agent's commits anyway (the @claude agent's main-sync, a second CLI session, a human). Every recovery procedure in claim-pr.md runs AFTER the collision. DENIES a bare `git push --force`/`-f`, whose remedy (`--force-with-lease --force-if-includes`) costs one word and is never the worse command, clearable with an `ALLOW_FORCE_PUSH=1` prefix for the unsatisfiable-lease case memories/git.md records. WARNS on everything else from a live read-only `git ls-remote` reading taken at push time, and stays silent on a fast-forward. Fails open on parse trouble, outside a repo, and on any network timeout. Tracked as #1883."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-nonglobal-substitution.py\"",
"timeout": 20,
"script": "warn-nonglobal-substitution.py",
"why": "shared/workflow/algorithmatize-checks.md. A non-global `s///` changes only the FIRST occurrence, which may not be the one meant. It bites hardest in mutation testing: a mutation that silently failed to apply reads as a surviving mutation, or worse a verified one, because the test stays green from nothing having changed. Measured four times in one session on 2026-08-21, twice where the substitution rewrote a neighbouring comment instead of the code. WARNS, never blocks: it fires only when perl/sed is the segment's leading command AND carries a genuine in-place flag, and scans script text only, never filenames. Tracked as #1900."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-duplicate-key-blind-verification.py\"",
"timeout": 10,
"script": "warn-duplicate-key-blind-verification.py",
"why": "shared/workflow/algorithmatize-checks.md. yaml.safe_load/load/full_load silently keeps the LAST of a duplicate mapping key, so a check that only inspects the parsed mapping's keys or their count (.keys(), len(...), a membership test, ==set(...)) cannot see a duplicate-key defect -- the duplicate is already gone by the time either analysis runs. Measured on gha#839: a sweep asking which caller stubs would uncomment into a DUPLICATE `with:` key used exactly this shape and reported 1 broken file where the true count, under a duplicate-rejecting loader, was 5. WARNS, never blocks, and only when a Bash command actually EXECUTES python (a -c argument, a heredoc piped to python's stdin, or a heredoc that writes a .py file the same command then runs) -- parsed with `ast`, not text search, so a phrase merely quoted inside a string literal (this corpus's own docs discussing the rule) cannot trip it, and a load already guarded by a registered add_constructor is silent, since that IS the fix this hook recommends."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-heredoc-doubled-backslash.py\"",
"timeout": 20,
"script": "warn-heredoc-doubled-backslash.py",
"why": "CLAUDE.md 'Tool transport collapses doubled backslashes'. On this Windows/MINGW64 transport a doubled backslash typed inside a Bash-tool heredoc body -- even with a quoted delimiter, which should be fully literal -- can arrive at the interpreter as a single backslash (measured 2026-08-22, ai-config#1923). It fails silently and plausibly: a match/assert reads as a slightly-wrong anchor, or a regex/escape sequence written this way is corrupted with no syntax error and a still-green suite. Recurred twice in one session on 2026-09-08 (ai-config#3362): a printf format string's `\\n` collapsed to a literal newline in an emitted snippet, and a Python-heredoc edit writing `\\\\n` escapes into a test fixture produced literal newlines and a pushed SyntaxError. WARNS, never blocks: a doubled backslash inside a heredoc body is not always the mistake (content destined for a regex that will itself be re-escaped downstream is a legitimate exception this hook cannot distinguish), so it only ever adds context naming the offending line and the remedy (build the character with chr(92) rather than typing it doubled; print repr() before writing). Tracked as #3362."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-blanket-worktree-force-remove.py\"",
"timeout": 20,
"script": "warn-blanket-worktree-force-remove.py",
"why": "skills/clean-worktrees/SKILL.md step 5: a `git worktree remove` refusal is a safety net -- do NOT blindly --force it; the refusal means the worktree was likely misclassified (dirty tree, unpushed work), so the prescribed response is to re-inspect, not retry forced. Self-hit during a clean-git session on 2026-09-10: a blanket `git worktree remove \"$p\" || git worktree remove --force \"$p\"` fallback was written, contradicting the skill's own explicit warning read earlier the same session -- flagged by hooks/no-mistake-without-a-hook.py as a mistake worth mechanizing, and the auto-mode permission classifier's denial of the command is the only reason nothing was lost. WARNS, never blocks: the skill documents one legitimate single-shot --force (a genuinely clean worktree that merely contains a submodule, refused with a DIFFERENT error -- 'fatal: working trees containing submodules cannot be moved or removed' -- not the dirty-tree refusal), and that exact case occurred later in the same session and was correct, so this hook can never safely deny; it only ever adds context naming the fallback/loop shape and the submodule exception. Fires on the tokenized argv (via scripts/lib/shellcmd.py's git_subcommand, plus its own bespoke operator/segment tokenizer -- not simple_commands, which drops the operator tokens this hook needs), not raw text, so a command merely quoting or documenting the pattern (a commit message, a heredoc writing this file) does not trip it."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-commit-chained-to-push.py\"",
"timeout": 10,
"script": "no-commit-chained-to-push.py",
"why": "shared/workflow/check-before-pushing.md, plus ai-config#2992, which is a REPORT rather than a measurement -- its author states they verified the mechanism from the hook registration and did not reproduce the lost commit. A PreToolUse deny rejects the WHOLE Bash invocation, so a call that chains `git commit` into `git push` loses the commit when any guard refuses the push -- and no-push-without-self-review.py and no-clobbering-push.py are both registered PreToolUse on Bash. Their refusal names only the push, so it reads as \"the push was blocked\" while the change is still an uncommitted working-tree edit. Reported 2026-09-02, caught only because an adversarial reviewer checked whether HEAD had moved. Note that on a HEREDOC commit neither sibling currently sees a push at all (they carry the #2993 splitter defect this hook's library fixes), so for that shape today nothing would be lost -- a reason to fix #2993, not to exempt the shape, and the deny message is worded so it never asserts a sibling would have refused a particular call. DENIES rather than warns, unlike its warn-dupe-check-chained-to-create.py sibling of the same shape: an advisory additionalContext is attached while the call proceeds, so a sibling's deny in the same pass still discards the commit, and whether the author is even TOLD depends on unverified harness behaviour (does a non-denying hook's context survive a sibling's deny -- see the hook docstring for the ten-minute experiment that settles it). The refusal does not depend on that answer: it stops the chain reaching the siblings at all. The refusal is always satisfiable (issue the same two commands as two calls) and clearable with an `ALLOW_COMMIT_AND_PUSH=1` env-assignment prefix. Matches over an argv split (scripts/lib/shellcmd.py) rather than the raw string, so a quoted commit message, a heredoc body, and `git commit-tree`/`git commit-graph` cannot trip it. Requires commit BEFORE push: push-then-commit loses nothing. No exemption for a --dry-run or --delete command: one was written on review advice and removed when a second review measured `git push --force --delete` and `--dry-run --no-dry-run --force` going silent here while no-clobbering-push.py denied both -- a refused dry-run costs one tool call, a missed force-delete costs the commit. Fails open. Tracked as #2992; the shared splitter's heredoc defect and the eight unmigrated copies are #2993 (eight, not seven: derive the set from `grep -rlF '_SHELL_OPS = set(\"();|&\")' hooks/`, since remind-ci-crosscheck-sim-verdict.py spells its copy without the leading underscore)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-chained-push.py\"",
"timeout": 10,
"script": "flag-chained-push.py",
"why": "Lacaedemon/sparta, 2026-09-05: three refusals in one session, each a `git push` either chained after another command with `&&`/`;`/`||` or suffixed by a pipe/redirect. no-clobbering-push.py and the plugin's own no-push-without-self-review.py push policy both parse the WHOLE command text for a push rather than the isolated push segment, so a trailing `2>&1` hands either parser a bare `2` sitting where a commit-ish token would sit in other shapes, and a chained prefix reads as part of the same invocation. A PreToolUse deny rejects the whole Bash call, so a refused chain runs NOTHING -- not the commands before the push either -- and the refusal naming only the push invites the misreading that the prefix succeeded. WARNS, never blocks: neither shape is wrong on its own (an ordinary `git fetch && git push`, a push piped to `tee` for a log), so the message asks the author to run the push alone and, after any refusal, re-check state rather than assume the prefix ran."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-generated-file-stale.py\"",
"timeout": 45,
"script": "warn-generated-file-stale.py",
"why": "algorithmatize-checks -- warns when a push changes a generated file's SOURCE (hooks/hooks.json) while the repo's own generator reports its output stale. Consumes scripts/gen-hooks-plugin.py --check rather than re-deriving; the gap it closes is timing, since validate.yml runs the same check minutes later and costs a red round. Warn-only: the condition is a path in the diff plus a generator exit code, so it is decidable, but a mid-stack push can legitimately be stale. Added after ai-config#3524 went red on exactly this."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-dupe-check-chained-to-create.py\"",
"timeout": 10,
"script": "warn-dupe-check-chained-to-create.py",
"why": "shared/workflow/report-mistakes-proactively.md, whose step 2 is the tracker dupe-check this defeats, plus ai-config#1954 which records the rule. The section stating it in that fragment arrives with ai-config#1955, so cite the issue rather than the section title until that PR merges. Measured 2026-08-22: a dupe-search and a `gh issue create` ran in ONE Bash call, the search returned the right match (#1737), and #1952 was created anyway. Nothing can branch on a result that arrives at the same instant as the action, so the check was decorative. WARNS, never blocks -- a duplicate is cheap to close, and the false positive it accepts (a list whose output FEEDS the create) costs one line. Fires only when a check and a create of the SAME object kind both sit at command positions with the check first, over text whose heredoc bodies and quoted spans have been stripped. Registered under Bash only: an MCP call carries one operation, so the shape cannot arise there. Fails open. Tracked as #1956.",
"_note_cannot_see_the_general_failure": [
"This detects ONE LEXICAL SHAPE: two commands sharing a call.",
"A search run in its own call whose result was never read is",
"indistinguishable from one that was read, so a silent run is",
"evidence only that the two commands were not in one string --",
"never that a dupe-check happened or that its answer was",
"consulted. Its sibling warn-pr-create-without-dupe-check.py has",
"the mirror limit: a session-wide lexical scan establishes that a",
"query ran and never that its result mattered."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-status-read-after-pipe.py\"",
"timeout": 10,
"script": "warn-status-read-after-pipe.py",
"why": "shared/coding/errexit-is-not-uniform.md, which owns the mechanism, plus the exit-status section of shared/workflow/algorithmatize-checks.md. Without `pipefail` a pipeline's status is its RIGHTMOST command's, so `checker | head -20; echo \"exit=$?\"` reports whether `head` succeeded. Measured 2026-08-24 on UCD-SERG/ucd-serg.github.io#111: that exact line reported `exit=0` while the checker exited 1, and the PR's cleanliness was reasoned from it. NOTE the honest justification -- errexit-is-not-uniform already named this exact shape, with `head` named, and was simply not consulted, so this is a guard because the condition is decidable at composition time rather than because the corpus lacked a rule. WARNS, never blocks: reading the last stage's status is correct under `pipefail` and correct when that stage is the one meant. Fires only when an expandable `$?` sits in the segment IMMEDIATELY after a pipeline, with `set ... pipefail` opening no strictly-earlier segment and appearing at the own command level of no enclosing paren group (a nested group's option does not protect its parent, measured). Excludes single-quoted spans, heredoc bodies and `#` comments (so the corpus's own documentation cannot trip it), process substitution, argument-position command substitution, any `$?` that FOLLOWS a substitution in its segment, `[[ ]]` alternation, `$(( ))` and `(( ))` arithmetic, extglob `@(a|b)`, `>|`, fd-duplication redirects like `2>&1`, and backgrounded pipelines. An assignment's substitution and a bare subshell are NOT excluded: bash reads the pipeline's status there, measured. Negative control 2026-08-24 at branch HEAD, method stated in the hook docstring so it reproduces: 657 fenced blocks over 358 tracked files, 8 of them discriminating (containing both `$?` and `|`), 2 fired, both genuine. Report the 8, not the 657; the rest could not fire under any implementation. An earlier run scored 3, and the third was a false positive caught in review -- see the docstring. Fails open. Tracked as #2149.",
"_note_deliberate_under_approximation": [
"A `$?` inside SINGLE quotes is literal to the shell, so it is",
"never recorded -- which also means `bash -c '... | head; echo",
"$?'` gets no warning. That is accepted rather than overlooked:",
"this corpus writes such demonstration lines constantly, and a",
"warn-only guard should miss a real case before it fires on",
"every fragment that describes the rule it enforces."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-verdict-line-filter.py\"",
"timeout": 10,
"script": "warn-verdict-line-filter.py",
"why": "Warns, never blocks, when a review round is read through a verdict-line jq filter, which drops non-blocking findings carried in the body and in the review-data findings array (ai-config#3493, measured 2026-09-09)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-push-without-self-review.py\"",
"timeout": 10,
"script": "no-push-without-self-review.py",
"why": "cai: don't push until you run self-review locally and get a clean verdict, and use an adversarial subagent for the local self-review -- blocks git push in PreToolUse unless an `adversarial-reviewer` subagent returned a clean verdict as its own call result AND that report's `Reviewed-Commit:` fingerprint matches the commits the push would ship, or the push itself is prefixed with ALLOW_UNREVIEWED_PUSH=1."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-uncited-rebuttal.py\"",
"timeout": 15,
"script": "flag-uncited-rebuttal.py",
"why": "shared/workflow/address-every-comment.md's 'read the cited source' rule, from Morrison-Lab/ai-config#2070 (2026-08-24): a rebuttal disputing a finding that cited an external URL was posted using only local grep evidence, never fetching the URL the reviewer named -- and the rebuttal was wrong. Warns, never blocks, when a PR/issue comment about to be posted disputes a finding whose most recently fetched citation named an external URL that no earlier WebFetch/WebSearch in the transcript touched."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/require-agent-disclosure.py\"",
"timeout": 10,
"script": "require-agent-disclosure.py",
"why": "cai 2026-08-24 'all comments online posted by bots should say so', citing UCD-SERG/ucd-serg.github.io#108's agent-authored claim comment posted under the repository owner as type: User, author_association MEMBER -- indistinguishable from a human's. memories/github.md already recorded auditors making that mistake. shared/workflow/disclose-agent-authorship.md is the rule; this is the instrument, since the rule is read at read time and the omission happens at composition time. WARNS, never blocks: a missing marker is cheap to repair with a follow-up comment, while a blocked `gh pr comment` interrupts the one action that makes a claim visible to other sessions -- trading a labelling defect for a collision. Anchored at a command position over text whose heredoc BODIES have been stripped -- the opener tail is kept, so piping a heredoc into `--body-file -` still fires while a doc-writing heredoc quoting `gh pr comment` does not. This corpus quotes that command constantly, and matching such a quote is the near-miss require-gh-repo-flag.py shipped with. Fails open.",
"_note_marker_avoids_the_robot_emoji": [
"The disclosure marker is prose, NOT the robot emoji, and the hook",
"warns separately when a body discloses with the emoji instead.",
"scripts/check-pr-fully-clean.py matches that emoji as a",
"REVIEW_BODY_MARKERS entry, so a disclosed claim comment would be",
"admitted into the fully-clean verdict scan as a review -- and a claim",
"carries no findings, so nothing blocking is found there. That is the",
"false-clean failure shared/workflow/fully-clean.md already records for",
"a human-authored self-review, arriving through the very mechanism",
"added to make authorship legible.",
"",
"Admission is necessary and not sufficient: the comment must",
"also name the head SHA to count toward criterion 2. The emoji",
"removes the one filter standing between a claim comment and",
"that scan, rather than single-handedly manufacturing a clean",
"verdict. Corrected after a cross-vendor review; the fragment",
"and the hook's user-facing warning say the same thing now."
],
"_note_cannot_see_every_body": [
"--body-file, --editor, `-F <file>` and an interpolated $BODY all put the",
"text where a literal scan cannot reach. The hook reports those as an",
"UNREADABLE body rather than as a missing marker, so its warning never",
"asserts more than it observed. That partial visibility is the second",
"reason it cannot deny: a block built on a check that sees a fraction of",
"its population would refuse compliant commands.",
"",
"skills/ard/SKILL.md's per-round summary is exactly that shape -- posted",
"via --body-file on every round of every PR -- so it states the marker",
"requirement in its own text rather than relying on this guard.",
"",
"REGISTERED TWICE, under Bash and under mcp__github__.*, because",
"hooks.json matches by TOOL NAME: the MCP tool list inside the script is",
"unreachable from a Bash-only entry however complete it is. A remote/web",
"session has no `gh` at all, so a Bash-only registration would be silent",
"in exactly the population the MCP branch was written for."
],
"_note_segmentation_is_quote_aware": [
"Segments are split on shell separators OUTSIDE quotes. A plain",
"re.split(r'[;&|\\n]+', ...) looked adequate and was not: the marker sits",
"on its own line at the END of a body, so splitting on every newline cuts",
"the marker off the very command it discloses, and every correctly",
"disclosed multi-line comment -- the normal shape -- warned. Caught by the",
"test suite before this hook was ever registered.",
"",
"Judging PER SEGMENT rather than per call is what stops one disclosed body",
"vouching for an undisclosed sibling in a batched round, which",
"shared/workflow/efficient-pr-babysitting.md actively encourages."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-uncounted-comment-claims.py\"",
"timeout": 10,
"script": "flag-uncounted-comment-claims.py",
"why": "ai-config#2377's sparta file-list incident (2026-08-26): a forge comment posted an enumerated file list recalled from memory rather than derived, and it was wrong. Warns, never blocks, when a `gh pr comment`/`gh issue comment`/`gh api .../comments` body about to post asserts an unverified count (no `grep -c`/`wc -l`-shaped discharge nearby) or a hand-typed enumerated list of hyphenated identifiers with no deriving command beside it in the body or elsewhere in the same Bash call -- `remind-brief-premises.py`'s cardinality/enumeration heuristic extended past Agent/Task/SendMessage briefs to forge-comment bodies, since that hook's own PATH clause is anchored to this corpus and a comment can be about any repo."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unmeasured-timestamp.py\"",
"timeout": 10,
"script": "flag-unmeasured-timestamp.py",
"why": "CLAUDE.md 'Timestamp recaps in local time', forge-comment surface (ai-config#2900, #2903; measured 2026-09-01: claim comments stamped 12:15/12:40/12:58 PT off one 12:02 PDT reading, the next real reading 12:21 PDT). Warns, never blocks, when a `gh pr comment`/`gh issue comment`/`gh pr review`/`gh api .../comments` body about to post states a Pacific clock time (the Stop sibling's `RX_CLAIM` shape: HH:MM, optional seconds, optional AM/PM, then PDT, PST, or PT) and no clock read appears in the transcript since the current turn began -- or, when the harness's injected reading is in this turn, the stamp runs ahead of its value. The chat-recap half is `no-unmeasured-clock-claim.py` at Stop, whose `scan()`, `RX_CLAIM`, and context exemptions this hook imports so the two agree on what a turn, a clock read, and a stamp are; a comment body never reaches a Stop hook, which is the gap this fills. A post whose body the hook cannot read (a `--body-file` written by a heredoc in the same call, `--body-file -`) gets a cannot-read note rather than silence, after require-agent-disclosure.py's third verdict. Warns because a quoted or relayed time (a CI timestamp, a reviewer's own stamp) is legitimate and not distinguishable by the body alone."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unread-commit-citation.py\"",
"timeout": 10,
"script": "flag-unread-commit-citation.py",
"why": "ai-config#3471's SHA half: a case entry cited two commit SHAs with a narrative reconstructed from `git log --oneline` alone, and got both backwards -- neither patch was actually opened. Warns, never blocks, when a `gh pr comment`/`gh issue comment`/`gh pr review`/`gh api .../comments` body about to post cites a commit SHA (a word-bounded 7-40 char hex run containing a hex letter, outside a fenced/indented evidence block, not a `Reviewed-Commit:`/`HEAD=` trailer or a position-report phrasing like \"pushed at\"/\"squash,\", and -- for a 7-char token -- carrying a nearby cue) and no command reading that commit (`git show`/`git diff`/`git cat-file`/`git log -p`/`gh api .../commits/<sha>`/`gh api .../pulls/N/commits`/`mcp__github__get_commit`) appears in the transcript since the current turn began. A `git log --oneline` that merely lists the SHA does not discharge it -- that is the exact failure this hook exists to catch. Reuses flag-unmeasured-timestamp.py's comment-post detection and no-unmeasured-clock-claim.py's scan() for the turn boundary."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-new-line-breaks-on-push.py\"",
"timeout": 15,
"script": "warn-new-line-breaks-on-push.py",
"why": "memories/mistake-patterns.md Pattern 25, ai-config#2590 (2026-08-29): warns, never blocks, before a `git push` carrying newly-added Markdown lines that violate semantic line breaks against the default base branch (e.g. origin/main)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-stale-review-diff-base.py\"",
"timeout": 10,
"script": "warn-stale-review-diff-base.py",
"why": "verify-the-right-artifact 'A comparison's base is an artifact too' -- a bare local branch as a diff's base widens the range when it is behind, and narrows it when it is ahead of or diverged from its remote in commits the head branch also carries, so a review runs on already-merged work or silently skips part of the change; this names the base and the remote-tracking form.",
"_note_warn_only": [
"Reminder only: emits hookSpecificOutput.additionalContext and never a",
"decision. The hook cannot tell a review diff from an ordinary local",
"comparison, and a bare local base is correct for plenty of those, so a",
"block would refuse correct commands on a heuristic.",
"No fetch-based discharge on purpose: keep-checkouts-fresh mandates a",
"session-start fetch, so keying on one would silence this in exactly the",
"sessions that follow the corpus.",
"The Agent|Task|SendMessage matcher is there because the measured failure",
"was a brief handed to a subagent, which cannot check a premise about the",
"author's own environment."
],
"_note_deliberate_under_approximation": [
"The Bash matcher requires the git invocation to sit at a command",
"position, so a range merely quoted inside another command's argument",
"is inert -- including one inside a backtick code span, or after the",
"words `then` or `do` used in ordinary English.",
"That deliberately UNDER-approximates: a real backtick substitution,",
"`bash -c \"...\"`, and any wrapper word outside",
"sudo/env/time/nohup/xargs/command are missed. A missed reminder costs a",
"review round; one fired on quoted prose trains everyone to ignore it.",
"Agent|Task|SendMessage briefs are prose with no command structure, so",
"they use an unanchored pattern instead."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-positional-figure-in-commit-message.py\"",
"timeout": 10,
"script": "flag-positional-figure-in-commit-message.py",
"why": "shared/writing/timestamp-volatile-claims.md, commit-message surface. Warns, never blocks, when a `git commit` message about to be written states a POSITIONAL figure about text -- \"13 lines above\", \"39 lines below\", \"77 lines earlier\", \"~130 lines later\". A commit message is permanent history, so such a figure is asserted once and re-derived by nobody: true when typed, false as soon as anything above it changes, and never contradicted by a failing check. Measured on this repository's own history (2026-09-03, roughly 2400 commits): 14 occurrences across 13 commit messages, every one decoration locating a passage, with no legitimate code-move description among them -- e.g. 4e1dea144, 593d25ccf, fcb4ee10d, f2f706fa1, 60edf4c1e. The warning says to DELETE the number rather than correct it, since naming the target beats counting to it and a recounted figure is a fresher instance of the same defect.",
"_note_scope_is_deliberately_narrow": [
"A bare dimensional count with no positional word is NOT matched.",
"Measured over the same roughly 2400 commits, `\\d+ (characters|chars|words)`",
"alone matches 53, overwhelmingly legitimate measured facts -- a",
"skill-listing context budget, GitHub's 65536-character comment cap, a",
"line length against the style guide. (3 of the 53 also carry a",
"positional figure, so they fire on that arm anyway.) Warning on the",
"rest is the noise that costs a warn-only hook its credibility, so a",
"dimensional figure fires only alongside above/below/earlier/later.",
"Diffstats (`3 files changed`), version numbers, issue and PR numbers,",
"SHAs, dates and clock times are all out of scope for the same reason.",
"",
"A `\\d+-to-\\d+ range` arm was written and then DROPPED after measuring",
"it. Its only match in the whole history was 0709c1a28's \"The 60-to-80",
"range is human guidance\", which states what a style guide asks,",
"locates no passage, and loses its meaning if the number is deleted --",
"a misfire, not a hit. Zero measured true positives against one",
"measured false positive is a losing trade for a guard whose only",
"capital is that a fire means something. Two tests pin that this shape",
"must stay silent.",
"",
"`commit(?![\\w-])`, not `commit\\b`, per no-unshipped-commit.py: a word",
"boundary sits happily between `commit` and `-`, so `\\b` matches",
"`git commit-tree` and `git commit-graph write`, neither of which",
"writes a commit message at all."
],
"_note_fails_silent": [
"Every parse failure -- unbalanced quotes, an unreadable or stdin `-F`",
"target, an unrecognised payload shape -- returns with no output. A",
"commit is the worst thing for a hook to break, and a missed warning",
"costs one stale figure in one commit message. An unreadable `-F` also",
"abandons the whole segment rather than judging the `-m` fraction it",
"can see, so the warning never asserts more than it observed.",
"",
"That guarantee once had an exception, recorded here because the shape is",
"worth keeping: a copied `_GIT_FLAGS` regex backtracked catastrophically",
"on a command line carrying many flag-shaped tokens, so such a command",
"stalled this hook until its 10s timeout rather than returning silently.",
"ai-config#3172 fixed that and moved the patterns to",
"`scripts/lib/git_cmd.py`; this hook imports `COMMIT` from there rather",
"than copying it, so the exception no longer applies."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-slide-major-tag.py\"",
"timeout": 15,
"script": "guard-slide-major-tag.py",
"why": "Denies `gh workflow run slide-major-tag.yml` when git diff tag..origin/<default> adds a job-level permission to a reusable workflow (workflow_call in on:). A called workflow cannot request permissions its caller does not grant, so sliding the tag breaks every un-updated consumer with startup_failure (Morrison-Lab/gha#830 breaking ucdavis/bcs#966; same class as gha#685). Clearable with ALLOW_BREAKING_SLIDE=1. Tracked as #3303."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unattributable-reviewer-request.py\"",
"timeout": 10,
"script": "flag-unattributable-reviewer-request.py",
"why": "Warns when a reviewer request is not the LAST command in a Bash call. no-unreviewed-pr.py credits a request by the whole call's exit status, so a request piped into `tail`, or followed by a verify read, shares its status with that command and cannot be attributed -- its discharge is withheld even though the request succeeded and a review landed. Measured on ai-config#3403, where the Stop block fired three times against a working request and its wording (\"no SUCCESSFUL request\") invited reissuing the same broken shape rather than unpiping it.",
"_note_warns_never_blocks": [
"Emits `additionalContext` and a `systemMessage` and nothing else -- no",
"`permissionDecision` key, so the normal permission flow is untouched.",
"A non-last request is legitimate when one call requests reviewers for",
"several PRs, which is why undischargeable_requests returns a LIST at",
"all, so a blocking guard would refuse a real shape."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unchanged-test-sibling.py\"",
"timeout": 10,
"if": "Bash(git commit *)",
"script": "flag-unchanged-test-sibling.py",
"why": "Warns when a commit stages a file whose conventionally-named test sibling is left unstaged, so the suite gets opened rather than reasoned about (ai-config#3419)."
}
]
},
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unassigned-worktree.py\"",
"timeout": 10,
"script": "flag-unassigned-worktree.py",
"why": "Surfaces a write-capable Agent launch with no `isolation`, so a decided practice cannot lapse silently -- the omission lives in a tool call, the one surface review, tests, and lint all miss.",
"_note_warns_never_blocks": [
"Emits `additionalContext` and a `systemMessage` and nothing else --",
"no `permissionDecision` key at all, so the normal permission flow is",
"untouched. Naming `allow` there would BYPASS a prompt the user would",
"otherwise have seen, which is a hook making the harness more",
"permissive than it was without it.",
"",
"Deny was considered and rejected on the numbers: 60 of 121 real Agent",
"launches in this machine's transcripts are write-capable with no",
"`isolation`, so a blocking guard would interrupt 49% of agent",
"launches. The lapse it catches is a visibility failure, and a warning",
"at the moment of the call fixes visibility completely.",
"",
"Activation follows this file's uniform model (see the top-level",
"_comment and README): the plugin loads it on enable, and",
"install-hooks.py --fix registers it for a non-plugin install."
]
}
]
},
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-fable-subagent.py\"",
"timeout": 10,
"script": "no-fable-subagent.py",
"why": "Denies an Agent launch that names Fable, or that omits `model` in a session whose own model is Fable (the inherit path is how the violation happens), unless FABLE_SUBAGENT_OK=1 records the user's explicit grant for that launch; warns on a Workflow launch in a Fable session, whose agent() calls it cannot inspect. User directive 2026-09-01, ai-config#2927: 8 of 10 launches in one session inherited Fable and the account hit its usage limit.",
"_note_blocks": [
"Emits `permissionDecision: deny` only for the two Fable cases above;",
"every other Agent launch is untouched, and an unreadable transcript",
"adds a note rather than a denial, so a missing file never stops work.",
"Never emits `allow`."
]
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-fable-subagent.py\"",
"timeout": 10,
"script": "no-fable-subagent.py",
"why": "Denies an Agent launch that names Fable, or that omits `model` in a session whose own model is Fable (the inherit path is how the violation happens), unless FABLE_SUBAGENT_OK=1 records the user's explicit grant for that launch; warns on a Workflow launch in a Fable session, whose agent() calls it cannot inspect. User directive 2026-09-01, ai-config#2927: 8 of 10 launches in one session inherited Fable and the account hit its usage limit.",
"_note_blocks": [
"Emits `permissionDecision: deny` only for the two Fable cases above;",
"every other Agent launch is untouched, and an unreadable transcript",
"adds a note rather than a denial, so a missing file never stops work.",
"Never emits `allow`."
]
}
]
},
{
"matcher": "Workflow",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-fable-subagent.py\"",
"timeout": 10,
"script": "no-fable-subagent.py",
"why": "Warns on a Workflow launch in a Fable session: its agent() calls inherit the session model unless each sets `model`, and the hook cannot inspect the script, so it names the risk and leaves the decision visible (ai-config#2927).",
"_note_warns_never_blocks": [
"On the Workflow matcher this script only ever adds context; the deny path is reachable from the Agent matcher alone."
]
}
]
},
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-brief-premises.py\"",
"timeout": 10,
"script": "remind-brief-premises.py",
"why": "shared/workflow/challenge-the-assignment.md -- reminds when a brief asserts corpus state that nothing derived. Covers SendMessage as well as Agent/Task: a follow-up message to a running agent is where corrections and new premises land, so it is the higher-risk brief (ai-config#1795). Split from the Agent-matcher group so flag-unassigned-worktree.py, which is Agent-only by nature, is not widened with it. Injects rather than blocks."
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-brief-premises.py\"",
"timeout": 10,
"script": "remind-brief-premises.py",
"why": "shared/workflow/challenge-the-assignment.md -- reminds when a brief asserts corpus state that nothing derived. Covers SendMessage as well as Agent/Task: a follow-up message to a running agent is where corrections and new premises land, so it is the higher-risk brief (ai-config#1795). Split from the Agent-matcher group so flag-unassigned-worktree.py, which is Agent-only by nature, is not widened with it. Injects rather than blocks."
}
]
},
{
"matcher": "SendMessage",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/remind-brief-premises.py\"",
"timeout": 10,
"script": "remind-brief-premises.py",
"why": "shared/workflow/challenge-the-assignment.md -- reminds when a brief asserts corpus state that nothing derived. Covers SendMessage as well as Agent/Task: a follow-up message to a running agent is where corrections and new premises land, so it is the higher-risk brief (ai-config#1795). Split from the Agent-matcher group so flag-unassigned-worktree.py, which is Agent-only by nature, is not widened with it. Injects rather than blocks."
}
]
},
{
"matcher": "mcp__github__.*",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/no-unauthorized-merge.py\"",
"timeout": 10,
"script": "no-unauthorized-merge.py",
"why": "cai 2026-08-04 'I want a mechanistic prohibition, not just a guideline' -- mechanistically blocks unauthorized GitHub MCP PR merge operations (mcp__github__merge_pull_request and defensive auto-merge tool variants) at the PreToolUse boundary unless explicit permission is present."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-pr-create-without-dupe-check.py\"",
"timeout": 10,
"script": "warn-pr-create-without-dupe-check.py",
"why": "Dual-registered with the Bash entry, following no-unauthorized-merge.py's precedent. tool-mappings.md maps CREATE_PR to mcp__github__create_pull_request and CREATE_ISSUE to mcp__github__issue_write (method=create) (legacy mcp__github__create_issue still matched), and CLAUDE.md mandates that substitute in remote sessions -- so registering only under Bash let a remote session discharge the guard via MCP reads while never being able to trip it via MCP creation."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-unlabelled-agent-issue.py\"",
"timeout": 10,
"script": "warn-unlabelled-agent-issue.py",
"why": "shared/workflow/issue-first.md, 'Label an agent-filed issue with its authorship and its model'. disclose-agent-authorship.md scopes its marker line to comment BODIES and excludes an issue body, so an agent-filed issue is the one artifact that discloses nothing -- it goes out under the account holder's login as type: User. The labels close that, and the rule is consulted at read time while the omission happens at composition time. WARNS, never blocks: an unlabelled issue is fixed by one `gh issue edit --add-label` while a blocked create loses the act that makes work visible (report-mistakes-proactively.md, 'filing is not gated on approval'), the rule is scoped to repos we administrate and this hook cannot tell which repo is ours (sup files upstream with neither label), and a repo whose labels cannot be created still gets the issue. Anchored on a command position with heredoc bodies stripped, for the reason warn-pr-create-without-dupe-check.py records: this corpus quotes `gh issue create` constantly. Fails open. Tracked as #3111."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-deferred-closing-keyword.py\"",
"timeout": 10,
"script": "warn-deferred-closing-keyword.py",
"why": "shared/workflow/issue-first.md, 'A closing keyword plus #N closes #N even when the sentence negates it'. GitHub's parser is purely lexical, so a body saying a LATER PR will close #N, or that #N is NOT being closed, closes it on merge. Scoped to a PR/issue DESCRIPTION and a commit message, since GitHub never scans a plain comment. Warns when a closing keyword sits next to an issue reference mid-line AND its sentence carries a deferral or negation cue; a bare `Closes #N` line never fires. Three measured occurrences: ai-config#1718/#1717, Morrison-Lab/gha#460/#322, ucdavis/bcs#982/#923. Warns rather than blocks -- the guard cannot read intent and the remedy costs one word. Fails open. Tracked as #3512."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/require-agent-disclosure.py\"",
"timeout": 10,
"script": "require-agent-disclosure.py",
"why": "The MCP half of the Bash entry above. A remote/web session has no `gh` on PATH, so mcp__github__add_issue_comment and its siblings are the ONLY way it posts a comment -- and hooks.json matches by tool name, so the Bash entry never sees them. Same script, same warn-only behaviour; it reads tool_input.body directly rather than parsing a command line, and stays silent when no string body is present (some pull_request_review_write methods submit without one)."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unmeasured-timestamp.py\"",
"timeout": 10,
"script": "flag-unmeasured-timestamp.py",
"why": "The MCP half of the Bash entry above, following require-agent-disclosure.py's precedent. The #2900 claim comments went out through mcp__github__add_issue_comment from a remote session with no `gh` on PATH, and hooks.json matches by tool name, so the Bash entry never sees them. Same script, same warn-only behaviour; it reads tool_input.body directly for every tool in require-agent-disclosure.py's MCP_POST_TOOLS (imported, so the two guards cover the same set) and stays silent when no string body is present."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unread-commit-citation.py\"",
"timeout": 10,
"script": "flag-unread-commit-citation.py",
"why": "The MCP half of the Bash entry above, following require-agent-disclosure.py's and flag-unmeasured-timestamp.py's precedent. A remote/web session has no `gh` on PATH, so an mcp__github__ comment tool is the only way it posts, and hooks.json matches by tool name, so the Bash entry never sees it. Same script, same warn-only behaviour, reading tool_input.body directly. ai-config#3471."
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-stale-issue-edit.py\"",
"timeout": 10,
"script": "warn-stale-issue-edit.py",
"why": "skills/check-history/SKILL.md invokes its issue/PR checks at read time, and the omission is broken at the first Write/Edit, which leaves no artifact for review or tests to catch. Warns, never blocks.",
"_note_matcher_shape": [
"Three single-tool groups rather than one `Write|Edit|NotebookEdit`",
"matcher, chosen under a documented ambiguity rather than despite one.",
"",
"memories/claude-code-hooks.md records that a matcher containing",
"characters OUTSIDE [A-Za-z0-9_- ,|] is evaluated as a JavaScript regex.",
"`|` is inside that set, so that note does not say which path a plain",
"name or an alternation takes, and nothing here establishes it.",
"",
"Two readings fail differently, and the shape is safe under any:",
" - If matchers are regexes, `Edit` is unanchored and also matches",
" NotebookEdit, so a NotebookEdit call fires this hook TWICE -- two",
" identical additionalContext injections. Benign: the hook only ever",
" adds context, so a duplicate costs tokens and nothing else.",
" - If matchers are exact strings, `Write|Edit|NotebookEdit` matches no",
" tool at all and the hook is silently inert -- which is the exact",
" failure ai-config#2390 exists to end.",
"",
"A third reading -- the harness splitting a delimited matcher into a",
"name list -- would make the alternation work too. Three groups is",
"correct under all three and merely redundant under one, while the",
"alternation is dead under one, so it trades a benign duplicate for",
"the failure being fixed.",
"",
"check-hook-catalog.py's registered() also merges these three groups into",
"the single string \"Write, Edit, NotebookEdit\", so the catalog check",
"cannot see a double binding; ai-config#2535 tracks resolving the",
"ambiguity empirically and teaching the checker about it.",
"",
"The hook re-checks tool_name against its own WRITE_TOOLS set on entry,",
"so the matcher is a prefilter rather than the only gate: the Cursor",
"names StrReplace/EditNotebook are handled there, not here."
]
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unmeasured-timestamp.py\"",
"timeout": 10,
"script": "flag-unmeasured-timestamp.py",
"why": "CLAUDE.md 'Timestamp recaps in local time', session notebook / memory file edit surface (ai-config#2900, #2947). Warns when writing to session notebooks or memory files stating a Pacific clock time without a date reading in the turn."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unread-commit-citation.py\"",
"timeout": 10,
"script": "flag-unread-commit-citation.py",
"why": "The Write half of the Bash entry above. ai-config#3471: warns when writing a commit SHA citation into a non-scratch prose file (.md/.markdown/.txt/.rst/.qmd/.rmd/.ipynb -- a case entry, a memory file) with no read of that commit in the transcript since the current turn began. Scoped to those extensions, not every tracked file, after adversarial review found a GitHub Actions SHA pin and a lockfile hash both false-positive under a broader scope."
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-stale-issue-edit.py\"",
"timeout": 10,
"script": "warn-stale-issue-edit.py",
"why": "skills/check-history/SKILL.md invokes its issue/PR checks at read time, and the omission is broken at the first Write/Edit, which leaves no artifact for review or tests to catch. Warns, never blocks."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unmeasured-timestamp.py\"",
"timeout": 10,
"script": "flag-unmeasured-timestamp.py",
"why": "CLAUDE.md 'Timestamp recaps in local time', session notebook / memory file edit surface (ai-config#2900, #2947). Warns when editing session notebooks or memory files stating a Pacific clock time without a date reading in the turn."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unread-commit-citation.py\"",
"timeout": 10,
"script": "flag-unread-commit-citation.py",
"why": "The Edit half of the Bash entry above. ai-config#3471: warns when editing a commit SHA citation into a non-scratch prose file (.md/.markdown/.txt/.rst/.qmd/.rmd/.ipynb) with no read of that commit in the transcript since the current turn began."
}
]
},
{
"matcher": "NotebookEdit",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-stale-issue-edit.py\"",
"timeout": 10,
"script": "warn-stale-issue-edit.py",
"why": "skills/check-history/SKILL.md invokes its issue/PR checks at read time, and the omission is broken at the first Write/Edit, which leaves no artifact for review or tests to catch. Warns, never blocks."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unmeasured-timestamp.py\"",
"timeout": 10,
"script": "flag-unmeasured-timestamp.py",
"why": "CLAUDE.md 'Timestamp recaps in local time', session notebook / memory file edit surface (ai-config#2900, #2947). Warns when editing session notebooks or memory files stating a Pacific clock time without a date reading in the turn."
},
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-unread-commit-citation.py\"",
"timeout": 10,
"script": "flag-unread-commit-citation.py",
"why": "The NotebookEdit half of the Bash entry above. ai-config#3471: warns when a notebook cell edit writes a commit SHA citation into a non-scratch prose file (.md/.markdown/.txt/.rst/.qmd/.rmd/.ipynb) with no read of that commit in the transcript since the current turn began."
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/flag-cd-into-main-checkout.py\"",
"timeout": 10,
"script": "flag-cd-into-main-checkout.py",
"why": "Warns when a worktree-rooted session cd's into the MAIN checkout of its own repo -- a real path on a different branch, where every edit and every check silently succeeds against somebody else's tree."
}
]
},
{
"matcher": "Agent",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-stale-review-diff-base.py\"",
"timeout": 10,
"script": "warn-stale-review-diff-base.py",
"why": "verify-the-right-artifact 'A comparison's base is an artifact too' -- a bare local branch as a diff's base widens the range when it is behind, and narrows it when it is ahead of or diverged from its remote in commits the head branch also carries, so a review runs on already-merged work or silently skips part of the change; this names the base and the remote-tracking form.",
"_note_see_bash_entry": "Design notes live on this hook's Bash entry, per this file's precedent (require-agent-disclosure.py, warn-stale-issue-edit.py). Repeating them per matcher guarantees sibling divergence on the next correction."
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "python3 \"${CLAUDE_PLUGIN_ROOT}/hooks/warn-stale-review-diff-base.py\"",
"timeout": 10,
"script": "warn-stale-review-diff-base.py",
"why": "verify-the-right-artifact 'A comparison's base is an artifact too' -- a bare local branch as a diff's base widens the range when it is behind, and narrows it when it is ahead of or diverged from its remote in commits the head branch also carries, so a review runs on already-merged work or silently skips part of the change; this names the base and the remote-tracking form.",
"_note_see_bash_entry": "Design notes live on this hook's Bash entry, per this file's precedent (require-agent-disclosure.py, warn-stale-issue-edit.py). Repeating them per matcher guarantees sibling divergence on the next correction."
}
]
},
{
"matcher": "SendMessage",