Aller au contenu

Votre CLAUDE.md ne suffit pas : quand l'agent dérive, les hooks tiennent la ligne

Votre CLAUDE.md ne suffit pas : quand l'agent dérive, les hooks tiennent la ligne. Dans cet article, je vous raconte comment l'agent a dérivé, et comment deux "simples" hooks ont remis les choses en ordre.

Vous avez écrit un fichier CLAUDE.md impeccable. Les conventions de l'équipe y sont, noir sur blanc. Les premières semaines, l'agent les respecte à la lettre. Et puis, sans que personne ne s'en rende compte, il commence à prendre des libertés ou à faire le contraire. 🤔

C'est ce qui m'est arrivé lors d'une mission. Nous construisions des outils de migration de données en Python avec Claude Code. Le gain est réel : trois outils livrés en un mois, là où les trois précédents nous avaient demandé trois mois. Mais ce gain a failli nous coûter nos conventions.

Dans cet article, je vous raconte comment l'agent a dérivé, et comment deux "simples" hooks ont remis les choses en ordre.

💡 Sur sfeir.dev, Rachid Chabane a déjà montré comment un hook peut empêcher un agent d'affirmer des résultats de tests qu'il n'a pas vérifiés. Ici, le problème est différent : l'agent ne ment pas, il oublie.

Pourquoi l'agent dérive-t-il malgré un CLAUDE.md précis ?

Mon équipe avait quelques règles simples, écrites dans le CLAUDE.md du projet :

## Conventions
- Code auto-documenté : des noms explicites plutôt que des commentaires.
- Pas de commentaire qui paraphrase le code.
- Tout nouveau code est couvert par des tests unitaires.

Au début, tout allait bien. Puis, au fil des semaines, deux symptômes récurrents sont apparus :

  • Des commentaires de plus en plus verbeux, exemple : # loop over each row au-dessus d'une boucle.
  • Des tests qui ne suivaient plus : le nouveau code arrivait en PR avec des tests manquants et des zones non couvertes par les tests.

Rien de dramatique pris isolément. C'est justement le piège : la dérive est progressive, et chaque PR semble acceptable.

Le problème peut venir de deux points précis. D'abord, le CLAUDE.md n'est qu'une partie du contexte : au fil d'une longue session, il pèse de moins en moins face au code, aux sorties d'outils et aux échanges. Ensuite, et c'est le plus sournois, l'agent imite le code qu'il lit. Dès que quelques commentaires verbeux passent en revue, ils deviennent le style « normal » du dépôt, et l'agent en produit davantage. La dérive s'auto-entretient. 🔄

Consigne ou contrôle : quelle différence ?

Notre premier réflexe a été de renforcer les prompts du CLAUDE.md : majuscules, répétitions, « IMPORTANT ». Cela a fonctionné… quelques jours. On ne corrige pas un problème probabiliste avec plus de probabilités.

💡
Une consigne se perd, un contrôle reste.

La réponse, c'est le harnais : des contrôles automatiques qui s'exécutent à chaque fois, que l'agent s'en souvienne ou non. Dans Claude Code, ce sont les hooks : des commandes que l'outil lance à des moments précis de son cycle de vie.

Harness Engineering : construire la confiance dans vos agents IA
Les agents de coding avancent trop vite pour que les équipes gardent le contrôle à coups de reviews manuelles. La vraie question n'est plus "mon LLM est-il assez bon ?", mais "comment construire un système de confiance ?" C'est précisément le problème que tente d'adresser le harness engineering.

C'est exactement ce que nous faisons déjà en Data Engineering. Personne ne compte sur la bonne volonté d'un job Spark pour respecter un schéma : on valide le schéma à l'ingestion. Pourquoi ne pas le faire avec notre agent ?

Le fichier CLAUDE.md ne disparaît pas pour autant. Les deux jouent des rôles différents :

Caractéristiques

CLAUDE.md

Hook

Nature

Une consigne

Un contrôle

Exécution

Interprétée par le modèle

Garantie à chaque déclenchement

Bon usage

Expliquer l'intention et le pourquoi

Vérifier ce qui est vérifiable

Ce qui arrive en cas d'oubli

Rien, la dérive passe

L'agent reçoit un retour et corrige

À quoi ressemble le harnais en pratique ? 🛠️

Notre harnais tient en deux niveaux de contrôle :

  1. Pendant le travail : après chaque modification de fichier, un hook repère les commentaires que l'agent vient d'ajouter. Le retour est immédiat, la correction aussi.
  2. Avant la PR : au moment où l'agent lance gh pr create, un second hook vérifie l'ensemble de la branche, commentaires et couverture du nouveau code.

La configuration

