L13 · Les process
Leçon 13 sur 17

Le filet qui n'en était plus un

Reconnaître les quatre étages où un filet automatique cesse de contrôler sans qu'on l'apprenne, savoir où regarder de chaque côté — git et Claude Code — et exercer un filet plutôt que le relire.

Lecture 31 min
Niveau Les process
Publié le 18 août 2026
Sommaire
  1. Ce qui se passe vraiment
  2. De quoi on parle
  3. Ce que j’ai laissé mourir, d’avril à juin 2026
  4. Les deux façons dont ça ne se voit pas
  5. Deux familles de hooks, un seul mode d’échec
  6. Ce qui tourne au bout du hook n’est pas toujours un programme
  7. Les quatre étages où ça casse
  8. Ce qu’on fait
  9. 1. Exercer le filet, pas le relire
  10. 2. Regarder la liste, pas le fichier
  11. 3. Le faire parler quand il passe
  12. 4. Mesurer ce qu’il a réellement attrapé, et faire démolir le filet
  13. 5. À quel moment
  14. Ce que ça ne fait pas
  15. Où c’est écrit
  16. Le point de bascule
  17. Vérifier que c’est passé

Tes filets automatiques tournent — une suite de tests, une intégration continue, un contrôle avant publication, peu importe lequel. Ils tournaient déjà le mois dernier, tu n’y as pas touché depuis, et aucun ne t’a rien signalé. Tu prends ça pour une bonne nouvelle.

C’est exactement le moment où tu ne sais plus s’ils tournent.

Ce qui se passe vraiment

De quoi on parle

Dans cette leçon, un filet est n’importe quel dispositif automatique censé attraper une faute avant qu’elle sorte : une suite de tests, une étape d’intégration continue, un contrôle branché sur ton gestionnaire de versions, un contrôle branché sur Claude Code. Mécanismes différents, même mode de panne — c’est le sujet. Deux mots reviendront, glosés une fois pour toutes : un hook, c’est un déclenchement automatique attaché à un événement ; un matcher, c’est le motif qui décide à quels cas un hook s’applique.

Un mot sur l’image, parce qu’elle ment sur un point. Un vrai filet est passif : on le tend, il attrape tout ce qui tombe, on n’a plus rien à en faire. Celui-là, non. Il ne couvre que ce qu’on lui a appris à couvrir, et il cesse d’exister sans prévenir. Toute la leçon tient dans cet écart.

Ce que j’ai laissé mourir, d’avril à juin 2026

Le projet est en production : déployé, utilisé, avec de l’argent réel en jeu. Voici la chronologie telle que le dépôt la garde, dates comprises.

  • 23 avril 2026. Un seul commit, et deux filets morts d’un coup. J’adopte husky, un gestionnaire de hooks git. Il change core.hooksPath, le réglage qui dit à git où chercher ses hooks : sur ce dépôt, il désigne désormais .husky. Tout ce qui vivait dans l’ancien dossier, .git/hooks, cesse d’être appelé : un contrôle construction + tests, et un scan de secrets avant enregistrement, le plus grave des deux puisque c’était le seul filet entre le code interne et l’extérieur. Le même commit pose un filet neuf, qui vérifie avant publication que chaque variable d’environnement lue côté navigateur est déclarée côté construction de l’image — deux fois de suite, on avait livré une de ces variables sans la déclarer, ce qui casse le tableau de bord en production. Un filet posé après deux accidents identiques. Il est commité sans son bit exécutable, 100644 au lieu de 100755 ; dans la configuration de ce dépôt, git lance les fichiers de hooks directement et saute celui qui n’est pas exécutable. Ce filet-là n’a jamais tourné non plus. Pas une fois, pendant sept semaines.
  • 21 mai 2026. Troisième couche, troisième erreur d’endroit : un contrôle avant publication écrit dans .git/hooks, le dossier court-circuité un mois plus tôt. Le commit d’audit le résumera d’une ligne — « écrit le 21 mai dans le mauvais dossier, jamais exécuté une seule fois ».
  • 12 juin 2026, 15h12. L’audit. Tout remonte dans .husky, les hooks morts sont supprimés, le fichier repasse en 100755. Intitulé : fix(hooks): consolidate all git hooks into .husky — dead .git/hooks gates never ran.

Trois choses que cette histoire ne dit pas, et je préfère les écrire que les combler. Ce qui a déclenché l’audit n’est consigné nulle part : le moment le plus intéressant, celui où je m’en suis aperçu, n’a été enregistré par personne. Combien de publications sont passées sans filet, je l’ignore : le dépôt garde la trace des enregistrements, pas des publications. Et aucun dégât n’est avéré : l’audit a trouvé les hooks morts, pas de conséquence. Attention au raccourci, il tire dans les deux sens — le filet avait été posé après deux accidents rapprochés, donc le risque de base n’était pas nul, donc sept semaines sans incident se lisent « on a eu de la chance » aussi bien que « ça ne servait à rien ». Les deux lectures sont indiscernables de l’extérieur, et c’est le pire des trois points : il n’y a même pas eu la punition qui aurait fait regarder plus tôt.

