dev@laptop:~/sessie$ cat shift-left.md

Shift left
hooks > goede bedoelingen

Eén uur. Over de plek van een regel, niet over de regel zelf.
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.
— gebruik / spatie om verder te gaan, F voor fullscreen.
dev@laptop:~/sessie$ why --are-we-here

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.

De fout was bekend op het moment dat het bestand geschreven werd. Alleen wist niemand het, want de enige plek waar we het controleren staat helemaal rechts.

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.

dev@laptop:~/meting$ time ./check.sh

Het probleem heeft een getal

hook: shellcheck, 1 bestand 21 ms hook: php -l, 1 bestand 35 ms CI: quality-workflow, 7 runs 17 – 41 min

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.

Factor 50.000 op de snélste run. Op de langste loopt het op naar 100.000 — dezelfde check, andere plek.

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?

dev@laptop:~/meting$ draw --loops

Twee lussen, zelfde fout

in de hook stderr terug naar het model edit hook fix 3 stappen · geen mens · hook: 21 ms via de CI mail, drie taken later edit commit remote CI 17–41 min mail mens fix
Dezelfde ongequote variabele, twee wegen terug naar een correctie. De bovenste lus sluit binnen één turn van de agent; de onderste heeft een commit, een runner, een mail en een mens nodig — en jij was al drie taken verder.
dev@laptop:~/meting$ why --now

Waarom AI dit urgenter maakt

Dát is de hele belofte: niet sneller schrijven, maar de correctielus sluiten voordat er een mens aan te pas komt.
dev@laptop:~/scope$ shift-left --what-it-is-not

Wat shift left niet is

fout

"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
goed

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.

dev@laptop:~/mythe$ wc -lc CLAUDE.md
de mythe

"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.

wc -lc CLAUDE.md 312 17459 CLAUDE.md grep -ciE 'never|nooit|altijd|always' CLAUDE.md 27

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.

dev@laptop:~/mythe$ curl code.claude.com/docs/en/memory

Wat Anthropic er zelf over zegt

Geen interpretatie van mij. Dit staat in de documentatie van Anthropic:

"Claude treats them as context, not enforced configuration. To block an action regardless of what Claude decides, use a PreToolUse hook instead."Anthropic — How Claude remembers your project

En ze geven een maat: "target under 200 lines per CLAUDE.md file. Longer files consume more context and reduce adherence."

Mijn eigen bestand: 312 regels. Ruim over de richtlijn van de makers zelf — en ik dacht dat ik het goed had staan.

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

dev@laptop:~/mythe$ diff context enforcement

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
Er bestaat geen formulering die van context afdwinging maakt. Je kunt de regel dwingender opschrijven; harder wordt alleen de toon.
dev@laptop:~/lagen$ where-does-what-go

Vijf lagen, zacht naar hard

zacht hard 1 · CLAUDE.md + rules/ advies · altijd, of alleen bij matchende paden 2 · skills / commands procedure · on-demand hieronder maakt het niet meer uit wat het model wil 3 · permissions toegangscontrole · deny > ask > allow 4 · hooks afdwinging · exit 2, elke keer 5 · CI/CD de gate · onafhankelijk, auditbaar probabilistisch deterministisch
De scheidslijn is het punt: alles bóven de streep mag het model wegen, alles eronder niet. Laag 1 kent twee vormen — CLAUDE.md laadt elke sessie, .claude/rules/ met paths: alleen bij een matchend bestand. Laag 4 staat bewust boven laag 3: exit 2 stopt de call vóórdat de permission-rules worden geëvalueerd.

Vuistregel: kun je de regel opschrijven als "als X, dan altijd Y" → hook. Moet je een keuze uitleggen → skill. Is het achtergrond → CLAUDE.md.

dev@laptop:~/lagen$ ls misplaatst/

De twee fouten

te hoog

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
te laag

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

dev@laptop:~/hooks$ man hooks | head -30

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.

stdin: { "hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": { "command": "..." }, "cwd": "/repo" }

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.

dev@laptop:~/hooks$ draw --lifecycle

Waar de hook in een tool-call zit

exit 2 → stderr terug naar het model Claude besluit PreToolUse de poort tool draait PostToolUse de terugkoppeling volgende turn exit 2 tool draait niet matcher filtert op tool_name — bijv. Bash, of Edit|Write als lijst
Links blokkeren, rechts terugkoppelen. Op PreToolUse stopt exit 2 de call vóórdat de tool draait — en volgens de docs zelfs vóórdat de permission-rules worden geëvalueerd. Op PostToolUse is de tool al gedraaid; daar is exit 2 geen rem maar een boodschap.

BRON docs/en/hooks — event-namen, matcher-syntax, exit-codes · docs/en/hooks-guide — de introductie met voorbeelden

dev@laptop:~/hooks$ ./deny-force-push.sh <<< "$payload"
live · demo 1

PreToolUse: exit 2 blokkeert

printf '%s' "$payload" | ./deny-force-push.sh BLOCKED: een force-push herschrijft geschiedenis op de remote. Dat vereist expliciete bevestiging van een mens. echo $? 2
Op PreToolUse is exit 2 een harde blokkade. De tool draait niet, en stderr is de reden die het model te zien krijgt.

