"""PostToolUse-Hook: startet den Regelwerk-Check nach einer Änderung an der Anweisungsschicht. Ersteller: Pascal Michel Erstellt: 2026-09-13 Entwurf: ENTWURF.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste) GELTUNGSBEREICH Ausgelöst wird nur, wenn tool_input.file_path innerhalb der Vault-Wurzel auf CLAUDE.md, AI/Regelwerk/**, .claude/agents/**, .claude/skills/** oder .claude/workflows/** zeigt. Die Wurzel ist ausschließlich der Ordner zwei Ebenen über dieser Datei. Weder die Umgebungsvariable CLAUDE_PROJECT_DIR noch das Feld cwd der Hook-Eingabe werden gelesen: cwd folgt einem cd im Bash-Werkzeug und zeigt nach einem Wechsel in einen Projektordner nicht mehr auf den Vault (Claude Code 2.1.263, Prüferbefund Runde 2). Dieser Hook ist ohnehin nur für den Vault eingetragen, in dem er selbst liegt; ein abweichender cwd-Wert ändert daran nichts (Koordinatorentscheidung, Nachbesserung Runde 2). Verglichen wird ohne Rücksicht auf Groß- und Kleinschreibung und auf die Richtung der Schrägstriche, nach Auflösen von "." und "..". Ein relativer Pfad gilt relativ zur Wurzel. Still bleibt der Hook nur in zwei Lagen: Die Eingabe taugt nicht (leer, kein JSON-Objekt, kein file_path als Zeichenkette), oder der Pfad liegt außerhalb des Geltungsbereichs. Ein Check ohne Befund bleibt ebenfalls still. Eine fehlende, abstürzende oder hängende Prüfung ist ein Befund. BENANNTE LÜCKEN * Ob tool_name Edit, Write oder MultiEdit ist, prüft der Matcher in settings.json, nicht dieses Skript. * Symlinks und Junctions werden nicht aufgelöst. Ein Link von außen auf eine Regelwerk-Datei löst nichts aus. * Still nach Exit 0 bleibt der Hook nur, solange regelwerk_check.py seine Schlusszeile "Keine der N gelaufenen Prüfungen hat angeschlagen." behält. Ändert sich der Wortlaut, meldet der Hook jeden Lauf, also laut statt still. * Die Längengrenze für additionalContext ist in der Hook-Doku nicht belegt. 40 Zeilen und 4000 Zeichen sind eine Projektvorgabe. DIESER HOOK IST EIN FANGNETZ, KEIN NACHWEIS. Er reicht nur weiter, was regelwerk_check.py sieht, und das sind formale Widersprüche, keine inhaltlichen. Exit ist immer 0. Nach dem Hilfetext von claude.exe 2.1.263 zeigt Exit 2 bei PostToolUse stderr sofort dem Modell. Ob JSON bei einem anderen Exit-Code gelesen wird, belegt der Hilfetext nicht; Exit 0 plus JSON ist die belegte Form (ENTWURF.md, Frage 7). """ from __future__ import annotations import json import os import re import subprocess import sys from pathlib import Path SKRIPT = Path(__file__).resolve() CHECK_REL = Path(".vault-tools") / "regelwerk-check" / "regelwerk_check.py" # Unter dem timeout von 30 s, den der settings.json-Eintrag setzen soll. TIMEOUT_S = 20 MAX_ZEILEN = 40 MAX_ZEICHEN = 4000 # Kleinbuchstaben und Schrägstrich vorwärts, verglichen gegen normiere(). GELTUNG_DATEIEN = ("claude.md",) GELTUNG_ORDNER = ("ai/regelwerk/", ".claude/agents/", ".claude/skills/", ".claude/workflows/") # Schlusszeile von regelwerk_check.py ohne Befund (dort Zeile 370). _GRUEN = re.compile(r"^Keine der \d+ gelaufenen Prüfungen hat angeschlagen\.$") def lies_stdin() -> bytes: if sys.stdin is None or sys.stdin.isatty(): return b"" return sys.stdin.buffer.read() def lies_eingabe(roh: bytes) -> object: # errors=replace: ein kaputtes Byte im Pfad soll laut enden, nicht still. text = roh.decode("utf-8-sig", errors="replace") if not text.strip(): return None try: return json.loads(text) except ValueError: return None def bestimme_wurzel() -> Path: # Dieser Hook ist nur fuer den Vault eingetragen, in dem er selbst liegt. # Nach Koordinatorentscheidung (Nachbesserung Runde 2, Pruefer-Befund 1) # zaehlt ausschliesslich der Skriptpfad; weder CLAUDE_PROJECT_DIR noch das # Feld cwd der Hook-Eingabe werden noch gelesen. cwd folgt einem cd im # Bash-Werkzeug und zeigt nach einem Wechsel in einen Projektordner nicht # mehr auf den Vault (Claude Code 2.1.263). return SKRIPT.parents[2] def normiere(pfad: str) -> str: return os.path.normpath(os.path.abspath(pfad)).replace("\\", "/").lower().rstrip("/") def im_geltungsbereich(wurzel: Path, datei: str) -> bool: kandidat = datei.replace("\\", "/") try: # Relativ zur Wurzel auflösen, nie zum Arbeitsverzeichnis des Prozesses. if not os.path.isabs(kandidat): kandidat = os.path.join(str(wurzel), kandidat) pfad = normiere(kandidat) basis = normiere(str(wurzel)) except (ValueError, OSError): return False # Mit Trenner vergleichen, sonst träfe "-CLAUDE.md" neben dem Vault. if not pfad.startswith(basis + "/"): return False rel = pfad[len(basis) + 1:] return rel in GELTUNG_DATEIEN or rel.startswith(GELTUNG_ORDNER) def dekodiere(roh: bytes | str | None) -> str: if roh is None: return "" if isinstance(roh, str): return roh return roh.decode("utf-8", errors="replace") def fuehre_check(wurzel: Path) -> tuple[str, str] | None: """None ohne Befund, sonst (systemMessage, Rumpf für additionalContext).""" check = wurzel / CHECK_REL if not check.is_file(): return ( f"Regelwerk-Check fehlt: {check}", f"{check} fehlt. Die Prüfung ist nicht gelaufen; das ist ein Befund, keine Entwarnung.", ) umgebung = dict(os.environ, PYTHONIOENCODING="utf-8") try: lauf = subprocess.run( [sys.executable, str(check), "--vault", str(wurzel)], cwd=str(wurzel), env=umgebung, stdin=subprocess.DEVNULL, capture_output=True, timeout=TIMEOUT_S, ) except subprocess.TimeoutExpired as abgelaufen: teil = (dekodiere(abgelaufen.stdout) + dekodiere(abgelaufen.stderr)).strip() return ( f"Regelwerk-Check nach {TIMEOUT_S} s abgebrochen", f"regelwerk_check.py lief länger als {TIMEOUT_S} s und wurde beendet. " f"Bis dahin ausgegeben:\n{teil or '(nichts)'}", ) except OSError as fehler: return ( f"Regelwerk-Check nicht startbar: {type(fehler).__name__}", f"regelwerk_check.py ließ sich nicht starten: {type(fehler).__name__}: {fehler}", ) ausgabe = dekodiere(lauf.stdout) fehlertext = dekodiere(lauf.stderr).strip() zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()] gruen = any(_GRUEN.match(z) for z in zeilen) achtung = any(z.startswith("ACHTUNG") for z in zeilen) if lauf.returncode == 0 and gruen and not achtung: return None befundzeilen = sum(1 for z in zeilen if z.startswith("[")) rumpf = ausgabe.strip() if fehlertext: rumpf += "\n--- stderr ---\n" + fehlertext return ( f"Regelwerk-Check: Exit {lauf.returncode}, {befundzeilen} Befundzeile(n)", rumpf or "(keine Ausgabe)", ) def kuerze(text: str) -> str: zeilen = text.splitlines() rest = len(zeilen) - MAX_ZEILEN if rest > 0: zeilen = zeilen[:MAX_ZEILEN] + [f"[... {rest} weitere Zeilen gekürzt]"] ergebnis = "\n".join(zeilen) if len(ergebnis) > MAX_ZEICHEN: ergebnis = ergebnis[:MAX_ZEICHEN] + f"\n[... gekürzt auf {MAX_ZEICHEN} Zeichen]" return ergebnis def melde(system: str, kontext: str) -> None: ausgabe = { "hookSpecificOutput": { "hookEventName": "PostToolUse", "additionalContext": kontext, }, "systemMessage": " ".join(system.split()), } sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True)) sys.stdout.flush() def main(roh: bytes | None = None) -> int: try: daten = lies_eingabe(lies_stdin() if roh is None else roh) if not isinstance(daten, dict): return 0 eingabe = daten.get("tool_input") if not isinstance(eingabe, dict): return 0 datei = eingabe.get("file_path") if not isinstance(datei, str) or not datei.strip(): return 0 wurzel = bestimme_wurzel() if not im_geltungsbereich(wurzel, datei): return 0 ergebnis = fuehre_check(wurzel) if ergebnis is None: return 0 system, rumpf = ergebnis melde(system, kuerze(f"Regelwerk-Check nach Änderung an {datei} (Vault: {wurzel}):\n{rumpf}")) except Exception as fehler: melde( f"regelwerk_nach_edit.py: {type(fehler).__name__}", f"regelwerk_nach_edit.py ist abgebrochen: {type(fehler).__name__}: {fehler}. " "Der Regelwerk-Check ist damit nicht gelaufen.", ) return 0 if __name__ == "__main__": try: main() except Exception: # Letzte Schranke: Exit 0 ist Pflicht, sonst liest Claude Code stdout nicht. pass sys.exit(0)