Shift left
hooks > goede bedoelingen
Doel: snappen waarom CLAUDE.md je regel niet afdwingt, en wat wel.
De casus: de check faalt in de CI, twintig minuten nadat je verder was gegaan.
De casus
Je agent schrijft code. Je commit. Twintig minuten later mailt de CI dat er een ongequote variabele in staat. Je zat al drie taken verder.
En de tweede helft van de casus: in CLAUDE.md stáát dat dit niet mag. Er staat een hele lijst dingen die niet mogen. Toch gebeurde het.
Het probleem heeft een getal
Gemeten, niet geschat — alle zeven afgeronde runs van één dag. Eén PHP-repo met zes analyse-legs, elk met eigen checkout en composer install.
In diezelfde runlijst stond een build met de titel "fix(lint): prune the one stale eslint suppression".
Eén lint-regel opruimen. Volledige CI-ronde. Wie doet dat vaker dan hij zou willen?
Twee lussen, zelfde fout
Waarom AI dit urgenter maakt
- De hoeveelheid code per uur gaat omhoog. De latency van de gate blijft gelijk.
- Het verschil komt in de wachtrij. Je merkt het niet als "de CI is traag" maar als "we mergen minder per dag".
- Een agent die zijn eigen fout pas na twintig minuten hoort, is een agent die je zelf moet nakijken.
- Krijgt hij de fout in dezelfde turn terug — de hook kost er 21 ms van — dan repareert hij hem zelf, zonder mens.
Wat shift left niet is
"Dan kan de CI-check eruit"
- De hook draaide op jouw laptop, met jouw tools
- Niets bewijst dat hij aan stond
- Geen log, geen artefact, geen audit
Detectie naar links, gate blijft rechts
- De hook is een feedbackloop: goedkoop, snel, mag ruis geven
- De CI is een gate: onafhankelijk, herhaalbaar, auditbaar
- Zelfde check, twee doelen
Haal je de CI weg, dan heb je geen bewijs meer. Haal je de hook weg, dan betaal je elke fout in minuten.
"CLAUDE.md is de waarheid"
Dat is het niet. CLAUDE.md is tekst in het promptvenster. Het model weegt die af tegen alles wat er verder in staat — jouw prompt, de bestanden, de tool-output, tachtig turns geschiedenis.
Zevenentwintig regels met nooit of altijd erin, over 312 regels. Vraag jezelf af welke daarvan bij turn 80 nog meegewogen worden — na compaction, met een halve codebase in het venster.
Wat Anthropic er zelf over zegt
Geen interpretatie van mij. Dit staat in de documentatie van Anthropic:
En ze geven een maat: "target under 200 lines per CLAUDE.md file. Longer files consume more context and reduce adherence."
De oplossing die ze erbij geven is niet "strenger schrijven" maar .claude/rules/ met paths:-frontmatter: laadt alleen bij een matchend bestand. Korter venster.
BRON Anthropic, code.claude.com/docs/en/memory — opgehaald 2026-08-21
Context ≠ afdwinging
CONTEXT — probabilistisch
- CLAUDE.md, .claude/rules/, skill-beschrijvingen, systeemprompt
- Het model kan het negeren, en doet dat vaker als het druk wordt
- Geen log als het niet gebeurt: je ziet het resultaat, niet de overweging
AFDWINGING — deterministisch
- Hooks, permissions, CI
- Zelfde input, zelfde uitkomst. Elke keer.
- exit 2 in de log als het misging
Vijf lagen, zacht naar hard
Vuistregel: kun je de regel opschrijven als "als X, dan altijd Y" → hook. Moet je een keuze uitleggen → skill. Is het achtergrond → CLAUDE.md.
De twee fouten
Non-negotiable in CLAUDE.md
"Nooit force-pushen naar main."
- Als proza een verzoek
- Als hook een feit
- Faalt stil, en juist als het spannend is
Procedure-uitleg in een hook
"Bij een nieuwe tenant doe je eerst…"
- Een hook zegt ja of nee
- Hij heeft geen plek om iets uit te leggen
- Developer krijgt exit 2 en geen idee waarom
Beide fouten voelen als zorgvuldigheid. Beide leveren een regel op die niet doet wat je dacht.
BRON de skills-docs zeggen wanneer je moet verhuizen: maak een skill "when a section of CLAUDE.md has grown into a procedure rather than a fact" — docs/en/skills
Anatomie van een hook
Een hook is een script dat Claude Code aanroept op een vast moment. Hij krijgt JSON op stdin en beslist — met een exit code, of met JSON op stdout.
De vier die je in de praktijk als eerste nodig hebt:
- PreToolUse en PostToolUse — vóór en ná een tool. Volgende slide.
- UserPromptSubmit — context injecteren bij élke prompt.
- SessionStart — één keer, bij openen.
matcher filtert op tool_name: Bash, Edit|Write (lijst), of een regex.
Waar de hook in een tool-call zit
BRON docs/en/hooks — event-namen, matcher-syntax, exit-codes · docs/en/hooks-guide — de introductie met voorbeelden
PreToolUse: exit 2 blokkeert
En het stukje dat weinig mensen weten: een permissionDecision: "allow" in de JSON-output kan exit 2 niet overrulen. Determinisme met een slot erop.
Een hook matcht tekst, geen intentie
Tijdens het maken van deze sessie blokkeerde mijn eigen guard-hook mijn poging om het demoscript te schrijven — omdat de string erin stond.
Er werd niets gepusht. Er werd een bestand geschreven dat het commando beschrijft. De hook kan dat verschil niet zien.
Drie keer op rij, op drie manieren. Uiteindelijk heb ik het bestand via een ander gereedschap geschreven — de hook keek naar de commandotekst, niet naar wat er gebeurde.
PostToolUse: de lus sluit zichzelf
PostToolUse kan de tool niet meer tegenhouden — die is al gedraaid. Wat exit 2 hier doet is stderr aan het model teruggeven.
De shift-left-matrix
Vier kolommen, links naar rechts. Elke check hoort ergens — en bijna niets hoort op alle vier.
Team-breed, of het bestaat niet
- Hooks uit verschillende scopes worden samengevoegd, niet overschreven — project én user draaien beide.
- Bij permissions is de volgorde deny → ask → allow; de eerste match beslist, en specifieker maakt niet uit.
- Alle matchende hooks draaien parallel. Dezelfde handler in twee bestanden draait één keer.
- Staat de regel in de repo, dan staat hij in code review. Dat is het echte winstpunt.
Aan de slag
Kies de check die je het vaakst in de CI ziet falen. Niet de belangrijkste — de meest irritante.
Schrijf .claude/hooks/lint-changed-file.sh: lees tool_input.file_path, draai de linter, exit 2 met de output op stderr.
Wire hem in .claude/settings.json op PostToolUse / Edit|Write. Commit beide bestanden.
Laat de agent iets kapots schrijven. Kijk of hij het zelf repareert.
Bonus: haal één regel uit CLAUDE.md die eigenlijk een hook had moeten zijn.
Eén hook. Niet drie. Werkt het, dan volgt de rest van zichzelf.
Zelf verder
de code van deze sessie
Beide hooks, de settings.json-blokken, fixtures en de hand-out:
github.com/MWest2020/westerweel-work
/workshops/claude-shift-left
de documentatie
- docs/en/hooks — events, matcher, exit-codes, JSON
- docs/en/hooks-guide — introductie, voorbeelden
- docs/en/settings — waar settings.json staat
- docs/en/permissions — allow / ask / deny
- docs/en/memory — wat CLAUDE.md wél is
- docs/en/skills — procedures buiten de hook houden
Alles in dit deck is tegen die docs geverifieerd, niet uit het hoofd opgeschreven. De getallen zijn gemeten; de herkomst staat in de repo.
De kern
- CLAUDE.md is advies. Hooks zijn afdwinging. Dat is geen nuance, dat is het verschil tussen hopen en weten.
- 21 ms in de hook tegen 17–41 min in de CI. Zelfde check, andere plek.
- De grens is niet "belangrijk of niet", maar: heeft deze check meer nodig dan één bestand?
- De CI-gate blijft. Shift left is eerder vángen, niet minder bewijzen.
- Een hook is code: self-test, timeout, review. Een guard die je niet test, is een guard waarvan je niet weet wat hij doet.
Bijlage
Bedoeld om na de sessie te lezen, of om erin te duiken als er tijd over is.
Dezelfde beslissing, expliciet
Wiring — projectniveau, dus in de repo:
Of, in plaats van de exit code, op stdout:
Precies twee waarden: allow en deny. Print op stdout. En let op: exit 2 wint hier altijd van — de docs zeggen dat zelfs een "allow" exit 2 niet kan overrulen.
${CLAUDE_PROJECT_DIR} houdt het pad repo-relatief; het if-veld gebruikt permission-rule-syntax en scheelt een process-start op elke andere Bash-call.
Concreet, per taal
PHP — in de hook
Blijft in de CI: psalm (hele project-graph), phpmd en phpmetrics (codebase-metrics), app:check-code (haalt een server op), PHPUnit.
TypeScript / Vue — in de hook
Blijft in de CI: tsc --noEmit (types komen uit ándere bestanden, dus niet per bestand), npm run build, de testsuite.
Niets nieuws installeren: de config in phpcs.xml, phpstan.neon en eslint.config.js staat er al — de hook draait hem alleen eerder. En geen npm install vanuit een hook: draait de repo de linter niet zelf, dan doet de hook niets.
Een hook is code
- Self-test. Een lijst payloads die moeten blokkeren en een lijst die door moet. Mijn eerste versie liet -f er ongemerkt door — de test vond het, ik niet.
- Timeout, env-tunable. Een hook die hangt is erger dan een check die je mist.
- Faal open bij ontbrekende tools. Geen linter geïnstalleerd → hook doet niets. Hij mag nooit de reden zijn dat een repo niet meer werkt.
- Faal dicht bij twijfel over veiligheid. Kan hij de input niet inspecteren, dan blokkeert hij. "Kon niet verifiëren" mag nooit "toegestaan" betekenen.
- Tokenize, geen regex-acrobatiek. Splits het commando en vergelijk exact. Boring en auditbaar verslaat clever.