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]>
This commit is contained in:
commit
fa1c26b407
137 files changed
+43926
No files matched your search
@@ -0,0 +1,233 @@
|
||||
"""PostToolUse-Hook: startet den Regelwerk-Check nach einer Änderung an der Anweisungsschicht.
|
||||
|
||||
Ersteller: Pascal Michel
|
||||
Erstellt: 2026-09-13
|
||||
Entwurf: ENTWURF.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste)
|
||||
|
||||
GELTUNGSBEREICH
|
||||
Ausgelöst wird nur, wenn tool_input.file_path innerhalb der Vault-Wurzel auf
|
||||
CLAUDE.md, AI/Regelwerk/**, .claude/agents/**, .claude/skills/** oder
|
||||
.claude/workflows/** zeigt.
|
||||
Die Wurzel ist ausschließlich der Ordner zwei Ebenen über dieser Datei. Weder
|
||||
die Umgebungsvariable CLAUDE_PROJECT_DIR noch das Feld cwd der Hook-Eingabe
|
||||
werden gelesen: cwd folgt einem cd im Bash-Werkzeug und zeigt nach einem
|
||||
Wechsel in einen Projektordner nicht mehr auf den Vault (Claude Code 2.1.263,
|
||||
Prüferbefund Runde 2). Dieser Hook ist ohnehin nur für den Vault eingetragen,
|
||||
in dem er selbst liegt; ein abweichender cwd-Wert ändert daran nichts
|
||||
(Koordinatorentscheidung, Nachbesserung Runde 2). Verglichen wird ohne
|
||||
Rücksicht auf Groß- und Kleinschreibung und auf die Richtung der
|
||||
Schrägstriche, nach Auflösen von "." und "..". Ein relativer Pfad gilt
|
||||
relativ zur Wurzel.
|
||||
|
||||
Still bleibt der Hook nur in zwei Lagen: Die Eingabe taugt nicht (leer, kein
|
||||
JSON-Objekt, kein file_path als Zeichenkette), oder der Pfad liegt außerhalb
|
||||
des Geltungsbereichs. Ein Check ohne Befund bleibt ebenfalls still. Eine
|
||||
fehlende, abstürzende oder hängende Prüfung ist ein Befund.
|
||||
|
||||
BENANNTE LÜCKEN
|
||||
* Ob tool_name Edit, Write oder MultiEdit ist, prüft der Matcher in
|
||||
settings.json, nicht dieses Skript.
|
||||
* Symlinks und Junctions werden nicht aufgelöst. Ein Link von außen auf eine
|
||||
Regelwerk-Datei löst nichts aus.
|
||||
* Still nach Exit 0 bleibt der Hook nur, solange regelwerk_check.py seine
|
||||
Schlusszeile "Keine der N gelaufenen Prüfungen hat angeschlagen." behält.
|
||||
Ändert sich der Wortlaut, meldet der Hook jeden Lauf, also laut statt still.
|
||||
* Die Längengrenze für additionalContext ist in der Hook-Doku nicht belegt.
|
||||
40 Zeilen und 4000 Zeichen sind eine Projektvorgabe.
|
||||
|
||||
DIESER HOOK IST EIN FANGNETZ, KEIN NACHWEIS. Er reicht nur weiter, was
|
||||
regelwerk_check.py sieht, und das sind formale Widersprüche, keine inhaltlichen.
|
||||
|
||||
Exit ist immer 0. Nach dem Hilfetext von claude.exe 2.1.263 zeigt Exit 2 bei
|
||||
PostToolUse stderr sofort dem Modell. Ob JSON bei einem anderen Exit-Code
|
||||
gelesen wird, belegt der Hilfetext nicht; Exit 0 plus JSON ist die belegte
|
||||
Form (ENTWURF.md, Frage 7).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SKRIPT = Path(__file__).resolve()
|
||||
CHECK_REL = Path(".vault-tools") / "regelwerk-check" / "regelwerk_check.py"
|
||||
# Unter dem timeout von 30 s, den der settings.json-Eintrag setzen soll.
|
||||
TIMEOUT_S = 20
|
||||
MAX_ZEILEN = 40
|
||||
MAX_ZEICHEN = 4000
|
||||
|
||||
# Kleinbuchstaben und Schrägstrich vorwärts, verglichen gegen normiere().
|
||||
GELTUNG_DATEIEN = ("claude.md",)
|
||||
GELTUNG_ORDNER = ("ai/regelwerk/", ".claude/agents/", ".claude/skills/", ".claude/workflows/")
|
||||
|
||||
# Schlusszeile von regelwerk_check.py ohne Befund (dort Zeile 370).
|
||||
_GRUEN = re.compile(r"^Keine der \d+ gelaufenen Prüfungen hat angeschlagen\.$")
|
||||
|
||||
|
||||
def lies_stdin() -> bytes:
|
||||
if sys.stdin is None or sys.stdin.isatty():
|
||||
return b""
|
||||
return sys.stdin.buffer.read()
|
||||
|
||||
|
||||
def lies_eingabe(roh: bytes) -> object:
|
||||
# errors=replace: ein kaputtes Byte im Pfad soll laut enden, nicht still.
|
||||
text = roh.decode("utf-8-sig", errors="replace")
|
||||
if not text.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def bestimme_wurzel() -> Path:
|
||||
# Dieser Hook ist nur fuer den Vault eingetragen, in dem er selbst liegt.
|
||||
# Nach Koordinatorentscheidung (Nachbesserung Runde 2, Pruefer-Befund 1)
|
||||
# zaehlt ausschliesslich der Skriptpfad; weder CLAUDE_PROJECT_DIR noch das
|
||||
# Feld cwd der Hook-Eingabe werden noch gelesen. cwd folgt einem cd im
|
||||
# Bash-Werkzeug und zeigt nach einem Wechsel in einen Projektordner nicht
|
||||
# mehr auf den Vault (Claude Code 2.1.263).
|
||||
return SKRIPT.parents[2]
|
||||
|
||||
|
||||
def normiere(pfad: str) -> str:
|
||||
return os.path.normpath(os.path.abspath(pfad)).replace("\\", "/").lower().rstrip("/")
|
||||
|
||||
|
||||
def im_geltungsbereich(wurzel: Path, datei: str) -> bool:
|
||||
kandidat = datei.replace("\\", "/")
|
||||
try:
|
||||
# Relativ zur Wurzel auflösen, nie zum Arbeitsverzeichnis des Prozesses.
|
||||
if not os.path.isabs(kandidat):
|
||||
kandidat = os.path.join(str(wurzel), kandidat)
|
||||
pfad = normiere(kandidat)
|
||||
basis = normiere(str(wurzel))
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
# Mit Trenner vergleichen, sonst träfe "<wurzel>-CLAUDE.md" neben dem Vault.
|
||||
if not pfad.startswith(basis + "/"):
|
||||
return False
|
||||
rel = pfad[len(basis) + 1:]
|
||||
return rel in GELTUNG_DATEIEN or rel.startswith(GELTUNG_ORDNER)
|
||||
|
||||
|
||||
def dekodiere(roh: bytes | str | None) -> str:
|
||||
if roh is None:
|
||||
return ""
|
||||
if isinstance(roh, str):
|
||||
return roh
|
||||
return roh.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def fuehre_check(wurzel: Path) -> tuple[str, str] | None:
|
||||
"""None ohne Befund, sonst (systemMessage, Rumpf für additionalContext)."""
|
||||
check = wurzel / CHECK_REL
|
||||
if not check.is_file():
|
||||
return (
|
||||
f"Regelwerk-Check fehlt: {check}",
|
||||
f"{check} fehlt. Die Prüfung ist nicht gelaufen; das ist ein Befund, keine Entwarnung.",
|
||||
)
|
||||
umgebung = dict(os.environ, PYTHONIOENCODING="utf-8")
|
||||
try:
|
||||
lauf = subprocess.run(
|
||||
[sys.executable, str(check), "--vault", str(wurzel)],
|
||||
cwd=str(wurzel),
|
||||
env=umgebung,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
timeout=TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired as abgelaufen:
|
||||
teil = (dekodiere(abgelaufen.stdout) + dekodiere(abgelaufen.stderr)).strip()
|
||||
return (
|
||||
f"Regelwerk-Check nach {TIMEOUT_S} s abgebrochen",
|
||||
f"regelwerk_check.py lief länger als {TIMEOUT_S} s und wurde beendet. "
|
||||
f"Bis dahin ausgegeben:\n{teil or '(nichts)'}",
|
||||
)
|
||||
except OSError as fehler:
|
||||
return (
|
||||
f"Regelwerk-Check nicht startbar: {type(fehler).__name__}",
|
||||
f"regelwerk_check.py ließ sich nicht starten: {type(fehler).__name__}: {fehler}",
|
||||
)
|
||||
|
||||
ausgabe = dekodiere(lauf.stdout)
|
||||
fehlertext = dekodiere(lauf.stderr).strip()
|
||||
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
|
||||
gruen = any(_GRUEN.match(z) for z in zeilen)
|
||||
achtung = any(z.startswith("ACHTUNG") for z in zeilen)
|
||||
if lauf.returncode == 0 and gruen and not achtung:
|
||||
return None
|
||||
|
||||
befundzeilen = sum(1 for z in zeilen if z.startswith("["))
|
||||
rumpf = ausgabe.strip()
|
||||
if fehlertext:
|
||||
rumpf += "\n--- stderr ---\n" + fehlertext
|
||||
return (
|
||||
f"Regelwerk-Check: Exit {lauf.returncode}, {befundzeilen} Befundzeile(n)",
|
||||
rumpf or "(keine Ausgabe)",
|
||||
)
|
||||
|
||||
|
||||
def kuerze(text: str) -> str:
|
||||
zeilen = text.splitlines()
|
||||
rest = len(zeilen) - MAX_ZEILEN
|
||||
if rest > 0:
|
||||
zeilen = zeilen[:MAX_ZEILEN] + [f"[... {rest} weitere Zeilen gekürzt]"]
|
||||
ergebnis = "\n".join(zeilen)
|
||||
if len(ergebnis) > MAX_ZEICHEN:
|
||||
ergebnis = ergebnis[:MAX_ZEICHEN] + f"\n[... gekürzt auf {MAX_ZEICHEN} Zeichen]"
|
||||
return ergebnis
|
||||
|
||||
|
||||
def melde(system: str, kontext: str) -> None:
|
||||
ausgabe = {
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PostToolUse",
|
||||
"additionalContext": kontext,
|
||||
},
|
||||
"systemMessage": " ".join(system.split()),
|
||||
}
|
||||
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main(roh: bytes | None = None) -> int:
|
||||
try:
|
||||
daten = lies_eingabe(lies_stdin() if roh is None else roh)
|
||||
if not isinstance(daten, dict):
|
||||
return 0
|
||||
eingabe = daten.get("tool_input")
|
||||
if not isinstance(eingabe, dict):
|
||||
return 0
|
||||
datei = eingabe.get("file_path")
|
||||
if not isinstance(datei, str) or not datei.strip():
|
||||
return 0
|
||||
wurzel = bestimme_wurzel()
|
||||
if not im_geltungsbereich(wurzel, datei):
|
||||
return 0
|
||||
ergebnis = fuehre_check(wurzel)
|
||||
if ergebnis is None:
|
||||
return 0
|
||||
system, rumpf = ergebnis
|
||||
melde(system, kuerze(f"Regelwerk-Check nach Änderung an {datei} (Vault: {wurzel}):\n{rumpf}"))
|
||||
except Exception as fehler:
|
||||
melde(
|
||||
f"regelwerk_nach_edit.py: {type(fehler).__name__}",
|
||||
f"regelwerk_nach_edit.py ist abgebrochen: {type(fehler).__name__}: {fehler}. "
|
||||
"Der Regelwerk-Check ist damit nicht gelaufen.",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# Letzte Schranke: Exit 0 ist Pflicht, sonst liest Claude Code stdout nicht.
|
||||
pass
|
||||
sys.exit(0)
|
||||
Reference in new issue
Block a user