Files
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

32 KiB

type, title, status, summary, tags, created, updated, modell, modell_begruendung
type title status summary tags created updated modell modell_begruendung
plan-task Task 02 — PostToolUse-Hook regelwerk_nach_edit.py draft 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.
plan-task
vault-tools
2026-09-13 2026-09-13 sonnet 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:

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

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:

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:

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

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.