Un agent de code n’a le droit d’écrire dans une pull request que ce qu’un enregistrement de faits lui permet de citer.
Un script capture à l’instant de la vérification la commande de test qui a tourné, son code de sortie, sa ligne de résumé et le SHA du commit testé. Le modèle ne retape rien.
Une suite qui n’a pas démarré est « non exécutée », pas « passée ». Un enregistrement plus vieux que HEAD est refusé sans réinterprétation.
Cinq fichiers imposent ces règles sur un petit service Python testé avec pytest et livré par Claude Code. Les voici, précédés de la raison de leur existence : une consigne dans le prompt ne tient pas seule.
Les trois règles, dans l’ordre où elles s’exécutent :
- Capturer au moment de l’observation : le wrapper de tests écrit
.agent/verify-record.json, l’agent ne l’écrit jamais ; - Ne rien réinterpréter, refuser ce qui est périmé : un hook
PreToolUsebloquegh pr createsi l’enregistrement manque, a échoué ou ne correspond plus àHEAD; - Des faits épinglés, de la prose de modèle : le corps de la PR est rendu depuis un squelette où la section de vérification vient du script et où le modèle n’écrit que les paragraphes entre les blocs.

Pourquoi une consigne au prompt ne tient pas, alors qu’un hook tient ?
Ce corps de PR, tout le monde l’a lu : « Tests : 14/14 verts. »
Le job de CI raconte autre chose. La suite a tourné une fois en début de session sur un commit qui n'est plus sur la branche depuis. Ou bien elle n’a pas tourné du tout, et le modèle a lu le fichier de tests, en a déduit que ça devait passer et a écrit que ça passait.
Le défaut n’est pas dans l’honnêteté du modèle, il est dans le canal.
Le résultat d’une commande entre dans le contexte sous forme de texte et en ressort deux cents tours plus tard sous forme de résumé. Entre les deux, la compression fait son travail : « suite lancée avant la dernière modification, 14 tests passés » devient « 14 tests passés », puis « tous les tests passent ».
Chaque étape est une paraphrase raisonnable. La somme est fausse.
Le relecteur humain ne fait pas mieux. Daniel Nwaneri raconte sur freeCodeCamp en août 2026 avoir signé un correctif HUMAN-VERIFIED après une seule exécution propre (zéro réponse inventée sur cinq). En retestant plus durement, il a mesuré que l’agent ne s'abstenait d’inventer une réponse que dans 66,7 % des cas, pas 100 % (le chiffre d’un seul auteur sur un seul cas). Mais le mécanisme, vous le connaissez. Son test creux tient en une ligne : un assert result is not None qui passe alors que la déduplication compare toujours les noms de fichiers au lieu du contenu.
Une exécution verte ne prouve rien tant qu’on ignore ce qui a été observé, quand et sur quel commit.
D’où le parti pris de l’article : rendre la phrase honnête la seule que le modèle puisse produire, ce qui dispense de lui demander de l’être.
La documentation de Claude Code décrit ses hooks comme des commandes shell exécutées à des points fixes du cycle de vie, ce qui donne un contrôle déterministe (certaines actions se produisent toujours, au lieu de s’en remettre au LLM pour décider de les lancer). C'est le même principe que les hooks pre-commit de git : un code de sortie non nul annule l’action correspondante. Il s’applique ici un étage plus haut, à l’outil que l’agent s’apprête à appeler.
D’où vient chaque nombre de la section tests de la dernière PR ouverte par un agent ? Si c’est le modèle qui les a tapés, la suite est pour vous.
Le wrapper de tests écrit l’enregistrement, jamais l’agent (règle 1)
L’enregistrement compte six champs : la commande exacte, son code de sortie, la ligne de résumé de pytest, le SHA de HEAD, un booléen pour l’état de l’arbre de travail (sale ou propre) et l’horodatage.
Il s’arrête là. Chaque champ de plus finirait un jour par être interprété par quelqu’un.
Claude Code déclenche le hook PostToolUse après chaque commande réussie. Ce serait le point de capture naturel. Sauf qu’un échec part dans l’événement PostToolUseFailure : le harnais sépare le succès de l’échec. Un hook n’en voit qu’un côté.
L’enregistrement demande aussi le SHA de HEAD et l’état de l’arbre de travail à l’instant du test, qu’aucun champ documenté du payload d’un hook ne porte. Un hook devrait donc relancer git rev-parse HEAD et git status --porcelain lui-même après coup (sur un arbre peut-être déjà modifié).
Côté Copilot CLI, postToolUse reçoit un résultat déjà mis en forme pour le modèle ({ resultType, textResultForLlm }) et aucun code de sortie numérique.
Le wrapper est le processus parent de pytest. Il reçoit ce code sous forme d’entier et voit HEAD au même instant. Il lance pytest avec sys.executable plutôt qu’avec un exécutable python absent de bien des machines, et l’enregistrement nomme alors l’interpréteur qui a tourné — ce pour quoi il existe.
#!/usr/bin/env python3
"""Run the test suite and record what was observed. The agent runs this; it never writes the record."""
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
RECORD = Path(".agent/verify-record.json")
COMMAND = [sys.executable, "-m", "pytest", "-q"]
SUMMARY = re.compile(r"(passed|failed|error|no tests ran).* in [\d.]+s")
def git(*args: str) -> str:
return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout.strip()
def main() -> int:
run = subprocess.run(COMMAND, capture_output=True, text=True)
sys.stdout.write(run.stdout)
sys.stderr.write(run.stderr)
summary = [line.strip("= ") for line in run.stdout.splitlines() if SUMMARY.search(line)]
RECORD.parent.mkdir(exist_ok=True)
RECORD.write_text(json.dumps({
"command": " ".join(COMMAND),
"exit_code": run.returncode,
"summary": summary[-1] if summary else None,
"head": git("rev-parse", "HEAD"),
"dirty": git("status", "--porcelain") != "",
"observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}, indent=2) + "\n")
return run.returncode
if __name__ == "__main__":
sys.exit(main())scripts/verify.py : il lance la suite, relaie sa sortie, écrit l’enregistrement et rend le code de sortie de pytest tel quel.
{
"command": "python -m pytest -q",
"exit_code": 0,
"summary": "14 passed in 0.62s",
"head": "9f2c7a1e4b0d3c5e8a7f6b2d1c0e9a8b7f6e5d4c",
"dirty": false,
"observed_at": "2026-09-02T09:41:17+00:00"
}L’enregistrement d’une exécution verte.
Ces codes de sortie, pytest les documente et les expose dans son API publique via pytest.ExitCode : 0 pour tous les tests collectés passés, 1 pour des tests en échec, 5 pour aucun test collecté. Un répertoire de tests vide fait terminer pytest sur ce 5 sans un seul échec. Un lecteur pressé lit une sortie sans erreur et conclut au succès, qu’il soit humain ou modèle.
Mais l’enregistrement porte un 5, et 5 n’est pas 0. Ce seul cas justifierait le wrapper.
.agent/* va dans le .gitignore, suivi d’une ligne !.agent/pr-template.md qui en exclut le squelette de PR, seul fichier de ce répertoire écrit à la main. L’enregistrement est une observation locale. Sa seule copie durable est la section de PR que la règle 3 en tire.
Dans les instructions de l’agent, la consigne de lancer les tests devient celle de lancer scripts/verify.py. Les deux règles suivantes la verrouillent.
Refuser l’enregistrement plus vieux que HEAD (règle 2)
gh pr create n’a pas de hook de pré-soumission (le manuel de GitHub CLI n’en documente aucun). Le refus revient donc au harnais en amont de la commande. Sur Claude Code, c’est un hook PreToolUse déclaré dans .claude/settings.json, sur les outils que nomme son matcher. Ces points d'extension de l'orchestration d'outils font de Claude Code autre chose qu'une boucle de chat.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit",
"hooks": [
{ "type": "command", "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/pr_guard.py\"" }
]
}
]
}
}.claude/settings.json : un seul hook, sur les commandes shell et sur les outils d’écriture.
Un matcher ne filtre que sur des noms d’outils, ce qui laisse le tri des commandes au script : avec ce seul matcher, le hook démarre à chaque appel, et vingt exécutions sur un ls -la qu’il ignore donnent ici une médiane de 29 ms (min 28 ms, max 139 ms).
La référence des hooks fixe un contrat court. Le hook reçoit sur stdin un JSON avec cwd, tool_name et tool_input.command. Un code de sortie 2 bloque l’appel, quel que soit le message affiché par le hook. Un JSON avec permissionDecision: "deny" sur stdout porte la raison. Le guide des hooks dit ce qu’elle devient (Claude Code annule l’appel et renvoie permissionDecisionReason au modèle).
Le guide demande de choisir une seule des deux formes par hook, mais le script ci-dessous les combine exprès et la référence définit ce qui en sort. Sur un code 2, le message de blocage est la raison portée par le JSON quand il en donne une, ou le texte de stderr sinon.
Ce refus est la seule chose que l’agent lit à la place du résultat de gh. Son message doit donc nommer la commande à relancer.
Le tout tient en une soixantaine de lignes :
#!/usr/bin/env python3
"""PreToolUse hook. Exit 2 denies the call; the deny reason is fed back to the model."""
import json
import os
import re
import subprocess
import sys
from pathlib import Path
RECORD = Path(".agent/verify-record.json")
VERIFY = "scripts/verify.py"
BODY = ".agent/pr-body.md"
BODY_BYPASS = re.compile(r"\s(-f|--fill)\S*|\s(--body[=\s]|-b[=\s])")
PROTECTED = (".claude/", ".agent/verify-record.json", "scripts/verify.py")
def deny(reason: str) -> None:
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}}))
sys.exit(2)
def current_head() -> str:
return subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip()
def check_record() -> None:
if not RECORD.exists():
deny(f"Test suite NOT RUN: run {VERIFY} before opening a PR.")
record = json.loads(RECORD.read_text())
if record["exit_code"] != 0:
deny(f"Last verification failed (exit {record['exit_code']}: {record['summary']}). Fix, then rerun {VERIFY}.")
if record["head"] != current_head() or record["dirty"]:
deny(f"Stale record: HEAD moved or the tree was dirty when tests ran. Commit, then rerun {VERIFY}.")
payload = json.load(sys.stdin)
os.chdir(payload["cwd"])
tool = payload.get("tool_name", "")
target = payload.get("tool_input", {}).get("file_path", "")
if tool != "Bash" and target:
relative = os.path.relpath(target, os.getcwd())
if relative.startswith(PROTECTED):
deny(f"{relative} is the guard itself: the agent never edits what verifies it.")
sys.exit(0)
command = payload.get("tool_input", {}).get("command", "")
if "pytest" in command and VERIFY not in command:
deny(f"Run the suite through {VERIFY}, never pytest directly: that is what writes the record.")
if "gh pr create" in command:
check_record()
if f"--body-file {BODY}" not in command and f"-F {BODY}" not in command:
deny(f"The PR body is rendered by scripts/render_pr_body.py: pass --body-file {BODY}.")
if BODY_BYPASS.search(command):
deny(f"The PR body comes from {BODY} only: no --fill, no --body.")
sys.exit(0).claude/hooks/pr_guard.py : pytest hors du wrapper est bloqué, et gh pr create l’est tant que l’enregistrement n’est pas vert, à jour et pris sur un arbre propre.
La fonction check_record refuse pour quatre motifs, et non trois : l’enregistrement absent, en échec, pris sur un autre HEAD ou pris sur un arbre sale. Un SHA n’identifie le code testé que sur un arbre propre.
Dans les quatre cas, la réponse est un refus sec (sans nuance de probabilité).
Sur un même commit et un arbre propre, l’enregistrement d’une exécution antérieure reste valable : la PR part même après un lancement du wrapper interrompu sans rien écrire. C’est le contrat et non une faille, puisque l’enregistrement atteste un commit, pas une session ; seuls un HEAD déplacé ou un arbre sale valent refus.
Le HEAD comparé est celui du répertoire de travail transmis par le harnais. Si les agents tournent chacun sur son propre worktree, c’est bien celui de la branche que la PR va porter.
HEAD est refusé, jamais réinterprété.Le filtre sur pytest est grossier à dessein. Un faux positif coûte un message de refus et une relance, mais un faux négatif coûterait la garantie.
On peut d’ailleurs tester le hook sans lancer Claude Code. Il suffit de lui donner le payload à la main :
$ rm -f .agent/verify-record.json
$ echo '{"cwd": ".", "tool_input": {"command": "gh pr create --fill"}}' | python3 .claude/hooks/pr_guard.py
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Test suite NOT RUN: run scripts/verify.py before opening a PR."}}
$ echo $?
2Le refus, hors harnais : même payload, même code de sortie.
Dans une vraie session, l’agent ne reçoit pas le JSON : il reçoit la phrase, à la place du résultat de sa commande.

La dernière garde porte sur le corps de la PR. Le hook exige --body-file .agent/pr-body.md et refuse --body, parce qu’un corps littéral tapé par le modèle réintroduirait le chiffre même que le pattern interdit.
Il refuse aussi --fill, qui remplit le corps depuis les commits. Le manuel ne décrit la préséance de --title et --body que sur le contenu auto-rempli et ne dit rien de --body-file. Un doute sur ce qui finit dans le corps est un motif de refus, au même titre qu’un enregistrement absent.
Pour voir la garde tourner, on installe le hook, on supprime .agent/verify-record.json et on demande à l’agent d’ouvrir une PR.
Le même refus sur Copilot CLI et OpenCode
La capture ne demande aucun portage puisqu’un wrapper Python ignore quel harnais l’a lancé. Seul le refus change de forme.
| Harnais | Déclaration du hook | Événement | Ce qui refuse l’appel |
|---|---|---|---|
| Claude Code | .claude/settings.json | PreToolUse sur Bash, Write et Edit | un code de sortie 2, la raison dans permissionDecisionReason |
| Copilot CLI | un fichier JSON sous .github/hooks/ (avec "version": 1) | preToolUse | exit 2, un crash ou toute autre sortie non nulle |
| OpenCode | un plugin TypeScript dans .opencode/plugins/ | tool.execute.before | une exception levée par le plugin |
Le même refus sur trois harnais : où se déclare le hook, quel événement, ce qui bloque l’appel.
Sur Copilot CLI, les hooks vont dans des fichiers JSON sous .github/hooks/ et preToolUse figure avec postToolUse parmi les quatorze événements que documente sa référence. La référence GitHub est explicite sur le refus. Parmi les façons dont un hook de commande échoue, seul un timeout laisse passer l’appel.
Le hook reçoit toolName et toolArgs. Le filtre gh pr create est réécrit sur ces deux champs. Mais ce filtre reste à vérifier parce que la documentation décrit le contrat général de preToolUse et non un hook qui cible gh pr create.
OpenCode passe par un plugin TypeScript déposé dans .opencode/plugins/, dont le hook tool.execute.before bloque en levant une exception, cas typique que la documentation des plugins de ce framework d’agents prend justement en exemple pour interdire la lecture d’un .env. Le contenu de son payload d’après-exécution n’a pas été confirmé ici. Mais il importe peu puisque le code de sortie vient du wrapper.
Le corps de la pull request : des faits épinglés, de la prose de modèle (règle 3)
Le corps de la PR est un squelette à deux trous et un seul est ouvert au modèle.
## Summary
{{prose}}
## Verification
<!-- Rendered from .agent/verify-record.json by scripts/render_pr_body.py. Do not edit by hand. -->
{{verification}}.agent/pr-template.md : la prose d’un côté, les faits de l’autre.
Un script remplit les deux trous. Il lit l’enregistrement, traduit le code de sortie en libellé, ouvre le seul fichier écrit par le modèle et refuse le rendu dès qu’un nombre y touche tests, passed ou failed, ou que tests y touche pass, passed ou green ; un 14/14 green passe entre les mailles.
#!/usr/bin/env python3
"""Render the PR body: facts from the record, prose from the model, nothing else."""
import json
import re
import sys
from pathlib import Path
RECORD = Path(".agent/verify-record.json")
PROSE = Path(".agent/pr-prose.md") # the only file the model writes
TEMPLATE = Path(".agent/pr-template.md")
OUT = Path(".agent/pr-body.md")
LABELS = {0: "all tests passed", 1: "some tests failed", 5: "no tests collected"}
HAND_TYPED_RESULT = re.compile(r"\d+\s+(tests?|passed|failed)|tests?\s+(pass|passed|green)", re.IGNORECASE)
def verification_block() -> str:
if not RECORD.exists():
return "- Test suite: **NOT RUN**"
r = json.loads(RECORD.read_text())
state = LABELS.get(r["exit_code"], f"exit code {r['exit_code']}")
tree = "dirty tree" if r["dirty"] else "clean tree"
return "\n".join([
f"- Command: `{r['command']}`",
f"- Result: **{state}** (exit {r['exit_code']}) — `{r['summary']}`",
f"- Verified commit: `{r['head'][:12]}` ({tree})",
f"- Observed at: {r['observed_at']}",
])
if not PROSE.exists():
sys.exit(f"{PROSE} is missing: write the prose there, then render.")
prose = PROSE.read_text().strip()
if HAND_TYPED_RESULT.search(prose):
sys.exit("Test results belong to the verification block, not to the prose. Remove the claim and re-render.")
body = TEMPLATE.read_text().replace("{{verification}}", verification_block()).replace("{{prose}}", prose)
OUT.write_text(body)
print(body)scripts/render_pr_body.py : la section de vérification vient de l’enregistrement, la prose du fichier du modèle, et un chiffre de test dans la prose fait échouer le rendu.
Le libellé NOT RUN (non exécutée) est la valeur par défaut du script en l’absence d’enregistrement.
La règle cesse là d’être une consigne. Dans la section de vérification, le libellé all tests passed n’existe que si exit_code valait 0 dans un fichier écrit par un processus.
Le lint sur la prose partage la grossièreté du filtre pytest. Il refusera une phrase comme "ces tests passent une fixture", que le modèle reformulera avant de relancer le rendu.
Filtrer la prose d’un modèle probabiliste est une partie qu’on ne gagne pas : "all 14 specs succeeded" ou "suite verte" franchissent la regex sans effort. D’où les faits épinglés hors de la prose.
Pour un correctif ordinaire, le corps rendu ressemble à ceci :
## Summary
Fix duplicate detection in the upload service: entries were compared by file name, not by content hash.
## Verification
<!-- Rendered from .agent/verify-record.json by scripts/render_pr_body.py. Do not edit by hand. -->
- Command: `python -m pytest -q`
- Result: **all tests passed** (exit 0) — `14 passed in 0.62s`
- Verified commit: `9f2c7a1e4b0d` (clean tree)
- Observed at: 2026-09-02T09:41:17+00:00Le corps de PR rendu : le relecteur sait ce qui a tourné, sur quel commit, et à quelle heure.
Un commit de plus, et ce même corps est encore rendu, à l’identique — avec un Verified commit qui n’est plus celui de HEAD. C’est gh pr create que le hook refuse alors.

Il reste au modèle à connaître l’ordre des opérations. Le prompt ne porte plus que ce bloc dans les instructions du projet. Les scripts tiennent le reste :
## Opening a pull request
1. Run `python3 scripts/verify.py`. Never run pytest directly.
2. Write the summary and the points of attention in `.agent/pr-prose.md`.
No test figure in it: the verification block is rendered, not written.
3. Run `python3 scripts/render_pr_body.py` and read the result.
4. Open the PR with `gh pr create --title "<title>" --body-file .agent/pr-body.md`.
When a step refuses, report the refusal message as is.
A suite that did not run is "not run", never "passed".Le bloc d’instructions de l’agent : quatre étapes, dont trois sont des scripts qui refusent.
Pour vérifier le pattern, on copie ces cinq fichiers et ce bloc dans un dépôt de test, on casse un test et on demande une PR. La réponse de l’agent doit alors citer le code de sortie 1 et la ligne de résumé, sans les commenter.
« Non exécutée » est une phrase que la PR peut enfin écrire
La phrase de vérification appartient désormais au processus qui a observé le résultat. Ni le modèle ni un humain n’ont plus à l’écrire de mémoire, et l’honnêteté de l’agent n’y change rien.
Le hook élargi ferme le chemin poli : un Edit sur lui-même, un Write sur l’enregistrement ou sur le wrapper reçoivent le même refus. Un rm .claude/hooks/pr_guard.py ou un echo {} > .claude/settings.json soumis comme commandes Bash passent en revanche, puisque le hook n’inspecte ces commandes-là que sur pytest et gh pr create ; une session headless à qui l’on demande de ici de désactiver la garde, faute de pouvoir l’éditer, a tendu ces deux lignes à l’utilisateur. Ce qui survit à un adversaire est côté serveur : un job de CI qui recalcule l’enregistrement sur le SHA poussé, une protection de branche au-dessus, et un enregistrement signé pour la suite.
La garde agit en aval. En amont, cadrer le ticket avant de lancer l'agent réduit ce qu'elle aura à refuser.
Cinq fichiers à copier (et le bloc d’instructions qui les ordonne) : scripts/verify.py, .claude/settings.json, .claude/hooks/pr_guard.py, .agent/pr-template.md, scripts/render_pr_body.py.
La référence des hooks de Claude Code liste les autres événements disponibles pour le même refus. Le même wrapper servira aux enregistrements suivants, ceux du linter et de la couverture.