Les deux façons dont ça ne se voit pas

Un filet qui passe et un filet qui n’a pas tourné produisent la même chose : rien. Côté Claude Code, la documentation l’écrit noir sur blanc — un hook qui a réussi n’affiche rien dans le fil, sauf si sa sortie JSON fait remonter quelque chose, comme systemMessage ou un retour de hook Stop.

Mais il y a une seconde mort, et mon histoire d’avril est celle-là : la mort annoncée que personne ne lit. Quand git saute un hook non exécutable, il ne se tait pas, il imprime un avertissement, qui pendant sept semaines est parti dans le défilement d’un terminal à chaque publication. Un filet meurt donc de deux façons : ou bien rien ne le dit, ou bien ce qui le dit passe dans le flot. La seconde est la plus embarrassante, et elle ne demande pas le même remède — contre le silence, exercer le filet ; contre l’avis noyé, le faire parler assez fort pour qu’on ne puisse pas ne pas le voir.

À retenir — tant que tu ne l’as pas vu rougir, tu ne sais pas si ton filet passe ou s’il n’existe plus. Ce sont les deux seules interprétations d’un même silence, et elles ne demandent pas la même réaction.

La leçon 5 t’a donné la moitié de la règle : un hook garantit son déclenchement, pas son résultat. Voici l’autre moitié. Le déclenchement n’est acquis qu’au bout d’une chaîne, et chaque maillon casse sans un mot : le fichier qui le déclare est lu ; le hook y est trouvé ; le programme qu’il lance (quand il en lance un, on verra plus bas que ce n’est pas obligatoire) existe au chemin indiqué et il est exécutable ; le dossier de travail est approuvé ; et personne d’autre ne l’a éteint.

Ce n’est pas une maladie des hooks. Le même mode de panne existe partout où un filet dépend d’un fichier de configuration : le fichier d’exclusion, celui qui dit au gestionnaire de versions ce qu’il ne doit pas enregistrer, ne proteste pas s’il est écrit dans un format qu’il ne sait pas relire. Il ne filtre simplement plus rien, et rien ne le signale. Chez toi, la question se pose en une commande : git check-ignore -v <fichier> dit si le fichier est bien exclu, et par quelle ligne. Prends l’habitude de la poser sur un fichier que tu crois protégé — c’est en trente secondes, et c’est le genre de filet dont on découvre la mort au pire moment.

Deux familles de hooks, un seul mode d’échec

L’histoire ci-dessus se passe du côté de git. Les faits qui suivent viennent, eux, de la documentation de Claude Code. Ce sont deux mécanismes distincts, et les confondre coûte cher :

Les hooks de gitLes hooks de Claude Code
Ce qu’ils gardentl’enregistrement (pre-commit), la publication (pre-push)l’appel d’outil (PreToolUse), la fin de tour (Stop), le démarrage de session (SessionStart)
Où ils sont déclarésun dossier, désigné par core.hooksPathsous la clé hooks d’un fichier de réglages (settings.json) pour quatre des sept emplacements possibles ; ailleurs pour les trois autres — le hooks/hooks.json d’un plugin, l’en-tête d’un skill, l’en-tête d’un subagent
Ce qui les tue sans réponseun chemin détourné, un fichier sans bit exécutableun chemin mal tapé, un matcher mal orthographié, un fichier de réglages rejeté
Où on regardegit config core.hooksPath, et le mode des fichiers/hooks, puis claude --debug

Le mode d’échec se transporte d’une colonne à l’autre ; les outils de diagnostic, non. /hooks n’aurait rien vu du bug d’avril — ce n’était pas ce mécanisme-là. C’est ce qui rend la leçon utile : tu as probablement des filets dans les deux colonnes, et ils ne se surveillent pas au même endroit.

Ce qui tourne au bout du hook n’est pas toujours un programme

Cette leçon parle beaucoup de scripts, parce que c’est de là que vient l’histoire d’avril. Mais un hook déclare le type de ce qu’il lance, et la documentation en donne cinq :

  • command — une commande shell, celle qu’on a en tête quand on dit « hook » ;
  • http — une adresse à qui l’événement est envoyé en POST ;
  • mcp_tool — un outil d’un serveur MCP déjà connecté ;
  • prompt — une consigne écrite dans le réglage, qu’un modèle rapide évalue en un seul aller-retour, sans aucun outil, et qui rend son verdict en JSON ;
  • agent — un ouvrier séparé qui, lui, peut lire des fichiers et fouiller le dépôt avant de rendre le même verdict. La documentation le donne pour expérimental et recommande un hook de commande pour ce qui tourne en production.

