Files
claude-regelwerk/.vault-tools/session-hooks/regelwerk_nach_edit.py
T
Pascal MichelandClaude Opus 5.5 3b7128c9e1 regelwerk_check: Pruefung 7 meldet unsichtbare Zeichen in gespeicherten Workflows
Jedes Zeichen der Kategorie Cc ausser LF und jedes der Kategorie Cf in
.claude/workflows/**/*.js, gelesen als Bytes. Anlass: plankritik.js mit
CRLF scheiterte am Workflow-Werkzeug. Der Hook nach dem Bearbeiten loest
auch bei Workflows aus. Regressionslauf 28/28 angeschlagen, 6/6 stumm;
Hook-Treiber 58/58; Vorfall in exakter Form gemeldet (U+000D).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-26 20:54:51 +02:00

234 lines
8.7 KiB
Python

"""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 "<wurzel>-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)