"""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 ausschließlich der Ordner zwei Ebenen über dieser Datei. Das Feld cwd der Hook-Eingabe wird nicht mehr gelesen: cwd folgt einem cd im Bash-Werkzeug und zeigt in einer laufenden Sitzung nicht zuverlässig auf die Vault-Wurzel (Prüferbefund Runde 2, gemessen gegen Claude Code 2.1.263). 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). 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"} # (? 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 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, das Feld cwd der Hook-Eingabe wird # nicht mehr gelesen: cwd folgt einem cd im Bash-Werkzeug und zeigt in # einer laufenden Sitzung nicht zuverlaessig auf die Vault-Wurzel. return SKRIPT.parents[2] def main(eingabe: str | None = None) -> int: zeilen: list[str] = [] try: if eingabe is None: # Wird nur entleert: kein Feld der Eingabe wird noch ausgewertet. lies_stdin() wurzel = bestimme_wurzel() 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)