Les trois premiers lancent quelque chose ; les deux derniers, non. C’est la variante que la leçon 10 t’envoie chercher ici pour son degré 3 : un hook Stop qui ne bloque pas sur la sortie d’un script, mais sur le jugement d’un modèle à qui on a écrit la condition. Et /goal, le degré 2 de cette même leçon, en est le raccourci intégré — la documentation le décrit comme un hook Stop de type prompt limité à la session en cours, là où le hook que tu écris vit dans un fichier et vaut pour toutes les sessions de sa portée.

Trois conditions à connaître avant de choisir cette forme :

  • tous les événements ne l’acceptent pas. Stop, SubagentStop et PreToolUse oui ; SessionStart et Setup n’acceptent que command et mcp_tool. La liste complète est dans la documentation, et elle se lit avant d’écrire le hook, pas après ;
  • le délai par défaut est plus court : 30 secondes pour prompt, 60 pour agent, contre 600 pour les trois types qui lancent quelque chose ;
  • le mode de panne change de nature. Un hook qui ne lance rien ne meurt ni d’un chemin mal tapé ni d’un bit exécutable manquant. Il rend un verdict que personne n’a relu, sur une consigne que personne n’a exercée — et le silence se lit exactement pareil.

Tout ce qui suit sur le chemin, le mode du fichier et le shell vaut donc pour les trois premiers types. L’étage 2, celui du filet qui regarde à côté, vaut pour les cinq, et c’est le seul du classement dont aucune forme ne dispense.

Les quatre étages où ça casse

Range-les par étage, ça évite de chercher au mauvais.

1. Il n’est jamais appelé. C’est avril, et c’est aussi le cas documenté côté Claude Code : un hook dont le chemin est faux ou dont le script n’est pas exécutable ne bloque rien, le shell sort avec un code du type 127, l’action se poursuit. D’où la consigne — « surveille cet avis au premier passage : un chemin mal tapé dans settings.json laisse la barrière silencieusement désactivée ». Trois autres causes valent d’être connues, et la page liée plus bas les tient toutes :

  • le matcher est sensible à la cassebash au lieu de Bash ne correspond à rien, et « le hook échoue en silence ». Détail à durée de vie courte, à vérifier contre la version que claude --version te donne : la virgule ne vaut le | qu’à partir de la 2.1.191 ;
  • une seule erreur de schéma fait rejeter le fichier de réglages entier — les hooks corrects du même fichier tombent avec celui qu’on a mal écrit, et aucun n’apparaît plus dans /hooks. Et pas seulement les hooks : les règles d’autorisation de la leçon 8 vivent dans ce fichier-là aussi, et elles tombent avec. Un hook mal écrit peut donc désarmer un refus de lecture qui n’a rien à voir avec lui ;
  • la durée de vie change selon l’endroit — un skill (une fiche réutilisable : du savoir ou une procédure) et un subagent (un ouvrier isolé qui travaille à part et ne rend qu’un résumé) peuvent déclarer un hook dans leur en-tête, les lignes de réglages en tête du fichier entre deux --- : celui d’un subagent ne tourne que pendant ce subagent, celui d’un skill s’enregistre à l’invocation et continue pour le reste de la session.

2. Il est appelé, et il ne voit pas ce qu’il devait voir. Le filet tourne, il rend vert, il ne regardait pas au bon endroit. C’est la moitié la plus contre-intuitive du classement : tous les voyants sont au vert pendant qu’il ne couvre rien. Trois mesures faites en août 2026 sur le même dépôt, dans un contrôle qui cherche des secrets avant enregistrement :

  • les motifs vivaient en double, recopiés à la main d’un fichier à l’autre, avec en commentaire « mêmes motifs que dans le hook d’enregistrement ». Ce commentaire était tout le problème : les deux copies avaient dérivé, et le format de clé que le projet emploie pour publier vers un prestataire manquait des deux ;
  • mesuré, pas déduit : en reconstruisant le motif comme le font ses deux consommateurs et en le passant sur les valeurs réelles, un seul type de secret sur les douze présents était reconnu — le filet couvrait trois fournisseurs que ce projet n’utilise pas. Après correction : douze sur douze ;
  • et le plus retors : une des expressions régulières du scan demandait 340 répétitions du même caractère. JavaScript l’accepte, le grep trouvé dans le PATH aussi — mais celui du système, celui qui exécute réellement le hook, plafonne les répétitions à 255 et refuse le motif. Il rend une erreur, que le hook lisait comme « aucun secret trouvé ». Le transposable n’est pas le chiffre 255, c’est le montage : un filet qui traite l’échec de son propre outil comme un résultat négatif est un filet qui s’éteint tout seul.

