„Alle Tests laufen. Der Use Case ist fertig.“ Jeder, der mit einem KI-Agenten entwickelt, hat diesen Satz schon gelesen. Und jeder hat mindestens einmal festgestellt, dass die Tests nie gelaufen sind. Der Agent hat eine View geändert, den Build ausgelassen und Erfolg gemeldet. Nicht aus böser Absicht. „Fertig“ zu sagen ist billiger, als es zu prüfen.

Man kann ins CLAUDE.md schreiben, dass der Agent vor dem Abschluss die Tests ausführen muss. Meistens tut er es auch. Aber eine Regel im CLAUDE.md ist ein Wunsch. Ein fehlschlagender Build ist eine Tatsache. Dieser Beitrag zeigt, wie Claude Code Hooks aus „fertig“ eine Behauptung machen, die die Session beweisen muss, mit Beispielen aus der PetClinic des AI Unified Process.

„Fertig“ sind zwei Behauptungen

Wenn der Agent sagt, er sei fertig, behauptet er zwei verschiedene Dinge:

  • Die Prüfungen sind gelaufen, und sie waren grün. Das ist eine Eigenschaft der Session. Nichts im Repository kann sagen, ob der Agent die Tests ausgeführt hat, bevor er seinen Turn beendet hat.
  • Die Spezifikation ist erfüllt. In der PetClinic hat jeder Use Case eine Status:-Zeile. Done oder Tested ist kein Etikett. Der Status schaltet einen Sensor ein, UseCaseTraceabilityTest, der ab dann einen Test für das Hauptszenario, jeden alternativen Ablauf und jede Geschäftsregel verlangt.

Die zweite Behauptung können Tests prüfen, aber nur, wenn jemand sie ausführt. Die erste Behauptung kann nur die Session selbst prüfen. Das ist die Aufgabe eines Hooks.

Was ein Hook ist

Ein Claude Code Hook ist ein Befehl, den Claude Code bei einem Ereignis der Session ausführt: wenn die Session startet, vor oder nach einem Tool-Aufruf, wenn ein Subagent fertig ist und wenn Claude seinen Turn beenden will. Der Hook bekommt das Ereignis als JSON auf stdin. Endet er mit Exit-Code 2, blockiert Claude Code die Aktion und gibt die Ausgabe des Hooks auf stderr als Rückmeldung an Claude. Claude liest sie und arbeitet weiter.

Hooks werden in .claude/settings.json konfiguriert und mit dem Code eingecheckt, so bekommt jeder Klon dieselben Leitplanken. Das ist der Hook-Abschnitt der PetClinic, gekürzt:

{
  "hooks": {
    "SessionStart": [
      { "matcher": "startup|clear",
        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\"" }] }
    ],
    "PreToolUse": [
      { "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-spec-status.sh\"" }] }
    ],
    "PostToolUse": [
      { "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-sensor-run.sh\"" }] },
      { "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-spec-status.sh\"" }] }
    ],
    "SubagentStop": [
      { "matcher": "aiup-vaadin-jooq:uc-coverage$",
        "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/record-coverage-check.sh\"" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/require-sensors.sh\"" }] }
    ]
  }
}

Sechs Hooks, aber sie dienen zwei Ideen: dem Stop-Hook und dem Status-Guard. Alles andere sammelt die Beweise, die diese beiden brauchen.

Der Stop-Hook: kein Ende des Turns ohne grünen Lauf

Der Stop-Hook läuft, wenn Claude seinen Turn beenden will. In der PetClinic verweigert er das, solange sich src/, docs/ oder pom.xml nach dem letzten grünen Lauf der Sensoren geändert haben. Claude bekommt eine Meldung, die sagt, was auszuführen ist, führt es aus und versucht erneut zu beenden.

Das echte Skript berücksichtigt Commits während der Session, gelöschte Dateien und Worktrees. Hier eine vereinfachte Version mit demselben Kern, die du in dein eigenes Projekt übernehmen kannst. Die Namen der Reports musst du an deine Testklassen anpassen:

#!/usr/bin/env bash
# Stop: no end of turn while code or specs changed after the last green test run.

# Fail open: without jq this hook cannot tell a first stop from a second one.
command -v jq >/dev/null 2>&1 || exit 0
input=$(cat)

# The second stop after a block goes through, so a session that cannot build
# does not loop forever.
[ "$(jq -r '.stop_hook_active // false' <<<"$input" 2>/dev/null)" = "true" ] && exit 0

cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null || exit 0
changed=$(git status --porcelain -- src docs pom.xml 2>/dev/null | sed 's/^...//') || exit 0
[ -n "$changed" ] || exit 0

problem=""
for name in ArchitectureTest UseCaseTraceabilityTest; do
    report="target/surefire-reports/TEST-com.example.$name.xml"
    if [ ! -f "$report" ]; then problem="there is no test report for $name"; break; fi
    if grep -q 'tests="0"' "$report"; then problem="$name ran no test"; break; fi
    if grep -qE '(failures|errors)="[1-9]' "$report"; then problem="$name failed"; break; fi
    while IFS= read -r file; do
        if [ "$file" -nt "$report" ]; then problem="$file changed after the last run"; break 2; fi
    done <<<"$changed"
