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]>
338 lines
12 KiB
Python
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)
|