Le même angle mort existe côté Claude Code. Un hook sur Edit|Write ne voit pas tout : Claude modifie aussi des fichiers en lançant des commandes shell, d’où la prescription d’un hook Stop qui balaie l’arbre de travail une fois par tour. Borne ce balayage dès que tu le poses — aux chemins que tu surveilles, ou aux fichiers modifiés dans le tour. Un hook Stop tourne à chaque fin de réponse, et le coût d’un balayage complet grandit avec le dépôt : c’est le seul geste de ce parcours dont la durée augmente toute seule pendant que tout marche. Ce qu’il en coûte quand il finit par dépasser son délai est à l’étage suivant. Les fichiers référencés par @ échappent à tout le monde : leur contenu est inséré en construisant le prompt, sans appel d’outil, donc PreToolUse ne se déclenche pas. Pas même un hook sur Read. Et sur Windows, l’outil Bash n’est pas enregistré quand Git Bash n’est pas installé, tandis que les chemins arrivent avec des antislashs.

3. Il voit, et son verdict n’atteint personne. Du 29 juillet au 17 août 2026, l’intégration continue de ce projet était rouge à chaque publication, dix fois d’affilée. La cause tenait en deux lignes : un fichier lisait une clé de service au chargement du module, et un test qui l’importe pour n’exercer que des fonctions pures, un test dont l’en-tête affirme « aucun réseau, aucune base », échouait dès l’import. Vert sur un poste de développement qui a le fichier d’environnement, rouge sur la machine d’intégration qui ne l’a pas. Trois semaines, pour trois raisons vérifiées plutôt que supposées : le filet qui décide réellement d’une publication est local et ne lance jamais cette suite de tests-là ; aucune protection de branche n’était en place, donc un rouge ne bloquait rien ; aucune alerte n’était câblée. Le seul endroit où ce test tournait était le seul que personne ne regardait.

Et l’alerte, quand on la câble, peut se taire au pire moment. Le 17 août, deux enchaînements d’intégration continue lisaient leurs identifiants d’alerte dans un fichier écrit par une étape antérieure du même travail. Or l’étape qui échoue le plus souvent est justement la reconstruction de l’environnement : le fichier n’existe alors pas, la condition est fausse, et aucune alerte ne part — panne totale et silencieuse, le scénario exact contre lequel ces étapes existent. Détail qui boucle avec avril : la liste de vérification de ce commit exige que le script soit exécutable dans l’index git (100755), sans quoi les quatre étapes échoueraient en permission denied, muettes à nouveau.

