Hooks — Tests als Gate im Hintergrund
Artikel 9 · Serie: Agentic Coding mit Claude Code
Drei Test-Layer hat byhaushalt nach Artikel 8: pytest für den Parser, Vitest für die React-Komponenten, Playwright für End-to-End. Ein Format-Standard pro Sprache: Ruff für Python, Prettier für TypeScript. Und keinerlei Mechanismus, der das automatisch durchsetzt. Wer im Eifer der Session eine Zeile editiert und vergisst zu formatieren, hinterlässt einen Diff voller Whitespace-Noise. Wer einen print("DEBUG") im Parser stehen lässt und committet, verliert beim Review Zeit. Wer die Session beendet ohne pytest laufen zu lassen, weiß nicht, ob die Änderung den Sum-Constraint noch erfüllt. Hooks lösen das, ohne dass Mensch oder Modell daran denken muss.
Hooks, Skills und Slash Commands — wer triggert wann
Claude Code unterscheidet drei Mechanismen, mit denen wiederkehrende Aktionen ins Projekt eingebaut werden. Sie überschneiden sich oberflächlich, aber wer triggert sie auslöst, ist jeweils ein anderer:
| Mechanismus | Wer triggert | Wann | Kann blockieren? |
|---|---|---|---|
| Skill | das Modell, wenn ein Trigger zur Beschreibung passt | im Verlauf der Konversation | nein |
| Slash Command | der Mensch, durch Tippen von /<name> | sofort beim Aufruf | nein |
| Hook | das System, automatisch bei einem Event | vor/nach Tool-Aufruf, am Turn-Ende, bei Session-Start | ja (PreToolUse, Stop) |
Ein Skill wie e2e-spec aus Artikel 8 wartet darauf, dass das Modell ihn aufruft — wir schreiben „generiere den E2E-Test", das Modell erkennt die Beschreibung des Skills und führt ihn aus. Ein Slash Command wie /check-totals braucht den Menschen, der den Befehl tippt. Ein Hook braucht weder das eine noch das andere — er feuert automatisch, wenn das definierte Event eintritt. Genau das macht ihn als Quality-Gate stark: was an einen Hook gebunden ist, läuft auch dann, wenn niemand daran gedacht hat.
Hook-Events im Überblick
Claude Code definiert über zwanzig Events, an die sich ein Hook hängen lässt. Für Quality-Gates sind drei davon zentral:
| Event | Wann | Typischer Einsatz |
|---|---|---|
PreToolUse | bevor ein Tool ausgeführt wird | Aktion blockieren oder modifizieren |
PostToolUse | nachdem ein Tool erfolgreich war | Ergebnis verarbeiten, formatieren |
Stop | wenn das Modell seinen Turn beendet | abschließende Prüfung, Tests |
Daneben existieren weitere Events: SessionStart lädt Kontext am Beginn, UserPromptSubmit validiert User-Eingaben, SubagentStop reagiert wenn ein Subagent fertig ist. Die Doku führt sie auf, wir nutzen für v0.9 nur die drei zentralen.
Ein Hook bekommt das Event-Payload als JSON auf stdin. Bei PreToolUse und PostToolUse enthält es unter anderem tool_name und tool_input — also bei Edit den Dateipfad, bei Bash das Kommando. Der Hook entscheidet anhand dieser Daten, was er tut: protokollieren, formatieren, blockieren. Der Exit-Code steuert das Ergebnis: Exit 0 ist immer „weiter", Exit 2 ist „blockieren" und gibt stderr als Begründung zurück. Für feinere Kontrolle kann der Hook JSON auf stdout schreiben — etwa permissionDecision: "deny" mit einer ausführlichen Begründung.
Plan-File für Art 9
Task 1 (parallel): PostToolUse-Format-Hook.
Liest tool_input.file_path. Endung → ruff oder prettier.
Exit immer 0 (Format ist Best-Effort).
Task 2 (parallel): PreToolUse-Commit-Guard.
Liest tool_input.command. Wenn `git commit`: staged Diff scannen
nach print(, console.log(, breakpoint(), debugger;.
Bei Treffer: JSON mit permissionDecision: deny.
Task 3 (nach Task 1 + 2): Stop-Hook mit Smart-Guard.
Liest git diff --name-only. Nur Layer testen, die geändert sind.
Playwright bewusst nicht im Hook.
Task 1: Formatieren nach jedem Edit
Der erste Hook ist der einfachste und der mit der höchsten Frequenz. Nach jedem Edit oder Write läuft ein Shell-Script, das die editierte Datei in Empfang nimmt und an den passenden Formatter weiterreicht. Python-Dateien gehen an Ruff, alles unter web/ an Prettier:
#!/usr/bin/env bash
set -uo pipefail
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
[[ -z "$FILE" ]] && exit 0
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
REL="${FILE#$PROJECT_DIR/}"
case "$REL" in
*.py)
(cd "$PROJECT_DIR" && uvx ruff format "$REL") >/dev/null 2>&1 || true ;;
web/*.ts|web/*.tsx|web/*.json|web/*.md|web/*.yml|web/*.yaml|web/*.css)
SUB="${REL#web/}"
(cd "$PROJECT_DIR/web" && npx --yes prettier@3 --write "$SUB") >/dev/null 2>&1 || true ;;
esac
exit 0
Die Wahl von uvx statt einer lokalen Ruff-Installation ist bewusst. uvx lädt das Tool bei Bedarf und cached es im uv-Cache; wir müssen Ruff nicht zwingend in den Dev-Dependencies des Parsers haben. Analog npx --yes prettier@3 für die Frontend-Dateien: Prettier wird on-demand geladen, falls es nicht in web/node_modules/ liegt. Der Trade-off: die erste Edit nach einem Reset des uv- oder npm-Cache braucht ein paar Sekunden mehr für den Download. Danach läuft der Hook unter 100 ms.
Wichtig ist die || true-Klammerung um beide Formatter-Aufrufe. Wenn Ruff oder Prettier aus irgendeinem Grund nicht laufen — fehlende Internetverbindung beim ersten Aufruf, kaputte Konfiguration — darf das nicht den Edit blockieren. Format ist Best-Effort, kein Quality-Gate. Wer hier Exit 2 zurückgibt, sorgt dafür, dass eine Code-Änderung am Format-Tool scheitert, was bei den Größenordnungen einer Session schnell zu Frust führt.
Die Registrierung in .claude/settings.json macht den Matcher explizit:
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/post-edit-format.sh",
"timeout": 30
}
]
}
]
Der Matcher ist eine Regex auf den Tool-Namen — Edit|Write reagiert auf beide, ignoriert aber Read, Grep, Bash und alle anderen. Wer den Matcher weglässt, lässt den Hook bei jedem Tool feuern. Das ist selten gewollt.
Task 2: Commit-Guard gegen Debug-Reste
Der zweite Hook hängt sich an PreToolUse mit Matcher Bash. Wir wollen aber nicht bei jedem Shell-Befehl prüfen, sondern nur bei git commit. Die Filterung passiert im Script selbst:
#!/usr/bin/env bash
set -uo pipefail
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if ! echo "$CMD" | grep -qE '^[[:space:]]*git[[:space:]]+commit(\b|$)'; then
exit 0
fi
cd "${CLAUDE_PROJECT_DIR:-$(pwd)}" || exit 0
PY_PATTERNS='(\bprint\(|\bbreakpoint\(\)|\bpdb\.set_trace|\bipdb)'
JS_PATTERNS='(\bconsole\.(log|debug)\(|\bdebugger[[:space:]]*;|\.only\()'
DIFF=$(git diff --cached --unified=0 2>/dev/null)
[[ -z "$DIFF" ]] && exit 0
HITS=""
CURRENT_FILE=""
while IFS= read -r line; do
if [[ "$line" =~ ^\+\+\+\ b/(.+)$ ]]; then
CURRENT_FILE="${BASH_REMATCH[1]}"
continue
fi
[[ "$line" =~ ^\+[^+] ]] || continue
content="${line:1}"
case "$CURRENT_FILE" in
*.py)
echo "$content" | grep -qE "$PY_PATTERNS" && \
HITS="${HITS}${CURRENT_FILE}: ${content}"$'\n' ;;
*.ts|*.tsx|*.js|*.jsx)
echo "$content" | grep -qE "$JS_PATTERNS" && \
HITS="${HITS}${CURRENT_FILE}: ${content}"$'\n' ;;
esac
done <<< "$DIFF"
if [[ -n "$HITS" ]]; then
REASON="Commit blockiert — Debug-Reste im staged Diff:\n${HITS}\nEntferne sie oder unstage die Datei."
jq -n --arg r "$REASON" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $r
}
}'
fi
exit 0
Zwei Details verdienen Aufmerksamkeit. Erstens die Regex für die Kommando-Erkennung: ^[[:space:]]*git[[:space:]]+commit(\b|$). Sie matcht git commit, git commit -m, git commit -a, aber nicht git config commit.gpgsign true oder git committed-files-script.sh. Das \b am Ende ist wichtig — ohne würde git commit auch auf git committers-list matchen.
Zweitens das Diff-Parsing: git diff --cached --unified=0 liefert nur die Header und die geänderten Zeilen, keine Context-Zeilen. Wir interessieren uns nur für hinzugefügte Zeilen (^\+[^+] — Plus am Anfang, aber nicht +++ aus dem Header). So fangen wir Debug-Reste, die in diesem Commit neu reinkommen, übersehen aber bewusst Debug-Code, der schon im Repo war und nicht angefasst wurde. Wer Altlasten aufräumen will, braucht einen anderen Mechanismus — der Commit-Guard ist als Filter für frisch entstandene Debug-Reste konzipiert.
Die Rückgabe als JSON statt einfachem Exit 2 hat den Vorteil, dass die Begründung strukturiert beim Modell ankommt. Claude sieht im Chat genau, welche Datei und welche Zeile blockiert haben, und kann gezielt zurückgehen und den print entfernen, statt den Commit-Aufruf zu wiederholen und sich zu wundern.
Task 3: Stop-Hook mit Smart-Guard
Der dritte Hook ist der heikelste. Stop feuert nach jeder Antwort, die das Modell beendet — nicht nur am Session-Ende, sondern auch nach jeder Zwischen-Antwort innerhalb eines längeren Workflows. Bei einer Session mit zwanzig Turns läuft der Hook zwanzig Mal. Wer hier die volle Test-Suite startet — pytest plus Vitest plus Playwright — produziert pro Session zehn bis zwanzig Minuten reine Wartezeit. Das frisst den Geschwindigkeitsvorteil von Agentic Coding komplett auf.
Unser Stop-Hook nutzt darum eine Smart-Guard: er liest git diff --name-only, schaut welche Layer angefasst wurden, und triggert nur die jeweils relevante Test-Suite. Eine Read-only-Antwort, in der nur Grep oder Read aufgerufen wurde, durchläuft den Hook in 50 ms ohne irgendetwas auszulösen.
#!/usr/bin/env bash
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-$(pwd)}" || exit 0
CHANGED=$( {
git diff --name-only HEAD 2>/dev/null
git diff --cached --name-only 2>/dev/null
git status --porcelain 2>/dev/null | awk '{print $NF}'
} | sort -u )
[[ -z "$CHANGED" ]] && exit 0
PARSER_CHANGED=$(echo "$CHANGED" | grep -E '^parser/' || true)
WEB_CHANGED=$(echo "$CHANGED" | grep -E '^web/src/' || true)
FAILS=""
if [[ -n "$PARSER_CHANGED" ]]; then
OUT=$(cd parser && uv run --extra dev pytest -x -q 2>&1 | tail -3) \
|| FAILS="${FAILS}[stop-hook] pytest failed:\n${OUT}\n"
fi
if [[ -n "$WEB_CHANGED" ]]; then
OUT=$(cd web && npm run test -- --run --reporter=basic 2>&1 | tail -3) \
|| FAILS="${FAILS}[stop-hook] vitest failed:\n${OUT}\n"
fi
if [[ -n "$FAILS" ]]; then
printf "%b\n" "$FAILS" >&2
fi
exit 0
Drei Designentscheidungen sind nicht offensichtlich. Erstens das Layer-Mapping: parser/ triggert pytest, web/src/ triggert Vitest. Geändert werden auch andere Bereiche — .claude/, plans/, docs/ — die keine Tests haben, also auch keinen Lauf rechtfertigen. Wer den Hook breiter aufzieht, riskiert Test-Läufe auf jede Dokumentations-Änderung.
Zweitens das tail -3 am Ende beider Test-Ausgaben. Bei Erfolg ist das die pytest/vitest-Summary („3 passed in 0.42s"), bei Fehler die Test-Stelle. Volle Test-Outputs in stderr zu schreiben wäre laut und unbrauchbar — drei Zeilen geben genug Kontext um zu sehen, was schiefging.
Drittens der Exit-Code: auch bei fehlgeschlagenen Tests gibt der Hook Exit 0 zurück. Stop-Hooks können zwar mit Exit 2 das Modell am Beenden des Turns hindern — aber das ist meist nicht gewollt. Wer den Hook hart blockierend macht, sitzt im Worst-Case in einer Endlosschleife: Modell will Turn beenden, Hook blockiert wegen Test-Fail, Modell versucht zu fixen, Hook blockiert wieder. Besser ist die Warn-Variante: der Hook schreibt nach stderr, das Modell sieht das im nächsten Turn und kann reagieren. Wer den Hook unterbrechen will, drückt Strg+C — das bricht den Test-Lauf ab, nicht die Session.
Was bewusst draußen bleibt, ist Playwright. Drei Gründe:
Startup-Kosten. Playwright startet einen echten Chromium (rund fünf Sekunden allein für den Browser-Spawn) und einen Vite-Dev-Server (drei bis acht Sekunden). Bei null tatsächlichen Tests entstehen schon acht bis dreizehn Sekunden Overhead pro Turn. Pro Session mit zwanzig Turns sind das mehrere Minuten Wartezeit für nichts.
Port-Konflikte. playwright.config.ts startet webServer auf Port 5173. Wenn parallel ein manueller npm run dev läuft, kommt es zum Konflikt oder zur reuseExistingServer-Race. Im interaktiven Entwickeln-Modus normal, im Hook-Kontext kaputt.
Baseline-Drift. Die Visual-Regression-Screenshots in tests/e2e/screenshots/baseline/ werden bei jedem Playwright-Lauf mit --update-baseline neu geschrieben. Ein Hook, der nach jeder Antwort läuft, sorgt für ständig wechselnde Baselines. Das gehört in eine kontrollierte Umgebung, nicht in jeden Turn-Ende.
Playwright bleibt darum manuell (npm run test:e2e wenn der Mensch es will) und wird in Artikel 10 in die CI verlagert, die einmal pro Push läuft.
Was die Smart-Guard nicht fängt
Der git diff-Check ist pragmatisch, nicht vollständig. Drei Edge-Cases übersieht er bewusst:
- Gelöschte Dateien. Wer eine getestete Datei wegwirft, hat damit den Layer geändert, aber
git diff --name-onlylistet den Pfad eventuell als gelöscht. Der Hook würde trotzdem triggern, weil der Pfad inCHANGEDlandet, aber die Tests könnten am fehlenden Modul scheitern. In der Praxis selten, im Buildlog der CI gefangen. - History-Rewrites. Ein
git rebase -iodergit commit --amendändert die Historie, abergit diff HEADzeigt danach gegebenenfalls weniger als erwartet. Der Hook trifft hier nur, was im Working-Tree liegt. - Branch-Wechsel ohne Commit. Wer den Branch wechselt und der neue Branch hat andere Tests, sieht der Hook das nicht — er kennt nur den aktuellen Working-Tree.
Für die 95 Prozent der Sessions, in denen man auf einem Branch sitzt und Code editiert, reicht der Check. Für die 5 Prozent fängt die CI in Artikel 10 das auf.
Hooks debuggen
Wenn ein Hook stumm fehlschlägt, sieht man im Chat nichts. Genau das ist die Falle: ein kaputter Hook produziert keine Fehlermeldung, sondern fehlt einfach. Der Format läuft nicht, der Commit-Guard greift nicht, die Tests werden übersprungen — und wir merken es erst, wenn der Code im Review auffällt.
Der Weg dorthin ist claude --debug. Im Debug-Log erscheinen alle Hook-Aufrufe mit stdin, stdout, stderr und Exit-Code. Ein typischer Debug-Auszug bei einem fehlgeschlagenen Format-Hook:
[DEBUG] PostToolUse hook fired: post-edit-format.sh
[DEBUG] Hook stdin: {"tool_name":"Edit","tool_input":{"file_path":"/.../normalize.py"}, ...}
[DEBUG] Hook stderr: jq: error: Cannot iterate over null (null)
[DEBUG] Hook exit: 0 (suppressed because exit 0)
Hier lief der Hook mit Exit 0 — also kein sichtbarer Fehler — aber jq scheiterte intern. Die Ursache: ein älteres Claude-Code mit anderem stdin-Schema. Ohne --debug wäre dieser Bug unsichtbar geblieben.
Ein zweiter nützlicher Trick: das Script direkt mit Test-JSON füttern.
echo '{"tool_input":{"file_path":"/path/to/file.py"}}' | .claude/hooks/post-edit-format.sh
echo "EXIT $?"
Was hier funktioniert, funktioniert auch im Hook. Was hier scheitert, scheitert auch dort — aber lässt sich isoliert debuggen, ohne die Session als Reproduktion zu brauchen.
Stand am Ende des Artikels
git clone https://codeberg.org/rotecodefraktion/byhaushalt.git
cd byhaushalt
git checkout v0.9
ls .claude/hooks/
# post-edit-format.sh
# pre-commit-guard.sh
# stop-quick-tests.sh
cat .claude/settings.json | jq '.hooks | keys'
# ["PostToolUse", "PreToolUse", "Stop"]
Vollständiger Stand unter byhaushalt @ v0.9.
v0.9 enthält drei Hook-Scripts unter .claude/hooks/, die erweiterte .claude/settings.json mit drei Hook-Registrierungen, und das Plan-File plans/v0.9-hooks.md. Die Test-Bilanz ist unverändert seit v0.8: 19 pytest grün mit 3 dokumentierten xfails, 3 Vitest grün, 3 Playwright grün. Was sich geändert hat, ist nicht die Anzahl der Tests, sondern wie oft sie laufen — Vitest und pytest jetzt automatisch bei jeder relevanten Änderung, Format-Lauf bei jedem Edit, Commit-Block bei jedem print(-Rest.
Wie es weitergeht
Artikel 10 schließt die Reihe ab: Codeberg-Woodpecker als CI, die alle drei Test-Layer in einer sauberen Umgebung laufen lässt, und ein statisches Deploy der Visualisierung auf eine Subdomain. Die schnelle Schleife ist mit Hooks fertig — die langsame Schleife wandert in die CI, wo Playwright endlich seinen Platz findet.
Wie der e2e-spec-Skill Markdown-Specs in Playwright-Tests übersetzt, steht in Artikel 8. Wie der Playwright-MCP-Server konfiguriert wird, in Artikel 7.