.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]>
793 lines
32 KiB
Markdown
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.
|