Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur, was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen, das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor. Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale Bezeichnung ersetzt; die Regeln selbst sind unverändert. Co-Authored-By: Claude Opus 5 <[email protected]>
233 lines
8.7 KiB
Python
233 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/** oder .claude/skills/** 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/")
|
|
|
|
# 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)
|