Côté Claude Code, le verdict se perd de deux façons documentées :

  • pour la plupart des événements, le code de sortie 2 est le seul qui bloque par lui-même. Sans JSON valide sur la sortie standard, le code 1, l’exit code d’échec conventionnel sous Unix, passe pour une erreur non bloquante et l’action continue. « La plupart » n’est pas une politesse : le 2 abat tout sur PreToolUse ou Stop, il n’est pas honoré sur PermissionRequest, et une vingtaine d’événements ne peuvent rien bloquer. Le tableau par événement est dans la documentation, à lire avant d’écrire un hook de politique ;
  • ton profil de shell peut manger la décision. Un hook en forme shell, une ligne de commande sans champ args, est lancé par sh -c sur macOS et Linux, par Git Bash sur Windows, ou par PowerShell quand Git Bash n’est pas installé — si ce profil imprime du texte au démarrage, il se colle devant le JSON du hook, la sortie ne commence plus par {, tout devient du texte brut, la décision est ignorée.

Il y en a une troisième, et c’est la seule qui arrive toute seule pendant que tout marche : le filet est devenu trop lent. Chaque hook a un délai au bout duquel il est annulé — sa sortie est jetée, et il ne rend aucune décision. Sur PreToolUse, la documentation le dit dans les termes mêmes de cette leçon : l’appel d’outil n’est pas bloqué, il repart par le circuit d’autorisation ordinaire, donc ne compte pas sur un hook bloqué pour tenir une barrière. Rien à l’écran ne distingue ce jour-là du précédent.

C’est la mort d’avril, avec la lenteur pour cause : le contrôle posé en mars marche, le dépôt grossit, et un jour il dépasse. Deux gestes, tant qu’il est temps :

  • chronomètre le contrôle à la main sur ton vrai dépôt avant de le poser en hook, et refais-le une fois par trimestre. C’est la seule mesure qui anticipe ; le reste ne fait que constater ;
  • donne-lui un timeout explicite plutôt que d’hériter d’un défaut que tu n’as pas lu. Les défauts ne sont pas les mêmes partout. 600 secondes pour les trois types qui lancent quelque chose, contre 30 pour prompt et 60 pour agent, on l’a vu plus haut. Et l’événement rabaisse ce défaut : 30 avant chaque prompt (« parce que ce hook tourne avant chaque prompt et bloque le traitement jusqu’à ce qu’il rende, un hook coincé fige la session »), et surtout un budget partagé d’une seconde et demie pour l’ensemble des hooks de fin de session — le plus court de tous, et le plus facile à dépasser sans s’en apercevoir : c’est précisément le moment où plus personne ne regarde l’écran. Ce budget-là, ton timeout le déplace : si tes réglages posent un timeout par hook plus long, Claude Code relève le budget d’autant, jusqu’à 60 secondes.

4. Il cède par conception. La leçon 10 t’a annoncé le chiffre : un hook Stop est passé outre après huit blocages consécutifs sans progrès. Un hook Stop se déclenche à chaque fin de réponse, pas seulement quand la tâche est finie ; s’il bloque parce qu’un contrôle échoue, Claude repart, retente, le hook se redéclenche, et si la condition ne peut pas se résoudre la boucle n’a aucune raison de s’arrêter. D’où deux protections : le champ stop_hook_active, reçu à true quand Claude Code continue déjà à cause d’un hook Stop, qu’un script correct lit pour sortir tôt ; et le plafond de huit blocages, un défaut et non une loi, que CLAUDE_CODE_STOP_HOOK_BLOCK_CAP règle et que 0 désactive — ce que tu achètes en le désactivant, c’est le risque que ton script ignore stop_hook_active.

La variante prompt a sa propre sortie de secours, et c’est la même idée. Le modèle peut accompagner son refus du champ impossible quand il juge que la condition ne pourra jamais être satisfaite : sur Stop et SubagentStop, Claude Code laisse alors le tour se terminer au lieu de renvoyer la raison à Claude. C’est le verdict « impossible » que la leçon 10 décrit pour l’objectif de session, au même endroit du mécanisme. Un agent, lui, n’a pas ce champ : il rend son verdict et sa raison, rien d’autre.

Et Stop a un trou qui appartient au sujet de cette leçon : il ne se déclenche pas sur une interruption utilisateur, et une erreur d’API déclenche StopFailure à sa place. Un tour coupé à la main ou tombé sur une erreur réseau passe entièrement à côté de la barrière, sans que rien ne le signale.

Chaque étage a son geste, et ils ne sont pas interchangeables :

L’étageCe qui le trouve
1. Jamais appeléla liste — /hooks, git config core.hooksPath, le mode des fichiers (section 2)
2. Appelé, mais aveuglel’exercice et la mesure — casser exprès, puis compter (sections 1 et 4)
3. Il voit, personne n’entendle faire parler quand il passe, et exercer l’alerte elle-même (section 3)
4. Il cède par conceptionrien à diagnostiquer : ça se connaît avant d’écrire le hook

Ce qu’on fait

1. Exercer le filet, pas le relire

La seule preuve rétrospective qu’un filet est vivant, c’est de l’avoir vu rougir. Casse exprès ce qu’il doit attraper, et regarde. Mais décide d’abord : le lecteur de ce parcours travaille parfois depuis une seule branche, sur la machine qui sert la production, et y fabriquer la panne revient à provoquer exactement ce contre quoi le filet existe. Fais-le dans un worktree (leçon 14) ou dans un clone jetable, et note d’avance comment tu reviens en arrière. Un cas demande plus de précaution : un faux secret fabriqué pour tester un scan reste dans l’historique même si tu annules le commit, donc celui-là se fabrique dans un dépôt jetable, jamais dans celui que tu publies.

Le 17 août 2026, en remplaçant une liste de fichiers de tests écrite à la main par un point d’entrée qui les découvre, j’ai éprouvé trois propriétés au lieu de les croire :

  • il rougit quand il doit — un test délibérément cassé fait sortir le lanceur en échec, et le fichier fautif est nommé dans la sortie. C’est à ça qu’on reconnaît que c’est bien le filet qui a rougi et pas autre chose : il nomme ce qu’il a attrapé ;
  • il ramasse ce qu’on ajoute — un fichier de test ajouté est bien compté, treize au lieu de douze. La liste manuelle qu’il remplace avait déjà perdu un fichier en route : il existait, il passait, et il n’avait jamais tourné en intégration continue. Une liste écrite à la main ne signale pas ses oublis ;
  • il tourne dans les conditions de l’autre machine — la suite entière passe dans une copie du dépôt sans fichier d’environnement, soit les conditions exactes de l’intégration continue.

Exercer révèle ce que lire ne révèle pas. Le même jour, exercer pour de vrai un script d’alerte, ce que personne n’avait fait sur les quatre copies existantes, a sorti deux défauts d’un coup. Le plus instructif : un garde sur une variable d’environnement rendait le script intestable ailleurs qu’en intégration continue. Il ne protégeait rien, et il garantissait qu’on n’exercerait jamais ce chemin-là.

Piège — un filet qui imprime ce qu’il attrape recopie le secret au moment précis où la discrétion importe le plus. Le hook de ce dépôt affichait la ligne entière, donc la clé en clair ; et le second défaut trouvé en l’exerçant était du même genre : le masquage prévu pour la plateforme d’intégration continue n’a rien masqué dans un terminal ordinaire, et le premier essai a recopié un jeton de bot à l’écran. Aggravant, et c’est propre à notre sujet : ces scripts sont lancés par une IA. La sortie part dans le défilement du terminal, dans la conversation enregistrée sur le disque et dans l’historique de la session. Trois copies qu’un retrait du fichier n’atteint pas.

2. Regarder la liste, pas le fichier

Deux commandes, une par colonne du tableau plus haut. Elles ne se remplacent pas.

/hooks

Dans Claude Code, cette commande liste tous les hooks enregistrés pour la session, groupés par événement ; le détail donne l’événement, le matcher, le type, le fichier source et la commande. Elle affiche six étiquettes de source — User Settings, Project Settings, Local Settings, Plugin Hooks, Session Hooks, Built-in Hooks. Six pour sept emplacements, et le compte ne tombe pas juste : les réglages gérés par une organisation n’ont pas d’étiquette à eux, et les hooks déclarés dans l’en-tête d’un skill ou d’un subagent n’apparaissent qu’une fois le skill invoqué, ou pendant que le subagent tourne. La règle qui vaut quand même le déplacement : un hook de fichier de réglages qui n’apparaît pas dans /hooks n’est pas lu du tout. Pour les deux cas d’en-tête, l’absence est normale.

git config core.hooksPath

Dans ton terminal, celle-ci dit où git va chercher ses hooks. C’est la ligne qui aurait répondu en avril, en une seconde. Trois sorties possibles : rien du tout, avec un code de sortie non nul, veut dire que le réglage n’existe pas et que git lit .git/hooks — le cas de la majorité des dépôts, et c’est normal ; un chemin veut dire que git ne lit plus que celui-là, et que tout ce qui reste dans .git/hooks est mort, y compris ce que tu y écriras demain ; un chemin que tu ne reconnais pas veut dire que quelque chose l’a posé pour toi, et c’est le moment de chercher quoi.

Réserve à connaître, parce qu’elle m’a coûté sept semaines : deux dépôts avec le même gestionnaire de hooks à la même version peuvent rendre deux valeurs différentes. Sur ma machine, deux projets en husky 9 répondent l’un .husky/_, l’autre le chemin de .husky. Dans le premier, husky pose ses propres enveloppes dans .husky/_ et ce sont elles qui lancent tes hooks avec sh : le bit exécutable de tes fichiers n’y change rien. Dans le second, git lance tes fichiers directement et saute ceux qui ne sont pas exécutables — le montage de l’histoire d’avril. La commande ne dit pas seulement où sont tes hooks, elle dit dans lequel des deux mondes tu es. D’où la seconde vérification, celle du mode, qui se lit dans l’index git et pas seulement sur le disque :

git ls-files -s .husky                        # 100755 = exécutable, 100644 = non
git update-index --chmod=+x .husky/pre-push   # et voilà comment on le corrige

Les deux vérifications, le bon dossier et le bon mode, sont devenues une règle écrite dans le dépôt : ne jamais écrire de hook dans .git/hooks tant que core.hooksPath pointe ailleurs.

Si le hook apparaît dans /hooks et ne se déclenche toujours pas, la documentation donne un ordre en quatre temps, et il vaut mieux retenir l’ordre que le recopier ici : claude doctor, qui imprime en terminal un diagnostic d’installation et de réglages sans démarrer de session ; claude --debug puis déclencher l’appel, pour voir quels matchers ont été testés et quel code de sortie le hook a rendu ; sortir le script de Claude Code et lui piper du JSON d’exemple ; et claude --safe-mode, qui démarre une session sans aucune personnalisation.

3. Le faire parler quand il passe

Exercer un filet prouve qu’il était vivant ce jour-là. C’est rétrospectif, et ça ne dit rien de demain. Le remède qui lève l’ambiguïté une fois pour toutes est ailleurs : faire dire au filet qu’il est passé. Côté Claude Code, systemMessage est un champ de sortie JSON documenté comme universel, un message affiché à l’utilisateur et honoré sur la plupart des événements ; côté git, c’est une ligne imprimée par le hook lui-même. Ça se paie, et il faut le savoir avant de le poser : la fatigue d’alerte est réelle — un message à chaque enregistrement devient un message que personne ne lit, c’est-à-dire la seconde mort décrite plus haut, fabriquée de tes mains ; systemMessage n’est pas honoré partout, plusieurs événements le jettent, dont Notification, PreCompact et MessageDisplay ; et ça ne remplace pas l’exercice, puisqu’un filet qui annonce « scan effectué » alors qu’il ne reconnaît qu’un secret sur douze parle très bien tout en ne servant à rien. C’est le geste que ce dépôt a fini par appliquer sans le nommer : le hook de secrets nomme désormais les fichiers et compte les lignes à chaque passage. Pas une décoration, un battement de cœur. Le jour où il s’arrête, ça se voit.

4. Mesurer ce qu’il a réellement attrapé, et faire démolir le filet

Un filet qui n’a jamais rien bloqué n’est pas un filet qui protège. Le 17 août 2026, j’ai mesuré ce qu’un filet posé une semaine plus tôt avait réellement intercepté. Le montage tenait en une ligne : rejeter une donnée quand le rapport entre deux valeurs de référence dépasse 2 — mais seulement si un second indicateur, sans rapport avec le premier, dépassait lui aussi son propre seuil. Sur 5 782 vérifications portant les deux valeurs, sept jours : 11 lignes dépassaient le rapport, 10 ont été diffusées, et zéro avait été interceptée depuis la pose du filet. La conjonction n’arrivait jamais. Un « et » de trop, et le filet ne se ferme pas.

Le détail qui vaut la leçon entière n’est pas le filet, c’est le fichier de suivi : il affirmait le préjudice neutralisé depuis la pose. C’est le genre d’affirmation qui fait renoncer à regarder. Une note qui dit qu’un problème est réglé est un filet de plus, avec le même mode de panne que les autres — et c’est le sujet de la leçon 11, qui traite ces fichiers-là pour eux-mêmes.

Et le filet lui-même se fait relire. Le 1er août, un relecteur lancé en contexte neuf (leçon 12) sur un filet de documentation a trouvé deux défauts dedans, tous deux silencieux, c’est-à-dire le mode de défaillance que ce filet existe précisément pour empêcher. Le plus retors : son assertion centrale ne reconnaissait les fichiers que cités entre marques de code, alors que les documents les citent massivement en nom nu. La convention qui neutralisait le filet était celle que les documents enseignent.

5. À quel moment

Je n’ai pas de rituel, et je ne vais pas t’en inventer un. Ce que le dépôt atteste, ce sont des campagnes : l’audit du 12 juin, trois jours d’audits contradictoires début août, une journée entière le 17 août. Rien de périodique. Ce qui suit est donc une proposition : exercer un filet le jour où on le pose, tant que la panne qu’il doit attraper est reproductible sous la main ; et le réexercer quand l’outillage bouge autour de lui — c’est le déclencheur d’avril, où ce n’est pas le hook qui a changé mais l’endroit où les hooks vivent.

Et l’aveu qui va avec, parce que la leçon 10 l’a démontré sur un autre objet : une discipline qui repose sur ta mémoire ne s’accumule pas. Cette proposition est de cette famille, un filet de mémoire avec le même mode de panne que les autres. La preuve est dans mon histoire : le 21 mai, un mois après avoir moi-même déplacé les hooks, j’en écrivais un dans le dossier mort. Le déclencheur s’était présenté et n’a rien déclenché. Le remède qui ne demande rien à personne est du premier étage : une étape d’intégration continue qui vérifie que core.hooksPath vaut ce qu’il doit valoir et que les fichiers de hooks sont exécutables — un filet sur les filets, là où personne n’a besoin de s’en souvenir. Je ne l’ai pas monté. Je préfère l’écrire que te vendre la discipline.

Ce que ça ne fait pas

Exercer un chemin ne valide que ce chemin. Un filet vu rougir sur le cas qu’on lui a fabriqué est vivant ; il n’est pas juste pour autant. Les douze secrets sur douze du mois d’août sont une couverture mesurée sur ce qui existait dans ce dépôt ce jour-là, pas une promesse sur le prochain type de clé.

Ces gestes répondent pour ton clone, pas pour ton équipe. /hooks répond pour ta session, git config core.hooksPath pour ta copie du dépôt, « je l’ai vu rougir » pour ton poste. Les hooks de git ne sont pas versionnés, et un gestionnaire n’arme core.hooksPath qu’à une installation de dépendances. La preuve est dans ce dépôt : un défaut de hook y est resté dormant parce que la configuration git de la machine appelait le hook autrement, et la prochaine installation l’aurait armé toute seule. C’est le principe que la leçon 9 posait pour les skills — commiter distribue une intention, pas une exécution. À plusieurs, la seule chose qui s’applique vraiment est ce qui tourne côté serveur ou en réglages gérés, et c’est aussi la vraie raison de ne pas confier à un filet local ce qui ne doit jamais arriver.

Un hook n’est pas une autorisation. Il leur est subordonné, et ça se lit dans les deux sens :

  • un PreToolUse qui rend deny bloque même en bypassPermissions, mais un hook qui rend allow ne contourne pas une règle de refus — ni la confirmation d’un outil de connecteur que ton organisation a réglé sur ask, ni celle d’un outil MCP marqué requiresUserInteraction. « Les hooks peuvent resserrer les restrictions, pas les desserrer au-delà de ce que les règles d’autorisation permettent » ;
  • et la règle d’autorisation n’est pas magique non plus : Bash(rm *) ne bloque ni /bin/rm ni find -delete, les règles de préfixe portant sur la chaîne littérale et non sur l’exécutable. Pour une garantie dure, il faut un hook PreToolUse ou le bac à sable, leçon 8.

Poser un filet n’est pas neutre. Un hook trop zélé se fait désarmer, et pas par malveillance : sur ce dépôt, un contrôle d’enregistrement mourait sur un commit qui ne fait que supprimer un fichier — aucune ligne ajoutée, le filtre correspondant sort en échec, le hook meurt avant son premier message, le commit est refusé, et la seule issue affichée est la commande qui désarme le hook en entier. Volume mesuré : deux commits sur trois cents. Un filet qui bloque à tort n’est pas un filet strict : c’est un filet qu’on finira par retirer.

Ça ne couvre pas les dispositifs qui vivent dans le contexte. Un hook est déclaré dans un fichier et se déclenche hors de la conversation — même quand ce qu’il lance est une consigne évaluée par un modèle : la compaction ne le touche pas. Mais la liste des skills, elle, ne revient pas après un résumé automatique (leçon 3).

Ce n’est pas un gain de temps. Fabriquer le cas qui doit faire rougir le filet prend du temps. Mesurer ce qu’il a réellement intercepté prend du temps. Ce que tu achètes, c’est de savoir laquelle des deux phrases est vraie ce soir : « rien à signaler » ou « personne n’a regardé ».

Où c’est écrit

Toute la partie Claude Code de cette leçon vient de quatre pages officielles. C’est là que se trouve le détail des procédures que cette leçon se contente de nommer :

Ces liens pointent vers les pages anglaises, pour les raisons données en leçon 4 : la version française existe — remplace /en/ par /fr/. Mais elle traduit une partie des mots que tu verras sur ton écran, et elle peut être en retard sur le fond.

Ce que cette leçon ajoute, et rien de plus. Les comportements sont dans ces pages, et c’est délibérément là qu’on t’envoie pour les procédures. Ce qui est à nous tient en trois choses : le classement en quatre étages et le geste attaché à chacun ; la jonction entre les deux familles de hooks, qu’aucune des deux documentations ne fait ; et une histoire datée et mesurée, trous compris.

Le point de bascule

C’est passé le jour où le silence d’un filet cesse d’être une bonne nouvelle. Devant un dispositif qui n’a rien signalé depuis six semaines, ta première question n’est plus « est-ce que tout va bien ? » mais « quand l’ai-je vu rougir pour la dernière fois ? ».

Vérifier que c’est passé

Ton contrôle avant publication n'a rien signalé depuis six semaines. Tu ouvres Claude Code, tu tapes /hooks, tout a l'air en ordre. Rassuré ?

Non, parce que tu viens de vérifier l’autre mécanisme. /hooks ne connaît que les hooks de Claude Code ; un contrôle branché sur ton gestionnaire de versions vit ailleurs, et se regarde ailleurs — git config core.hooksPath, puis le mode des fichiers qui s’y trouvent, lu dans l’index avec git ls-files -s. C’est le bug d’avril 2026 mot pour mot : /hooks ne l’aurait pas vu.

Ton scan de secrets tourne à chaque enregistrement, il n'a jamais échoué, et il n'a jamais rien bloqué non plus. Tu le relis : les motifs sont là, ils sont corrects. Il te protège ?

Tu n’en sais rien, et le relire ne te l’apprendra pas — c’est l’étage 2, celui où tout est vert pendant que le filet ne couvre rien. Trois pannes de ce type se sont produites sur un seul dépôt : des motifs corrects mais couvrant des fournisseurs que le projet n’utilise pas, une expression régulière que l’outil du système refuse de compiler et dont l’erreur était lue comme « rien trouvé », et une condition avec un « et » de trop qui n’était jamais vraie. Deux gestes y répondent : casser exprès ce qu’il doit attraper (dans un dépôt jetable pour un faux secret, qui resterait sinon dans l’historique) et compter ce qu’il a réellement intercepté depuis sa pose. Un compteur à zéro n’est pas une bonne nouvelle.

Ta barrière automatique bloque la fin du tour parce que les tests échouent. Elle bloque une deuxième fois, une troisième, et les tests ne passeront jamais. Est-ce que quelque chose finira par arrêter la boucle ?

Oui, et c’est une protection, pas un bug : au neuvième tour, Claude Code passe outre. Un hook Stop se déclenche à chaque fin de réponse ; s’il bloque sur une condition qui ne se résoudra jamais, plus rien n’arrête la boucle. D’où le plafond : huit blocages consécutifs sans progrès, puis Claude Code passe outre. Et le champ stop_hook_active, que ton script doit lire pour sortir tôt. Le plafond se règle (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, 0 pour l’ôter) ; le vrai correctif est dans le script — ou, si ta barrière est une consigne évaluée par un modèle plutôt qu’un script, dans le champ impossible qu’il peut rendre avec son refus. Et ne confonds pas ce point de rupture avec celui du mode automatique (leçon 8) ni avec l’arrêt pour absence de progrès d’un objectif de session (leçon 10).

Sur votre projet

Ces principes, appliqués à votre code.

Le parcours donne les principes et les déclencheurs. Le reste — la configuration réelle, les scripts, l'adaptation à votre projet — se travaille ensemble.