done
[ -z "$problem" ] && exit 0

cat >&2 <<MSG
You are not done: $problem.
Run ./mvnw -q test in the foreground and let it finish. Console output
does not count, only a fresh and green test report.
MSG
exit 2

Unter "Stop" in .claude/settings.json eingetragen, kann der Agent einen Turn mit geändertem Code nicht mehr ohne frischen, grünen Report beenden.

Wichtig ist die Prüfung von stop_hook_active am Anfang. Versucht Claude nach einer Blockade ein zweites Mal zu beenden, setzt Claude Code dieses Flag, und der Hook lässt los. Ohne diese Prüfung würde eine Session, die nicht bauen kann, etwa weil eine Datenbank nicht läuft, endlos in einer Schleife hängen. Der Stop-Hook ist also eine erzwungene Erinnerung pro Turn, keine Mauer. Ein Turn, der trotzdem ohne grünen Lauf endet, ist im Transkript genau als das sichtbar.

Positive Beweise: der Report, nicht die Konsole

Der naheliegende Weg zu wissen, ob die Tests gelaufen sind, ist ein Blick auf die Ausgabe des letzten ./mvnw test. Die erste Version der PetClinic-Hooks hat genau das gemacht, und ein Review hat gezeigt, wie leicht sich das täuschen lässt. Ein Lauf im Hintergrund, ein Lauf durch tail gepiped, nach /dev/null umgeleitet oder gefolgt von || true: Keiner davon gibt Fehlertext aus, und alle sahen grün aus. Ein eingeschränkter Lauf mit -Dtest=… ist tatsächlich grün, lässt aber die Sensoren aus.

Deshalb verlangen die Hooks positive Beweise. Ein Lauf zählt nur, wenn der Surefire-Report jeder Sensor-Klasse:

  • in target/surefire-reports/ existiert,
  • neuer ist als der Start der Session und als jede geänderte Datei,
  • mindestens einen Test zählt,
  • und weder einen Fehler noch einen Abbruch zählt.

Diese Dateien schreibt die Test-JVM selbst, egal was die Konsole zeigt. Ein übersprungener Lauf schreibt keine. Ein fehlgeschlagener Lauf steht so in der Datei. Ein Lauf, der noch im Hintergrund läuft, hat sie noch nicht geschrieben. Ein eingeschränkter Lauf lässt die Reports der anderen Sensoren veraltet zurück. Und ein touch auf eine Marker-Datei beweist nichts, weil der Hook die Reports hinter dem Marker liest.

Der Beweis muss außerdem billig sein, sonst sucht der Agent nach Gründen, ihn auszulassen. Die fünf Sensoren der PetClinic tragen das JUnit-Tag sensor, und ein Maven-Profil überspringt für sie die Code-Generierung und die Coverage-Messung. ./mvnw -q test -Dgroups=sensor dauert Sekunden und braucht kein Docker. Damit ist die häufigste Ausrede weg: „Docker ist nicht verfügbar, deshalb konnte ich die Tests nicht ausführen.“

Der Status-Guard: Done erst nach einem Audit

Die zweite Behauptung ist die Status:-Zeile. Setzt man einen Use Case von Hand auf Done, bricht entweder der Build, oder, schlimmer, es wird eine Abdeckung bescheinigt, die nie geprüft wurde. In der PetClinic wird Done, Tested oder Automated nur akzeptiert, nachdem der uc-coverage-Agent des aiup-vaadin-jooq-Plugins diese Spezifikation in dieser Session geprüft hat, und zwar nach der letzten Änderung am Code und an den Tests. Das Audit startest du mit /coverage-check UC-003. Es ordnet jeden Schritt, jeden Ablauf und jede Geschäftsregel des Use Cases dem Code und den Tests zu und meldet die Lücken.

Dafür arbeiten drei Hooks zusammen:

Hook Ereignis Was er tut
record-coverage-check.sh SubagentStop Wenn der uc-coverage-Agent fertig ist, schreibt er für jede geprüfte ID einen Marker. Das ist der Beweis.
guard-spec-status.sh PreToolUse (Edit, Write) Verweigert eine Änderung, die einen behauptenden Status ohne frischen Audit-Marker setzt, bevor die Änderung geschrieben wird.
check-spec-status.sh PostToolUse (jedes Tool) Liest nach jedem Tool-Aufruf alle Status:-Zeilen neu und meldet eine, die sich ohne Audit geändert hat, egal mit welchem Tool.

Warum zwei Guards? Der PreToolUse-Guard kann die Änderung stoppen, bevor sie passiert, aber er sieht nur Edit und Write. Ein sed oder ein Heredoc über Bash geht an ihm vorbei, und im Auto-Modus bevorzugt Claude Code für kleine Änderungen oft Bash. Deshalb ignoriert der zweite Guard den Tool-Aufruf ganz und liest die Dateien. Ein Guard, der den Zustand des Repositorys liest, hält, egal welches Tool die Änderung gemacht hat.