Tout se déclare dans .claude/settings.json. Ce fichier est versionné avec le projet : tous les développeurs de l'équipe ont le même harnais, sans rien installer.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check_comments.py"]
          }
        ]
      }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(gh pr create *)",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/pre_pr_gate.sh",
            "args": [],
            "timeout": 300
          }
        ]
      }
    ]
  }
}

Deux détails comptent :

  • Le champ if filtre directement sur la commande. Le script ne démarre que pour un gh pr create, et pas à chaque ls ou git status.
  • Le timeout de 300 secondes laisse le temps à la suite de tests de tourner. Elle peut être adaptée en fonction du projet.

Le contrôle des commentaires

Ce script sert les deux niveaux. Appelé par le hook, il lit sur l'entrée standard le JSON que Claude Code lui transmet, et n'analyse que les lignes touchées par la modification. Appelé avec --diff, il analyse les lignes ajoutées depuis la branche develop.

#!/usr/bin/env python3
import io
import json
import subprocess
import sys
import tokenize
from pathlib import Path

ALLOWED_PREFIXES = ("# WHY:", "#!", "# noqa", "# type:", "# pragma")


def comment_lines(source: str) -> dict[int, str]:
    found = {}
    try:
        for token in tokenize.generate_tokens(io.StringIO(source).readline):
            if token.type == tokenize.COMMENT and not token.string.startswith(ALLOWED_PREFIXES):
                found[token.start[0]] = token.string
    except (tokenize.TokenError, SyntaxError):
        pass
    return found


def lines_touched_by_edit(source: str, payload: dict) -> range:
    tool_input = payload["tool_input"]
    if payload["tool_name"] == "Write":
        return range(1, source.count("\n") + 2)
    inserted = tool_input.get("new_string", "")
    offset = source.find(inserted)
    if not inserted or offset < 0:
        return range(0)
    first = source.count("\n", 0, offset) + 1
    return range(first, first + inserted.count("\n") + 1)


def added_lines_since(base: str) -> dict[str, set[int]]:
    diff = subprocess.run(
        ["git", "diff", "--unified=0", f"{base}...HEAD", "--", "*.py"],
        capture_output=True, text=True, check=True,
    ).stdout
    added, current = {}, None
    for line in diff.splitlines():
        if line.startswith("+++ b/"):
            current = line[6:]
        elif line.startswith("@@") and current:
            start, _, count = line.split("+")[1].split(" ")[0].partition(",")
            first, length = int(start), int(count or 1)
            added.setdefault(current, set()).update(range(first, first + length))
    return added


def report(violations: list[str]) -> None:
    print(
        "Convention du projet : code auto-documenté, pas de commentaire qui paraphrase le code.\n"
        + "\n".join(violations)
        + "\nSupprime ces commentaires, ou renomme fonctions et variables pour rendre l'intention explicite. "
        "Un commentaire qui explique un POURQUOI invisible dans le code reste permis s'il commence par '# WHY:'.",
        file=sys.stderr,
    )
    sys.exit(2)


def check_edit() -> None:
    payload = json.load(sys.stdin)
    path = Path(payload["tool_input"].get("file_path", ""))
    if path.suffix != ".py" or not path.exists():
        sys.exit(0)
    source = path.read_text()
    touched = lines_touched_by_edit(source, payload)
    violations = [f"{path.name}:{n}  {text}" for n, text in comment_lines(source).items() if n in touched]
    if violations:
        report(violations)


def check_diff(base: str) -> None:
    violations = []
    for file, lines in added_lines_since(base).items():
        if not Path(file).exists():
            continue
        found = comment_lines(Path(file).read_text())
        violations += [f"{file}:{n}  {found[n]}" for n in sorted(lines & found.keys())]
    if violations:
        report(violations)


if __name__ == "__main__":
    if sys.argv[1:2] == ["--diff"]:
        check_diff(sys.argv[2])
    else:
        check_edit()

.claude/hooks/check_comments.py : les commentaires sont repérés avec le module tokenize de Python, ce qui évite les faux positifs sur un # dans une chaîne de caractères.

Le contrôle avant la PR

#!/usr/bin/env bash
set -uo pipefail

BASE="${PR_BASE_BRANCH:-origin/main}"
MIN_COVERAGE="${MIN_NEW_CODE_COVERAGE:-90}"
HOOKS_DIR="$(dirname "$0")"

cd "${CLAUDE_PROJECT_DIR:-.}"

python3 "$HOOKS_DIR/check_comments.py" --diff "$BASE" || exit 2

if ! python3 -m pytest -q --cov --cov-report=xml >/tmp/pre_pr_pytest.log 2>&1; then
  echo "Tests en échec : corrige-les avant d'ouvrir la PR (extrait ci-dessous)." >&2
  tail -n 15 /tmp/pre_pr_pytest.log >&2
  exit 2
fi

