Files
claude-regelwerk/.vault-tools/session-hooks/sitzungsstart.py
T
Pascal MichelandClaude Opus 5 c064c729ed Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen
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]>
2026-09-17 22:38:15 +02:00

338 lines
12 KiB
Python

"""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"}
# (?<!\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 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)