En het stukje dat weinig mensen weten: een permissionDecision: "allow" in de JSON-output kan exit 2 niet overrulen. Determinisme met een slot erop.

dev@laptop:~/hooks$ grep -r 'git push --force' demos/

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.

cat > demo.sh <<'EOF' ... git push --force ... EOF BLOCKED: '<die vlag>' requires explicit user confirmation.

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.

Dat is de prijs van determinisme, en die betaal je graag. Maar het betekent wél: een hook is code. Self-test, shellcheck, code review.
dev@laptop:~/lab$ ./lint-changed-file.sh <<< "$payload"
live · demo 2

PostToolUse: de lus sluit zichzelf

Claude schrijft backup.sh … [shellcheck] backup.sh is niet schoon: 4:20: Double quote to prevent globbing [SC2086] exit 2 → stderr gaat terug naar het model Claude past het bestand aan. Geen commit. Geen CI. Geen mens.

PostToolUse kan de tool niet meer tegenhouden — die is al gedraaid. Wat exit 2 hier doet is stderr aan het model teruggeven.

Geen preventie. Een lus van seconden in plaats van twintig minuten — en de hook kost er 21 ms van.
dev@laptop:~/matrix$ classify --all-checks

De shift-left-matrix

Vier kolommen, links naar rechts. Elke check hoort ergens — en bijna niets hoort op alle vier.

goedkoop om hier te vinden duur om hier te vinden IDE realtime, in de editor › typefouten › lint-squiggles › format-on-save milliseconden terminal pre-commit, op staged › formatter › lint op de diff › tsc --noEmit seconden Claude-hook PostToolUse, 1 bestand › linter op dát bestand › exit 2 → terug › agent fixt zelf 21 ms CI de gate, blijft staan › hele suite › build, scan, SBOM › echte omgeving 17–41 min deze kolom staat bij de meeste teams leeg
Vier plekken, oplopend in kosten. De grens tussen kolom 3 en 4 is geen kwestie van belang maar van omvang: heeft de check meer nodig dan één bestand, dan hoort hij rechts.
De grens is niet "belangrijk of niet". De grens is: heeft deze check meer nodig dan één bestand?
dev@laptop:~/team$ git add .claude/

Team-breed, of het bestaat niet

.claude/settings.json → in git. Het team. .claude/hooks/*.sh → in git. Komt door review. .claude/settings.local.json → .gitignore. Alleen jij. ~/.claude/settings.json → jouw machine. Niet deelbaar.
  • Hooks uit verschillende scopes worden samengevoegd, niet overschreven — project én user draaien beide.
  • Bij permissions is de volgorde denyaskallow; 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.
Een guard op één laptop is een gewoonte. In de repo is het beleid.
dev@laptop:~/lab$ ./hands-on.sh # 20 min
live · samen doen

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.

dev@laptop:~/sessie$ cat bronnen.md

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

./hooks/lint-changed-file.sh --self-test self-test OK (2 geblokkeerd, 2 doorgelaten)

de documentatie

Alles in dit deck is tegen die docs geverifieerd, niet uit het hoofd opgeschreven. De getallen zijn gemeten; de herkomst staat in de repo.

dev@laptop:~/sessie$ recap && exit 0

De kern

Boring & auditable: een regel waarvan je kunt aanwijzen wáár hij wordt afgedwongen. Niet "het staat in de markdown".
Vragen?
dev@laptop:~/sessie$ ls bijlage/
bijlage

Bijlage

Hier stopt de sessie. Wat volgt is de diepte: de wiring in detail, de commando's per taal, en wat je van een hook mag verwachten.
Bedoeld om na de sessie te lezen, of om erin te duiken als er tijd over is.
als je verder wilt.
dev@laptop:~/hooks$ cat settings.json

Dezelfde beslissing, expliciet

Wiring — projectniveau, dus in de repo:

"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "if": "Bash(git push*)", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/deny.sh" }] }]

Of, in plaats van de exit code, op stdout:

{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Force-push vereist een mens." }}

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.

Past de regel volledig in een patroon? Dan hoort hij op laag 3 als deny-rule — declaratief, geen script. Een hook pak je zodra er logica bij komt.
dev@laptop:~/matrix$ classify --lang php,ts

Concreet, per taal

PHP — in de hook

php -l "$FILE" vendor/bin/phpcs --standard=phpcs.xml "$FILE" vendor/bin/phpstan analyse \ --error-format=raw "$FILE"

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

node_modules/.bin/eslint "$FILE" node_modules/.bin/prettier --check "$FILE" node_modules/.bin/stylelint "$FILE" # stylelint: alleen .vue / .scss / .css

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.

Kijk wel eerst of een check érgens draait. strict: true in tsconfig.json zonder tsc in je scripts is documentatie, geen check.
dev@laptop:~/hooks$ ./hook.sh --self-test

Een hook is code

shift left — hooks > goede bedoelingen · westerweel.work 01 / 25 spatie · F fullscreen · leesversie