if ! report=$(diff-cover coverage.xml --compare-branch="$BASE" --fail-under="$MIN_COVERAGE" 2>&1); then
  echo "Couverture du nouveau code sous ${MIN_COVERAGE} % : ajoute les tests manquants pour ces lignes." >&2
  echo "$report" | grep -E "Missing|Total|Coverage" >&2
  exit 2
fi

exit 0

.claude/hooks/pre_pr_gate.sh : la couverture est mesurée avec diff-cover, qui ne regarde que les lignes ajoutées par la branche.

C'est ce dernier point qui a tout changé pour nous. Une couverture globale à 80 % peut masquer un nouveau module testé à 0 %. Mesurer la couverture du nouveau code, c'est mesurer exactement la règle que l'agent oubliait.

Comment parler à un agent qu'on vient de bloquer ?

Un hook qui dit seulement « non » ne sert pas à grand-chose. L'agent doit comprendre quoi corriger et comment. C'est là que le code de sortie 2 devient intéressant.

  • Sur un PostToolUse, la modification a déjà eu lieu. Un code de sortie 2 ne l'annule pas, mais transmet le message d'erreur à l'agent, qui corrige ensuite la réponse.
  • Sur un PreToolUse, le code 2 bloque l'appel : la PR n'est pas créée, et l'agent reçoit la raison à la place du résultat de sa commande.

Voici ce que l'agent reçoit quand il ajoute des commentaires superflus :

Convention du projet : code auto-documenté, pas de commentaire qui paraphrase le code.
migration.py:5  # Filter the rows to keep only the valid ones
migration.py:7  # loop over each row
migration.py:8  # keep rows with an id
Supprime ces commentaires, ou renomme fonctions et variables pour rendre l'intention explicite. Un commentaire qui explique un POURQUOI invisible dans le code reste permis s'il commence par '# WHY:'.

Et voici le refus avant la PR, quand le nouveau code n'est pas testé :

Couverture du nouveau code sous 90 % : ajoute les tests manquants pour ces lignes.
Diff Coverage
src/migration.py (50.0%): Missing lines 6,11
Missing: 2 lines
Coverage: 50%

Trois choix de conception font la différence :

  1. Rappeler la règle et sa raison, pas seulement l'erreur. L'agent récupère l'intention que le contexte avait diluée.
  2. Donner l'emplacement exact : fichier, numéro de ligne, ligne fautive.
  3. Prévoir une porte de sortie légitime. Certains commentaires sont utiles : ceux qui expliquent un pourquoi invisible dans le code. Le préfixe # WHY: les autorise, et les rend faciles à repérer en revue. Sans cette porte, l'agent finit par contourner la règle autrement.

Avec ces messages, la correction attendue devient plus limpide : supprimer les commentaires, renommer keep_valid en keep_rows_with_id, ajouter les deux tests manquants, puis relancer la PR. L'agent a toutes les informations pour le faire seul, sans qu'un humain ait à lui rappeler la règle. ✅

Quelles sont les limites ? ⚠️

Un harnais n'est pas une baguette magique. Quelques points à garder en tête :

  • Le hook protège de l'oubli, pas d'un adversaire. Un agent qui exécute des commandes shell peut toujours contourner un fichier local. La vérification qui fait foi reste côté serveur : la CI et la protection de branche.
  • Le filtre if est un filtre de confort. La documentation de Claude Code le décrit comme « best-effort ». Pour une interdiction stricte, ce sont les permissions qu'il faut configurer.
  • Un hook lent devient vite un hook désactivé. Le contrôle des commentaires prend quelques millisecondes et tourne à chaque modification. La suite de tests, elle, ne tourne qu'avant la PR.
  • Tout ne se vérifie pas. Un nom de variable mal choisi passe tous les contrôles. La relecture humaine du code produit reste indispensable, et je relis toujours l'intégralité de ce que l'agent génère.

Qu'en retenir ?

Le fichier CLAUDE.md dit ce que l'on veut. Le hook vérifie qu'on l'a obtenu. Les deux sont nécessaires, mais seul le second tient dans la durée.

Si vous démarrez avec Claude Code en équipe, voici ce que je ferais dès le lancement du projet :

  • Repérez vos règles vérifiables dans le fichier CLAUDE.md : chacune est candidate à un hook.
  • Contrôlez tôt ce qui est rapide, après chaque modification, et tard ce qui est lent, avant la PR.
  • Écrivez des messages pour l'agent : la règle, l'emplacement, la correction attendue.
  • Versionnez le harnais dans .claude/settings.json, pour que toute l'équipe en profite.
  • Gardez l'œil humain sur ce qu'aucun script ne peut juger.

L'IA agentique en production, c'est de l'ingénierie, pas de la magie.

J'espère que ce retour d'expérience vous aidera à garder vos agents dans le droit chemin !

Sources

Dernier