Das sieht Claude, wenn es das Audit auslassen will:

Refused: UC-003-register-new-owner.md would read "**Status:** Done".

That line is an assertion the traceability sensors act on, not a label, and no
coverage audit of UC-003 has finished in this session since the code and tests
last changed. Run

  aiup-vaadin-jooq:coverage-check UC-003

first. When it reports no gaps, make this edit again and then run the sensors
(./mvnw -q test -Dgroups=sensor) so the one it switches on actually votes. When
it reports gaps, close them or leave the status as it is.

Die Meldung sagt nicht nur nein. Sie sagt, was als Nächstes zu tun ist, damit der Agent das Problem im selben Turn lösen kann. Und der Marker sagt nur, dass ein Audit stattgefunden hat, nicht, dass es nichts gefunden hat. Ob die Behauptung stimmt, entscheiden die Sensoren beim nächsten Lauf.

Fail Open

Ein Hook läuft in jeder Session. Wenn er kaputt ist, ist die Session kaputt. Deshalb ist jeder Hook in der PetClinic „fail open“: Bei nicht lesbarer Eingabe, fehlendem .git-Verzeichnis oder ohne jq auf dem Rechner endet der Hook mit 0 und sagt nichts. Im Beispiel oben sieht man das an command -v jq || exit 0 und an || exit 0 nach jedem Befehl, der fehlschlagen kann.

Das klingt nach einer Lücke, und es ist eine. Aber eine Leitplanke, die eine Session wegen eines eigenen Fehlers blockiert, richtet mehr Schaden an als die Abweichung, die sie verhindern sollte. Entwickler schalten Hooks ab, die ihnen im Weg stehen, und dann wird gar nichts mehr geprüft. CI bleibt als letzte Verteidigungslinie.

Fail open heißt nicht ungetestet. Die PetClinic hat neben den Hooks ein smoke.sh, das alle gegen ein Wegwerf-Git-Repository ausführt: Eine Änderung ohne Lauf blockiert, ein fehlgeschlagener Report blockiert, ein veralteter Report blockiert, ein Commit hebt den Guard nicht auf, ein sed auf Done wird gemeldet. Und eine Änderung an einem Hook hält den Turn fest, bis smoke.sh grün ist. So bewachen sich die Guards auch selbst.

Hook oder Test?

Sobald Hooks funktionieren, ist es verlockend, jede Regel in einen Hook zu packen. Lass es. Hooks laufen nur in einer Claude Code Session. Sie laufen nicht in CI, nicht in einem anderen Editor und nicht für einen Entwickler ohne Claude Code. Ein Test bindet alle und kommt mit jedem Klon mit.

Die Faustregel lautet deshalb: Was sich durch Lesen des Repositorys prüfen lässt, wird ein Test. Nur Regeln über die Session werden Hooks. „Ein Browserless-Test muss *Test heißen“ ist eine Eigenschaft des Repositorys, deshalb wurde daraus ein ArchUnit-Test. „Der Agent hat die Sensoren ausgeführt, bevor er fertig gemeldet hat“ ist eine Eigenschaft der Session, deshalb ist es ein Hook. Hooks kaufen Geschwindigkeit, Tests kaufen Korrektheit. Halte die Hook-Schicht klein.

Selbst ausprobieren

Das ist Lab 11 aus meinem Spec-Driven Development Workshop:

  1. Klone die PetClinic. Sie hat bereits Spezifikationen, Sensoren und Hooks.
  2. Führe ./mvnw test -Dgroups=sensor aus und schau dir die Reports in target/surefire-reports/ an.
  3. Benenne in einer der Dateien docs/use_cases/UC-###.md einen alternativen Ablauf um und führe die Sensoren erneut aus. UseCaseTraceabilityTest zeigt dir, welche Annotation jetzt ins Leere zeigt.
  4. Starte Claude Code und bitte es, einen Use Case auf Done zu setzen. Schau zu, wie die Hooks das verweigern und wie Claude /coverage-check ausführt, um sich den Status zu verdienen.
  5. Lies ADR-007, ADR-010 und ADR-011 in docs/architecture/adr/. Sie erklären, warum es jeden Sensor und jeden Hook gibt.

Die ganze Geschichte dieser Hooks, mit der ersten Version und dem, was das Review gefunden hat, steht in Harness Engineering: Warum ein minimales CLAUDE.md und ein gutes Architektur-Dokument zusammengehören.

Fazit

Ein Agent, der „fertig“ sagt, stellt eine Behauptung auf. Mit Hooks kann die Session einen Beweis verlangen: Der Stop-Hook will einen frischen, grünen Test-Report, und der Status-Guard will ein Audit, bevor ein Use Case als erledigt markiert wird. Beide lesen den Zustand des Repositorys, nicht das, was der Agent sagt oder die Konsole ausgibt. Beide sind fail open. Und beide bleiben klein, denn was ein Test sein kann, soll ein Test sein.