--- type: plan-task title: "Task 02 — Zeile „Waisen außerhalb der Ausnahmen“ im SessionStart-Hook" status: draft summary: "sitzungsstart.py ruft waisen.py --zahl als eigenen Prozess mit 15 s Grenze und meldet die Zahl als Zeile; der Treiber bekommt eine Grundlinie mit .obsidian/app.json, neue Fälle und angepasste Erwartungen; ENTWURF.md bekommt einen Nachtrag." tags: [plan-task, vault-tools] created: 2026-09-29 updated: 2026-09-29 modell: haiku modell_begruendung: "Fünfzehn Aus-wird-Blöcke und ein angehängter Abschnitt stehen wörtlich im Plan und werden per Skript angewendet; keine typografischen Zeichen in den Codeblöcken." --- Übersicht: [00-uebersicht.md](00-uebersicht.md) # Task 02 — Zeile im SessionStart-Hook Globale Vorgaben: `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben“. Voraussetzung: Task 01 ist umgesetzt, `waisen.py` und `waisen-ausnahmen.txt` liegen unter `.vault-tools/linkcheck/`. ## Zieldateien - `D:\Claude\.vault-tools\session-hooks\sitzungsstart.py` (geändert) - `D:\Claude\.vault-tools\session-hooks\faelle_sitzungsstart.py` (geändert) - `D:\Claude\.vault-tools\session-hooks\ENTWURF.md` (geändert) ## Verbote - `.claude/settings.json` bleibt, wie es ist. Der Timeout von 30 s wird nicht geändert. - `regelwerk_nach_edit.py` und `faelle_nach_edit.py` bleiben unverändert. - `ZIEL_LAUFZEIT_S` in `faelle_sitzungsstart.py` bleibt bei 5.0, auch wenn die Messung darüber liegt. - Nichts unter `.vault-tools/linkcheck/` ändern. ## Schritt 1 — Blöcke anwenden ``` python D:/Claude/.vault-tools/plan-kopie/plan_materialisiere.py D:/Claude/.vault-tools/linkcheck/docs/plans/2026-09-29-waisen D:/Claude task-02-sitzungsstart.md ``` Erwartet: eine Zeile mit `'ersetzt': 15` und `'angehaengt': 1` und die Schlusszeile `0 Befund(e).` ### Geltungsbereich im Kopf von `.vault-tools/session-hooks/sitzungsstart.py` Aus: ``` 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 ``` wird: ``` 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, dazu die Zahl der Waisen außerhalb der Ausnahmen aus .vault-tools/linkcheck/waisen.py (eigener Prozess, nach 15 s abgebrochen; Regeln und Lücken stehen im Kopf von waisen.py). Die Vault-Wurzel ist ausschließlich der Ordner zwei Ebenen ``` ### Importe in `.vault-tools/session-hooks/sitzungsstart.py` Aus: ``` import re import sys ``` wird: ``` import re import subprocess import sys ``` ### Konstanten in `.vault-tools/session-hooks/sitzungsstart.py` Aus: ``` SKRIPT = Path(__file__).resolve() LINKCHECK = SKRIPT.parent.parent / "linkcheck" ``` wird: ``` SKRIPT = Path(__file__).resolve() LINKCHECK = SKRIPT.parent.parent / "linkcheck" WAISEN = LINKCHECK / "waisen.py" # Das Budget des Hooks in .claude/settings.json sind 30 s für alles. ZEITGRENZE_WAISEN_S = 15.0 ``` ### Neuer Punkt in `.vault-tools/session-hooks/sitzungsstart.py` Aus: ``` PUNKTE = [ ("Datum", punkt_datum), ("Tagesnotiz", punkt_tagesnotiz), ("Inbox", punkt_inbox), ("needs-review/review", punkt_tags), ("Regelwerk", punkt_regelwerk), ("Sitzungen", punkt_sitzungen), ] ``` wird: ``` def punkt_waisen(wurzel: Path, heute: str) -> list[str]: if not WAISEN.is_file(): raise RuntimeError(f"waisen.py fehlt unter {WAISEN}") umgebung = dict(os.environ) umgebung["PYTHONIOENCODING"] = "utf-8" try: lauf = subprocess.run( [sys.executable, str(WAISEN), "--vault", str(wurzel), "--zahl"], capture_output=True, stdin=subprocess.DEVNULL, timeout=ZEITGRENZE_WAISEN_S, env=umgebung, ) except subprocess.TimeoutExpired: raise RuntimeError( f"waisen.py nach {ZEITGRENZE_WAISEN_S:g} s abgebrochen, nicht gezählt" ) from None ausgabe = lauf.stdout.decode("utf-8", errors="replace").strip() if lauf.returncode in (0, 1) and ausgabe.isdigit(): return [f"- Waisen außerhalb der Ausnahmen: {ausgabe}"] erste = ausgabe or lauf.stderr.decode("utf-8", errors="replace").strip() raise RuntimeError(f"waisen.py Exit {lauf.returncode}: {erste.split(chr(10))[0].strip()}") PUNKTE = [ ("Datum", punkt_datum), ("Tagesnotiz", punkt_tagesnotiz), ("Inbox", punkt_inbox), ("needs-review/review", punkt_tags), ("Regelwerk", punkt_regelwerk), ("Sitzungen", punkt_sitzungen), ("Waisen", punkt_waisen), ] ``` ### Konstanten in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` SI = f"- Sitzungsdateien AI/sessions/{HEUTE}-*.md: " FEHLERZEILE = "Sitzungsstart-Hook:" ``` wird: ``` SI = f"- Sitzungsdateien AI/sessions/{HEUTE}-*.md: " WA = "- Waisen außerhalb der Ausnahmen: " FEHLERZEILE = "Sitzungsstart-Hook:" WAISEN = HIER.parent / "linkcheck" / "waisen.py" AUSNAHMEN = HIER.parent / "linkcheck" / "waisen-ausnahmen.txt" ``` ### Grundlinie in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` schreibe(v, "Notizen/sauber.md", "---\ntype: note\ntags: [thema]\n---\n\nKein Tag hier, nur das Wort review ohne Raute.\n") ``` wird: ``` schreibe(v, "Notizen/sauber.md", "---\ntype: note\ntags: [thema]\n---\n\nKein Tag hier, nur das Wort review ohne Raute.\n") # waisen.py braucht einen Obsidian-Vault; ohne Filter hat die Grundlinie sechs Waisen. schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": []}') ``` ### Erwartete Zeilen der Grundlinie in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` SI + f"1 ({HEUTE}-10.md)", ] ``` wird: ``` SI + f"1 ({HEUTE}-10.md)", WA + "6", ] ``` ### Kopf von kopiere_hook in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` def kopiere_hook(v: Path, linkcheck_kopieren: bool = True) -> Path: ``` wird: ``` def kopiere_hook(v: Path, linkcheck_kopieren: bool = True, waisen_kopieren: bool = True) -> Path: ``` ### Rumpf von kopiere_hook in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` if linkcheck_kopieren: lc_ziel = v / ".vault-tools" / "linkcheck" lc_ziel.mkdir(parents=True, exist_ok=True) shutil.copy2(LINKCHECK, lc_ziel / "linkcheck.py") return skript ``` wird: ``` lc_ziel = v / ".vault-tools" / "linkcheck" lc_ziel.mkdir(parents=True, exist_ok=True) if linkcheck_kopieren: shutil.copy2(LINKCHECK, lc_ziel / "linkcheck.py") if waisen_kopieren: shutil.copy2(WAISEN, lc_ziel / "waisen.py") shutil.copy2(AUSNAHMEN, lc_ziel / "waisen-ausnahmen.txt") return skript ``` ### Feld im Fall in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` stdin: Callable[[Path], bytes] | None = None linkcheck_kopieren: bool = True ``` wird: ``` stdin: Callable[[Path], bytes] | None = None linkcheck_kopieren: bool = True waisen_kopieren: bool = True ``` ### Fall ohne jeden Ordner in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` 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"]), ``` wird: ``` 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"], verboten=[WA], praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: kein Obsidian-Vault"]), ``` ### Fall ohne linkcheck.py und neue Fälle in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` verboten=[NR, RV], praefixe=["Sitzungsstart-Hook: needs-review/review: RuntimeError: linkcheck.py nicht importierbar"], stdin=lambda v: b"{}", linkcheck_kopieren=False), ] ``` wird: ``` verboten=[NR, RV, WA], praefixe=["Sitzungsstart-Hook: needs-review/review: RuntimeError: linkcheck.py nicht importierbar", "Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: linkcheck.py nicht importierbar"], stdin=lambda v: b"{}", linkcheck_kopieren=False), Fall("waisen | verlinkte Notiz senkt die Zahl", lambda v: schreibe(v, "Notizen/verweis.md", "[[Notizen/sauber]]\n"), [WA + "5"]), Fall("waisen | Waise an erster und an letzter Stelle", lambda v: (schreibe(v, "0-erste/a.md", "x\n"), schreibe(v, "zz-letzte/z.md", "x\n")), [WA + "8"]), Fall("waisen | Muster aus der Ausnahmedatei greift", lambda v: schreibe(v, "Bookstack/archiv/seite.md", "x\n"), [WA + "6"]), Fall("waisen | waisen.py fehlt", nichts, [DA, TN + "vorhanden", IN + "1 (offen.md)", NR + "0", RV + "0", PR + "0", SI + f"1 ({HEUTE}-10.md)"], verboten=[WA], praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py fehlt"], waisen_kopieren=False), Fall("waisen | kaputte app.json meldet Fehler statt Zahl", lambda v: schreibe(v, ".obsidian/app.json", "{kaputt"), [DA, TN + "vorhanden", NR + "0", SI + f"1 ({HEUTE}-10.md)"], verboten=[WA], praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: .obsidian/app.json nicht lesbar"]), Fall("umgebung | ohne PYTHONIOENCODING zaehlt Waisen mit Umlaut im Namen", lambda v: schreibe(v, "Notizen/Übersicht 🚀.md", "x\n"), [WA + "7"], umgebung=ohne_pythonioencoding), ] ``` ### Aufruf von kopiere_hook in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` skript = kopiere_hook(v, fall.linkcheck_kopieren) ``` wird: ``` skript = kopiere_hook(v, fall.linkcheck_kopieren, fall.waisen_kopieren) ``` ### Gesperrte Datei in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` erwartet = grundzeilen(v) + ["Sitzungsstart-Hook: 1 Datei(en) nicht lesbar (erste: Notizen/gesperrt.md)"] ``` wird: ``` # gesperrt.md ist für waisen.py lesbar und die siebte Waise. erwartet = [z for z in grundzeilen(v) if not z.startswith(WA)] + [WA + "7"] erwartet.append("Sitzungsstart-Hook: 1 Datei(en) nicht lesbar (erste: Notizen/gesperrt.md)") ``` ### Zeitgrenze im Prozess in `.vault-tools/session-hooks/faelle_sitzungsstart.py` Aus: ``` IM_PROZESS = [ ("absturz | ein Punkt wirft", ip_punkt_wirft), ("absturz | Hauptablauf wirft", ip_hauptablauf_wirft), ("lesen | Datei nicht lesbar", ip_datei_gesperrt), ("lesen | Ordner nicht lesbar", ip_ordner_gesperrt), ] ``` wird: ``` def ip_waisen_zeitgrenze(v: Path) -> tuple[bool, str]: """Die Zeitgrenze lässt sich von außen nicht billig überschreiten: im Prozess auf eine Millisekunde gesetzt, startet waisen.py nicht rechtzeitig.""" skript = kopiere_hook(v) modul = lade_hook("hook_waisen_zeitgrenze", skript) modul.ZEITGRENZE_WAISEN_S = 0.001 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(WA)] erwartet.append("Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py nach 0.001 s abgebrochen, nicht gezählt") probleme = bewerte(zeilen, erwartet, [WA], []) 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), ("lesen | Ordner nicht lesbar", ip_ordner_gesperrt), ("waisen | Zeitgrenze ueberschritten", ip_waisen_zeitgrenze), ] ``` ### Nachtrag am Ende von `.vault-tools/session-hooks/ENTWURF.md` Häng diesen Block ans Ende: ````markdown ## Nachtrag 2026-09-29: Zahl der Waisen **Anlass.** Die Spec Waisen im Graphen vom 2026-09-29 verlangt beim Sitzungsstart eine Zeile mit der Zahl der Waisen außerhalb der Ausnahmen, nur die Zahl, keine Liste. Plan: `.vault-tools/linkcheck/docs/plans/2026-09-29-waisen/`. | # | Frage | Antwort | Herkunft | |---|---|---|---| | 16 | Woher kommt die Zahl? | `waisen.py --zahl` aus `.vault-tools/linkcheck/`, als eigener Prozess mit `sys.executable`, stdin geschlossen, `PYTHONIOENCODING=utf-8`. Die Regeln des Zählers stehen in `.vault-tools/linkcheck/ENTWURF-waisen.md`. | **Projektvorgabe**: Plan | | 17 | Wie lange darf sie dauern? | 15 s, dann bricht der Hook den Zähler ab und meldet eine Fehlerzeile. Das Budget des Hooks in `.claude/settings.json` sind 30 s für alles. | **Projektvorgabe** | | 18 | Was, wenn `waisen.py` fehlt, abbricht oder keine Zahl liefert? | Fehlerzeile `Sitzungsstart-Hook: Waisen:` mit der ersten Ausgabezeile des Zählers, keine Zahl. | **Projektvorgabe**: `pruefwerkzeuge.md` Abschnitt 6 | **Treiber.** Die Grundlinie trägt jetzt `.obsidian/app.json` und hat sechs Waisen. `kopiere_hook()` kopiert `waisen.py` und `waisen-ausnahmen.txt` mit. Neue Fälle: ein Link senkt die Zahl, Waisen an erster und letzter Stelle, ein Muster aus der Ausnahmedatei greift, `waisen.py` fehlt, kaputte `app.json`, Umlaut und Emoji im Namen ohne `PYTHONIOENCODING`, und im Prozess die überschrittene Zeitgrenze. Zwei bestehende Fälle erwarten zusätzlich die Fehlerzeile des Zählers: Vault ohne jeden Ordner (dann fehlt `.obsidian`) und `linkcheck.py` fehlt. Dass der Hook die Zahl wirklich vom Zähler übernimmt, zeigen die verschiedenen erwarteten Zahlen 5, 6, 7 und 8 in den neuen Fällen. **Laufzeit.** Frage 15 peilt unter 5 s an. Ob das mit dem Zähler hält, misst `faelle_sitzungsstart.py --echt`. ```` ## Schritt 2 — Angriffsliste ``` PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_sitzungsstart.py; echo "exit=$?" ``` Erwartet: `Grundlinie gruen`, keine Zeile, die mit `FEHLER` beginnt, die Fälle mit `waisen` im Namen als `BESTANDEN`, Schlusszeile `N von N Faellen bestanden.` mit zweimal derselben Zahl, `exit=0`. ## Schritt 3 — Messung M2 über den echten Vault ``` PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_sitzungsstart.py --echt; echo "exit=$?" ``` Erwartet: die Kontextzeilen des echten Vaults, darunter eine Zeile `- Waisen außerhalb der Ausnahmen: ` mit einer Zahl und keine Zeile `Sitzungsstart-Hook: Waisen:`. Steht dort `LAUFZEIT UEBER ZIEL` und `exit=1`: nichts ändern, die Laufzeit melden. Das ist Offener Punkt 4 der Übersicht, kein Fehler dieses Tasks. ## Schritt 4 — Nachbarn unverändert grün ``` PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_nach_edit.py; echo "exit=$?" ``` Erwartet: `exit=0`. ## Bericht Melde die vollständige Ausgabe von Schritt 1, 3 und 4, von Schritt 2 die Schlusszeile und jede Zeile, die nicht mit `BESTANDEN` beginnt. Läuft etwas anders als erwartet: nichts anpassen, melden.