Files
claude-werkzeuge/.vault-tools/session-hooks/docs/plans/2026-09-13-sitzungs-hooks/task-02-regelwerk-nach-edit.md
T
Pascal MichelandClaude Opus 5.5 fa1c26b407 Erste Fassung: Claude-Werkzeuge aus dem Vault
.vault-tools (ohne trading-plattform-pruefung und watermarks-remover),
.claude/agents, .claude/skills, .claude/workflows, .claude/settings.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:27:59 +02:00

793 lines
32 KiB
Markdown

---
type: plan-task
title: "Task 02 — PostToolUse-Hook regelwerk_nach_edit.py"
status: draft
summary: "Hook startet regelwerk_check.py nach jeder Änderung an Stamm, Regelwerk, Agenten oder Skills und meldet Befunde, fehlende, abstürzende oder hängende Prüfung; Regressionstreiber mit Attrappe."
tags: [plan-task, vault-tools]
created: 2026-09-13
updated: 2026-09-13
modell: sonnet
modell_begruendung: "Der Code steht wörtlich im Plan, aber der Treiber läuft zum ersten Mal gegen Windows-Pfad- und Prozessverhalten (Laufwerksschreibweise, Timeout mit Kill); schlägt ein Fall fehl, muss die Ursache eingegrenzt und als Planwiderspruch gemeldet werden, das ist Fehlersuche und keine Abschrift."
---
# Task 02 — PostToolUse-Hook `regelwerk_nach_edit.py`
Globale Vorgaben: `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben". Sie gelten hier vollständig und werden nicht wiederholt.
## Zieldateien
- `D:\Claude\.vault-tools\session-hooks\faelle_nach_edit.py` (neu)
- `D:\Claude\.vault-tools\session-hooks\regelwerk_nach_edit.py` (neu)
## Verbote
- Keine andere Datei anlegen oder ändern. `ENTWURF.md` in diesem Ordner gehört Task 1, `regelwerk_check.py` bleibt unberührt.
- Kein Eintrag in `.claude/settings.json` oder `.claude/settings.local.json`, und diese Dateien auch nicht lesen.
- Den echten `regelwerk_check.py` im Treiber nicht verändern oder ersetzen. Die Attrappe entsteht ausschließlich im Temp-Verzeichnis.
- Schlägt ein Fall fehl: Code nicht anpassen, bis er grün ist. Fall, Ausgabe und vermutete Ursache melden.
## Schritt 1 — Test zuerst: `faelle_nach_edit.py`
Datei `D:\Claude\.vault-tools\session-hooks\faelle_nach_edit.py` mit genau diesem Inhalt anlegen:
```python
"""Fährt die Angriffsliste gegen regelwerk_nach_edit.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner
Statt des echten regelwerk_check.py liegt im Wegwerf-Vault eine Attrappe. Sie
schreibt ihren Aufruf nach aufruf.json und zeigt dann ein festgelegtes
Verhalten: grün, Befund, Abbruch, ACHTUNG, stumm, Absturz, hängt, sehr viel
Ausgabe. Ihre Zeilen haben die Form der echten Ausgabe (regelwerk_check.py
Zeilen 114, 368, 370 und 372). So lässt sich je Fall unterscheiden, ob der
Hook still blieb, ohne die Prüfung zu starten, oder still blieb, nachdem sie
grün war.
Grundlinie zuerst: Ein grüner Check muss still bleiben und nachweislich
gelaufen sein, ein Befund muss gemeldet werden. Sonst misst der Rest nichts.
Aufruf:
python .vault-tools/session-hooks/faelle_nach_edit.py
python .vault-tools/session-hooks/faelle_nach_edit.py --echt
"""
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
HIER = Path(__file__).resolve().parent
HOOK = HIER / "regelwerk_nach_edit.py"
MAX_KONTEXT = 4100
FAKE_KOPF = '''import json
import sys
import time
from pathlib import Path
Path(__file__).resolve().parents[2].joinpath("aufruf.json").write_text(
json.dumps({"argv": sys.argv[1:], "exe": sys.executable}), encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
'''
FAKES = {
"gruen": FAKE_KOPF + '''print()
print("Keine der 6 gelaufenen Prüfungen hat angeschlagen.")
sys.exit(0)
''',
"befund": FAKE_KOPF + '''print("[klein] AI/Regelwerk/regel.md — status: pending-review — Freigabe steht aus")
print()
print("1 Befund(e) aus 6 von 6 Prüfungen.")
sys.exit(1)
''',
"abbruch": FAKE_KOPF + '''print("[blockierend] regelwerk_check.py — Abbruch: KeyError: 'x'")
print()
print("1 Befund(e) aus 2 von 6 Prüfungen.")
sys.exit(2)
''',
"achtung": FAKE_KOPF + '''print()
print("Keine der 5 gelaufenen Prüfungen hat angeschlagen.")
print("ACHTUNG: nur 5 von 6 Prüfungen sind gelaufen — der Rest hatte keine Voraussetzung.")
sys.exit(0)
''',
"stumm": FAKE_KOPF + '''sys.exit(0)
''',
"absturz": FAKE_KOPF + '''raise RuntimeError("Absturz im Check")
''',
"haengt": FAKE_KOPF + '''time.sleep(120)
''',
"viel": FAKE_KOPF + '''for i in range(200):
print("[wichtig] AI/Regelwerk/regel.md — toter Wikilink: [[ziel%d]]" % i)
print()
print("200 Befund(e) aus 6 von 6 Prüfungen.")
sys.exit(1)
''',
}
def schreibe(vault: Path, rel: str, inhalt: str | bytes) -> None:
# Immer Bytes: write_text würde unter Windows jedes LF zu CRLF machen.
pfad = vault / rel
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_bytes(inhalt if isinstance(inhalt, bytes) else inhalt.encode("utf-8"))
def baue_vault(v: Path, fake: str | None) -> None:
schreibe(v, "CLAUDE.md", "# Stamm\n")
schreibe(v, "AI/Regelwerk/regel.md", "---\nstatus: reference\n---\n")
schreibe(v, "AI/Regelwerk/unter/tief.md", "x\n")
schreibe(v, ".claude/agents/planer.md", "---\nname: planer\n---\n")
schreibe(v, ".claude/skills/demo/SKILL.md", "---\nname: demo\n---\n")
schreibe(v, "Inbox/notiz.md", "x\n")
schreibe(v, "Code/CLAUDE.md", "x\n")
if fake is not None:
schreibe(v, ".vault-tools/regelwerk-check/regelwerk_check.py", FAKES[fake])
def vorwaerts(pfad: Path) -> str:
return str(pfad).replace("\\", "/")
def laufwerk_getauscht(pfad: Path) -> str:
s = str(pfad)
if re.match(r"^[A-Za-z]:", s):
return s[0].swapcase() + s[1:]
return s
def j(daten: object) -> bytes:
return json.dumps(daten).encode("utf-8")
def edit(v: Path, datei: object, **extra: object) -> bytes:
daten: dict[str, object] = {
"session_id": "faelle",
"transcript_path": "",
"cwd": str(v),
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {"file_path": datei},
"tool_response": {},
}
daten.update(extra)
return j(daten)
def ohne_cwd(v: Path, datei: object) -> bytes:
daten = json.loads(edit(v, datei))
del daten["cwd"]
return j(daten)
@dataclass
class Fall:
name: str
fake: str | None
stdin: Callable[[Path], bytes]
erwartung: str
enthaelt: list[str] = field(default_factory=list)
system_enthaelt: str = ""
kopie: bool = False
max_dauer: float = 10.0
BEFUND = ["[klein] AI/Regelwerk/regel.md"]
BEFUND_SYSTEM = "Exit 1, 1 Befundzeile(n)"
def still(name: str, stdin: Callable[[Path], bytes]) -> Fall:
return Fall(name, "befund", stdin, "still-ohne-lauf")
def meldet(name: str, stdin: Callable[[Path], bytes], kopie: bool = False) -> Fall:
return Fall(name, "befund", stdin, "meldung", BEFUND, BEFUND_SYSTEM, kopie)
GRUNDLINIE = [
Fall("GRUNDLINIE | gruener Check bleibt still und lief", "gruen",
lambda v: edit(v, str(v / "CLAUDE.md")), "still-nach-lauf"),
Fall("GRUNDLINIE | Befund wird gemeldet", "befund",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung", BEFUND, BEFUND_SYSTEM),
]
FAELLE = [
still("leer | stdin leer", lambda v: b""),
still("leer | stdin nur Leerraum", lambda v: b" \r\n"),
still("falscher Typ| stdin kein JSON", lambda v: b"kein json"),
still("kodierung | stdin ungueltiges UTF-8", lambda v: b"\xff\xfe\x00"),
still("falscher Typ| stdin ist eine Liste",
lambda v: j([json.loads(edit(v, str(v / "CLAUDE.md")))])),
still("falscher Typ| stdin ist eine Zahl", lambda v: b"5"),
still("fehlt | tool_input fehlt", lambda v: j({"cwd": str(v), "tool_name": "Edit"})),
still("null | tool_input ist null", lambda v: edit(v, "x", tool_input=None)),
still("falscher Typ| tool_input ist eine Liste",
lambda v: edit(v, "x", tool_input=[str(v / "CLAUDE.md")])),
still("fehlt | file_path fehlt", lambda v: edit(v, "x", tool_input={"content": "a"})),
still("null | file_path ist null", lambda v: edit(v, None)),
still("falscher Typ| file_path ist eine Zahl", lambda v: edit(v, 5)),
still("falscher Typ| file_path ist eine Liste", lambda v: edit(v, [str(v / "CLAUDE.md")])),
still("leer | file_path ist leer", lambda v: edit(v, "")),
still("wertebereich| file_path mit Nullzeichen",
lambda v: edit(v, str(v / "CLAUDE.md") + "\u0000")),
still("zusaetzlich | Pfad ausserhalb des Vaults",
lambda v: edit(v, str(v.parent / "anderswo" / "CLAUDE.md"))),
still("verweis | Nachbarordner mit gleichem Namensanfang",
lambda v: edit(v, str(v) + "-kopie\\CLAUDE.md")),
still("verweis | Datei neben dem Vault mit gleichem Namensanfang",
lambda v: edit(v, str(v) + "-CLAUDE.md")),
still("zusaetzlich | im Vault, ausserhalb des Geltungsbereichs",
lambda v: edit(v, str(v / "Inbox" / "notiz.md"))),
still("zusaetzlich | CLAUDE.md in einem Unterordner",
lambda v: edit(v, str(v / "Code" / "CLAUDE.md"))),
still("verweis | Ordnername mit gleichem Anfang",
lambda v: edit(v, str(v / "AI" / "RegelwerkAlt" / "x.md"))),
still("zusaetzlich | settings.json unter .claude",
lambda v: edit(v, str(v / ".claude" / "settings.json"))),
still("wertebereich| der Regelwerk-Ordner selbst",
lambda v: edit(v, str(v / "AI" / "Regelwerk"))),
still("verweis | .. fuehrt aus dem Regelwerk hinaus",
lambda v: edit(v, vorwaerts(v) + "/AI/Regelwerk/../../Inbox/notiz.md")),
still("verweis | .. fuehrt aus dem Vault hinaus",
lambda v: edit(v, vorwaerts(v) + "/../fremd/AI/Regelwerk/x.md")),
Fall("gruen | CLAUDE.md, Check ohne Befund", "gruen",
lambda v: edit(v, str(v / "CLAUDE.md")), "still-nach-lauf"),
Fall("gruen | Skill, Check ohne Befund", "gruen",
lambda v: edit(v, str(v / ".claude" / "skills" / "demo" / "SKILL.md")), "still-nach-lauf"),
meldet("pfad | Schraegstriche vorwaerts",
lambda v: edit(v, vorwaerts(v) + "/CLAUDE.md")),
meldet("pfad | Backslashes",
lambda v: edit(v, str(v) + "\\AI\\Regelwerk\\regel.md")),
meldet("pfad | Laufwerksbuchstabe in anderer Schreibung",
lambda v: edit(v, laufwerk_getauscht(v / "AI" / "Regelwerk" / "regel.md"))),
meldet("pfad | ganzer Pfad in Grossbuchstaben",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "regel.md").upper())),
meldet("pfad | Unterordner im Regelwerk",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "unter" / "tief.md"))),
meldet("pfad | Agentendatei",
lambda v: edit(v, str(v / ".claude" / "agents" / "planer.md"))),
meldet("pfad | Skilldatei",
lambda v: edit(v, str(v / ".claude" / "skills" / "demo" / "SKILL.md"))),
meldet("verweis | neue Datei, die es noch nicht gibt",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "neu.md"))),
meldet("pfad | relativer Pfad", lambda v: edit(v, "AI/Regelwerk/regel.md")),
meldet("verweis | .. fuehrt ins Regelwerk hinein",
lambda v: edit(v, vorwaerts(v) + "/Inbox/../AI/Regelwerk/regel.md")),
meldet("zusaetzlich | Write statt Edit",
lambda v: edit(v, str(v / "CLAUDE.md"), tool_name="Write")),
meldet("zusaetzlich | MultiEdit",
lambda v: edit(v, str(v / "CLAUDE.md"), tool_name="MultiEdit")),
meldet("kodierung | ungueltiges UTF-8 im Pfad",
lambda v: edit(v, "PLATZHALTER").replace(
b"PLATZHALTER", vorwaerts(v).encode("utf-8") + b"/AI/Regelwerk/regel\xff.md")),
meldet("pfad | cwd mit abschliessendem Backslash",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=str(v) + "\\")),
meldet("fehlt | cwd fehlt, Rueckfall auf den Skriptpfad",
lambda v: ohne_cwd(v, str(v / "CLAUDE.md")), kopie=True),
meldet("null | cwd ist null, Rueckfall",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=None), kopie=True),
meldet("falscher Typ| cwd ist eine Zahl, Rueckfall",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=5), kopie=True),
meldet("verweis | cwd existiert nicht, Rueckfall",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=str(v / "gibtsnicht")), kopie=True),
Fall("check | Exit 2, Abbruch im Check", "abbruch",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["Abbruch: KeyError"], "Exit 2, 1 Befundzeile(n)"),
Fall("check | Exit 0 mit ACHTUNG", "achtung",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["ACHTUNG: nur 5 von 6"], "Exit 0, 0 Befundzeile(n)"),
Fall("check | Exit 0 ohne Schlusszeile", "stumm",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["(keine Ausgabe)"], "Exit 0, 0 Befundzeile(n)"),
Fall("check | Check stuerzt ab", "absturz",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["--- stderr ---", "RuntimeError: Absturz im Check"], "Exit 1, 0 Befundzeile(n)"),
Fall("umgebung | regelwerk_check.py fehlt", None,
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["regelwerk_check.py fehlt"], "Regelwerk-Check fehlt"),
Fall("umgebung | regelwerk_check.py haengt", "haengt",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["länger als 20 s"], "nach 20 s abgebrochen", max_dauer=28.0),
Fall("wertebereich| sehr lange Ausgabe wird gekuerzt", "viel",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["weitere Zeilen gekürzt"], "Exit 1, 200 Befundzeile(n)"),
]
def fahre(skript: Path, eingabe: bytes, arbeitsordner: Path) -> tuple[int, str, str, float]:
start = time.perf_counter()
lauf = subprocess.run(
[sys.executable, str(skript)],
input=eingabe,
capture_output=True,
cwd=str(arbeitsordner),
timeout=90,
)
dauer = time.perf_counter() - start
return (
lauf.returncode,
lauf.stdout.decode("utf-8", errors="replace"),
lauf.stderr.decode("utf-8", errors="replace"),
dauer,
)
def pruefe_aufruf(aufruf: Path, v: Path) -> list[str]:
if not aufruf.exists():
return ["Check wurde nicht gestartet"]
daten = json.loads(aufruf.read_text(encoding="utf-8"))
probleme: list[str] = []
if daten.get("argv") != ["--vault", str(v)]:
probleme.append(f"Aufruf-Argumente {daten.get('argv')!r} statt {['--vault', str(v)]!r}")
if os.path.normcase(str(daten.get("exe"))) != os.path.normcase(sys.executable):
probleme.append(f"Interpreter {daten.get('exe')!r} statt {sys.executable!r}")
return probleme
def pruefe_meldung(fall: Fall, out: str) -> list[str]:
roh = out.strip()
if not (roh.startswith("{") and roh.endswith("}")):
return [f"stdout ist kein JSON-Objekt: {out[:300]!r}"]
try:
daten = json.loads(roh)
except ValueError as exc:
return [f"stdout nicht parsebar: {exc}"]
if not isinstance(daten, dict) or set(daten) != {"hookSpecificOutput", "systemMessage"}:
art = sorted(daten) if isinstance(daten, dict) else type(daten).__name__
return [f"falsche Schluessel: {art}"]
innen = daten["hookSpecificOutput"]
system = daten["systemMessage"]
if not isinstance(innen, dict) or innen.get("hookEventName") != "PostToolUse":
return ["hookSpecificOutput.hookEventName ist nicht PostToolUse"]
kontext = innen.get("additionalContext")
if not isinstance(kontext, str) or not kontext:
return ["additionalContext fehlt oder ist leer"]
probleme: list[str] = []
if not isinstance(system, str) or not system or "\n" in system:
probleme.append(f"systemMessage ist keine einzelne Zeile: {system!r}")
elif fall.system_enthaelt not in system:
probleme.append(f"systemMessage ohne {fall.system_enthaelt!r}: {system!r}")
probleme += [f"Kontext ohne {t!r}" for t in fall.enthaelt if t not in kontext]
if len(kontext) > MAX_KONTEXT:
probleme.append(f"Kontext hat {len(kontext)} Zeichen, Grenze {MAX_KONTEXT}")
if probleme:
probleme.append("Kontext war:\n" + kontext)
return probleme
def pruefe(fall: Fall, v: Path, code: int, out: str, dauer: float) -> list[str]:
probleme: list[str] = []
if code != 0:
probleme.append(f"Exit {code} statt 0")
if dauer > fall.max_dauer:
probleme.append(f"Laufzeit {dauer:.1f} s ueber {fall.max_dauer:.0f} s")
aufruf = v / "aufruf.json"
if fall.erwartung == "still-ohne-lauf":
if out != "":
probleme.append(f"stdout nicht leer: {out[:300]!r}")
if aufruf.exists():
probleme.append("Check wurde gestartet, obwohl er nicht haette laufen duerfen")
elif fall.erwartung == "still-nach-lauf":
if out != "":
probleme.append(f"stdout nicht leer: {out[:300]!r}")
probleme += pruefe_aufruf(aufruf, v)
else:
probleme += pruefe_meldung(fall, out)
if fall.fake is not None:
probleme += pruefe_aufruf(aufruf, v)
return probleme
def lauf_fall(fall: Fall, arbeit: Path) -> tuple[bool, str]:
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
baue_vault(v, fall.fake)
skript = HOOK
if fall.kopie:
ziel = v / ".vault-tools" / "session-hooks"
ziel.mkdir(parents=True, exist_ok=True)
skript = ziel / HOOK.name
shutil.copy2(HOOK, skript)
code, out, err, dauer = fahre(skript, fall.stdin(v), arbeit)
kopf = f"exit={code} {dauer:.2f}s"
probleme = pruefe(fall, v, code, out, dauer)
if not probleme:
return True, kopf
text = kopf + "\n" + "\n".join(" " + p for p in probleme)
if err.strip():
text += "\n stderr: " + err.strip()[:800]
return False, text
def ip_hook_wirft(arbeit: Path) -> tuple[bool, str]:
"""Fehlerpfad im Hook selbst, von aussen nicht ausloesbar: im Prozess."""
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
baue_vault(v, "befund")
spec = importlib.util.spec_from_file_location("hook_wirft", HOOK)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
def wirft(wurzel: Path) -> None:
raise RuntimeError("Testfehler")
modul.fuehre_check = wirft
puffer = io.StringIO()
with contextlib.redirect_stdout(puffer):
code = modul.main(edit(v, str(v / "CLAUDE.md")))
erwartet = Fall("", "befund", lambda _: b"", "meldung",
["RuntimeError: Testfehler"], "regelwerk_nach_edit.py: RuntimeError")
probleme = pruefe_meldung(erwartet, puffer.getvalue())
if code != 0:
probleme.append(f"main gab {code} zurueck statt 0")
return not probleme, "\n".join(" " + p for p in probleme)
def echter_lauf() -> int:
vault = HIER.parent.parent
code, out, err, dauer = fahre(HOOK, edit(vault, str(vault / "CLAUDE.md")), HIER)
print(f"exit={code} Laufzeit {dauer:.2f} s")
if out.strip():
try:
print(json.dumps(json.loads(out), ensure_ascii=False, indent=2))
except ValueError:
print("FORMFEHLER, stdout ist kein JSON:")
print(out)
return 1
else:
print("(keine Ausgabe: Regelwerk-Check ohne Befund)")
if err.strip():
print("--- stderr ---")
print(err)
return 0 if code == 0 else 1
def main() -> int:
if not HOOK.is_file():
print(f"ABBRUCH: {HOOK} fehlt")
return 2
if "--echt" in sys.argv[1:]:
return echter_lauf()
arbeit = Path(tempfile.mkdtemp(prefix="nach-edit-faelle-")).resolve()
try:
print("=" * 78)
print("GRUNDLINIE")
print("=" * 78)
for fall in GRUNDLINIE:
ok, text = lauf_fall(fall, arbeit)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {fall.name} {text}")
if not ok:
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 2
print()
print("=" * 78)
print("ANGRIFFSLISTE")
print("=" * 78)
bestanden = 0
gesamt = 0
for fall in FAELLE:
gesamt += 1
try:
ok, text = lauf_fall(fall, arbeit)
except Exception as exc:
ok, text = False, f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"
bestanden += int(ok)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {fall.name} {text}")
gesamt += 1
try:
ok, text = ip_hook_wirft(arbeit)
except Exception as exc:
ok, text = False, f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"
bestanden += int(ok)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} absturz | Hook selbst wirft (im Prozess) {text}")
print()
print(f"{bestanden} von {gesamt} Faellen bestanden.")
return 0 if bestanden == gesamt else 1
finally:
shutil.rmtree(arbeit, ignore_errors=True)
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())
```
## Schritt 2 — Fehlschlag beobachten
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python -m py_compile .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
```
Erwartet vor der Umsetzung:
```text
exit=0
ABBRUCH: D:\Claude\.vault-tools\session-hooks\regelwerk_nach_edit.py fehlt
exit=2
```
## Schritt 3 — Umsetzung: `regelwerk_nach_edit.py`
Datei `D:\Claude\.vault-tools\session-hooks\regelwerk_nach_edit.py` mit genau diesem Inhalt anlegen:
```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 das Feld cwd der Hook-Eingabe; ohne brauchbares cwd der Ordner
zwei Ebenen über dieser Datei. 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: PostToolUse ignoriert Exit 2, und JSON auf stdout wird nur
bei Exit 0 gelesen.
"""
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(daten: dict) -> Path:
cwd = daten.get("cwd")
if isinstance(cwd, str) and cwd.strip() and os.path.isdir(cwd):
return Path(cwd)
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(daten)
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)
```
## Verifikation
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python -m py_compile .vault-tools\session-hooks\regelwerk_nach_edit.py .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_nach_edit.py --echt; "exit=$LASTEXITCODE"
```
Erwartet:
1. `py_compile`: keine Ausgabe, `exit=0`.
2. Treiber: Beide Grundlinien-Zeilen `BESTANDEN`, keine Zeile beginnt mit `FEHLER` oder enthält `ABSTURZ`, Schlusszeile `N von N Faellen bestanden.` mit derselben Zahl vorn und hinten, `exit=0`. Der Hänge-Fall allein dauert rund 20 Sekunden.
3. `--echt`: erste Ausgabezeile `exit=0 Laufzeit … s`, danach ein JSON-Objekt, am Ende `exit=0`. Solange `AI/Regelwerk/pruefwerkzeuge.md` auf `status: pending-review` steht, enthält `systemMessage` den Text `Exit 1, 1 Befundzeile(n)` und `additionalContext` die Zeile `[klein] AI/Regelwerk/pruefwerkzeuge.md — status: pending-review — Freigabe steht aus`. Weicht das ab, die Ausgabe wörtlich melden, nicht bewerten.
## Bericht an den Koordinator
- Ausgabe von Schritt 2 wörtlich.
- Vollständige Ausgabe des Treibers und des Laufs mit `--echt`.
- Jede Abweichung vom Plantext der beiden Dateien, auch Leerzeichen. Es darf keine geben.
## Nachtrag Runde 2
Nachbesserung nach der ersten Prüfrunde (Befunde siehe `ENTWURF.md`, Abschnitt „Nachbesserung Runde 2"). Weicht vom Plantext oben ab:
- **`bestimme_wurzel()`** nimmt keinen Parameter mehr und liest weder `CLAUDE_PROJECT_DIR` noch `cwd`. Sie gibt ausschließlich `SKRIPT.parents[2]` zurück. Grund: `cwd` folgt einem `cd` im Bash-Werkzeug und zeigt nach einem Wechsel in einen Projektordner nicht mehr auf den Vault (Prüferbefund Runde 2, Koordinatorentscheidung — der Hook ist ohnehin nur für den einen Vault eingetragen, in dem er liegt).
- Der Kopfkommentar-Satz „PostToolUse ignoriert Exit 2" ist korrigiert: Exit 2 zeigt stderr laut Hilfetext von `claude.exe 2.1.263` sofort dem Modell. Die Umsetzung bleibt bei Exit 0 plus JSON.
- **`faelle_nach_edit.py`** kopiert den Hook seither für jeden Fall in den Wegwerf-Vault (das Feld `kopie` auf `Fall` entfällt, weil es jetzt immer geschieht). Die Fälle `mit_projekt_dir`/`cd_in_anderes_projekt` sind entfallen, dafür neu: `cwd` zeigt auf einen anderen Ordner (Ergebnis bleibt der Skript-Vault), ein zweiter Umgebungsfall mit echtem Nicht-ASCII-Pfad ohne `PYTHONIOENCODING`. `ip_hook_wirft()` lädt jetzt ebenfalls eine Kopie des Hooks aus dem Wegwerf-Vault, sonst fand `main()` keinen Geltungsbereich für den Testpfad und der Fall blieb unbemerkt grün mit leerem `stdout`.
- Details, Herkunft und Mutationsbelege stehen in `ENTWURF.md`, nicht hier verdoppelt.