Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur, was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen, das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor. Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale Bezeichnung ersetzt; die Regeln selbst sind unverändert. Co-Authored-By: Claude Opus 5 <[email protected]>
49 KiB
type, title, status, summary, tags, created, updated, modell, modell_begruendung
| type | title | status | summary | tags | created | updated | modell | modell_begruendung | ||
|---|---|---|---|---|---|---|---|---|---|---|
| plan-task | Task 01 — SessionStart-Hook sitzungsstart.py | draft | Hook gibt zu Sitzungsbeginn Datum, Tagesnotiz, offene Inbox-Notizen, needs-review, review ohne review-done, pending-review und heutige Sitzungsdateien als Kontext aus; Regressionstreiber und ENTWURF.md. |
|
2026-09-13 | 2026-09-13 | sonnet | Der Code steht wörtlich im Plan, aber der Treiber läuft zum ersten Mal gegen Windows-Datei- und Prozessverhalten (CRLF, BOM, Temp-Pfade, Laufzeit über den echten Vault); schlägt ein Fall fehl, muss die Ursache eingegrenzt und als Planwiderspruch gemeldet werden, das ist Fehlersuche und keine Abschrift. |
Task 01 — SessionStart-Hook sitzungsstart.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_sitzungsstart.py(neu)D:\Claude\.vault-tools\session-hooks\sitzungsstart.py(neu)D:\Claude\.vault-tools\session-hooks\ENTWURF.md(neu)
Verbote
- Keine andere Datei anlegen oder ändern.
regelwerk_nach_edit.pyundfaelle_nach_edit.pygehören Task 2, alles unter.vault-tools\linkcheck\gehört Task 3. - Kein Eintrag in
.claude/settings.jsonoder.claude/settings.local.json, und diese Dateien auch nicht lesen. - Der Treiber schreibt ausschließlich in sein Temp-Verzeichnis. Im echten Vault wird nichts angelegt, auch nicht zum Ausprobieren.
- Schlägt ein Fall fehl: Code nicht anpassen, bis er grün ist. Fall, Ausgabe und vermutete Ursache melden.
Abhängigkeit zur Laufzeit
sitzungsstart.py importiert strip_code aus D:\Claude\.vault-tools\linkcheck\linkcheck.py. Task 3 ändert diese Datei. Laufen beide Tasks gleichzeitig, kann der Treiber hier kurz auf eine halb geschriebene Datei treffen. Tritt in einem Lauf ein Fehler mit linkcheck im Text auf, den der Plan nicht erwartet, den Treiber nach dem Ende von Task 3 einmal wiederholen und beide Läufe melden.
Schritt 1 — Test zuerst: faelle_sitzungsstart.py
Datei D:\Claude\.vault-tools\session-hooks\faelle_sitzungsstart.py mit genau diesem Inhalt anlegen:
"""Fährt die Angriffsliste gegen sitzungsstart.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner
Grundlinie zuerst: Liefert der unbeschädigte Wegwerf-Vault nicht genau die
erwarteten Zeilen, misst der Rest nichts und der Treiber bricht ab.
Je Fall eine frische Kopie der Grundlinie, genau eine Änderung und der Hook als
eigener Prozess. Drei Fehlerpfade laufen im Prozess, weil sie sich von außen
nicht auslösen lassen. Protokolliert werden Exit-Code, Laufzeit und bei
Abweichung der ganze Kontext. Bestanden heißt: Exit 0, stdout ist genau ein
JSON-Objekt der erwarteten Form, jede erwartete Zeile steht da, keine verbotene.
Aufruf:
python .vault-tools/session-hooks/faelle_sitzungsstart.py
python .vault-tools/session-hooks/faelle_sitzungsstart.py --echt
"""
from __future__ import annotations
import contextlib
import datetime
import importlib.util
import io
import json
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 / "sitzungsstart.py"
LINKCHECK = HIER.parent / "linkcheck" / "linkcheck.py"
HEUTE = datetime.date.today().isoformat()
GESTERN = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
ZIEL_LAUFZEIT_S = 5.0
DA = f"- Datum laut Maschinenuhr: {HEUTE} (das Sitzungsdatum aus dem Kontext hat Vorrang)"
TN = f"- Tagesnotiz Inbox/{HEUTE}.md: "
IN = "- Inbox-Notizen ohne type: daily: "
NR = "- Notizen mit Tag needs-review: "
RV = "- Notizen mit Tag review ohne review-done: "
PR = "- Regelwerk-Dateien mit status: pending-review: "
SI = f"- Sitzungsdateien AI/sessions/{HEUTE}-*.md: "
FEHLERZEILE = "Sitzungsstart-Hook:"
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_grundlinie(v: Path) -> None:
schreibe(v, f"Inbox/{HEUTE}.md",
"---\ntype: daily\ntitle: \"Heute\"\nstatus: open\ntags: [daily]\n---\n\n# Heute\n")
schreibe(v, "Inbox/offen.md", "---\ntype: note\ntags: [inbox]\n---\n\nUnverarbeitet.\n")
schreibe(v, "AI/Regelwerk/regel.md", "---\ntype: resource\nstatus: reference\n---\n\n# Regel\n")
schreibe(v, f"AI/sessions/{HEUTE}-10.md", "---\ntype: session\n---\n")
schreibe(v, f"AI/sessions/{GESTERN}-10.md", "---\ntype: session\n---\n")
schreibe(v, "Notizen/sauber.md",
"---\ntype: note\ntags: [thema]\n---\n\nKein Tag hier, nur das Wort review ohne Raute.\n")
def grundzeilen(v: Path) -> list[str]:
return [
f"Sitzungsstart-Hook (Vault: {v})",
DA,
TN + "vorhanden",
IN + "1 (offen.md)",
NR + "0",
RV + "0",
PR + "0",
SI + f"1 ({HEUTE}-10.md)",
]
@dataclass
class Fall:
name: str
aendern: Callable[[Path], object]
# None heisst: genau die Zeilen der Grundlinie, Kopfzeile eingeschlossen.
erwartet: list[str] | None
verboten: list[str] = field(default_factory=lambda: [FEHLERZEILE])
praefixe: list[str] = field(default_factory=list)
# None heisst: Original-Hook mit cwd auf den Wegwerf-Vault. Sonst liegt
# eine Kopie des Hooks im Wegwerf-Vault und bekommt dieses stdin.
stdin: Callable[[Path], bytes] | None = None
linkcheck_kopieren: bool = True
def nichts(v: Path) -> None:
return None
FAELLE: list[Fall] = [
Fall("umgebung | Inbox-Ordner fehlt",
lambda v: shutil.rmtree(v / "Inbox"),
[TN + "fehlt", IN + "Ordner Inbox/ fehlt"]),
Fall("fehlt | Tagesnotiz fehlt",
lambda v: (v / "Inbox" / f"{HEUTE}.md").unlink(),
[TN + "fehlt", IN + "1 (offen.md)"]),
Fall("wertebereich| nur die Tagesnotiz von gestern",
lambda v: (v / "Inbox" / f"{HEUTE}.md").rename(v / "Inbox" / f"{GESTERN}.md"),
[TN + "fehlt", IN + "1 (offen.md)"]),
Fall("leer | leere Datei in der Inbox",
lambda v: schreibe(v, "Inbox/leer.md", b""),
[IN + "2 (leer.md, offen.md)"]),
Fall("leer | leere Notiz im Vault",
lambda v: schreibe(v, "Notizen/leer.md", b""),
[NR + "0", RV + "0"]),
Fall("typ | Tagesnotiz ohne Frontmatter",
lambda v: schreibe(v, f"Inbox/{HEUTE}.md", "# Heute\n"),
[TN + "vorhanden", IN + f"2 ({HEUTE}.md, offen.md)"]),
Fall("typ | type in Anfuehrungszeichen",
lambda v: schreibe(v, "Inbox/offen.md", "---\ntype: \"daily\"\n---\n"),
[IN + "0"]),
Fall("CRLF | Frontmatter-Tag mit CRLF",
lambda v: schreibe(v, "Notizen/crlf.md",
b"---\r\ntype: note\r\ntags: [needs-review]\r\n---\r\n\r\nText\r\n"),
[NR + "1 (Notizen/crlf.md)"]),
Fall("CRLF | type: daily mit CRLF",
lambda v: schreibe(v, "Inbox/offen.md", b"---\r\ntype: daily\r\n---\r\n"),
[IN + "0"]),
Fall("BOM | BOM vor dem Frontmatter mit Tag",
lambda v: schreibe(v, "Notizen/bom.md", b"\xef\xbb\xbf---\ntags: [needs-review]\n---\n"),
[NR + "1 (Notizen/bom.md)"]),
Fall("BOM | BOM vor type: daily",
lambda v: schreibe(v, "Inbox/offen.md", b"\xef\xbb\xbf---\ntype: daily\n---\n"),
[IN + "0"]),
Fall("kodierung | ungueltiges UTF-8 vor dem Tag",
lambda v: schreibe(v, "Notizen/kaputt.md", b"Text \xff\xfe kaputt\n#needs-review\n"),
[NR + "1 (Notizen/kaputt.md)"]),
Fall("yaml | Tag-Liste nicht geschlossen",
lambda v: schreibe(v, "Notizen/yaml.md", "---\ntags: [needs-review\ntype: note\n---\n"),
[NR + "1 (Notizen/yaml.md)"]),
Fall("yaml | Frontmatter ohne Schlusszeile",
lambda v: schreibe(v, "Notizen/ohne-schluss.md", "---\ntags: [needs-review]\ntype: note\n"),
[NR + "0"]),
Fall("yaml | Block-Liste, Doppelpunkt im Titel",
lambda v: schreibe(v, "Notizen/block.md",
"---\ntitle: \"A: B\"\ntags:\n - thema\n - needs-review\n---\n"),
[NR + "1 (Notizen/block.md)"]),
Fall("yaml | Tag mit Raute in Anfuehrungszeichen",
lambda v: schreibe(v, "Notizen/raute.md", "---\ntags: [\"#needs-review\"]\n---\n"),
[NR + "1 (Notizen/raute.md)"]),
Fall("falscher Typ| tags als Skalar statt Liste",
lambda v: schreibe(v, "Notizen/skalar.md", "---\ntags: needs-review\n---\n"),
[NR + "1 (Notizen/skalar.md)"]),
Fall("code | Tags im Fenced-Codeblock",
lambda v: schreibe(v, "Notizen/code.md", "Text\n\n```\n#needs-review\n#review\n```\n"),
[NR + "0", RV + "0"]),
Fall("code | Tag im Tilde-Codeblock",
lambda v: schreibe(v, "Notizen/tilde.md", "~~~\n#needs-review\n~~~\n"),
[NR + "0"]),
Fall("code | Tags im Inline-Code",
lambda v: schreibe(v, "Notizen/inline.md",
"Das Wort `#needs-review` und `#review` nur erwaehnt.\n"),
[NR + "0", RV + "0"]),
Fall("reihenfolge | Tag an erster Stelle der Datei",
lambda v: schreibe(v, "Notizen/erste.md", "#needs-review am Anfang\n\nText\n"),
[NR + "1 (Notizen/erste.md)"]),
Fall("reihenfolge | Tag an letzter Stelle, ohne Zeilenende",
lambda v: schreibe(v, "Notizen/letzte.md", "Text\n\nzuletzt #needs-review"),
[NR + "1 (Notizen/letzte.md)"]),
Fall("reihenfolge | Tag direkt nach einem Codeblock",
lambda v: schreibe(v, "Notizen/nach-code.md", "```\ncode\n```\n#needs-review\n"),
[NR + "1 (Notizen/nach-code.md)"]),
Fall("grenze | aehnliche Tags zaehlen nicht",
lambda v: schreibe(v, "Notizen/aehnlich.md",
"#needs-review-done #needs-reviewer abc#needs-review #needs-review/unter\n"),
[NR + "0"]),
Fall("grenze | Grossschreibung zaehlt",
lambda v: schreibe(v, "Notizen/gross.md", "Bitte #Needs-Review.\n"),
[NR + "1 (Notizen/gross.md)"]),
Fall("review | review ohne review-done",
lambda v: schreibe(v, "Notizen/r.md", "Text #review\n"),
[RV + "1"]),
Fall("review | review-done neben review",
lambda v: schreibe(v, "Notizen/r.md", "#review\n\nspaeter #review-done\n"),
[RV + "0"]),
Fall("review | review im Frontmatter, review-done im Text",
lambda v: schreibe(v, "Notizen/r.md", "---\ntags: [review]\n---\n\nAbgenommen #review-done\n"),
[RV + "0"]),
Fall("review | review-done allein",
lambda v: schreibe(v, "Notizen/r.md", "#review-done\n"),
[RV + "0"]),
Fall("review | needs-review ist kein review",
lambda v: schreibe(v, "Notizen/nur-needs.md", "#needs-review\n"),
[NR + "1 (Notizen/nur-needs.md)", RV + "0"]),
Fall("reihenfolge | review an erster und an letzter Stelle",
lambda v: (schreibe(v, "Notizen/a-erste.md", "#review\n\nText\n"),
schreibe(v, "Notizen/z-letzte.md", "Text\n\n#review")),
[RV + "2"]),
Fall("ausschluss | Import/ unter der Wurzel",
lambda v: schreibe(v, "Import/x.md", "#needs-review\n#review\n"),
[NR + "0", RV + "0"]),
Fall("ausschluss | _index/ unter der Wurzel",
lambda v: schreibe(v, "_index/INDEX.md", "#needs-review\n"),
[NR + "0"]),
Fall("ausschluss | Punktordner",
lambda v: schreibe(v, ".obsidian/x.md", "#needs-review\n"),
[NR + "0"]),
Fall("ausschluss | node_modules in der Tiefe",
lambda v: schreibe(v, "Code/app/node_modules/pkg/README.md", "#needs-review\n"),
[NR + "0"]),
Fall("ausschluss | Import/ in der Tiefe zaehlt",
lambda v: schreibe(v, "Projekt/Import/x.md", "#needs-review\n"),
[NR + "1 (Projekt/Import/x.md)"]),
Fall("zusaetzlich | sieben weitere Inbox-Notizen, Namen gekuerzt",
lambda v: [schreibe(v, f"Inbox/n0{i}.md", "---\ntype: note\n---\n") for i in range(1, 8)],
[IN + "8 (n01.md, n02.md, n03.md, n04.md, n05.md, +3 weitere)"]),
Fall("zusaetzlich | Notiz in einem Inbox-Unterordner",
lambda v: schreibe(v, "Inbox/sub/tief.md", "x\n"),
[IN + "2 (offen.md, sub/tief.md)"]),
Fall("regelwerk | status: pending-review",
lambda v: schreibe(v, "AI/Regelwerk/b.md", "---\ntype: resource\nstatus: pending-review\n---\n"),
[PR + "1 (b.md)"]),
Fall("regelwerk | status in Anfuehrungszeichen",
lambda v: schreibe(v, "AI/Regelwerk/c.md", "---\nstatus: \"pending-review\"\n---\n"),
[PR + "1 (c.md)"]),
Fall("regelwerk | pending-review nur im Text",
lambda v: schreibe(v, "AI/Regelwerk/d.md", "---\nstatus: reference\n---\nstatus: pending-review\n"),
[PR + "0"]),
Fall("umgebung | AI/Regelwerk fehlt",
lambda v: shutil.rmtree(v / "AI" / "Regelwerk"),
[PR + "Ordner AI/Regelwerk/ fehlt"]),
Fall("doppelt | zwei Sitzungsdateien von heute",
lambda v: schreibe(v, f"AI/sessions/{HEUTE}-10b.md", "x\n"),
[SI + f"2 ({HEUTE}-10.md, {HEUTE}-10b.md)"]),
Fall("umgebung | AI/sessions fehlt",
lambda v: shutil.rmtree(v / "AI" / "sessions"),
[SI + "Ordner AI/sessions/ fehlt"]),
Fall("leer | Vault ohne jeden Ordner",
lambda v: [shutil.rmtree(p) for p in list(v.iterdir())],
[TN + "fehlt", IN + "Ordner Inbox/ fehlt", NR + "0", RV + "0",
PR + "Ordner AI/Regelwerk/ fehlt", SI + "Ordner AI/sessions/ fehlt"]),
Fall("leer | stdin leer", nichts, None, stdin=lambda v: b""),
Fall("falscher Typ| stdin kein JSON", nichts, None, stdin=lambda v: b"kein json"),
Fall("kodierung | stdin ungueltiges UTF-8", nichts, None, stdin=lambda v: b"\xff\xfe{"),
Fall("falscher Typ| stdin ist eine Liste", nichts, None, stdin=lambda v: b'[{"cwd": "x"}]'),
Fall("fehlt | cwd fehlt", nichts, None,
stdin=lambda v: b'{"hook_event_name": "SessionStart"}'),
Fall("null | cwd ist null", nichts, None, stdin=lambda v: b'{"cwd": null}'),
Fall("falscher Typ| cwd ist eine Zahl", nichts, None, stdin=lambda v: b'{"cwd": 5}'),
Fall("leer | cwd ist leer", nichts, None, stdin=lambda v: b'{"cwd": ""}'),
Fall("verweis | cwd existiert nicht", nichts, None,
stdin=lambda v: json.dumps({"cwd": str(v / "gibtsnicht")}).encode("utf-8")),
Fall("falscher Typ| cwd zeigt auf eine Datei", nichts, None,
stdin=lambda v: json.dumps({"cwd": str(v / "Inbox" / "offen.md")}).encode("utf-8")),
Fall("umgebung | linkcheck.py fehlt", nichts,
[DA, TN + "vorhanden", IN + "1 (offen.md)", PR + "0", SI + f"1 ({HEUTE}-10.md)"],
verboten=[NR, RV],
praefixe=["Sitzungsstart-Hook: needs-review/review: RuntimeError: linkcheck.py nicht importierbar"],
stdin=lambda v: b"{}",
linkcheck_kopieren=False),
]
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=60,
)
dauer = time.perf_counter() - start
return (
lauf.returncode,
lauf.stdout.decode("utf-8", errors="replace"),
lauf.stderr.decode("utf-8", errors="replace"),
dauer,
)
def lies_kontext(code: int, out: str) -> tuple[list[str] | None, str]:
if code != 0:
return None, f"Exit {code} statt 0"
roh = out.strip()
if not (roh.startswith("{") and roh.endswith("}")):
return None, "stdout ist kein JSON-Objekt"
try:
daten = json.loads(roh)
except ValueError as exc:
return None, f"stdout nicht parsebar: {exc}"
if not isinstance(daten, dict) or set(daten) != {"hookSpecificOutput"}:
return None, "oberste Ebene ist nicht genau {hookSpecificOutput}"
innen = daten["hookSpecificOutput"]
if (not isinstance(innen, dict)
or innen.get("hookEventName") != "SessionStart"
or not isinstance(innen.get("additionalContext"), str)):
return None, "hookSpecificOutput hat nicht die erwartete Form"
return innen["additionalContext"].split("\n"), ""
def bewerte(zeilen: list[str], erwartet: list[str], verboten: list[str], praefixe: list[str]) -> list[str]:
probleme = [f"fehlt: {z}" for z in erwartet if z not in zeilen]
probleme += [f"fehlt Zeile mit Anfang: {p}" for p in praefixe
if not any(z.startswith(p) for z in zeilen)]
probleme += [f"verboten: {z}" for z in zeilen if any(z.startswith(p) for p in verboten)]
return probleme
def frische_kopie(basis: Path, arbeit: Path) -> Path:
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
shutil.copytree(basis, v)
return v
def lauf_fall(fall: Fall, basis: Path, arbeit: Path) -> tuple[bool, str]:
v = frische_kopie(basis, arbeit)
fall.aendern(v)
if fall.stdin is None:
skript = HOOK
eingabe = json.dumps({"session_id": "faelle", "cwd": str(v),
"hook_event_name": "SessionStart", "source": "startup"}).encode("utf-8")
else:
ziel = v / ".vault-tools" / "session-hooks"
ziel.mkdir(parents=True)
skript = ziel / HOOK.name
shutil.copy2(HOOK, skript)
if fall.linkcheck_kopieren:
(v / ".vault-tools" / "linkcheck").mkdir(parents=True)
shutil.copy2(LINKCHECK, v / ".vault-tools" / "linkcheck" / "linkcheck.py")
eingabe = fall.stdin(v)
code, out, err, dauer = fahre(skript, eingabe, arbeit)
kopf = f"exit={code} {dauer:.2f}s"
zeilen, problem = lies_kontext(code, out)
if zeilen is None:
return False, f"{kopf}\n FORM: {problem}\n stdout: {out[:500]!r}\n stderr: {err[:800]!r}"
if fall.erwartet is None:
erwartet = grundzeilen(v)
else:
erwartet = [f"Sitzungsstart-Hook (Vault: {v})"] + fall.erwartet
probleme = bewerte(zeilen, erwartet, fall.verboten, fall.praefixe)
if not probleme:
return True, kopf
text = kopf + "\n" + "\n".join(" " + p for p in probleme)
text += "\n Kontext:\n" + "\n".join(" " + z for z in zeilen)
return False, text
def lade_hook(name: str):
spec = importlib.util.spec_from_file_location(name, HOOK)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def im_prozess(modul, v: Path) -> list[str] | None:
puffer = io.StringIO()
with contextlib.redirect_stdout(puffer):
code = modul.main(json.dumps({"cwd": str(v)}))
zeilen, _ = lies_kontext(code, puffer.getvalue())
return zeilen
def ip_punkt_wirft(v: Path) -> tuple[bool, str]:
modul = lade_hook("hook_punkt_wirft")
def wirft(wurzel: Path, heute: str) -> list[str]:
raise RuntimeError("Testfehler")
for i, (name, _) in enumerate(modul.PUNKTE):
if name == "Sitzungen":
modul.PUNKTE[i] = (name, wirft)
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
erwartet = [z for z in grundzeilen(v) if not z.startswith(SI)]
erwartet.append("Sitzungsstart-Hook: Sitzungen: RuntimeError: Testfehler")
probleme = bewerte(zeilen, erwartet, [SI], [])
return not probleme, "\n".join(" " + p for p in probleme)
def ip_hauptablauf_wirft(v: Path) -> tuple[bool, str]:
modul = lade_hook("hook_hauptablauf_wirft")
def kaputt(wurzel: Path, heute: str, zeilen: list[str]) -> None:
zeilen.append("- vorher gesammelt")
raise RuntimeError("Testfehler Hauptablauf")
modul.baue_kontext = kaputt
zeilen = im_prozess(modul, v)
erwartet = ["- vorher gesammelt", "Sitzungsstart-Hook: RuntimeError: Testfehler Hauptablauf"]
if zeilen != erwartet:
return False, f" erwartet {erwartet!r}, bekommen {zeilen!r}"
return True, ""
def ip_datei_gesperrt(v: Path) -> tuple[bool, str]:
schreibe(v, "Notizen/gesperrt.md", "#needs-review\n")
modul = lade_hook("hook_datei_gesperrt")
original = modul.lies_text
def gesperrt(pfad: Path) -> str:
if Path(pfad).name == "gesperrt.md":
raise PermissionError("Testsperre")
return original(pfad)
modul.lies_text = gesperrt
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
erwartet = grundzeilen(v) + ["Sitzungsstart-Hook: 1 Datei(en) nicht lesbar (erste: Notizen/gesperrt.md)"]
probleme = bewerte(zeilen, erwartet, [], [])
return not probleme, "\n".join(" " + p for p in probleme)
IM_PROZESS = [
("absturz | ein Punkt wirft", ip_punkt_wirft),
("absturz | Hauptablauf wirft", ip_hauptablauf_wirft),
("lesen | Datei nicht lesbar", ip_datei_gesperrt),
]
def echter_lauf() -> int:
vault = HIER.parent.parent
eingabe = json.dumps({"session_id": "echt", "cwd": str(vault),
"hook_event_name": "SessionStart", "source": "startup"}).encode("utf-8")
code, out, err, dauer = fahre(HOOK, eingabe, HIER)
print(f"exit={code} Laufzeit {dauer:.2f} s (Ziel: unter {ZIEL_LAUFZEIT_S:.0f} s)")
zeilen, problem = lies_kontext(code, out)
if zeilen is None:
print(f"FORMFEHLER: {problem}")
print(out)
print(err)
return 1
print("\n".join(zeilen))
if err.strip():
print("--- stderr ---")
print(err)
if dauer >= ZIEL_LAUFZEIT_S:
print("LAUFZEIT UEBER ZIEL")
return 1
return 0
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="sitzungsstart-faelle-")).resolve()
try:
basis = arbeit / "grundlinie"
baue_grundlinie(basis)
print("=" * 78)
print("GRUNDLINIE")
print("=" * 78)
v = frische_kopie(basis, arbeit)
eingabe = json.dumps({"cwd": str(v), "hook_event_name": "SessionStart"}).encode("utf-8")
code, out, err, dauer = fahre(HOOK, eingabe, arbeit)
zeilen, problem = lies_kontext(code, out)
if zeilen != grundzeilen(v):
print(f"exit={code} {problem}")
print("erwartet:")
print("\n".join(grundzeilen(v)))
print("bekommen (stdout):")
print(out)
print("stderr:")
print(err)
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 2
print(f"Grundlinie gruen (exit 0, {dauer:.2f}s).")
print()
print("=" * 78)
print("ANGRIFFSLISTE")
print("=" * 78)
bestanden = 0
gesamt = 0
for fall in FAELLE:
gesamt += 1
try:
ok, text = lauf_fall(fall, basis, 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}")
for name, funktion in IM_PROZESS:
gesamt += 1
try:
ok, text = funktion(frische_kopie(basis, 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} {name} (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_sitzungsstart.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_sitzungsstart.py; "exit=$LASTEXITCODE"
Erwartet vor der Umsetzung:
exit=0
ABBRUCH: D:\Claude\.vault-tools\session-hooks\sitzungsstart.py fehlt
exit=2
Schritt 3 — Umsetzung: sitzungsstart.py
Datei D:\Claude\.vault-tools\session-hooks\sitzungsstart.py mit genau diesem Inhalt anlegen:
"""SessionStart-Hook: meldet den Stand des Vaults als Kontext für die neue Sitzung.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste)
GELTUNGSBEREICH
Die Punkte aus dem Abschnitt Sitzungsprotokoll in CLAUDE.md, gezählt und
nicht bewertet: Datum laut Maschinenuhr, Tagesnotiz, Inbox-Notizen ohne
type: daily, Notizen mit Tag needs-review, Notizen mit Tag review ohne
review-done, Regelwerk-Dateien mit status: pending-review, heutige
Sitzungsdateien. Die Vault-Wurzel ist das Feld cwd der Hook-Eingabe, ohne
brauchbares cwd der Ordner zwei Ebenen über dieser Datei.
BENANNTE LÜCKEN
* Tags erkennt dieses Skript nach eigener Regel (ENTWURF.md), nicht mit dem
Parser von Obsidian. Ein Tag direkt hinter einer Klammer oder einem
Sternchen zählt nicht, ein verschachtelter Tag wie needs-review/x auch nicht.
* Frontmatter wird tolerant gelesen. Ein Tag in einer nicht geschlossenen
YAML-Liste zählt mit, obwohl Obsidian ihn nicht anzeigt. Nur der Schlüssel
tags zählt, nicht tag oder Tags.
* Codeblöcke erkennt strip_code() aus .vault-tools/linkcheck/linkcheck.py,
mit dessen Lücken: eingerückte Blöcke ohne Zaun zählen nicht als Code.
* Das Datum kommt von der Maschinenuhr. Im Kontext steht es so beschriftet,
weil das Sitzungsdatum aus dem Kontext Vorrang hat.
DIESES SKRIPT IST EIN FANGNETZ, KEIN NACHWEIS. Es nimmt das Zählen ab, nicht
das Lesen.
Exit ist immer 0, stdout ist genau ein JSON-Objekt. Jeder Fehler steht als
Zeile im Kontext; was bis dahin gesammelt war, bleibt stehen.
"""
from __future__ import annotations
import datetime
import json
import os
import re
import sys
from pathlib import Path
SKRIPT = Path(__file__).resolve()
LINKCHECK = SKRIPT.parent.parent / "linkcheck"
MAX_NAMEN = 5
# Nur direkt unter der Vault-Wurzel ausgeschlossen, verglichen in Kleinbuchstaben.
AUSSCHLUSS_OBEN = {"import", "_index"}
# In jeder Tiefe ausgeschlossen. Punktordner sind es zusätzlich immer.
AUSSCHLUSS_UEBERALL = {"node_modules"}
# (?<!\S): nur am Zeilenanfang oder nach Leerraum, also nicht in abc#tag.
# (?![\w/-]): kein längerer Tag wie review-done, reviewer oder review/unter.
_NEEDS_REVIEW = re.compile(r"(?<!\S)#needs-review(?![\w/-])", re.IGNORECASE)
_REVIEW = re.compile(r"(?<!\S)#review(?![\w/-])", re.IGNORECASE)
_REVIEW_DONE = re.compile(r"(?<!\S)#review-done(?![\w/-])", re.IGNORECASE)
def lies_text(pfad: Path) -> str:
# utf-8-sig schluckt die BOM, errors=replace hält ungültige Bytes aus,
# der Textmodus macht aus CRLF ein LF.
with open(pfad, "r", encoding="utf-8-sig", errors="replace") as datei:
return datei.read()
def entwerte(wert: str) -> str:
return wert.strip().strip("[]").strip().strip("\"'").strip().lstrip("#").strip()
def lies_felder(zeilen: list[str]) -> dict[str, object]:
felder: dict[str, object] = {}
schluessel: str | None = None
for zeile in zeilen:
inhalt = zeile.strip()
if not inhalt or inhalt.startswith("#"):
continue
if zeile[:1] in (" ", "\t") or inhalt.startswith("- "):
if schluessel is not None and inhalt.startswith("-"):
liste = felder.get(schluessel)
if not isinstance(liste, list):
liste = []
felder[schluessel] = liste
liste.append(inhalt[1:])
continue
name, trenner, wert = zeile.partition(":")
if not trenner:
schluessel = None
continue
schluessel = name.strip()
wert = wert.strip()
if wert.startswith("["):
felder[schluessel] = wert.strip("[]").split(",")
elif wert:
felder[schluessel] = wert
else:
felder[schluessel] = []
return felder
def teile_frontmatter(text: str) -> tuple[dict[str, object], str]:
zeilen = text.split("\n")
if not zeilen or zeilen[0].strip() != "---":
return {}, text
for i in range(1, len(zeilen)):
if zeilen[i].strip() == "---":
return lies_felder(zeilen[1:i]), "\n".join(zeilen[i + 1:])
# Ohne Schlusszeile ist es kein Frontmatter, sondern Text.
return {}, text
def tags_von(felder: dict[str, object]) -> set[str]:
roh = felder.get("tags")
if isinstance(roh, str):
teile = re.split(r"[,\s]+", roh)
elif isinstance(roh, list):
teile = [t for t in roh if isinstance(t, str)]
else:
teile = []
return {entwerte(t).lower() for t in teile if entwerte(t)}
def skalar(felder: dict[str, object], name: str) -> str:
roh = felder.get(name)
return entwerte(roh).lower() if isinstance(roh, str) else ""
def mit_namen(namen: list[str]) -> str:
if not namen:
return "0"
sortiert = sorted(namen)
text = ", ".join(sortiert[:MAX_NAMEN])
rest = len(sortiert) - MAX_NAMEN
if rest > 0:
text += f", +{rest} weitere"
return f"{len(sortiert)} ({text})"
def unlesbar_zeilen(unlesbar: list[str]) -> list[str]:
if not unlesbar:
return []
return [f"Sitzungsstart-Hook: {len(unlesbar)} Datei(en) nicht lesbar (erste: {unlesbar[0]})"]
def durchlaufe(wurzel: Path, ordnerfehler: list[str]):
start = str(wurzel)
def bei_fehler(fehler: OSError) -> None:
ordnerfehler.append(str(fehler.filename or fehler))
for ordner, unterordner, dateien in os.walk(start, onerror=bei_fehler):
oben = ordner == start
unterordner[:] = sorted(
name for name in unterordner
if not name.startswith(".")
and name.lower() not in AUSSCHLUSS_UEBERALL
and not (oben and name.lower() in AUSSCHLUSS_OBEN)
)
for name in sorted(dateien):
if name.lower().endswith(".md") and not name.startswith("."):
yield Path(ordner) / name
def lade_strip_code():
if str(LINKCHECK) not in sys.path:
sys.path.insert(0, str(LINKCHECK))
try:
from linkcheck import strip_code
except Exception as fehler:
# Ohne Codeblock-Filter wäre jede Erwähnung in Code ein Treffer:
# lieber den Punkt als Fehler melden als falsch zählen.
raise RuntimeError(
f"linkcheck.py nicht importierbar unter {LINKCHECK} "
f"({type(fehler).__name__}: {fehler}), Tags nicht gezählt"
) from fehler
return strip_code
def punkt_datum(wurzel: Path, heute: str) -> list[str]:
return [f"- Datum laut Maschinenuhr: {heute} (das Sitzungsdatum aus dem Kontext hat Vorrang)"]
def punkt_tagesnotiz(wurzel: Path, heute: str) -> list[str]:
vorhanden = (wurzel / "Inbox" / f"{heute}.md").is_file()
return [f"- Tagesnotiz Inbox/{heute}.md: {'vorhanden' if vorhanden else 'fehlt'}"]
def punkt_inbox(wurzel: Path, heute: str) -> list[str]:
inbox = wurzel / "Inbox"
if not inbox.is_dir():
return ["- Inbox-Notizen ohne type: daily: Ordner Inbox/ fehlt"]
offen: list[str] = []
unlesbar: list[str] = []
for pfad in sorted(inbox.rglob("*.md")):
rel = pfad.relative_to(inbox)
if any(teil.startswith(".") for teil in rel.parts) or not pfad.is_file():
continue
try:
felder, _ = teile_frontmatter(lies_text(pfad))
except OSError:
unlesbar.append(pfad.relative_to(wurzel).as_posix())
continue
if skalar(felder, "type") != "daily":
offen.append(rel.as_posix())
return [f"- Inbox-Notizen ohne type: daily: {mit_namen(offen)}"] + unlesbar_zeilen(unlesbar)
def punkt_tags(wurzel: Path, heute: str) -> list[str]:
strip_code = lade_strip_code()
needs: list[str] = []
review = 0
unlesbar: list[str] = []
ordnerfehler: list[str] = []
for pfad in durchlaufe(wurzel, ordnerfehler):
rel = pfad.relative_to(wurzel).as_posix()
try:
text = lies_text(pfad)
except OSError:
unlesbar.append(rel)
continue
if "review" not in text.lower():
continue
felder, rumpf = teile_frontmatter(text)
tags = tags_von(felder)
sauber = strip_code(rumpf)
if "needs-review" in tags or _NEEDS_REVIEW.search(sauber):
needs.append(rel)
hat_review = "review" in tags or _REVIEW.search(sauber)
hat_done = "review-done" in tags or _REVIEW_DONE.search(sauber)
if hat_review and not hat_done:
review += 1
zeilen = [
f"- Notizen mit Tag needs-review: {mit_namen(needs)}",
f"- Notizen mit Tag review ohne review-done: {review}",
]
zeilen += unlesbar_zeilen(unlesbar)
if ordnerfehler:
zeilen.append(
f"Sitzungsstart-Hook: {len(ordnerfehler)} Ordner nicht lesbar (erster: {ordnerfehler[0]})"
)
return zeilen
def punkt_regelwerk(wurzel: Path, heute: str) -> list[str]:
ordner = wurzel / "AI" / "Regelwerk"
if not ordner.is_dir():
return ["- Regelwerk-Dateien mit status: pending-review: Ordner AI/Regelwerk/ fehlt"]
offen: list[str] = []
unlesbar: list[str] = []
for pfad in sorted(ordner.glob("*.md")):
if not pfad.is_file():
continue
try:
felder, _ = teile_frontmatter(lies_text(pfad))
except OSError:
unlesbar.append(pfad.relative_to(wurzel).as_posix())
continue
if skalar(felder, "status") == "pending-review":
offen.append(pfad.name)
return [f"- Regelwerk-Dateien mit status: pending-review: {mit_namen(offen)}"] + unlesbar_zeilen(unlesbar)
def punkt_sitzungen(wurzel: Path, heute: str) -> list[str]:
ordner = wurzel / "AI" / "sessions"
if not ordner.is_dir():
return [f"- Sitzungsdateien AI/sessions/{heute}-*.md: Ordner AI/sessions/ fehlt"]
namen = [p.name for p in ordner.glob(f"{heute}-*.md") if p.is_file()]
return [f"- Sitzungsdateien AI/sessions/{heute}-*.md: {mit_namen(namen)}"]
PUNKTE = [
("Datum", punkt_datum),
("Tagesnotiz", punkt_tagesnotiz),
("Inbox", punkt_inbox),
("needs-review/review", punkt_tags),
("Regelwerk", punkt_regelwerk),
("Sitzungen", punkt_sitzungen),
]
def baue_kontext(wurzel: Path, heute: str, zeilen: list[str]) -> None:
zeilen.append(f"Sitzungsstart-Hook (Vault: {wurzel})")
for name, punkt in PUNKTE:
try:
zeilen.extend(punkt(wurzel, heute))
except Exception as fehler:
zeilen.append(f"Sitzungsstart-Hook: {name}: {type(fehler).__name__}: {fehler}")
def lies_stdin() -> str:
if sys.stdin is None or sys.stdin.isatty():
return ""
return sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
def parse_eingabe(roh: str) -> object:
if not roh.strip():
return None
try:
return json.loads(roh)
except ValueError:
return None
def bestimme_wurzel(daten: object) -> Path:
cwd = daten.get("cwd") if isinstance(daten, dict) else None
if isinstance(cwd, str) and cwd.strip() and os.path.isdir(cwd):
return Path(cwd)
return SKRIPT.parents[2]
def main(eingabe: str | None = None) -> int:
zeilen: list[str] = []
try:
roh = lies_stdin() if eingabe is None else eingabe
wurzel = bestimme_wurzel(parse_eingabe(roh))
heute = datetime.date.today().isoformat()
baue_kontext(wurzel, heute, zeilen)
except Exception as fehler:
zeilen.append(f"Sitzungsstart-Hook: {type(fehler).__name__}: {fehler}")
ausgabe = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "\n".join(zeilen),
}
}
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
sys.stdout.flush()
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)
Schritt 4 — ENTWURF.md
Datei D:\Claude\.vault-tools\session-hooks\ENTWURF.md mit genau diesem Inhalt anlegen:
# Entwurf: Sitzungs-Hooks
Angelegt am 2026-09-13 als Entwurfsschritt nach [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]], Abschnitt 1. Er gilt für zwei Skripte: `sitzungsstart.py` (SessionStart) und `regelwerk_nach_edit.py` (PostToolUse).
## Anlass
Das Sitzungsprotokoll in `CLAUDE.md` verlangt zu Beginn jeder Sitzung dieselben Handgriffe: Tagesnotiz suchen, Inbox durchsehen, `needs-review` suchen. Nach jeder Änderung an Stamm, Regelwerk, Skills oder Agenten soll außerdem `regelwerk_check.py` laufen. Beides ist Zählen und Starten, kein Urteil.
Für den Sitzungsbeginn ist belegt, dass es schiefgeht: Das Datum kam zweimal aus der falschen Quelle, eine Sitzungsdatei hat eine andere überschrieben, und die Suche nach `needs-review` traf Erwähnungen in Backticks. Für den Regelwerk-Check liegt kein solcher Fall vor. Der Hook soll ihn starten, ohne dass jemand daran denken muss.
## Woher die Hook-Schnittstelle kommt
Eingabe auf stdin, Ausgabeform, Bedeutung der Exit-Codes und Matcher stammen aus der Hook-Doku (code.claude.com/docs/en/hooks). Der Koordinator hat sie am 2026-09-13 gegen Claude Code 2.1.263 gemessen und im Planauftrag festgehalten. Beim Planen wurde die Doku nicht noch einmal gezogen. Diese Herkunft steht deshalb unten als **Quelle (Koordinator)** und nicht als eigene Quelle.
## Entscheidungsliste `sitzungsstart.py`
| # | Frage | Antwort | Herkunft |
|---|---|---|---|
| 1 | Welche Punkte meldet der Hook? | Datum, Tagesnotiz, Inbox ohne `type: daily`, `needs-review`, `review` ohne `review-done`, `pending-review` im Regelwerk, heutige Sitzungsdateien | **Projektvorgabe** — `CLAUDE.md`, Abschnitt Sitzungsprotokoll, und Planauftrag |
| 2 | Welches Datum? | Maschinenuhr, im Text ausdrücklich so beschriftet | **Projektvorgabe** — das Sitzungsdatum aus dem Kontext hat Vorrang (Harness-Gedächtnis `datum-aus-dem-sitzungskontext`) |
| 3 | Welche Ausgabeform? | Exit 0, genau ein JSON-Objekt mit `hookSpecificOutput.hookEventName` und `additionalContext` | **Quelle (Koordinator)** — Hook-Doku, SessionStart |
| 4 | Woher kommt die Vault-Wurzel? | Feld `cwd` der Eingabe, wenn es ein vorhandenes Verzeichnis ist; sonst zwei Ebenen über dem Skript | **Projektvorgabe** — Planauftrag |
| 5 | Was ist eine offene Inbox-Notiz? | Jede `.md` unter `Inbox/`, auch in Unterordnern ohne Punkt, deren Frontmatter nicht `type: daily` trägt. Ohne Frontmatter zählt sie als offen | **Projektvorgabe** — `CLAUDE.md` Sitzungsprotokoll Punkt 2; Unterordner als Annahme im Plan benannt |
| 6 | Was ist ein Inline-Tag? | `#name` am Zeilenanfang oder nach Leerraum, danach kein Buchstabe, keine Ziffer, kein `_`, `-` oder `/`. Groß- und Kleinschreibung egal | **Projektvorgabe** — die Obsidian-Doku zur Tag-Syntax wurde nicht gezogen; Grenze und Schreibweise sind unsere Festlegung |
| 7 | Zählt ein Tag in Code? | Nein, weder im Fenced-Codeblock noch im Inline-Code. Ausgeblendet über `strip_code()` aus `.vault-tools/linkcheck/linkcheck.py` | **Projektvorgabe** — [[AI/Regelwerk/frontmatter\|frontmatter.md]], Abschnitt Tags: ein nur erwähnter Tag steht in Backticks. Die Codeblock-Regel selbst ist in `.vault-tools/linkcheck/ENTWURF-codeblock-erkennung.md` belegt |
| 8 | Was ist ein Frontmatter-Tag? | Ein Eintrag unter `tags:` als Inline-Liste, Block-Liste oder Skalar, ohne Anführungszeichen, Klammern und führende Raute verglichen | **Projektvorgabe** — frontmatter.md: Tags im Frontmatter ohne `#`, als Liste |
| 9 | Kaputtes YAML? | Tolerant: Ein Tag in einer nicht geschlossenen Liste zählt. Ein Frontmatter ohne Schlusszeile ist keins | **Projektvorgabe** — lieber eine Markierung zu viel melden als eine übersehen |
| 10 | Welche Ordner fallen aus der Tag-Suche? | `Import/` und `_index/` direkt unter der Wurzel, Punktordner und `node_modules` in jeder Tiefe | **Projektvorgabe** — Planauftrag; dieselben Ausschlüsse wie im Vernetzungs-Audit vom 2026-09-13 |
| 11 | Wann zählt `review`? | Wenn die Notiz `review` trägt (Frontmatter oder Inline) und nirgends `review-done`. Gleiche Ausschlüsse wie Frage 10 | **Projektvorgabe** — `CLAUDE.md` Sitzungsprotokoll Punkt 3; die gleichen Ausschlüsse sind als Annahme im Plan benannt |
| 12 | Wie viele Namen? | Höchstens fünf je Punkt, alphabetisch, danach `+N weitere` | **Projektvorgabe** — Planauftrag |
| 13 | Was passiert bei einem Fehler? | Jeder Punkt läuft für sich in `try`. Ein Fehler wird als Zeile `Sitzungsstart-Hook: …` gemeldet, die übrigen Punkte laufen weiter. Ein Fehler außerhalb der Punkte behält die gesammelten Zeilen | **Projektvorgabe** — pruefwerkzeuge.md Abschnitt 4 |
| 14 | Fehlt `linkcheck.py`? | Der Tag-Punkt meldet sich als Fehler und zählt nicht | **Projektvorgabe** — pruefwerkzeuge.md Abschnitt 6: eine fehlende Voraussetzung ist ein Befund |
| 15 | Laufzeit? | Unter 5 Sekunden über den ganzen Vault angepeilt, gemessen mit `faelle_sitzungsstart.py --echt` | **Projektvorgabe** — Planauftrag |
## Entscheidungsliste `regelwerk_nach_edit.py`
| # | Frage | Antwort | Herkunft |
|---|---|---|---|
| 1 | Wann läuft der Check? | Wenn `tool_input.file_path` innerhalb der Wurzel auf `CLAUDE.md`, `AI/Regelwerk/**`, `.claude/agents/**` oder `.claude/skills/**` zeigt | **Projektvorgabe** — `CLAUDE.md`, Abschnitt Selbstpflege: nach jeder Änderung an Stamm, Regelwerk, Skills oder Agenten |
| 2 | Wie wird der Pfad verglichen? | Backslash zu Schrägstrich, relativ zur Wurzel aufgelöst, `.` und `..` aufgelöst, alles klein geschrieben, Vergleich mit Trenner hinter der Wurzel | **Projektvorgabe** — Windows-Dateisystem ohne Rücksicht auf Groß- und Kleinschreibung; Symlinks bleiben unaufgelöst (Lücke) |
| 3 | Woher die Wurzel? | Wie bei `sitzungsstart.py`, Frage 4 | **Projektvorgabe** — Planauftrag, auf diesen Hook übertragen |
| 4 | Welche Eingaben bleiben still? | Leeres stdin, kein JSON-Objekt, `tool_input` oder `file_path` fehlt oder hat den falschen Typ, Pfad außerhalb des Geltungsbereichs | **Projektvorgabe** — Planauftrag |
| 5 | Wann gilt der Check als ohne Befund? | Exit 0 **und** die Schlusszeile `Keine der N gelaufenen Prüfungen hat angeschlagen.` **und** keine Zeile `ACHTUNG` | **Quelle** — `regelwerk_check.py` Zeilen 366–376 |
| 6 | Was ist ein Befund? | Alles andere: Exit ungleich 0, fehlende Schlusszeile, `ACHTUNG`, fehlendes Skript, Timeout, Startfehler, eigener Absturz | **Projektvorgabe** — pruefwerkzeuge.md Abschnitt 6 |
| 7 | Welche Ausgabe? | Exit 0, ein JSON-Objekt mit `hookSpecificOutput` (PostToolUse, `additionalContext`) und einzeiliger `systemMessage`; ohne Befund keine Ausgabe | **Quelle (Koordinator)** — Hook-Doku: PostToolUse ignoriert Exit 2, JSON nur bei Exit 0 |
| 8 | Wie lange darf der Check laufen? | 20 Sekunden, dann Abbruch und Meldung | **Projektvorgabe** — unter dem `timeout` von 30 Sekunden, den der settings.json-Eintrag setzen soll |
| 9 | Wie viel Text geht in den Kontext? | Höchstens 40 Zeilen und 4000 Zeichen, danach ein Kürzungshinweis | **Projektvorgabe** — eine Längengrenze der Doku ist nicht belegt (Lücke) |
| 10 | Prüft der Hook `tool_name`? | Nein, das macht der Matcher `Edit\|Write\|MultiEdit` | **Projektvorgabe** |
| 11 | Mit welchem Interpreter? | `sys.executable`, stdin geschlossen, `PYTHONIOENCODING=utf-8` | **Projektvorgabe** — [[AI/Regelwerk/windows-shell\|windows-shell.md]] |
Keine Zeile steht auf „Annahme". Wo eine Antwort auf einer Annahme beruhte, ist sie zur Projektvorgabe erhoben und im Plan unter „Getroffene Annahmen" benannt.
## Angriffslisten
Die einzelnen Fälle stehen an genau einer Stelle, in den Treibern `faelle_sitzungsstart.py` und `faelle_nach_edit.py`. Hier steht nur, welche Klassen aus pruefwerkzeuge.md Abschnitt 3 abgedeckt sind, damit eine Lücke auffällt.
| Klasse | `sitzungsstart.py` | `regelwerk_nach_edit.py` |
|---|---|---|
| fehlt | Tagesnotiz, `cwd` | `tool_input`, `file_path`, `cwd`, `regelwerk_check.py` |
| null | `cwd` | `tool_input`, `file_path`, `cwd` |
| falscher Typ | stdin als Liste, `cwd` als Zahl oder Datei, `tags` als Skalar | stdin als Liste oder Zahl, `tool_input` als Liste, `file_path` als Zahl oder Liste |
| leer | leere Datei, leere Notiz, leeres stdin, leeres `cwd`, Vault ohne Ordner | leeres stdin, leerer `file_path` |
| doppelt | zwei Sitzungsdateien von heute | — |
| zusätzlich | sieben Inbox-Notizen (Namensgrenze), Inbox-Unterordner | Pfad außerhalb, Unterordner-`CLAUDE.md`, `settings.json`, Write und MultiEdit |
| Wertebereich | Tagesnotiz von gestern | Nullzeichen, Regelwerk-Ordner selbst, sehr lange Ausgabe |
| Verweis ins Leere | `cwd` existiert nicht | `cwd` existiert nicht, neue Datei, `..` hinaus und hinein, Präfix-Fallen |
| Reihenfolge | Tag an erster und letzter Stelle, `review` an erster und letzter Stelle | — (eine Eingabe hat keine Listenposition) |
| Umgebung | Inbox, Regelwerk, sessions, `linkcheck.py` fehlen; CRLF, BOM, ungültiges UTF-8, kaputtes YAML | Check fehlt, hängt, stürzt ab, Exit 2, Exit 0 mit ACHTUNG, Exit 0 ohne Schlusszeile; Backslash, Laufwerksschreibweise, ungültiges UTF-8 |
| Code | Tag im Fenced-, Tilde- und Inline-Code | — |
| Absturz im Skript | ein Punkt wirft, Hauptablauf wirft, Datei nicht lesbar | Hook selbst wirft |
Beide Treiber prüfen zuerst eine Grundlinie. Beim Regelwerk-Hook besteht sie aus zwei Läufen: Ein grüner Check bleibt still und ist nachweislich gelaufen, ein Befund wird gemeldet. Ohne den zweiten Lauf bestünde ein Hook, der immer schweigt, alle stillen Fälle.
## Absturzfestigkeit
Beide Skripte enden in jedem Fall mit Exit 0, weil Claude Code stdout sonst nicht als JSON liest. Jede Ebene der Eingabe wird auf ihren Typ geprüft, bevor darauf zugegriffen wird. Ein unerwarteter Fehler in der Arbeit selbst wird gemeldet. Die letzte Schranke im `__main__`-Block greift nur noch, wenn das Schreiben der Ausgabe selbst scheitert, etwa an einem geschlossenen stdout. Dann gibt es nichts mehr zu melden.
## Abbruchbedingung
Schluss ist, wenn beide Treiber mit grüner Grundlinie und ohne Fehlschlag durchlaufen, der Lauf mit `--echt` über den echten Vault Ergebnisse liefert, die der Koordinator mit eigener Suche bestätigt hat, und eine Prüfrunde nur noch Umformulierungen findet.
## Was diese Skripte nicht sind
Ein Fangnetz. `sitzungsstart.py` nimmt das Zählen ab, nicht das Lesen der Inbox. `regelwerk_nach_edit.py` reicht weiter, was `regelwerk_check.py` sieht, und das sind formale Widersprüche, keine inhaltlichen. Der belastbare Nachweis, dass die Hooks in Claude Code wirken, ist eine echte Sitzung nach dem Eintrag in `settings.json`, nicht dieser Entwurf.
Verifikation
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python -m py_compile .vault-tools\session-hooks\sitzungsstart.py .vault-tools\session-hooks\faelle_sitzungsstart.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_sitzungsstart.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_sitzungsstart.py --echt; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_sitzungsstart.py --echt; "exit=$LASTEXITCODE"
Erwartet:
py_compile: keine Ausgabe,exit=0.- Treiber:
Grundlinie gruen (exit 0, …s)., keine Zeile beginnt mitFEHLERoder enthältABSTURZ, SchlusszeileN von N Faellen bestanden.mit derselben Zahl vorn und hinten,exit=0. - Beide Läufe mit
--echt: erste Zeileexit=0 Laufzeit … s (Ziel: unter 5 s), danach die KopfzeileSitzungsstart-Hook (Vault: D:\Claude)und sieben Zeilen, die mit-beginnen. Keine ZeileLAUFZEIT UEBER ZIEL,exit=0. Die beiden Laufzeiten gehören in den Bericht, der zweite Lauf zeigt den Wert bei warmem Dateicache. - Die Inhalte des echten Laufs nicht bewerten, sondern wörtlich melden. Die Gegenprüfung macht der Koordinator.
Bericht an den Koordinator
- Ausgabe von Schritt 2 wörtlich.
- Vollständige Ausgabe des Treibers und beider Läufe mit
--echt. - Jede Abweichung vom Plantext der drei 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()liestcwdnicht mehr. Sie nimmt ausschließlichSKRIPT.parents[2], ohne Parameter. Grund:cwdfolgt einemcdim Bash-Werkzeug und zeigt in einer laufenden Sitzung nicht zuverlässig auf die Vault-Wurzel (Prüferbefund Runde 2, Koordinatorentscheidung).main()liest stdin nur noch, um es zu entleeren;parse_eingabe()entfällt.faelle_sitzungsstart.pykopiert den Hook seither für jeden Fall zuerst in den Wegwerf-Vault (kopiere_hook()), weil die Wurzel jetzt am Skriptpfad hängt. Neue Fälle:cwdzeigt auf einen anderen Ordner (Ergebnis bleibt der Skript-Vault), ein Umgebungsfall ohnePYTHONIOENCODINGmit Nicht-ASCII-Inhalt, die Grenzen von#review/#review-done, ein großgeschriebener Frontmatter-Tag, ein Punktordner innerhalb der Inbox, und im Prozess deronerror-Pfad vonos.walk.- Details, Herkunft und Mutationsbelege stehen in
ENTWURF.md, nicht hier verdoppelt.