Générer du code ne coûte presque plus rien. Un agent Claude Code te sort un contrôleur, un handler Messenger et ses tests en quelques minutes. Ce qui coûte, c'est de savoir si le résultat est juste. Sans contrôle exécutable, le seul signal que tu reçois est « ça a l'air fini » : tu relis le diff, tu lances la suite à la main, tu renvoies l'erreur. La doc des bonnes pratiques le dit sans détour : dans ce cas, c'est toi qui deviens la boucle de vérification.
La vérification d'abord consiste à écrire la commande qui prouve qu'une tâche est terminée avant de laisser l'agent la réaliser, puis à brancher cette commande sur sa condition d'arrêt. Sur un projet Symfony, la bonne question n'est plus « quel prompt écrire ? » mais « qu'est-ce qui empêche l'agent de s'arrêter tant que ce n'est pas vrai ? ». Cet article construit cette commande, la branche à quatre niveaux, de la consigne au sous-agent vérificateur, et verrouille les tests pour que l'agent ne puisse pas tricher.
Pourquoi la vérification est-elle devenue le vrai goulot ?
Parce que l'agent produit plus vite que tu ne relis. Quand rien ne rend de verdict, tu fais le tour de contrôle après chaque réponse, et la boucle tourne à ta vitesse : plus tu confies de tâches en parallèle, plus tu deviens le point de saturation. Avec un contrôle qui renvoie un succès ou un échec, la boucle se ferme seule : l'agent code, lance le contrôle, lit le résultat, itère, et ne te rend la main que sur un état vérifié.
Anthropic a vécu la même chose en faisant écrire un compilateur C par des agents en parallèle. Leur conclusion : le vérificateur doit être quasi parfait, sinon Claude résout le mauvais problème. Et un avertissement qui vaut pour tout projet : il est facile de voir les tests passer et de croire le travail terminé, alors que c'est rarement le cas. Le code généré sans garde-fou accumule une dette réelle, documentée dans notre article sur la qualité du code produit par les agents.
Comment concevoir un contrôle que le modèle sait lire ?
Ta CI produit des logs pensés pour un humain qui fait défiler l'écran. L'agent, lui, paie chaque ligne en contexte, et une trace de pile de deux cents lignes noie le signal. Le billet sur le compilateur C donne trois règles : afficher quelques lignes et écrire le détail dans un fichier, mettre ERROR et la raison sur la même ligne, et proposer un mode rapide, parce que Claude n'a pas la notion du temps écoulé. Regroupe tout dans une commande unique, que l'agent, le hook et la CI appellent à l'identique :
#!/usr/bin/env bash
# bin/verify : une commande de vérité, sortie courte, code retour fiable
# Usage : bin/verify [--fast] (--fast = suite unitaire seule)
set -uo pipefail
cd "$(dirname "$0")/.."
PHP="docker compose exec -T php" # -T : pas de TTY, indispensable en hook et en CI
SUITE=""; [ "${1:-}" = "--fast" ] && SUITE="--testsuite unit"
LOG=var/verify.log; : > "$LOG"
rc=0
run() { # run <label> <commande...> : détail dans le log, une ligne ERROR en sortie
local label=$1; shift
if ! "$@" >> "$LOG" 2>&1; then
echo "ERROR $label: $(grep -m1 -iE 'fail|error' "$LOG" | cut -c1-160) (détail : var/verify.log)"
rc=1
fi
}
run phpunit $PHP vendor/bin/phpunit --no-progress --colors=never --stop-on-defect $SUITE
run phpstan $PHP vendor/bin/phpstan analyse --no-progress --error-format=raw
run container $PHP bin/console lint:container
run yaml $PHP bin/console lint:yaml config
run twig $PHP bin/console lint:twig templates
run schema $PHP bin/console doctrine:schema:validate --skip-sync
[ $rc -eq 0 ] && echo "OK verify (phpunit, phpstan, lint, schema)"
exit $rcLes options PHPUnit utilisées existent dans la doc de PHPUnit 12.5 : --no-progress, --colors=never et --stop-on-defect, qui s'arrête au premier échec, erreur, avertissement ou test risqué. Mets aussi failOnWarning et failOnRisky à true dans phpunit.xml.dist pour qu'un test « presque vert » ne passe pas, et garde PHPStan à un niveau qui bloque vraiment. Côté versions, PHPUnit 12 demande PHP 8.3 et PHPUnit 13 PHP 8.4 : aligne-toi sur ton image avant de documenter la commande dans CLAUDE.md. La base de la stratégie de tests est dans testing et qualité sur Symfony.
Quels sont les quatre niveaux pour contraindre l'arrêt de l'agent ?
Un agent s'arrête quand il estime avoir fini : toute la question est de savoir qui juge. La doc des bonnes pratiques décrit quatre niveaux, qui échangent du temps de mise en place contre de l'autonomie, et qui se cumulent.
- Niveau 1, la consigne : « quand tu as fini, lance bin/verify et colle sa sortie complète ; ne dis pas que c'est bon sans elle ». Gratuit, efficace souvent, mais rien n'y oblige l'agent et la consigne se dilue sur une longue session.
- Niveau 2,
/goal: un évaluateur, sur un petit modèle rapide, revérifie une condition après chaque tour. L'agent ne peut plus conclure sur sa seule appréciation. - Niveau 3, le Stop hook : un script lance
bin/verifyà chaque tentative d'arrêt et la refuse tant que c'est rouge. Le juge est un code retour, pas une opinion. - Niveau 4, le vérificateur indépendant : un sous-agent au contexte frais tente de réfuter le résultat, pour que celui qui code ne soit pas celui qui note.
Comment fixer une condition d’arrêt avec /goal ?
D'après la doc de /goal, l'évaluateur ne lance aucune commande et ne lit aucun fichier : il juge ce que Claude a affiché dans la conversation. La condition, limitée à 4 000 caractères, doit donc être démontrable par la sortie de l'agent, et bornée en nombre de tours :
/goal bin/verify affiche « OK verify » (sortie collée dans le dernier tour),
git diff --stat HEAD -- tests/ est vide, et aucun fichier hors src/, config/,
templates/ et migrations/ n'est modifié, or stop after 20 turnsL'évaluateur tourne sur le petit modèle rapide (Haiku par défaut) et son coût est faible face aux tours principaux. Son évaluation est différée tant qu'un sous-agent ou un shell en arrière-plan tourne, et /goal fonctionne aussi en mode non interactif avec claude -p. Il est indisponible si les hooks sont désactivés par la configuration managée, car il fait lui-même partie du système de hooks.
Comment bloquer la fin d’une tâche avec un Stop hook PHPUnit et PHPStan ?
Un hook Stop s'exécute quand Claude s'apprête à terminer son tour. D'après la doc des hooks, il bloque l'arrêt en renvoyant {"decision":"block","reason":"…"} sur la sortie standard, ou en sortant avec le code 2, auquel cas la sortie d'erreur devient la raison transmise à Claude. Il reçoit notamment le champ stop_hook_active, qui indique qu'un hook l'a déjà relancé. Déclaration dans .claude/settings.json :
Votre équipe utilise Claude Code ?
Formation intra sur vos projets réels : de la demi-journée à 2 jours, 6 devs max.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/verify-stop.sh",
"timeout": 300
}
]
}
]
}
}#!/usr/bin/env bash
# .claude/hooks/verify-stop.sh : refuse la fin de tour tant que bin/verify échoue
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}"
SID=$(jq -r '.session_id')
STATE=.claude/.state; mkdir -p "$STATE" # à ajouter au .gitignore
COUNT_FILE="$STATE/verify-$SID"
ATTEMPTS=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
MAX_ATTEMPTS=4
block() { # renvoie la décision de blocage avec une raison courte
echo $((ATTEMPTS + 1)) > "$COUNT_FILE"
jq -n --arg r "$(printf '%s' "$1" | head -c 1200)" '{decision: "block", reason: $r}'
exit 0
}
# 1. Aucun changement de code : simple discussion, on ne dérange pas
[ -z "$(git status --porcelain -- src tests config templates migrations)" ] && exit 0
# 2. Budget épuisé : on laisse l'agent s'arrêter, l'humain reprend la main
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
rm -f "$COUNT_FILE"; exit 0
fi
# 3. Tests verrouillés pendant la phase d'implémentation
if [ "${VERIFY_LOCK_TESTS:-0}" = "1" ]; then
CHANGED=$(git status --porcelain -- tests phpstan-baseline.neon phpunit.xml.dist | head -5)
[ -n "$CHANGED" ] && block "ERROR tests verrouillés modifiés. Restaure-les avec git checkout HEAD -- tests et corrige le code, pas les tests."
fi
# 4. Le contrôle unique
OUT=$(bin/verify 2>&1) && { rm -f "$COUNT_FILE"; exit 0; }
block "$(echo "$OUT" | grep '^ERROR' | head -5). Lis var/verify.log, corrige la cause racine, relance bin/verify."- La raison est courte, une ligne
ERRORpar contrôle : c'est elle qui entre dans le contexte à chaque itération. - Le risque de boucle : Claude Code coupe lui-même un Stop hook après 8 blocages consécutifs sans appel d'outil entre deux, mais ne compte pas sur ce plafond. Sortir dès que
stop_hook_activevauttrue, comme le suggère la doc, ne donne qu'une seule relance : si la correction échoue, l'agent s'arrête sur un état rouge. Le compteur par session donne quelques essais, puis rend la main. - Les tours de discussion, sans diff, ne déclenchent pas la suite complète.
- Le temps d'exécution : le hook tourne à chaque fin de tour. Branche
bin/verify --fasts'il faut, et réserve la suite complète à la condition/goalet à la CI.
Comment pratiquer le TDD agentique et verrouiller les tests ?
Le Stop hook ne vaut que si les tests existent et disent vrai. Pour un bug, la doc recommande d'écrire un test qui reproduit le défaut et échoue, puis de corriger. Elle suggère aussi qu'un Claude écrive les tests et qu'un autre écrive le code. Le billet Effective harnesses for long-running agents pose la règle sans nuance : « It is unacceptable to remove or edit tests ». Un agent coincé sur un test rouge a une issue facile, toucher au test, et une consigne dans CLAUDE.md n'est qu'une demande. Il faut un contrôle déterministe, en trois couches :
- Une règle de permission
Edit(/tests/**)endeny. D'après la doc des permissions, les règlesEditcouvrent les outils d'édition, les commandes Bash reconnues commesedouteeet les redirections. Écris le chemin ancré par une barre initiale : sans elle, la règle matche aussi les dossierstestsdevendor/. - Le garde
git statusdu Stop hook, qui attrape aussi la baseline PHPStan et la configuration PHPUnit, deux autres façons de « faire passer » sans corriger. - Un montage en lecture seule du dossier
testsdans le conteneur PHP pendant l'implémentation, via un fichier compose d'override. C'est la seule couche qui résiste à un script PHP qui écrirait lui-même un fichier.
Le flux qui en découle tient en deux sessions. Session « rouge » : l'agent écrit les tests depuis la spécification, tu les relis, tu les commites ; bin/verify doit échouer pour la bonne raison, une assertion, pas une erreur de syntaxe. Session « verte » : VERIFY_LOCK_TESTS=1, règle deny active, /goal posé. L'agent qui implémente n'a pas vu l'écriture des tests, il ne les a donc pas adaptés à son code.
Comment utiliser un sous-agent vérificateur indépendant ?
Des tests verts disent que ce que tu as pensé à vérifier tient. Ils ne disent rien d'un cas limite oublié, d'une migration Doctrine manquante ou d'un diff qui déborde du périmètre. Un sous-agent au contexte vierge, qui n'a pas vu le raisonnement de l'implémenteur, relit la demande et le diff, et pose la question que les tests ne posent pas. L'isolation de contexte est détaillée dans le workflow sous-agents et worktrees.
---
name: verifier
description: Vérifie de façon indépendante qu'un changement Symfony respecte la spec. À invoquer avant de déclarer une tâche terminée.
tools: Read, Grep, Glob, Bash
disallowedTools: Write, Edit
model: sonnet
maxTurns: 15
---
Tu es un vérificateur. Tu ne corriges rien, tu ne modifies aucun fichier.
1. Lis la spec (PROGRESS.md) et git diff HEAD.
2. Lance bin/verify et rapporte la sortie brute.
3. Pour chaque critère de la spec, trouve le test qui le prouve. Sinon, c'est un écart.
4. Cherche à réfuter : cas limites (null, doublons, droits), migration Doctrine
manquante, route non couverte, test qui n'asserte rien, mock qui masque le réel.
5. Ne signale que les écarts de correction ou de spec, pas le style.
Réponse finale :
VERDICT: PASS ou FAIL
GAPS: un écart par ligne, avec fichier:ligne
EVIDENCE: sortie de bin/verify, 10 lignes au plusLe point 5 n'est pas décoratif : la doc recommande de demander au relecteur de ne signaler que les écarts de correction, car un relecteur qui cherche des manques en trouve toujours, ce qui pousse à la sur-ingénierie. Le vérificateur garde Bash, donc il pourrait techniquement écrire via le shell : sa consigne et son contexte frais font le reste, la sandbox fait le blocage réel. Il coûte des tokens et réintroduit du jugement de modèle : réserve-le aux critères qu'une commande ne sait pas exprimer.
Quels sont les pièges de la vérification automatique ?
- Le vérificateur faible : une suite qui ne teste que le chemin nominal valide un contrôleur qui plante sur l'entrée vide ; un PHPStan permissif laisse passer des types faux ; un test qui mocke tout valide un handler qui ne fait rien. Le hook est vert, le travail est faux. Traite ton vérificateur comme du code de production, et durcis-le à chaque faux positif.
- Les tests instables : sur un test qui échoue au hasard, l'agent « corrige » du code juste pour calmer le test. Mets-les en quarantaine avant d'automatiser.
- Les règles contournables : la règle
denyne couvre ni un script arbitraire ni une commande lancée dans le conteneur. D'où les trois couches. - Le coût : l'évaluateur de
/goalest bon marché, les tours principaux non. Borne toujours avec « or stop after N turns ».
Par où commencer lundi matin ?
- Créer
bin/verify: une commande, une ligneERRORpar échec, un code retour fiable, un mode--fast. - La documenter dans CLAUDE.md et la reprendre dans chaque condition
/goal, avec une borne de tours. - Ajouter le Stop hook avec compteur de tentatives et garde sur
tests/, la baseline et la configuration PHPUnit. - Séparer les sessions : tests rouges relus et commités par toi, puis implémentation sous verrou.
- Terminer par le sous-agent
verifier, et lire les preuves plutôt que le résumé.
Un Stop hook qui lance PHPUnit et PHPStan, une condition /goal démontrable, des tests verrouillés et un vérificateur indépendant : avec ces quatre garde-fous, Claude Code peut travailler seul sur un projet Symfony sans que tu sois la boucle de vérification. Tu gardes pour toi ce qui compte vraiment, le périmètre fonctionnel et les arbitrages d'architecture. Ces mécanismes deviennent une convention d'équipe dès qu'ils sont versionnés dans le dépôt. Prochain article : Claude Code en entreprise, avec les runners auto-hébergés et la passerelle d'administration.

Écrit par
Flavien Métivier
Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.
Mon parcours