Erste Fassung: Claude-Werkzeuge aus dem Vault

.vault-tools (ohne trading-plattform-pruefung und watermarks-remover),
.claude/agents, .claude/skills, .claude/workflows, .claude/settings.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-14 11:27:59 +02:00
commit fa1c26b407
137 files changed
+43926

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
# 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? | Ausschließlich der Ordner zwei Ebenen über dem Skript. Das Feld `cwd` der Hook-Eingabe wird nicht mehr gelesen | **Projektvorgabe (Koordinator, Nachbesserung Runde 2)** — Prüferbefund 1: `cwd` folgt einem `cd` im Bash-Werkzeug und zeigt in einer laufenden Sitzung nicht zuverlässig auf die Vault-Wurzel (Claude Code 2.1.263). Der Hook ist ohnehin nur für diesen einen Vault eingetragen, siehe „Nachbesserung Runde 2" unten |
| 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/**`, `.claude/skills/**` oder, seit 2026-09-26, `.claude/workflows/**` zeigt | **Projektvorgabe** — `CLAUDE.md`, Abschnitt Selbstpflege: nach jeder Änderung an Stamm, Regelwerk, Skills, Agenten oder gespeicherten Workflows |
| 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: ausschließlich der Ordner zwei Ebenen über dem Skript, `cwd` zählt nicht | **Projektvorgabe (Koordinator, Nachbesserung Runde 2)** — 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, korrigiert nach Prüfrunde 2)** — Hilfetext von `claude.exe 2.1.263`: bei PostToolUse zeigt Exit 2 stderr sofort dem Modell. Ob JSON bei einem Exit ungleich 0 gelesen wird, sagt der Hilfetext nicht; der Prüfer fand im Binary Hinweise, dass es auch dann übernommen wird. Exit 0 plus JSON ist die in Doku und laufenden Plugins belegte Form, deshalb bleibt es dabei |
| 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, seit Runde 2 auch ein Punktordner innerhalb der Inbox (eigener Fall neben dem Punktordner-Ausschluss unter der Vault-Wurzel) | 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` zeigt auf einen anderen Ordner (Ergebnis bleibt der Skript-Vault, Runde 2) | `cwd` existiert nicht, `cwd` zeigt auf einen anderen Ordner (Runde 2), 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) |
| Grenze | `#needs-review` gegen `#needs-reviewer`/`#needs-review/x`/`abc#needs-review`, Inline-Großschreibung zählt; seit Runde 2 auch `#review` gegen `#reviewer`/`#review/x`/`abc#review`, `#review-done` gegen `#review-done-x`, und ein großgeschriebener Tag im Frontmatter (`tags: [Needs-Review]`) | — |
| Umgebung | Inbox, Regelwerk, sessions, `linkcheck.py` fehlen; CRLF, BOM, ungültiges UTF-8, kaputtes YAML; seit Runde 2 auch ohne `PYTHONIOENCODING` mit Nicht-ASCII-Inhalt (Notizinhalt mit Umlauten und Emoji) | Check fehlt, hängt, stürzt ab, Exit 2, Exit 0 mit ACHTUNG, Exit 0 ohne Schlusszeile; Backslash, Laufwerksschreibweise, ungültiges UTF-8; seit Runde 2 auch ohne `PYTHONIOENCODING` mit ungültigem UTF-8 und mit echtem Nicht-ASCII-Pfad |
| Code | Tag im Fenced-, Tilde- und Inline-Code | — |
| Absturz im Skript | ein Punkt wirft, Hauptablauf wirft, Datei nicht lesbar, seit Runde 2 auch ein Ordner nicht lesbar (`onerror` von `os.walk`) | 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.
## Nachbesserung Runde 2
**Anlass.** Die erste Prüfrunde gegen beide Hooks fand zwei wichtige und sechs kleine Befunde; der CRLF-Befund zu `ENTWURF-codeblock-erkennung.md` wurde bewusst nicht behoben. „Runde 2" meint in Kommentaren und Nachträgen die Nachbesserung nach dieser ersten Prüfrunde. Die Fixe stehen hier zusammengefasst, Details in den jeweiligen Kopfkommentaren und in den Angriffslisten oben.
**Befund 1 (wichtig): Vault-Wurzel aus `cwd`.** Beide Hooks lasen die Vault-Wurzel zuerst aus `cwd` der Hook-Eingabe. `cwd` folgt aber einem `cd` im Bash-Werkzeug (z. B. in einen Projektordner mit eigenem Git-Repo) und zeigt in einer laufenden Sitzung nicht mehr auf den Vault — `sitzungsstart.py` hätte dann bei `resume`/`clear`/`compact` einen falschen Stand gemeldet, `regelwerk_nach_edit.py` wäre bei Änderungen am Regelwerk still geblieben, ohne dass das sichtbar gewesen wäre. **Korrektur (Koordinatorentscheidung):** Beide Hooks bestimmen ihre Wurzel jetzt ausschließlich aus dem eigenen Skriptpfad (`Path(__file__).resolve().parents[2]`), das Feld `cwd` wird nicht mehr gelesen — auch nicht über die zwischenzeitlich erwogene Umgebungsvariable `CLAUDE_PROJECT_DIR`. Die Hooks sind ohnehin nur für den einen Vault eingetragen, in dem sie liegen. Neuer Fall in beiden Treibern: `cwd` zeigt auf einen anderen, echten Ordner — das Ergebnis muss trotzdem der Vault sein, in dem der (kopierte) Hook selbst liegt. Weil die Wurzel jetzt am Ort des Skripts hängt, kopieren beide Regressionstreiber den Hook seither für **jeden** Fall zuerst in den Wegwerf-Vault (`kopiere_hook()` bzw. der entsprechende Block in `lauf_fall()`), statt das Original mit einem `cwd`-Feld auf den Wegwerf-Vault zu verweisen. Dieselbe Kopie war auch für `ip_hook_wirft()` in `faelle_nach_edit.py` nötig: ohne sie fand `main()` keinen Geltungsbereich für den Testpfad, der monkeygepatchte Absturz wurde nie ausgelöst, und der Fall wäre unbemerkt grün geblieben, weil `stdout` leer war statt eines JSON-Objekts.
**Befund 2 (wichtig): Umgebung/Ausgabekodierung fehlte in der Angriffsliste.** Claude Code setzt `PYTHONIOENCODING` beim Hook-Aufruf nicht; nur die globale Planvorgabe setzte sie bisher vor jedem Treiberlauf selbst. Ohne die Variable ist `sys.stdout.encoding` auf dieser Maschine `cp1252`. Solange `ensure_ascii=True` gilt (beide Hooks), bleibt die Ausgabe reines ASCII und das Problem ist unsichtbar — genau deshalb bekam die Klasse eigene Fälle: **beide** Treiber fahren jetzt mindestens einen Fall mit einer Umgebung ohne `PYTHONIOENCODING` und mit echtem Nicht-ASCII-Inhalt (Notiztext mit Umlauten und Emoji bzw. ein Dateipfad mit Umlauten und Emoji) und prüfen, dass `stdout` als UTF-8 dekodierbares, gültiges JSON bleibt. Mutationsbeleg (auf Scratchpad-Kopien, nicht im Vault): `ensure_ascii=False` gesetzt, derselbe Fall wird in beiden Treibern rot, aber aus unterschiedlichen Gründen (so von der zweiten Prüfrunde nachgemessen): `sitzungsstart.py` gibt nur Dateinamen aus, das Ü im Namen landet als cp1252-Byte in stdout; `regelwerk_nach_edit.py` wirft beim Emoji im Pfad einen `UnicodeEncodeError`, den `main()` fängt und durch eine vollständige Ersatzmeldung ersetzt. Dass der Umlaut-Fall auch ohne Emoji anschlägt, sichert erst die strenge Dekodierung aus der Nachbesserung nach Prüfrunde 2.
**Befunde 3 und 4 (klein): fehlende Grenzfälle.** Ergänzt: die Grenzen von `#review`/`#review-done` (`#reviewer`, `#review/x`, `abc#review`, `#review-done-x`), ein großgeschriebener Tag im Frontmatter (`tags: [Needs-Review]`, nicht nur inline), ein Punktordner innerhalb der Inbox (getrennt vom Punktordner-Ausschluss unter der Vault-Wurzel), und der `onerror`-Pfad von `os.walk` in `durchlaufe()` (im Prozess, weil von außen nicht ohne echte Rechteänderung auslösbar).
**Befund 5 und 6 (klein, Linkcheck): „gleiches Zaunzeichen" ohne Fall, Tab vor dem Schließzaun.** In `linkcheck.py`/`faelle.py` behoben, siehe `.vault-tools/linkcheck/ENTWURF-codeblock-erkennung.md`, Abschnitt „Nachbesserung Runde 2".
**Befund 7 (klein): Exit-2-Aussage korrigiert.** Frage 7 der Entscheidungsliste zu `regelwerk_nach_edit.py` behauptete, PostToolUse ignoriere Exit 2. Nach dem Hilfetext von `claude.exe 2.1.263` zeigt Exit 2 bei PostToolUse stattdessen stderr sofort dem Modell. Die Umsetzung war davon nicht betroffen (Exit ist immer 0), nur die Begründung in Frage 7 und im Kopfkommentar von `regelwerk_nach_edit.py` war falsch und ist jetzt korrigiert.
## Nachbesserung nach Prüfrunde 2
Die zweite Prüfrunde fand keinen wichtigen Befund an den Hooks mehr, nur eine Aufräumliste. Der Koordinator hat sie ohne weitere Executer-Runde selbst abgearbeitet:
- **Strenge Dekodierung in beiden Treibern.** `fahre()` dekodiert stdout jetzt ohne `errors="replace"`. Ungültiges UTF-8 ergibt eine Meldung, die kein JSON ist, und der Fall wird rot.
- **Zwei Fälle ergänzt** in `faelle_sitzungsstart.py`: `abc#review-done` zählt nicht als erledigt, eine Punktdatei außerhalb von Punktordnern (`Notizen/.versteckt.md`) zählt nicht.
- **Linkcheck:** Die Schließregel ist wieder symmetrisch zur Öffnerregel (`\s{0,3}`). Die strengere Fassung ließ einen in einer Liste mit Tab eingerückten Block bis zum Dateiende offen. Details: `.vault-tools/linkcheck/ENTWURF-codeblock-erkennung.md`.
- **Begründungen korrigiert:** Mutationsbeleg zu Befund 2 und Frage 7 (Exit-Codes), siehe oben.
## 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.
## 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`.
### Entscheidung zum Laufzeitziel (2026-10-05)
- **Gemessen:** Mit der Waisenzeile braucht der Hook im warmen Dateicache 5,0 bis 5,1 s (Prüfung vom 2026-10-05, drei Läufe), `waisen.py --zahl` allein 2,2 s. Den kalten Fall hat niemand gemessen.
- **Entschieden:** `ZIEL_LAUFZEIT_S` in `faelle_sitzungsstart.py` steht auf 8 s statt 5 s. Das Budget des Hooks in `.claude/settings.json` sind 30 s, die Zeitgrenze der Waisenzeile 15 s.
@@ -0,0 +1,229 @@
---
type: plan
title: "Sitzungs-Hooks und Schließzaun im Linkcheck"
status: active
summary: "Nach dem Plan gibt ein SessionStart-Hook den Stand des Sitzungsprotokolls als Kontext aus, ein PostToolUse-Hook startet den Regelwerk-Check nach Änderungen an der Anweisungsschicht, und der Linkcheck schließt Codeblöcke nach CommonMark."
tags: [plan, vault-tools]
created: 2026-09-13
updated: 2026-09-13
---
# Sitzungs-Hooks und Schließzaun im Linkcheck
## Ziel
Nach diesem Plan liegen zwei Hook-Skripte unter `.vault-tools/session-hooks/`, jedes mit Regressionstreiber. `sitzungsstart.py` zählt zu Sitzungsbeginn, was das Sitzungsprotokoll in `CLAUDE.md` verlangt, und gibt es als Kontext aus. `regelwerk_nach_edit.py` startet `regelwerk_check.py`, sobald eine Datei von Stamm, Regelwerk, Agenten oder Skills geändert wurde, und meldet Befunde, eine fehlende, abstürzende oder hängende Prüfung. Außerdem schließt `strip_code()` im Linkcheck einen Codeblock nur noch mit einem passenden Zaun. Damit verschwindet der Fehlalarm `[[:space:]]` in `Code Projekte/active - Logging/docs/plans/2026-09-08-a-betriebsmodelle/task-09-gate.md`. Wirksam in Claude Code werden die Hooks erst mit dem Eintrag in `settings.json`, und der ist nicht Teil dieses Plans.
## Nicht im Umfang
- Einträge in `.claude/settings.json` oder `.claude/settings.local.json`. Die macht der Koordinator nach der Prüfung.
- Jede Änderung außerhalb von `.vault-tools/session-hooks/` und `.vault-tools/linkcheck/`. Auch der Kommentar in `regelwerk_check.py`, der die Zahl der Linkcheck-Fälle nennt, bleibt stehen.
- Die Öffnerregel des Linkchecks (Backtick im Info-String). Sie ist im Entwurf als Lücke benannt.
- `CHANGELOG.md`: `.vault-tools/` führt keins.
## Architektur
```text
Claude Code
SessionStart ──stdin JSON──> sitzungsstart.py ──stdout JSON──> additionalContext
│
└─ importiert strip_code() aus linkcheck/linkcheck.py
PostToolUse (Edit|Write|MultiEdit)
──stdin JSON──> regelwerk_nach_edit.py
│ Pfad im Geltungsbereich? nein -> still
└─ startet regelwerk-check/regelwerk_check.py --vault <wurzel>
ohne Befund -> still
sonst -> stdout JSON (additionalContext, systemMessage)
linkcheck/linkcheck.py strip_code() <── auch von regelwerk_check.py importiert
```
Beide Hooks enden immer mit Exit 0. Entscheidungen und Herkunft stehen in `.vault-tools/session-hooks/ENTWURF.md` (entsteht in Task 1), die Regel zum Schließzaun in `.vault-tools/linkcheck/ENTWURF-codeblock-erkennung.md` (Task 3).
## Globale Vorgaben
Diese Vorgaben gelten für jeden Task. Die Taskdateien verweisen hierher und wiederholen sie nicht.
1. **Abschrift, keine Autorschaft.** Code und Texte aus dem Plan werden Zeichen für Zeichen übernommen. Wer einen Widerspruch im Plan findet, meldet ihn und löst ihn nicht still auf. Kommentare und Docstrings werden nicht umformuliert.
2. **Test zuerst.** Erst den Treiber oder die Fälle anlegen, den Fehlschlag im Wortlaut beobachten und melden, dann die Umsetzung. Weicht der Fehlschlag vom Plan ab, anhalten und melden.
3. **Nur die Zieldateien des eigenen Tasks.** Keine weiteren Dateien, keine Hilfsskripte im Vault. Temporäres gehört ins Temp-Verzeichnis, das der Treiber selbst anlegt und löscht.
4. **Shell.** PowerShell, Arbeitsverzeichnis `D:\Claude`. Vor jedem Python-Aufruf `$env:PYTHONIOENCODING = "utf-8"`. Interpreter ist `python`, nie `python3`. Kein `bash`, kein Heredoc, kein `python -c`, kein `python -`.
5. **Dateien.** UTF-8 ohne BOM, Zeilenende LF. Nach dem Anlegen oder Ändern einer Python-Datei `python -m py_compile <datei>`.
6. **Nur Standardbibliothek**, Python 3.13.
7. **Kein Commit.** Der Vault-Root ist kein Git-Repository, und Subagenten committen nie.
8. **Keine Geheimnisse lesen.** `.claude/settings.json`, `.claude/settings.local.json` und Transkripte werden nicht geöffnet.
9. **Schlägt ein Fall fehl, wird nicht am Code gedreht, bis er grün ist.** Gemeldet werden Fall, vollständige Ausgabe und die vermutete Ursache. Die Korrektur passiert im Plan.
## Dateiübersicht
| Datei | Task | Art |
|---|---|---|
| `.vault-tools/session-hooks/sitzungsstart.py` | 1 | neu |
| `.vault-tools/session-hooks/faelle_sitzungsstart.py` | 1 | neu |
| `.vault-tools/session-hooks/ENTWURF.md` | 1 | neu |
| `.vault-tools/session-hooks/regelwerk_nach_edit.py` | 2 | neu |
| `.vault-tools/session-hooks/faelle_nach_edit.py` | 2 | neu |
| `.vault-tools/linkcheck/linkcheck.py` | 3 | ändern |
| `.vault-tools/linkcheck/faelle.py` | 3 | ändern |
| `.vault-tools/linkcheck/ENTWURF-codeblock-erkennung.md` | 3 | ändern |
## Tasks
| Nr | Datei | Zieldateien | Modell | parallel mit |
|---|---|---|---|---|
| 1 | `task-01-sitzungsstart.md` | `sitzungsstart.py`, `faelle_sitzungsstart.py`, `ENTWURF.md` in `session-hooks/` | sonnet | 2, 3 |
| 2 | `task-02-regelwerk-nach-edit.md` | `regelwerk_nach_edit.py`, `faelle_nach_edit.py` in `session-hooks/` | sonnet | 1, 3 |
| 3 | `task-03-schliesszaun.md` | `linkcheck.py`, `faelle.py`, `ENTWURF-codeblock-erkennung.md` in `linkcheck/` | haiku | 1, 2 |
Die Zieldateien sind disjunkt, alle drei Tasks können gleichzeitig laufen. Eine Abhängigkeit gibt es zur Laufzeit: `sitzungsstart.py` importiert `strip_code()` aus `linkcheck.py`, das Task 3 ändert. Die Fälle von Task 1 hängen nicht von der Schließzaun-Regel ab, ein Lauf während einer halb geschriebenen Datei kann aber scheitern. Deshalb wiederholt die Abnahme den Treiber von Task 1 nach dem Ende von Task 3. Wer Nebenläufigkeit ganz vermeiden will, startet Task 3 zuerst, er ist der kleinste.
## Vor dem Start (Koordinator)
Den Linkcheck einmal vor Task 3 laufen lassen und die Ausgabe sichern, sonst fehlt der Vergleich:
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python .vault-tools\linkcheck\linkcheck.py --vault . --alle > "$env:TEMP\linkcheck-vorher.txt"; "exit=$LASTEXITCODE"
```
Laut Sitzungsprotokoll `AI/sessions/2026-09-13-18.md` meldete er 3 tote Links bei 1006 Notizen, einer davon der Fehlalarm in `task-09-gate.md`.
## Abnahme
1. Die Verifikationsbefehle aller drei Taskdateien selbst wiederholen, nicht aus dem Bericht übernehmen.
2. Den Treiber von Task 1 nach dem Ende von Task 3 noch einmal fahren.
3. Jede neu angelegte Datei byteweise gegen ihren Codeblock im Plan vergleichen, bei Task 3 jede ersetzte Stelle gegen den Neutext. `.vault-tools/plan-diff/plan_diff.py` erwartet eine andere Form der Pfadzeile, als diese Taskdateien sie haben. Ob es hier trägt, ist nicht geprüft, im Zweifel von Hand vergleichen.
4. Je Treiber mindestens einen Fall absichtlich kaputt machen und rot sehen. In `sitzungsstart.py` `(?<!\S)` aus `_NEEDS_REVIEW` entfernen: Der Fall „aehnliche Tags zaehlen nicht" muss fallen. In `regelwerk_nach_edit.py` das `+ "/"` im Präfixvergleich streichen: Der Fall „Datei neben dem Vault mit gleichem Namensanfang" muss fallen. Der Fall „Nachbarordner mit gleichem Namensanfang" bleibt bei diesem Eingriff grün, weil der Rest des Pfads nicht passt; das ist kein blinder Fleck. In `faelle.py` von Task 3 genügt der Blick auf die drei Fälle, die vor der Umsetzung rot waren. Danach alles zurücksetzen.
5. Lauf mit echten Daten wie unten.
## Lauf mit echten Daten
**Task 1.** `python .vault-tools\session-hooks\faelle_sitzungsstart.py --echt` zweimal. Stand beim Planen, zum Vergleich:
- Tagesnotiz `Inbox/2026-09-13.md` vorhanden.
- Inbox ohne `type: daily`: `2026-09-13-vernetzung-vault-claude.md`. Die übrigen 28 Dateien in `Inbox/` tragen `type: daily`.
- `needs-review`: `Inbox/2026-09-13-vernetzung-vault-claude.md` über den Frontmatter-Tag. Den Text `#needs-review` gab es beim Planen in 17 anderen Dateien. In den gelesenen Zeilen steht er in Backticks, auch in `CLAUDE.md` Zeile 118, oder in Zeilen, die zu Codebeispielen gehören; ob diese Zeilen in einem Fenced-Block liegen, wurde nicht geprüft. Nicht gelesen wurden zwei lange Zeilen: `Code Projekte/active - Agentic Cockpit/docs/plans/2026-09-05-agentic-cockpit/task-04-vault-reader-teil-1.md` Zeile 53 und `Code Projekte/active - Agentic Cockpit/docs/code/vault_reader.md` Zeile 26. Jeden Treffer des Hooks an seiner Zeile nachlesen. Steht das Tag dort ohne Backticks im Fließtext, stimmt der Treffer.
- `review` ohne `review-done`: Zahl gegen `Review-Queue.base` in Obsidian halten. Eine Abweichung muss sich aus den Ausschlüssen (`Import/`, Codeblöcke, Inline-Code) erklären lassen, sonst ist sie ein Befund.
- `pending-review`: `pruefwerkzeuge.md`.
- Sitzungsdateien vom 2026-09-13: beim Planen `2026-09-13-16.md`, `2026-09-13-16b.md`, `2026-09-13-18.md`, dazu alles, was seitdem entstanden ist.
- Laufzeit beider Läufe unter 5 Sekunden.
Die Gegenprüfung für `needs-review` läuft mit eigener Suche, nicht mit dem Treiber: Grep über `*.md` nach `needs-review`, dann Ausschlüsse, Backticks und Codeblöcke von Hand abziehen.
**Task 2.** `python .vault-tools\session-hooks\faelle_nach_edit.py --echt`. Solange `pruefwerkzeuge.md` auf `pending-review` steht: `systemMessage` mit `Exit 1, 1 Befundzeile(n)` und im Kontext `[klein] AI/Regelwerk/pruefwerkzeuge.md — status: pending-review — Freigabe steht aus`. Danach einen Hook-Lauf mit einem Pfad außerhalb des Geltungsbereichs von Hand prüfen, er muss still bleiben:
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
'{"cwd": "D:\\Claude", "tool_name": "Edit", "tool_input": {"file_path": "D:\\Claude\\Inbox\\2026-09-13.md"}}' | python .vault-tools\session-hooks\regelwerk_nach_edit.py; "exit=$LASTEXITCODE"
```
Erwartet: nur `exit=0`, keine Zeile davor.
**Task 3.** Den Linkcheck nach Task 3 erneut fahren und gegen `linkcheck-vorher.txt` halten. Erwartet: Die Zeile mit `task-09-gate.md -> [[:space:]]` fehlt, alle anderen Befunde sind gleich. Dazu der Vergleich alte gegen neue Zaunerkennung über alle Notizen, weil der Linkcheck nur tote Links zeigt und ein jetzt verschluckter gültiger Link dort nicht auffällt. Das Skript gehört ins Scratchpad, nicht in den Vault:
```python
"""Vergleicht alte und neue Zaunerkennung über den Vault: Welche Notizen liefern andere Wikilinks?
Nur für die Abnahme von Task 3, gehört nicht in den Vault.
"""
import re
import sys
VAULT = r"D:\Claude"
sys.path.insert(0, VAULT + r"\.vault-tools\linkcheck")
sys.path.insert(0, VAULT + r"\.vault-tools\obsidian-vault-navigator\scripts")
import vaultlib as V # noqa: E402
from linkcheck import strip_code as neu # noqa: E402
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
_INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)*?\1")
def alt(body):
"""strip_code() in der Fassung vor Task 3."""
if not body:
return ""
zaun = None
aus = []
for zeile in body.split("\n"):
m = _FENCE_RE.match(zeile)
if zaun is None:
if m:
zaun = m.group(1)[0] * 3
aus.append(" " * len(zeile))
continue
aus.append(_INLINE_CODE_RE.sub(lambda t: " " * len(t.group(0)), zeile))
else:
if m and m.group(1)[0] * 3 == zaun:
zaun = None
aus.append(" " * len(zeile))
return "\n".join(aus)
def links(text):
return {m.group(1).strip() for m in V._WIKILINK_RE.finditer(text)}
def main():
sys.stdout.reconfigure(encoding="utf-8")
geprueft = 0
anders = 0
for ap, rp in V.walk_vault(VAULT):
if not rp.endswith(".md"):
continue
with open(ap, "r", encoding="utf-8", errors="replace") as fh:
_, body = V.parse_frontmatter(fh.read())
geprueft += 1
a, n = links(alt(body)), links(neu(body))
if a != n:
anders += 1
print(rp)
print(" nur alt:", sorted(a - n))
print(" nur neu:", sorted(n - a))
print(f"{anders} von {geprueft} Notizen liefern andere Wikilinks.")
main()
```
Erwartet ist mindestens `task-09-gate.md` mit `nur alt: [':space:']`. Jede weitere Notiz mit `nur alt:` ist ein Link, den der Linkcheck nicht mehr sieht, und wird an der Stelle nachgelesen. Ist es ein echter Link hinter einem Block, den bisher eine Zeile mit Info-String versehentlich geschlossen hat, stimmt die neue Regel und die Notiz hat einen Zaunfehler. Ist es kein Zaunfehler, ist das ein Befund gegen Task 3.
## Risiken
- **Rauschen nach jedem Edit.** Solange `pruefwerkzeuge.md` auf `pending-review` steht, meldet `regelwerk_nach_edit.py` bei jeder Änderung an Stamm, Regelwerk, Agenten oder Skills denselben Befund. Das sieht man an der `systemMessage`. Ein Hook, der immer anschlägt, wird überlesen.
- **SessionStart feuert öfter als gedacht.** Ohne Matcher läuft der Hook auch bei `resume`, `clear`, `compact` und `fork` und legt denselben Kontext erneut ab. Die Laufzeit fällt dann jedes Mal an.
- **Verschluckte Links.** Ein Block, den bisher eine Zeile mit Info-String versehentlich geschlossen hat, läuft nach Task 3 weiter, notfalls bis Dateiende. Der Linkcheck prüft die Links dahinter dann nicht mehr. Sichtbar macht das nur der Vergleichslauf oben.
- **Timeout-Reihenfolge.** `regelwerk_nach_edit.py` bricht den Check nach 20 Sekunden selbst ab. Setzt der `settings.json`-Eintrag ein `timeout` unter etwa 25 Sekunden, beendet Claude Code den Hook, bevor er melden kann, und die Meldung fehlt.
- **Falsche Wurzel.** Beide Hooks nehmen `cwd` aus der Eingabe. Läuft eine Sitzung mit einem anderen `cwd` als `D:\Claude` und greift der Eintrag trotzdem, zeigt `sitzungsstart.py` das in der Kopfzeile. `regelwerk_nach_edit.py` bleibt dann bei Vault-Dateien still, und das sieht niemand. Ob Einträge aus `D:\Claude\.claude\settings.json` bei einem anderen Startordner überhaupt geladen werden, ist nicht geprüft.
- **Laufzeit.** Die 5 Sekunden sind ein Ziel, gemessen wird am echten Vault. Bei kaltem Dateicache oder wachsendem Vault kann `sitzungsstart.py` länger brauchen. Der erste `--echt`-Lauf zeigt eher den kalten Wert, der zweite den warmen.
- **Mitternacht.** Die Treiber rechnen das Datum einmal beim Start. Läuft ein Treiber über Mitternacht, schlagen datumsabhängige Fälle fehl, ohne dass am Hook etwas falsch ist.
- **Regressionslauf des Regelwerk-Checks.** `.vault-tools/regelwerk-check/regressionslauf.py` bricht nach dem Code schon heute an der Grundlinie ab, weil die Kopie des Regelwerks `pruefwerkzeuge.md` mit `pending-review` enthält. Das ist aus dem Code gelesen, nicht gefahren, und hat mit Task 3 nichts zu tun. Ein Abbruch dort ist kein Beleg gegen Task 3.
- **Python auf dem PATH.** Die Exec-Form startet `python` über den PATH. Ändert sich der PATH, scheitert der Hook mit einem Exit ungleich 0. Claude Code meldet das als nicht blockierenden Fehler, die Hook-Ausgabe fehlt dann.
## Getroffene Annahmen
- Inbox-Notizen werden auch in Unterordnern von `Inbox/` gezählt, nur `.md`-Dateien, Punktordner ausgenommen.
- Die Zählung `review` ohne `review-done` hat dieselben Ausschlüsse wie `needs-review` (`Import/`, `_index/`, Punktordner, `node_modules`).
- `Import/` und `_index/` fallen nur direkt unter der Wurzel heraus, Punktordner und `node_modules` in jeder Tiefe.
- Tags werden ohne Rücksicht auf Groß- und Kleinschreibung verglichen. Ein Inline-Tag steht am Zeilenanfang oder nach Leerraum. `#needs-review/x` und `#needs-review-done` zählen nicht als `needs-review`.
- Kaputtes YAML wird tolerant gelesen: Ein Tag in einer nicht geschlossenen Liste zählt. Ein Frontmatter ohne Schlusszeile gilt nicht als Frontmatter.
- `regelwerk_nach_edit.py` bestimmt die Wurzel wie `sitzungsstart.py` (`cwd`, sonst zwei Ebenen über dem Skript).
- Ein relativer `file_path` gilt relativ zur Wurzel. Der Pfadvergleich ignoriert Groß- und Kleinschreibung im ganzen Pfad, nicht nur beim Laufwerk.
- Still bleibt der Regelwerk-Hook nach dem Check nur bei Exit 0 mit der Schlusszeile „Keine der N gelaufenen Prüfungen hat angeschlagen." und ohne `ACHTUNG`-Zeile. Exit 0 mit `ACHTUNG` ist ein Befund.
- Interner Timeout 20 Sekunden, Kontext höchstens 40 Zeilen und 4000 Zeichen.
- `tool_name` wird nicht geprüft, das übernimmt der Matcher.
- `ENTWURF.md` für beide Hooks gehört zu Task 1, damit die Zieldateien disjunkt bleiben.
- Jeder Punkt des Sitzungsstart-Hooks läuft in einem eigenen `try`. Das geht über den Auftrag hinaus, dort war nur ein Fehler insgesamt verlangt.
- Der Wortlaut der CommonMark-Spezifikation wurde nicht gezogen. Die Schließzaun-Regel ist an markdown-it-py 4.2.0 belegt, das lokal im Projekt Agentic Cockpit liegt.
- In `ENTWURF-codeblock-erkennung.md` wird die gepflegte Zahl „vierzehn Angriffsfälle" durch „alle Angriffsfälle aus `faelle.py`" ersetzt.
## Offene Punkte für den Koordinator
- `settings.json`-Einträge: Matcher für SessionStart (alle Quellen oder nur `startup` und `clear`) und ein `timeout` von mindestens 30 Sekunden für den PostToolUse-Eintrag.
- Das Rauschen durch `pruefwerkzeuge.md` auf `pending-review`: freigeben lassen oder für die Zeit bis zur Freigabe hinnehmen.
- Den Abschnitt 4.5 der CommonMark-Spezifikation 0.31.2 gegen die Regel in `ENTWURF-codeblock-erkennung.md` halten und den Vermerk „Abgleich offen" danach streichen oder korrigieren.
- `regelwerk_check.py` nennt im Kommentar 17 Linkcheck-Fälle, nach Task 3 sind es mehr. Die Datei liegt außerhalb dieses Plans.
- `regressionslauf.py` des Regelwerk-Checks bricht wegen `pending-review` an der Grundlinie ab. Das ist unabhängig von diesem Plan, verdeckt aber jeden Regressionsnachweis dort.
@@ -0,0 +1,792 @@
---
type: plan-task
title: "Task 02 — PostToolUse-Hook regelwerk_nach_edit.py"
status: draft
summary: "Hook startet regelwerk_check.py nach jeder Änderung an Stamm, Regelwerk, Agenten oder Skills und meldet Befunde, fehlende, abstürzende oder hängende Prüfung; Regressionstreiber mit Attrappe."
tags: [plan-task, vault-tools]
created: 2026-09-13
updated: 2026-09-13
modell: sonnet
modell_begruendung: "Der Code steht wörtlich im Plan, aber der Treiber läuft zum ersten Mal gegen Windows-Pfad- und Prozessverhalten (Laufwerksschreibweise, Timeout mit Kill); schlägt ein Fall fehl, muss die Ursache eingegrenzt und als Planwiderspruch gemeldet werden, das ist Fehlersuche und keine Abschrift."
---
# Task 02 — PostToolUse-Hook `regelwerk_nach_edit.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_nach_edit.py` (neu)
- `D:\Claude\.vault-tools\session-hooks\regelwerk_nach_edit.py` (neu)
## Verbote
- Keine andere Datei anlegen oder ändern. `ENTWURF.md` in diesem Ordner gehört Task 1, `regelwerk_check.py` bleibt unberührt.
- Kein Eintrag in `.claude/settings.json` oder `.claude/settings.local.json`, und diese Dateien auch nicht lesen.
- Den echten `regelwerk_check.py` im Treiber nicht verändern oder ersetzen. Die Attrappe entsteht ausschließlich im Temp-Verzeichnis.
- Schlägt ein Fall fehl: Code nicht anpassen, bis er grün ist. Fall, Ausgabe und vermutete Ursache melden.
## Schritt 1 — Test zuerst: `faelle_nach_edit.py`
Datei `D:\Claude\.vault-tools\session-hooks\faelle_nach_edit.py` mit genau diesem Inhalt anlegen:
```python
"""Fährt die Angriffsliste gegen regelwerk_nach_edit.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner
Statt des echten regelwerk_check.py liegt im Wegwerf-Vault eine Attrappe. Sie
schreibt ihren Aufruf nach aufruf.json und zeigt dann ein festgelegtes
Verhalten: grün, Befund, Abbruch, ACHTUNG, stumm, Absturz, hängt, sehr viel
Ausgabe. Ihre Zeilen haben die Form der echten Ausgabe (regelwerk_check.py
Zeilen 114, 368, 370 und 372). So lässt sich je Fall unterscheiden, ob der
Hook still blieb, ohne die Prüfung zu starten, oder still blieb, nachdem sie
grün war.
Grundlinie zuerst: Ein grüner Check muss still bleiben und nachweislich
gelaufen sein, ein Befund muss gemeldet werden. Sonst misst der Rest nichts.
Aufruf:
python .vault-tools/session-hooks/faelle_nach_edit.py
python .vault-tools/session-hooks/faelle_nach_edit.py --echt
"""
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import os
import re
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 / "regelwerk_nach_edit.py"
MAX_KONTEXT = 4100
FAKE_KOPF = '''import json
import sys
import time
from pathlib import Path
Path(__file__).resolve().parents[2].joinpath("aufruf.json").write_text(
json.dumps({"argv": sys.argv[1:], "exe": sys.executable}), encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
'''
FAKES = {
"gruen": FAKE_KOPF + '''print()
print("Keine der 6 gelaufenen Prüfungen hat angeschlagen.")
sys.exit(0)
''',
"befund": FAKE_KOPF + '''print("[klein] AI/Regelwerk/regel.md — status: pending-review — Freigabe steht aus")
print()
print("1 Befund(e) aus 6 von 6 Prüfungen.")
sys.exit(1)
''',
"abbruch": FAKE_KOPF + '''print("[blockierend] regelwerk_check.py — Abbruch: KeyError: 'x'")
print()
print("1 Befund(e) aus 2 von 6 Prüfungen.")
sys.exit(2)
''',
"achtung": FAKE_KOPF + '''print()
print("Keine der 5 gelaufenen Prüfungen hat angeschlagen.")
print("ACHTUNG: nur 5 von 6 Prüfungen sind gelaufen — der Rest hatte keine Voraussetzung.")
sys.exit(0)
''',
"stumm": FAKE_KOPF + '''sys.exit(0)
''',
"absturz": FAKE_KOPF + '''raise RuntimeError("Absturz im Check")
''',
"haengt": FAKE_KOPF + '''time.sleep(120)
''',
"viel": FAKE_KOPF + '''for i in range(200):
print("[wichtig] AI/Regelwerk/regel.md — toter Wikilink: [[ziel%d]]" % i)
print()
print("200 Befund(e) aus 6 von 6 Prüfungen.")
sys.exit(1)
''',
}
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_vault(v: Path, fake: str | None) -> None:
schreibe(v, "CLAUDE.md", "# Stamm\n")
schreibe(v, "AI/Regelwerk/regel.md", "---\nstatus: reference\n---\n")
schreibe(v, "AI/Regelwerk/unter/tief.md", "x\n")
schreibe(v, ".claude/agents/planer.md", "---\nname: planer\n---\n")
schreibe(v, ".claude/skills/demo/SKILL.md", "---\nname: demo\n---\n")
schreibe(v, "Inbox/notiz.md", "x\n")
schreibe(v, "Code/CLAUDE.md", "x\n")
if fake is not None:
schreibe(v, ".vault-tools/regelwerk-check/regelwerk_check.py", FAKES[fake])
def vorwaerts(pfad: Path) -> str:
return str(pfad).replace("\\", "/")
def laufwerk_getauscht(pfad: Path) -> str:
s = str(pfad)
if re.match(r"^[A-Za-z]:", s):
return s[0].swapcase() + s[1:]
return s
def j(daten: object) -> bytes:
return json.dumps(daten).encode("utf-8")
def edit(v: Path, datei: object, **extra: object) -> bytes:
daten: dict[str, object] = {
"session_id": "faelle",
"transcript_path": "",
"cwd": str(v),
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {"file_path": datei},
"tool_response": {},
}
daten.update(extra)
return j(daten)
def ohne_cwd(v: Path, datei: object) -> bytes:
daten = json.loads(edit(v, datei))
del daten["cwd"]
return j(daten)
@dataclass
class Fall:
name: str
fake: str | None
stdin: Callable[[Path], bytes]
erwartung: str
enthaelt: list[str] = field(default_factory=list)
system_enthaelt: str = ""
kopie: bool = False
max_dauer: float = 10.0
BEFUND = ["[klein] AI/Regelwerk/regel.md"]
BEFUND_SYSTEM = "Exit 1, 1 Befundzeile(n)"
def still(name: str, stdin: Callable[[Path], bytes]) -> Fall:
return Fall(name, "befund", stdin, "still-ohne-lauf")
def meldet(name: str, stdin: Callable[[Path], bytes], kopie: bool = False) -> Fall:
return Fall(name, "befund", stdin, "meldung", BEFUND, BEFUND_SYSTEM, kopie)
GRUNDLINIE = [
Fall("GRUNDLINIE | gruener Check bleibt still und lief", "gruen",
lambda v: edit(v, str(v / "CLAUDE.md")), "still-nach-lauf"),
Fall("GRUNDLINIE | Befund wird gemeldet", "befund",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung", BEFUND, BEFUND_SYSTEM),
]
FAELLE = [
still("leer | stdin leer", lambda v: b""),
still("leer | stdin nur Leerraum", lambda v: b" \r\n"),
still("falscher Typ| stdin kein JSON", lambda v: b"kein json"),
still("kodierung | stdin ungueltiges UTF-8", lambda v: b"\xff\xfe\x00"),
still("falscher Typ| stdin ist eine Liste",
lambda v: j([json.loads(edit(v, str(v / "CLAUDE.md")))])),
still("falscher Typ| stdin ist eine Zahl", lambda v: b"5"),
still("fehlt | tool_input fehlt", lambda v: j({"cwd": str(v), "tool_name": "Edit"})),
still("null | tool_input ist null", lambda v: edit(v, "x", tool_input=None)),
still("falscher Typ| tool_input ist eine Liste",
lambda v: edit(v, "x", tool_input=[str(v / "CLAUDE.md")])),
still("fehlt | file_path fehlt", lambda v: edit(v, "x", tool_input={"content": "a"})),
still("null | file_path ist null", lambda v: edit(v, None)),
still("falscher Typ| file_path ist eine Zahl", lambda v: edit(v, 5)),
still("falscher Typ| file_path ist eine Liste", lambda v: edit(v, [str(v / "CLAUDE.md")])),
still("leer | file_path ist leer", lambda v: edit(v, "")),
still("wertebereich| file_path mit Nullzeichen",
lambda v: edit(v, str(v / "CLAUDE.md") + "\u0000")),
still("zusaetzlich | Pfad ausserhalb des Vaults",
lambda v: edit(v, str(v.parent / "anderswo" / "CLAUDE.md"))),
still("verweis | Nachbarordner mit gleichem Namensanfang",
lambda v: edit(v, str(v) + "-kopie\\CLAUDE.md")),
still("verweis | Datei neben dem Vault mit gleichem Namensanfang",
lambda v: edit(v, str(v) + "-CLAUDE.md")),
still("zusaetzlich | im Vault, ausserhalb des Geltungsbereichs",
lambda v: edit(v, str(v / "Inbox" / "notiz.md"))),
still("zusaetzlich | CLAUDE.md in einem Unterordner",
lambda v: edit(v, str(v / "Code" / "CLAUDE.md"))),
still("verweis | Ordnername mit gleichem Anfang",
lambda v: edit(v, str(v / "AI" / "RegelwerkAlt" / "x.md"))),
still("zusaetzlich | settings.json unter .claude",
lambda v: edit(v, str(v / ".claude" / "settings.json"))),
still("wertebereich| der Regelwerk-Ordner selbst",
lambda v: edit(v, str(v / "AI" / "Regelwerk"))),
still("verweis | .. fuehrt aus dem Regelwerk hinaus",
lambda v: edit(v, vorwaerts(v) + "/AI/Regelwerk/../../Inbox/notiz.md")),
still("verweis | .. fuehrt aus dem Vault hinaus",
lambda v: edit(v, vorwaerts(v) + "/../fremd/AI/Regelwerk/x.md")),
Fall("gruen | CLAUDE.md, Check ohne Befund", "gruen",
lambda v: edit(v, str(v / "CLAUDE.md")), "still-nach-lauf"),
Fall("gruen | Skill, Check ohne Befund", "gruen",
lambda v: edit(v, str(v / ".claude" / "skills" / "demo" / "SKILL.md")), "still-nach-lauf"),
meldet("pfad | Schraegstriche vorwaerts",
lambda v: edit(v, vorwaerts(v) + "/CLAUDE.md")),
meldet("pfad | Backslashes",
lambda v: edit(v, str(v) + "\\AI\\Regelwerk\\regel.md")),
meldet("pfad | Laufwerksbuchstabe in anderer Schreibung",
lambda v: edit(v, laufwerk_getauscht(v / "AI" / "Regelwerk" / "regel.md"))),
meldet("pfad | ganzer Pfad in Grossbuchstaben",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "regel.md").upper())),
meldet("pfad | Unterordner im Regelwerk",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "unter" / "tief.md"))),
meldet("pfad | Agentendatei",
lambda v: edit(v, str(v / ".claude" / "agents" / "planer.md"))),
meldet("pfad | Skilldatei",
lambda v: edit(v, str(v / ".claude" / "skills" / "demo" / "SKILL.md"))),
meldet("verweis | neue Datei, die es noch nicht gibt",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "neu.md"))),
meldet("pfad | relativer Pfad", lambda v: edit(v, "AI/Regelwerk/regel.md")),
meldet("verweis | .. fuehrt ins Regelwerk hinein",
lambda v: edit(v, vorwaerts(v) + "/Inbox/../AI/Regelwerk/regel.md")),
meldet("zusaetzlich | Write statt Edit",
lambda v: edit(v, str(v / "CLAUDE.md"), tool_name="Write")),
meldet("zusaetzlich | MultiEdit",
lambda v: edit(v, str(v / "CLAUDE.md"), tool_name="MultiEdit")),
meldet("kodierung | ungueltiges UTF-8 im Pfad",
lambda v: edit(v, "PLATZHALTER").replace(
b"PLATZHALTER", vorwaerts(v).encode("utf-8") + b"/AI/Regelwerk/regel\xff.md")),
meldet("pfad | cwd mit abschliessendem Backslash",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=str(v) + "\\")),
meldet("fehlt | cwd fehlt, Rueckfall auf den Skriptpfad",
lambda v: ohne_cwd(v, str(v / "CLAUDE.md")), kopie=True),
meldet("null | cwd ist null, Rueckfall",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=None), kopie=True),
meldet("falscher Typ| cwd ist eine Zahl, Rueckfall",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=5), kopie=True),
meldet("verweis | cwd existiert nicht, Rueckfall",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=str(v / "gibtsnicht")), kopie=True),
Fall("check | Exit 2, Abbruch im Check", "abbruch",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["Abbruch: KeyError"], "Exit 2, 1 Befundzeile(n)"),
Fall("check | Exit 0 mit ACHTUNG", "achtung",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["ACHTUNG: nur 5 von 6"], "Exit 0, 0 Befundzeile(n)"),
Fall("check | Exit 0 ohne Schlusszeile", "stumm",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["(keine Ausgabe)"], "Exit 0, 0 Befundzeile(n)"),
Fall("check | Check stuerzt ab", "absturz",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["--- stderr ---", "RuntimeError: Absturz im Check"], "Exit 1, 0 Befundzeile(n)"),
Fall("umgebung | regelwerk_check.py fehlt", None,
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["regelwerk_check.py fehlt"], "Regelwerk-Check fehlt"),
Fall("umgebung | regelwerk_check.py haengt", "haengt",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["länger als 20 s"], "nach 20 s abgebrochen", max_dauer=28.0),
Fall("wertebereich| sehr lange Ausgabe wird gekuerzt", "viel",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["weitere Zeilen gekürzt"], "Exit 1, 200 Befundzeile(n)"),
]
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=90,
)
dauer = time.perf_counter() - start
return (
lauf.returncode,
lauf.stdout.decode("utf-8", errors="replace"),
lauf.stderr.decode("utf-8", errors="replace"),
dauer,
)
def pruefe_aufruf(aufruf: Path, v: Path) -> list[str]:
if not aufruf.exists():
return ["Check wurde nicht gestartet"]
daten = json.loads(aufruf.read_text(encoding="utf-8"))
probleme: list[str] = []
if daten.get("argv") != ["--vault", str(v)]:
probleme.append(f"Aufruf-Argumente {daten.get('argv')!r} statt {['--vault', str(v)]!r}")
if os.path.normcase(str(daten.get("exe"))) != os.path.normcase(sys.executable):
probleme.append(f"Interpreter {daten.get('exe')!r} statt {sys.executable!r}")
return probleme
def pruefe_meldung(fall: Fall, out: str) -> list[str]:
roh = out.strip()
if not (roh.startswith("{") and roh.endswith("}")):
return [f"stdout ist kein JSON-Objekt: {out[:300]!r}"]
try:
daten = json.loads(roh)
except ValueError as exc:
return [f"stdout nicht parsebar: {exc}"]
if not isinstance(daten, dict) or set(daten) != {"hookSpecificOutput", "systemMessage"}:
art = sorted(daten) if isinstance(daten, dict) else type(daten).__name__
return [f"falsche Schluessel: {art}"]
innen = daten["hookSpecificOutput"]
system = daten["systemMessage"]
if not isinstance(innen, dict) or innen.get("hookEventName") != "PostToolUse":
return ["hookSpecificOutput.hookEventName ist nicht PostToolUse"]
kontext = innen.get("additionalContext")
if not isinstance(kontext, str) or not kontext:
return ["additionalContext fehlt oder ist leer"]
probleme: list[str] = []
if not isinstance(system, str) or not system or "\n" in system:
probleme.append(f"systemMessage ist keine einzelne Zeile: {system!r}")
elif fall.system_enthaelt not in system:
probleme.append(f"systemMessage ohne {fall.system_enthaelt!r}: {system!r}")
probleme += [f"Kontext ohne {t!r}" for t in fall.enthaelt if t not in kontext]
if len(kontext) > MAX_KONTEXT:
probleme.append(f"Kontext hat {len(kontext)} Zeichen, Grenze {MAX_KONTEXT}")
if probleme:
probleme.append("Kontext war:\n" + kontext)
return probleme
def pruefe(fall: Fall, v: Path, code: int, out: str, dauer: float) -> list[str]:
probleme: list[str] = []
if code != 0:
probleme.append(f"Exit {code} statt 0")
if dauer > fall.max_dauer:
probleme.append(f"Laufzeit {dauer:.1f} s ueber {fall.max_dauer:.0f} s")
aufruf = v / "aufruf.json"
if fall.erwartung == "still-ohne-lauf":
if out != "":
probleme.append(f"stdout nicht leer: {out[:300]!r}")
if aufruf.exists():
probleme.append("Check wurde gestartet, obwohl er nicht haette laufen duerfen")
elif fall.erwartung == "still-nach-lauf":
if out != "":
probleme.append(f"stdout nicht leer: {out[:300]!r}")
probleme += pruefe_aufruf(aufruf, v)
else:
probleme += pruefe_meldung(fall, out)
if fall.fake is not None:
probleme += pruefe_aufruf(aufruf, v)
return probleme
def lauf_fall(fall: Fall, arbeit: Path) -> tuple[bool, str]:
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
baue_vault(v, fall.fake)
skript = HOOK
if fall.kopie:
ziel = v / ".vault-tools" / "session-hooks"
ziel.mkdir(parents=True, exist_ok=True)
skript = ziel / HOOK.name
shutil.copy2(HOOK, skript)
code, out, err, dauer = fahre(skript, fall.stdin(v), arbeit)
kopf = f"exit={code} {dauer:.2f}s"
probleme = pruefe(fall, v, code, out, dauer)
if not probleme:
return True, kopf
text = kopf + "\n" + "\n".join(" " + p for p in probleme)
if err.strip():
text += "\n stderr: " + err.strip()[:800]
return False, text
def ip_hook_wirft(arbeit: Path) -> tuple[bool, str]:
"""Fehlerpfad im Hook selbst, von aussen nicht ausloesbar: im Prozess."""
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
baue_vault(v, "befund")
spec = importlib.util.spec_from_file_location("hook_wirft", HOOK)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
def wirft(wurzel: Path) -> None:
raise RuntimeError("Testfehler")
modul.fuehre_check = wirft
puffer = io.StringIO()
with contextlib.redirect_stdout(puffer):
code = modul.main(edit(v, str(v / "CLAUDE.md")))
erwartet = Fall("", "befund", lambda _: b"", "meldung",
["RuntimeError: Testfehler"], "regelwerk_nach_edit.py: RuntimeError")
probleme = pruefe_meldung(erwartet, puffer.getvalue())
if code != 0:
probleme.append(f"main gab {code} zurueck statt 0")
return not probleme, "\n".join(" " + p for p in probleme)
def echter_lauf() -> int:
vault = HIER.parent.parent
code, out, err, dauer = fahre(HOOK, edit(vault, str(vault / "CLAUDE.md")), HIER)
print(f"exit={code} Laufzeit {dauer:.2f} s")
if out.strip():
try:
print(json.dumps(json.loads(out), ensure_ascii=False, indent=2))
except ValueError:
print("FORMFEHLER, stdout ist kein JSON:")
print(out)
return 1
else:
print("(keine Ausgabe: Regelwerk-Check ohne Befund)")
if err.strip():
print("--- stderr ---")
print(err)
return 0 if code == 0 else 1
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="nach-edit-faelle-")).resolve()
try:
print("=" * 78)
print("GRUNDLINIE")
print("=" * 78)
for fall in GRUNDLINIE:
ok, text = lauf_fall(fall, arbeit)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {fall.name} {text}")
if not ok:
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 2
print()
print("=" * 78)
print("ANGRIFFSLISTE")
print("=" * 78)
bestanden = 0
gesamt = 0
for fall in FAELLE:
gesamt += 1
try:
ok, text = lauf_fall(fall, 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}")
gesamt += 1
try:
ok, text = ip_hook_wirft(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} absturz | Hook selbst wirft (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
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python -m py_compile .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
```
Erwartet vor der Umsetzung:
```text
exit=0
ABBRUCH: D:\Claude\.vault-tools\session-hooks\regelwerk_nach_edit.py fehlt
exit=2
```
## Schritt 3 — Umsetzung: `regelwerk_nach_edit.py`
Datei `D:\Claude\.vault-tools\session-hooks\regelwerk_nach_edit.py` mit genau diesem Inhalt anlegen:
```python
"""PostToolUse-Hook: startet den Regelwerk-Check nach einer Änderung an der Anweisungsschicht.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste)
GELTUNGSBEREICH
Ausgelöst wird nur, wenn tool_input.file_path innerhalb der Vault-Wurzel auf
CLAUDE.md, AI/Regelwerk/**, .claude/agents/** oder .claude/skills/** zeigt.
Die Wurzel ist das Feld cwd der Hook-Eingabe; ohne brauchbares cwd der Ordner
zwei Ebenen über dieser Datei. Verglichen wird ohne Rücksicht auf Groß- und
Kleinschreibung und auf die Richtung der Schrägstriche, nach Auflösen von
"." und "..". Ein relativer Pfad gilt relativ zur Wurzel.
Still bleibt der Hook nur in zwei Lagen: Die Eingabe taugt nicht (leer, kein
JSON-Objekt, kein file_path als Zeichenkette), oder der Pfad liegt außerhalb
des Geltungsbereichs. Ein Check ohne Befund bleibt ebenfalls still. Eine
fehlende, abstürzende oder hängende Prüfung ist ein Befund.
BENANNTE LÜCKEN
* Ob tool_name Edit, Write oder MultiEdit ist, prüft der Matcher in
settings.json, nicht dieses Skript.
* Symlinks und Junctions werden nicht aufgelöst. Ein Link von außen auf eine
Regelwerk-Datei löst nichts aus.
* Still nach Exit 0 bleibt der Hook nur, solange regelwerk_check.py seine
Schlusszeile "Keine der N gelaufenen Prüfungen hat angeschlagen." behält.
Ändert sich der Wortlaut, meldet der Hook jeden Lauf, also laut statt still.
* Die Längengrenze für additionalContext ist in der Hook-Doku nicht belegt.
40 Zeilen und 4000 Zeichen sind eine Projektvorgabe.
DIESER HOOK IST EIN FANGNETZ, KEIN NACHWEIS. Er reicht nur weiter, was
regelwerk_check.py sieht, und das sind formale Widersprüche, keine inhaltlichen.
Exit ist immer 0: PostToolUse ignoriert Exit 2, und JSON auf stdout wird nur
bei Exit 0 gelesen.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
SKRIPT = Path(__file__).resolve()
CHECK_REL = Path(".vault-tools") / "regelwerk-check" / "regelwerk_check.py"
# Unter dem timeout von 30 s, den der settings.json-Eintrag setzen soll.
TIMEOUT_S = 20
MAX_ZEILEN = 40
MAX_ZEICHEN = 4000
# Kleinbuchstaben und Schrägstrich vorwärts, verglichen gegen normiere().
GELTUNG_DATEIEN = ("claude.md",)
GELTUNG_ORDNER = ("ai/regelwerk/", ".claude/agents/", ".claude/skills/")
# Schlusszeile von regelwerk_check.py ohne Befund (dort Zeile 370).
_GRUEN = re.compile(r"^Keine der \d+ gelaufenen Prüfungen hat angeschlagen\.$")
def lies_stdin() -> bytes:
if sys.stdin is None or sys.stdin.isatty():
return b""
return sys.stdin.buffer.read()
def lies_eingabe(roh: bytes) -> object:
# errors=replace: ein kaputtes Byte im Pfad soll laut enden, nicht still.
text = roh.decode("utf-8-sig", errors="replace")
if not text.strip():
return None
try:
return json.loads(text)
except ValueError:
return None
def bestimme_wurzel(daten: dict) -> Path:
cwd = daten.get("cwd")
if isinstance(cwd, str) and cwd.strip() and os.path.isdir(cwd):
return Path(cwd)
return SKRIPT.parents[2]
def normiere(pfad: str) -> str:
return os.path.normpath(os.path.abspath(pfad)).replace("\\", "/").lower().rstrip("/")
def im_geltungsbereich(wurzel: Path, datei: str) -> bool:
kandidat = datei.replace("\\", "/")
try:
# Relativ zur Wurzel auflösen, nie zum Arbeitsverzeichnis des Prozesses.
if not os.path.isabs(kandidat):
kandidat = os.path.join(str(wurzel), kandidat)
pfad = normiere(kandidat)
basis = normiere(str(wurzel))
except (ValueError, OSError):
return False
# Mit Trenner vergleichen, sonst träfe "<wurzel>-CLAUDE.md" neben dem Vault.
if not pfad.startswith(basis + "/"):
return False
rel = pfad[len(basis) + 1:]
return rel in GELTUNG_DATEIEN or rel.startswith(GELTUNG_ORDNER)
def dekodiere(roh: bytes | str | None) -> str:
if roh is None:
return ""
if isinstance(roh, str):
return roh
return roh.decode("utf-8", errors="replace")
def fuehre_check(wurzel: Path) -> tuple[str, str] | None:
"""None ohne Befund, sonst (systemMessage, Rumpf für additionalContext)."""
check = wurzel / CHECK_REL
if not check.is_file():
return (
f"Regelwerk-Check fehlt: {check}",
f"{check} fehlt. Die Prüfung ist nicht gelaufen; das ist ein Befund, keine Entwarnung.",
)
umgebung = dict(os.environ, PYTHONIOENCODING="utf-8")
try:
lauf = subprocess.run(
[sys.executable, str(check), "--vault", str(wurzel)],
cwd=str(wurzel),
env=umgebung,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=TIMEOUT_S,
)
except subprocess.TimeoutExpired as abgelaufen:
teil = (dekodiere(abgelaufen.stdout) + dekodiere(abgelaufen.stderr)).strip()
return (
f"Regelwerk-Check nach {TIMEOUT_S} s abgebrochen",
f"regelwerk_check.py lief länger als {TIMEOUT_S} s und wurde beendet. "
f"Bis dahin ausgegeben:\n{teil or '(nichts)'}",
)
except OSError as fehler:
return (
f"Regelwerk-Check nicht startbar: {type(fehler).__name__}",
f"regelwerk_check.py ließ sich nicht starten: {type(fehler).__name__}: {fehler}",
)
ausgabe = dekodiere(lauf.stdout)
fehlertext = dekodiere(lauf.stderr).strip()
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
gruen = any(_GRUEN.match(z) for z in zeilen)
achtung = any(z.startswith("ACHTUNG") for z in zeilen)
if lauf.returncode == 0 and gruen and not achtung:
return None
befundzeilen = sum(1 for z in zeilen if z.startswith("["))
rumpf = ausgabe.strip()
if fehlertext:
rumpf += "\n--- stderr ---\n" + fehlertext
return (
f"Regelwerk-Check: Exit {lauf.returncode}, {befundzeilen} Befundzeile(n)",
rumpf or "(keine Ausgabe)",
)
def kuerze(text: str) -> str:
zeilen = text.splitlines()
rest = len(zeilen) - MAX_ZEILEN
if rest > 0:
zeilen = zeilen[:MAX_ZEILEN] + [f"[... {rest} weitere Zeilen gekürzt]"]
ergebnis = "\n".join(zeilen)
if len(ergebnis) > MAX_ZEICHEN:
ergebnis = ergebnis[:MAX_ZEICHEN] + f"\n[... gekürzt auf {MAX_ZEICHEN} Zeichen]"
return ergebnis
def melde(system: str, kontext: str) -> None:
ausgabe = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": kontext,
},
"systemMessage": " ".join(system.split()),
}
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
sys.stdout.flush()
def main(roh: bytes | None = None) -> int:
try:
daten = lies_eingabe(lies_stdin() if roh is None else roh)
if not isinstance(daten, dict):
return 0
eingabe = daten.get("tool_input")
if not isinstance(eingabe, dict):
return 0
datei = eingabe.get("file_path")
if not isinstance(datei, str) or not datei.strip():
return 0
wurzel = bestimme_wurzel(daten)
if not im_geltungsbereich(wurzel, datei):
return 0
ergebnis = fuehre_check(wurzel)
if ergebnis is None:
return 0
system, rumpf = ergebnis
melde(system, kuerze(f"Regelwerk-Check nach Änderung an {datei} (Vault: {wurzel}):\n{rumpf}"))
except Exception as fehler:
melde(
f"regelwerk_nach_edit.py: {type(fehler).__name__}",
f"regelwerk_nach_edit.py ist abgebrochen: {type(fehler).__name__}: {fehler}. "
"Der Regelwerk-Check ist damit nicht gelaufen.",
)
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)
```
## Verifikation
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python -m py_compile .vault-tools\session-hooks\regelwerk_nach_edit.py .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_nach_edit.py; "exit=$LASTEXITCODE"
python .vault-tools\session-hooks\faelle_nach_edit.py --echt; "exit=$LASTEXITCODE"
```
Erwartet:
1. `py_compile`: keine Ausgabe, `exit=0`.
2. Treiber: Beide Grundlinien-Zeilen `BESTANDEN`, keine Zeile beginnt mit `FEHLER` oder enthält `ABSTURZ`, Schlusszeile `N von N Faellen bestanden.` mit derselben Zahl vorn und hinten, `exit=0`. Der Hänge-Fall allein dauert rund 20 Sekunden.
3. `--echt`: erste Ausgabezeile `exit=0 Laufzeit … s`, danach ein JSON-Objekt, am Ende `exit=0`. Solange `AI/Regelwerk/pruefwerkzeuge.md` auf `status: pending-review` steht, enthält `systemMessage` den Text `Exit 1, 1 Befundzeile(n)` und `additionalContext` die Zeile `[klein] AI/Regelwerk/pruefwerkzeuge.md — status: pending-review — Freigabe steht aus`. Weicht das ab, die Ausgabe wörtlich melden, nicht bewerten.
## Bericht an den Koordinator
- Ausgabe von Schritt 2 wörtlich.
- Vollständige Ausgabe des Treibers und des Laufs mit `--echt`.
- Jede Abweichung vom Plantext der beiden 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()`** nimmt keinen Parameter mehr und liest weder `CLAUDE_PROJECT_DIR` noch `cwd`. Sie gibt ausschließlich `SKRIPT.parents[2]` zurück. Grund: `cwd` folgt einem `cd` im Bash-Werkzeug und zeigt nach einem Wechsel in einen Projektordner nicht mehr auf den Vault (Prüferbefund Runde 2, Koordinatorentscheidung — der Hook ist ohnehin nur für den einen Vault eingetragen, in dem er liegt).
- Der Kopfkommentar-Satz „PostToolUse ignoriert Exit 2" ist korrigiert: Exit 2 zeigt stderr laut Hilfetext von `claude.exe 2.1.263` sofort dem Modell. Die Umsetzung bleibt bei Exit 0 plus JSON.
- **`faelle_nach_edit.py`** kopiert den Hook seither für jeden Fall in den Wegwerf-Vault (das Feld `kopie` auf `Fall` entfällt, weil es jetzt immer geschieht). Die Fälle `mit_projekt_dir`/`cd_in_anderes_projekt` sind entfallen, dafür neu: `cwd` zeigt auf einen anderen Ordner (Ergebnis bleibt der Skript-Vault), ein zweiter Umgebungsfall mit echtem Nicht-ASCII-Pfad ohne `PYTHONIOENCODING`. `ip_hook_wirft()` lädt jetzt ebenfalls eine Kopie des Hooks aus dem Wegwerf-Vault, sonst fand `main()` keinen Geltungsbereich für den Testpfad und der Fall blieb unbemerkt grün mit leerem `stdout`.
- Details, Herkunft und Mutationsbelege stehen in `ENTWURF.md`, nicht hier verdoppelt.
@@ -0,0 +1,301 @@
---
type: plan-task
title: "Task 03 — Linkcheck: Schließzaun nach CommonMark"
status: draft
summary: "strip_code schließt einen Fenced-Codeblock nur noch mit gleichem Zeichen, mindestens gleicher Länge und ohne Info-String; sechs neue Regressionsfälle, Herkunft im Entwurf."
tags: [plan-task, vault-tools]
created: 2026-09-13
updated: 2026-09-13
modell: haiku
modell_begruendung: "Vier Ersetzungen mit wörtlichem Alt- und Neutext in drei namentlich genannten Dateien, erwartete Ausgabe vor und nach der Umsetzung steht fest; kein Ermessen."
---
# Task 03 — Linkcheck: Schließzaun nach CommonMark
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\linkcheck\faelle.py` (ändern)
- `D:\Claude\.vault-tools\linkcheck\linkcheck.py` (ändern)
- `D:\Claude\.vault-tools\linkcheck\ENTWURF-codeblock-erkennung.md` (ändern)
## Verbote
- Keine andere Datei anfassen, auch nicht `regelwerk_check.py`, obwohl dessen Kommentar die Zahl der Fälle nennt.
- Keine Änderung an der Öffnerregel `_FENCE_RE`, an `_INLINE_CODE_RE`, an `wikilinks()` oder an `lauf()`.
- Jede Ersetzung positionsgenau über den angegebenen Alttext, kein globales Suchen und Ersetzen.
## Schritt 1 — Test zuerst: sechs Fälle in `faelle.py`
Ersetze in `D:\Claude\.vault-tools\linkcheck\faelle.py` diesen Alttext:
```python
(16, "escaptes Pipe am Zeilenende ohne Alias",
r"Siehe [[Ziel\|]] dort.", ["Ziel"]),
]
```
durch diesen Neutext:
````python
(16, "escaptes Pipe am Zeilenende ohne Alias",
r"Siehe [[Ziel\|]] dort.", ["Ziel"]),
(17, "Dreier-Zaun mit Info-String in Vierer-Zaun",
"````markdown\n```bash\ngrep '[[:space:]]' datei\n```\n````\n[[Danach]]",
["Danach"]),
(18, "Dreier-Schliesszaun schliesst Vierer nicht",
"````\n```\n[[drin]]\n````\n[[Danach]]", ["Danach"]),
(19, "Vierer-Zaun schliesst Vierer-Zaun",
"````\n[[drin]]\n````\n[[Danach]]", ["Danach"]),
(20, "```bash in einem ```-Block schliesst nicht",
"```\n```bash\n[[drin]]\n```\n[[Danach]]", ["Danach"]),
(21, "Schliesszaun mit Leerraum dahinter",
"```\n[[drin]]\n``` \t\n[[Danach]]", ["Danach"]),
(22, "Schliesszaun mit CR am Zeilenende",
"```\r\n[[drin]]\r\n```\r\n[[Danach]]\r\n", ["Danach"]),
]
````
Fall 19, 21 und 22 laufen schon vor der Umsetzung grün. Sie sichern ab, dass die neue Regel nichts kaputtmacht, was bisher ging (19), und dass der neue Ausdruck Leerraum und CR hinter dem Schließzaun zulässt (21, 22).
## Schritt 2 — Fehlschlag beobachten
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python .vault-tools\linkcheck\faelle.py; "exit=$LASTEXITCODE"
```
Erwartet vor der Umsetzung:
- Die Zeilen für `FALL 17`, `FALL 18` und `FALL 20` tragen `FEHLER`.
- `FALL 17` meldet `erwartet ['Danach'], bekommen [':space:', 'Danach']`.
- `FALL 18` meldet `erwartet ['Danach'], bekommen ['drin']`.
- `FALL 20` meldet `erwartet ['Danach'], bekommen ['drin']`.
- Alle anderen Fälle `ok`.
- Schlusszeile `20 von 23 Faellen bestanden.`, danach `exit=1`.
Sieht die Ausgabe anders aus, hier anhalten und melden. Nicht mit Schritt 3 weitermachen.
## Schritt 3 — Umsetzung in `linkcheck.py`
### 3a Kopfkommentar, Herkunft
Alttext:
```text
- Ungeschlossener Zaun gilt bis Dateiende: PROJEKTVORGABE - die
konservative Wahl, lieber einen echten Link uebersehen als Rauschen
erzeugen.
```
Neutext:
```text
- Ungeschlossener Zaun gilt bis Dateiende: PROJEKTVORGABE - die
konservative Wahl, lieber einen echten Link uebersehen als Rauschen
erzeugen.
- Schliesszaun: QUELLE - dasselbe Zeichen wie der Oeffner, mindestens so
lang, danach nur Leerzeichen oder Tab. Abgeschrieben aus markdown-it-py
4.2.0, rules_block/fence.py Zeilen 42-43, 106 und 119-123; Referenz ist
CommonMark 0.31.2, Abschnitt 4.5 Fenced code blocks. Fundstellen und der
offene Abgleich mit dem Spezifikationstext: ENTWURF-codeblock-erkennung.md.
```
### 3b Kopfkommentar, benannte Lücke
Alttext:
```text
- Eingerueckte Codebloecke (vier Leerzeichen) werden NICHT erkannt. Sie
wuerden eingerueckte Listenpunkte mit echten Links verschlucken.
```
Neutext:
```text
- Eingerueckte Codebloecke (vier Leerzeichen) werden NICHT erkannt. Sie
wuerden eingerueckte Listenpunkte mit echten Links verschlucken.
- Ein Oeffner mit Backtick im Info-String gilt hier als Zaun. CommonMark
liest so eine Zeile als Inline-Code. Nicht behandelt.
```
### 3c Ausdruck für den Schließzaun
Alttext:
```python
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
_INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)*?\1")
```
Neutext:
```python
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
# Schliesszaun: nur Zaunzeichen, danach hoechstens Leerzeichen, Tab und ein
# CR. Eine Zeile mit Info-String im Block ist deshalb Inhalt, kein Ende.
_SCHLUSS_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})[ \t]*\r?$")
_INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)*?\1")
```
### 3d Zustand in `strip_code()`
Alttext:
```python
if zaun is None:
if m:
zaun = m.group(1)[0] * 3
aus.append(" " * len(zeile))
continue
aus.append(_INLINE_CODE_RE.sub(lambda t: " " * len(t.group(0)), zeile))
else:
if m and m.group(1)[0] * 3 == zaun:
zaun = None
aus.append(" " * len(zeile))
```
Neutext:
```python
if zaun is None:
if m:
zaun = m.group(1)
aus.append(" " * len(zeile))
continue
aus.append(_INLINE_CODE_RE.sub(lambda t: " " * len(t.group(0)), zeile))
else:
s = _SCHLUSS_RE.match(zeile)
# Gleiches Zeichen und mindestens so lang wie der Oeffner: ein
# Dreier-Zaun schliesst keinen Vierer-Zaun.
if s and s.group(1)[0] == zaun[0] and len(s.group(1)) >= len(zaun):
zaun = None
aus.append(" " * len(zeile))
```
## Schritt 4 — Entwurf ergänzen
Alle Ersetzungen in `D:\Claude\.vault-tools\linkcheck\ENTWURF-codeblock-erkennung.md`.
### 4a Entscheidungsliste, neue Zeile 7
Alttext (eine Zeile):
```text
| 6 | Auch Überschriften betroffen? | **Ja, gleicher Fehler** — `extract_headings` liest `# Kommentar` aus Bash-Blöcken als Überschrift. **In dieser Runde nicht geändert**, weil es den Index-Inhalt verschiebt und einen eigenen Nachweis braucht | **Befund, benannt statt stillschweigend miterledigt** |
```
Neutext (zwei Zeilen):
```text
| 6 | Auch Überschriften betroffen? | **Ja, gleicher Fehler** — `extract_headings` liest `# Kommentar` aus Bash-Blöcken als Überschrift. **In dieser Runde nicht geändert**, weil es den Index-Inhalt verschiebt und einen eigenen Nachweis braucht | **Befund, benannt statt stillschweigend miterledigt** |
| 7 | Welche Zeile schließt einen Zaun? | Nur eine Zeile aus demselben Zaunzeichen, mindestens so lang wie der Öffner, bis zu drei Zeichen eingerückt, danach nur Leerzeichen oder Tab. Eine Zeile mit Info-String (drei Backticks und `bash`) im Block ist Inhalt | **Quelle** — Fundstellen im Abschnitt „Nachbesserung 2026-09-13" |
```
### 4b Angriffsliste, sechs neue Zeilen
Alttext (eine Zeile):
```text
| 16 | escaptes Pipe ohne Alias | Ziel ohne Backslash |
```
Neutext (sieben Zeilen):
```text
| 16 | escaptes Pipe ohne Alias | Ziel ohne Backslash |
| 17 | Dreier-Zaun mit Info-String innerhalb eines Vierer-Zauns, darin `[[:space:]]` | kein Link aus dem Block, Link danach bleibt |
| 18 | Dreier-Schließzaun in einem Vierer-Zaun | schließt nicht |
| 19 | Vierer-Zaun schließt Vierer-Zaun | schließt, Link danach bleibt |
| 20 | Zeile mit drei Backticks und `bash` in einem Dreier-Block | schließt nicht |
| 21 | Schließzaun mit Leerzeichen und Tab dahinter | schließt — neuer Ausdruck, eigener Fall |
| 22 | Schließzaun mit CR am Zeilenende | schließt — neuer Ausdruck, eigener Fall |
```
### 4c Neuer Abschnitt vor „Absturzfestigkeit"
Alttext (eine Zeile):
```text
## Absturzfestigkeit
```
Neutext:
````text
## Nachbesserung 2026-09-13: Schließzaun nach CommonMark
**Anlass.** Der Linkcheck meldete am 2026-09-13 einen toten Wikilink `[[:space:]]` in `Code Projekte/active - Logging/docs/plans/2026-09-08-a-betriebsmodelle/task-09-gate.md`. Die Datei öffnet in Zeile 356 einen Block mit vier Backticks und zeigt darin ab Zeile 406 ein eingerücktes Beispiel mit drei Backticks und `bash`. Die alte Regel schloss den äußeren Block schon an Zeile 406, weil jede Zaunzeile aus demselben Zeichen genügte. Zeile 407 mit der POSIX-Zeichenklasse lag damit im Fließtext.
**Regel.** Ein Block schließt nur mit einer Zeile, die
- aus demselben Zaunzeichen besteht wie der Öffner (Backtick oder Tilde),
- mindestens so viele Zeichen hat wie der Öffner,
- nach den Zaunzeichen nur Leerzeichen oder Tab trägt, also keinen Info-String.
Die Einrückung von bis zu drei Zeichen übernimmt der Schließzaun vom Öffner (`\s{0,3}`). Ein CR am Zeilenende ist zugelassen, damit eine Eingabe mit CRLF nicht an jedem Schließzaun vorbeiläuft.
**Herkunft.**
| Aussage | Fundstelle |
|---|---|
| dasselbe Zeichen | markdown-it-py 4.2.0, `markdown_it/rules_block/fence.py` Zeile 106 |
| mindestens so lang | ebenda Zeilen 42–43, `closing_len >= opening_len` |
| danach nur Leerraum | ebenda Zeilen 119–123; Leerraum heißt Leerzeichen oder Tab laut `markdown_it/common/utils.py` Zeilen 167–169 |
Gelesen am 2026-09-13 unter `Code Projekte/active - Agentic Cockpit/.venv/Lib/site-packages/markdown_it/`. Die Bibliothek nennt ihre Regel selbst CommonMark-konform (`fence.py` Zeile 145). Referenz der Regel ist die CommonMark-Spezifikation 0.31.2, Abschnitt 4.5 Fenced code blocks, https://spec.commonmark.org/0.31.2/#fenced-code-blocks. **Den Wortlaut der Spezifikation hat beim Planen niemand gezogen, der Planlauf hatte keinen Netzzugriff. Belegt ist die Regel an der Implementierung. Der Abgleich mit dem Spezifikationstext ist offen.**
**Benannte Lücke.** Ein Öffner mit Backtick im Info-String ist nach markdown-it-py kein Zaun (`fence.py` Zeilen 79–80). Hier gilt er weiter als Zaun. Diese Runde ändert nur den Schließzaun. Die Öffnerregel verschiebt, welche Zeilen überhaupt als Code gelten, und braucht einen eigenen Lauf über den Vault.
**Was schiefgehen kann.** Hat bisher eine Zeile mit Info-String einen Block versehentlich geschlossen, läuft der Block jetzt weiter, notfalls bis zum Dateiende. Wikilinks dahinter prüft der Linkcheck dann nicht mehr. Deshalb gehört zur Abnahme ein Vergleichslauf alt gegen neu über den ganzen Vault.
## Absturzfestigkeit
````
### 4d Abbruchbedingung ohne gepflegte Zahl
Alttext:
```text
**und** die vierzehn Angriffsfälle grün sind.
```
Neutext:
```text
**und** alle Angriffsfälle aus `faelle.py` grün sind.
```
## Verifikation
```powershell
Set-Location D:\Claude
$env:PYTHONIOENCODING = "utf-8"
python -m py_compile .vault-tools\linkcheck\linkcheck.py .vault-tools\linkcheck\faelle.py; "exit=$LASTEXITCODE"
python .vault-tools\linkcheck\faelle.py; "exit=$LASTEXITCODE"
python .vault-tools\linkcheck\linkcheck.py --vault . --alle; "exit=$LASTEXITCODE"
python .vault-tools\regelwerk-check\regelwerk_check.py --vault .; "exit=$LASTEXITCODE"
```
Erwartet:
1. `py_compile`: keine Ausgabe, `exit=0`.
2. `faelle.py`: keine Zeile mit `FEHLER` oder `ABSTURZ`, Schlusszeile `N von N Faellen bestanden.` mit derselben Zahl vorn und hinten, `exit=0`.
3. `linkcheck.py`: In der Liste der toten Wikilinks steht keine Zeile mehr mit `task-09-gate.md -> [[:space:]]`. Die übrigen Befunde vor und nach der Änderung melden (Liste wörtlich in den Bericht übernehmen). `exit` ist 1, solange andere tote Links bleiben, sonst 0.
4. `regelwerk_check.py`: dieselben Befunde wie vorher. Beim Planen war das genau einer, `[klein] AI/Regelwerk/pruefwerkzeuge.md — status: pending-review — Freigabe steht aus`, und `exit=1`. Eine zusätzliche Befundzeile ist ein Fehler dieses Tasks und wird gemeldet.
## Bericht an den Koordinator
- Ausgabe von Schritt 2 wörtlich (die drei FEHLER-Zeilen und die Schlusszeile).
- Ausgabe der vier Verifikationsbefehle wörtlich.
- Jede Stelle, an der ein Alttext nicht genau einmal gefunden wurde. In diesem Fall die Ersetzung nicht vornehmen, sondern melden.
## Nachtrag Runde 2
Nachbesserung nach der ersten Prüfrunde (Befunde 5 und 6, Details in `ENTWURF-codeblock-erkennung.md`, Abschnitt „Nachbesserung Runde 2"). Weicht vom Plantext oben ab:
- **`_SCHLUSS_RE`** in `linkcheck.py` verlangt jetzt `^[ ]{0,3}` statt `^\s{0,3}` vor dem Zaunzeichen — nur Leerzeichen, kein Tab. Ein Tab vor dem Schließzaun zählt bei markdown-it-py als vier Spalten Einrückung und macht die Zeile zu eingerücktem Code, kein Zaun mehr (`state_block.py` Zeilen 257–261). Die Bedingung „gleiches Zaunzeichen" (`s.group(1)[0] == zaun[0]`) war schon vorher im Code, hatte aber keinen Fall.
- **`faelle.py`** bekam drei weitere Fälle (23–25): anderes Zaunzeichen schließt nicht, Schließzaun mit vier Leerzeichen davor schließt nicht, Schließzaun mit Tab davor schließt nicht.
- Details, Herkunft und Mutationsbelege stehen in `ENTWURF-codeblock-erkennung.md`, nicht hier verdoppelt.
@@ -0,0 +1,552 @@
"""Fährt die Angriffsliste gegen regelwerk_nach_edit.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner
Statt des echten regelwerk_check.py liegt im Wegwerf-Vault eine Attrappe. Sie
schreibt ihren Aufruf nach aufruf.json und zeigt dann ein festgelegtes
Verhalten: grün, Befund, Abbruch, ACHTUNG, stumm, Absturz, hängt, sehr viel
Ausgabe. Ihre Zeilen haben die Form der echten Ausgabe (regelwerk_check.py
Zeilen 114, 368, 370 und 372). So lässt sich je Fall unterscheiden, ob der
Hook still blieb, ohne die Prüfung zu starten, oder still blieb, nachdem sie
grün war.
Grundlinie zuerst: Ein grüner Check muss still bleiben und nachweislich
gelaufen sein, ein Befund muss gemeldet werden. Sonst misst der Rest nichts.
Seit der Nachbesserung Runde 2 bestimmt regelwerk_nach_edit.py seine
Vault-Wurzel ausschließlich aus dem eigenen Skriptpfad, das Feld cwd der
Hook-Eingabe zählt nicht mehr (Prüferbefund Runde 2, Koordinatorentscheidung).
lauf_fall() kopiert den Hook deshalb für jeden Fall zuerst in den Wegwerf-
Vault, statt das Original mit einem cwd-Feld dorthin zu verweisen.
Aufruf:
python .vault-tools/session-hooks/faelle_nach_edit.py
python .vault-tools/session-hooks/faelle_nach_edit.py --echt
"""
from __future__ import annotations
import contextlib
import importlib.util
import io
import json
import os
import re
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 / "regelwerk_nach_edit.py"
MAX_KONTEXT = 4100
FAKE_KOPF = '''import json
import sys
import time
from pathlib import Path
Path(__file__).resolve().parents[2].joinpath("aufruf.json").write_text(
json.dumps({"argv": sys.argv[1:], "exe": sys.executable}), encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
'''
FAKES = {
"gruen": FAKE_KOPF + '''print()
print("Keine der 6 gelaufenen Prüfungen hat angeschlagen.")
sys.exit(0)
''',
"befund": FAKE_KOPF + '''print("[klein] AI/Regelwerk/regel.md — status: pending-review — Freigabe steht aus")
print()
print("1 Befund(e) aus 6 von 6 Prüfungen.")
sys.exit(1)
''',
"abbruch": FAKE_KOPF + '''print("[blockierend] regelwerk_check.py — Abbruch: KeyError: 'x'")
print()
print("1 Befund(e) aus 2 von 6 Prüfungen.")
sys.exit(2)
''',
"achtung": FAKE_KOPF + '''print()
print("Keine der 5 gelaufenen Prüfungen hat angeschlagen.")
print("ACHTUNG: nur 5 von 6 Prüfungen sind gelaufen — der Rest hatte keine Voraussetzung.")
sys.exit(0)
''',
"stumm": FAKE_KOPF + '''sys.exit(0)
''',
"absturz": FAKE_KOPF + '''raise RuntimeError("Absturz im Check")
''',
"haengt": FAKE_KOPF + '''time.sleep(120)
''',
"viel": FAKE_KOPF + '''for i in range(200):
print("[wichtig] AI/Regelwerk/regel.md — toter Wikilink: [[ziel%d]]" % i)
print()
print("200 Befund(e) aus 6 von 6 Prüfungen.")
sys.exit(1)
''',
}
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_vault(v: Path, fake: str | None) -> None:
schreibe(v, "CLAUDE.md", "# Stamm\n")
schreibe(v, "AI/Regelwerk/regel.md", "---\nstatus: reference\n---\n")
schreibe(v, "AI/Regelwerk/unter/tief.md", "x\n")
schreibe(v, ".claude/agents/planer.md", "---\nname: planer\n---\n")
schreibe(v, ".claude/skills/demo/SKILL.md", "---\nname: demo\n---\n")
schreibe(v, ".claude/workflows/demo.js", "export const meta = {}\n")
schreibe(v, "Inbox/notiz.md", "x\n")
schreibe(v, "Code/CLAUDE.md", "x\n")
if fake is not None:
schreibe(v, ".vault-tools/regelwerk-check/regelwerk_check.py", FAKES[fake])
def vorwaerts(pfad: Path) -> str:
return str(pfad).replace("\\", "/")
def laufwerk_getauscht(pfad: Path) -> str:
s = str(pfad)
if re.match(r"^[A-Za-z]:", s):
return s[0].swapcase() + s[1:]
return s
def j(daten: object) -> bytes:
return json.dumps(daten).encode("utf-8")
def edit(v: Path, datei: object, **extra: object) -> bytes:
daten: dict[str, object] = {
"session_id": "faelle",
"transcript_path": "",
"cwd": str(v),
"permission_mode": "default",
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {"file_path": datei},
"tool_response": {},
}
daten.update(extra)
return j(daten)
def ohne_cwd(v: Path, datei: object) -> bytes:
daten = json.loads(edit(v, datei))
del daten["cwd"]
return j(daten)
@dataclass
class Fall:
name: str
fake: str | None
stdin: Callable[[Path], bytes]
erwartung: str
enthaelt: list[str] = field(default_factory=list)
system_enthaelt: str = ""
max_dauer: float = 10.0
# None: Subprozess erbt die Umgebung dieses Treibers unveraendert (bisheriges
# Verhalten). Gesetzt: ersetzt die Umgebung vollstaendig (Pruefbefunde Runde 2).
umgebung: Callable[[Path], dict[str, str]] | None = None
BEFUND = ["[klein] AI/Regelwerk/regel.md"]
BEFUND_SYSTEM = "Exit 1, 1 Befundzeile(n)"
def still(name: str, stdin: Callable[[Path], bytes]) -> Fall:
return Fall(name, "befund", stdin, "still-ohne-lauf")
def meldet(name: str, stdin: Callable[[Path], bytes]) -> Fall:
return Fall(name, "befund", stdin, "meldung", BEFUND, BEFUND_SYSTEM)
def cwd_zeigt_anderswo(v: Path) -> bytes:
"""cwd zeigt auf einen anderen, echten (leeren) Ordner. Seit der
Nachbesserung Runde 2 liest der Hook cwd gar nicht mehr - die Wurzel ist
ausschliesslich der Ordner, in dem der Hook selbst liegt (hier: v, weil
lauf_fall() ihn dorthin kopiert). Das Ergebnis muss trotzdem melden,
nicht still bleiben (Pruefbefund Runde 2)."""
anderswo = v.parent / "anderswo"
anderswo.mkdir(parents=True, exist_ok=True)
return edit(v, str(v / "AI" / "Regelwerk" / "regel.md"), cwd=str(anderswo))
def ohne_pythonioencoding(_: Path) -> dict[str, str]:
"""Claude Code setzt PYTHONIOENCODING beim Hook-Aufruf nicht; nur die globale
Planvorgabe setzt sie vor dem Aufruf dieses Treibers selbst (Pruefbefund Runde 2)."""
umgebung = dict(os.environ)
umgebung.pop("PYTHONIOENCODING", None)
return umgebung
GRUNDLINIE = [
Fall("GRUNDLINIE | gruener Check bleibt still und lief", "gruen",
lambda v: edit(v, str(v / "CLAUDE.md")), "still-nach-lauf"),
Fall("GRUNDLINIE | Befund wird gemeldet", "befund",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung", BEFUND, BEFUND_SYSTEM),
]
FAELLE = [
still("leer | stdin leer", lambda v: b""),
still("leer | stdin nur Leerraum", lambda v: b" \r\n"),
still("falscher Typ| stdin kein JSON", lambda v: b"kein json"),
still("kodierung | stdin ungueltiges UTF-8", lambda v: b"\xff\xfe\x00"),
still("falscher Typ| stdin ist eine Liste",
lambda v: j([json.loads(edit(v, str(v / "CLAUDE.md")))])),
still("falscher Typ| stdin ist eine Zahl", lambda v: b"5"),
still("fehlt | tool_input fehlt", lambda v: j({"cwd": str(v), "tool_name": "Edit"})),
still("null | tool_input ist null", lambda v: edit(v, "x", tool_input=None)),
still("falscher Typ| tool_input ist eine Liste",
lambda v: edit(v, "x", tool_input=[str(v / "CLAUDE.md")])),
still("fehlt | file_path fehlt", lambda v: edit(v, "x", tool_input={"content": "a"})),
still("null | file_path ist null", lambda v: edit(v, None)),
still("falscher Typ| file_path ist eine Zahl", lambda v: edit(v, 5)),
still("falscher Typ| file_path ist eine Liste", lambda v: edit(v, [str(v / "CLAUDE.md")])),
still("leer | file_path ist leer", lambda v: edit(v, "")),
still("wertebereich| file_path mit Nullzeichen",
lambda v: edit(v, str(v / "CLAUDE.md") + "\u0000")),
still("zusaetzlich | Pfad ausserhalb des Vaults",
lambda v: edit(v, str(v.parent / "anderswo" / "CLAUDE.md"))),
still("verweis | Nachbarordner mit gleichem Namensanfang",
lambda v: edit(v, str(v) + "-kopie\\CLAUDE.md")),
still("verweis | Datei neben dem Vault mit gleichem Namensanfang",
lambda v: edit(v, str(v) + "-CLAUDE.md")),
still("zusaetzlich | im Vault, ausserhalb des Geltungsbereichs",
lambda v: edit(v, str(v / "Inbox" / "notiz.md"))),
still("zusaetzlich | CLAUDE.md in einem Unterordner",
lambda v: edit(v, str(v / "Code" / "CLAUDE.md"))),
still("verweis | Ordnername mit gleichem Anfang",
lambda v: edit(v, str(v / "AI" / "RegelwerkAlt" / "x.md"))),
still("zusaetzlich | settings.json unter .claude",
lambda v: edit(v, str(v / ".claude" / "settings.json"))),
still("verweis | Ordnername mit gleichem Anfang wie workflows",
lambda v: edit(v, str(v / ".claude" / "workflowsAlt" / "x.js"))),
still("wertebereich| der Regelwerk-Ordner selbst",
lambda v: edit(v, str(v / "AI" / "Regelwerk"))),
still("verweis | .. fuehrt aus dem Regelwerk hinaus",
lambda v: edit(v, vorwaerts(v) + "/AI/Regelwerk/../../Inbox/notiz.md")),
still("verweis | .. fuehrt aus dem Vault hinaus",
lambda v: edit(v, vorwaerts(v) + "/../fremd/AI/Regelwerk/x.md")),
Fall("gruen | CLAUDE.md, Check ohne Befund", "gruen",
lambda v: edit(v, str(v / "CLAUDE.md")), "still-nach-lauf"),
Fall("gruen | Skill, Check ohne Befund", "gruen",
lambda v: edit(v, str(v / ".claude" / "skills" / "demo" / "SKILL.md")), "still-nach-lauf"),
meldet("pfad | Schraegstriche vorwaerts",
lambda v: edit(v, vorwaerts(v) + "/CLAUDE.md")),
meldet("pfad | Backslashes",
lambda v: edit(v, str(v) + "\\AI\\Regelwerk\\regel.md")),
meldet("pfad | Laufwerksbuchstabe in anderer Schreibung",
lambda v: edit(v, laufwerk_getauscht(v / "AI" / "Regelwerk" / "regel.md"))),
meldet("pfad | ganzer Pfad in Grossbuchstaben",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "regel.md").upper())),
meldet("pfad | Unterordner im Regelwerk",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "unter" / "tief.md"))),
meldet("pfad | Agentendatei",
lambda v: edit(v, str(v / ".claude" / "agents" / "planer.md"))),
meldet("pfad | Skilldatei",
lambda v: edit(v, str(v / ".claude" / "skills" / "demo" / "SKILL.md"))),
meldet("pfad | Workflowdatei",
lambda v: edit(v, str(v / ".claude" / "workflows" / "demo.js"))),
meldet("verweis | neue Datei, die es noch nicht gibt",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "neu.md"))),
meldet("pfad | relativer Pfad", lambda v: edit(v, "AI/Regelwerk/regel.md")),
meldet("verweis | .. fuehrt ins Regelwerk hinein",
lambda v: edit(v, vorwaerts(v) + "/Inbox/../AI/Regelwerk/regel.md")),
meldet("zusaetzlich | Write statt Edit",
lambda v: edit(v, str(v / "CLAUDE.md"), tool_name="Write")),
meldet("zusaetzlich | MultiEdit",
lambda v: edit(v, str(v / "CLAUDE.md"), tool_name="MultiEdit")),
meldet("kodierung | ungueltiges UTF-8 im Pfad",
lambda v: edit(v, "PLATZHALTER").replace(
b"PLATZHALTER", vorwaerts(v).encode("utf-8") + b"/AI/Regelwerk/regel\xff.md")),
Fall("umgebung | ungueltiges UTF-8 im Pfad bleibt gueltiges JSON ohne PYTHONIOENCODING",
"befund",
lambda v: edit(v, "PLATZHALTER").replace(
b"PLATZHALTER", vorwaerts(v).encode("utf-8") + b"/AI/Regelwerk/regel\xff.md"),
"meldung", enthaelt=BEFUND, system_enthaelt=BEFUND_SYSTEM, umgebung=ohne_pythonioencoding),
Fall("umgebung | Nicht-ASCII-Pfad ohne PYTHONIOENCODING bleibt gueltiges UTF-8-JSON",
"befund",
lambda v: edit(v, str(v / "AI" / "Regelwerk" / "Prüfüng Übersicht 🚀.md")),
"meldung", enthaelt=BEFUND, system_enthaelt=BEFUND_SYSTEM, umgebung=ohne_pythonioencoding),
meldet("pfad | cwd mit abschliessendem Backslash",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=str(v) + "\\")),
meldet("fehlt | cwd fehlt, wird nicht mehr gelesen",
lambda v: ohne_cwd(v, str(v / "CLAUDE.md"))),
meldet("null | cwd ist null, wird nicht mehr gelesen",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=None)),
meldet("falscher Typ| cwd ist eine Zahl, wird nicht mehr gelesen",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=5)),
meldet("verweis | cwd existiert nicht, wird nicht mehr gelesen",
lambda v: edit(v, str(v / "CLAUDE.md"), cwd=str(v / "gibtsnicht"))),
meldet("verweis | cwd zeigt auf einen anderen Ordner, Ergebnis bleibt der Skript-Vault",
cwd_zeigt_anderswo),
Fall("check | Exit 2, Abbruch im Check", "abbruch",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["Abbruch: KeyError"], "Exit 2, 1 Befundzeile(n)"),
Fall("check | Exit 0 mit ACHTUNG", "achtung",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["ACHTUNG: nur 5 von 6"], "Exit 0, 0 Befundzeile(n)"),
Fall("check | Exit 0 ohne Schlusszeile", "stumm",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["(keine Ausgabe)"], "Exit 0, 0 Befundzeile(n)"),
Fall("check | Check stuerzt ab", "absturz",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["--- stderr ---", "RuntimeError: Absturz im Check"], "Exit 1, 0 Befundzeile(n)"),
Fall("umgebung | regelwerk_check.py fehlt", None,
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["regelwerk_check.py fehlt"], "Regelwerk-Check fehlt"),
Fall("umgebung | regelwerk_check.py haengt", "haengt",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["länger als 20 s"], "nach 20 s abgebrochen", max_dauer=28.0),
Fall("wertebereich| sehr lange Ausgabe wird gekuerzt", "viel",
lambda v: edit(v, str(v / "CLAUDE.md")), "meldung",
["weitere Zeilen gekürzt"], "Exit 1, 200 Befundzeile(n)"),
]
def streng_utf8(roh: bytes) -> str:
# stdout muss gueltiges UTF-8 sein. Ein ungueltiges Byte darf nicht still
# ersetzt werden, sonst besteht der Umgebungsfall auch dann, wenn der Hook
# cp1252 schreibt (Pruefrunde 2, Befund zur Dekodierung).
try:
return roh.decode("utf-8")
except UnicodeDecodeError as exc:
return "UNGUELTIGES-UTF-8: %s" % exc
def fahre(
skript: Path,
eingabe: bytes,
arbeitsordner: Path,
umgebung: dict[str, str] | None = None,
) -> 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=90,
env=umgebung,
)
dauer = time.perf_counter() - start
return (
lauf.returncode,
streng_utf8(lauf.stdout),
lauf.stderr.decode("utf-8", errors="replace"),
dauer,
)
def pruefe_aufruf(aufruf: Path, v: Path) -> list[str]:
if not aufruf.exists():
return ["Check wurde nicht gestartet"]
daten = json.loads(aufruf.read_text(encoding="utf-8"))
probleme: list[str] = []
if daten.get("argv") != ["--vault", str(v)]:
probleme.append(f"Aufruf-Argumente {daten.get('argv')!r} statt {['--vault', str(v)]!r}")
if os.path.normcase(str(daten.get("exe"))) != os.path.normcase(sys.executable):
probleme.append(f"Interpreter {daten.get('exe')!r} statt {sys.executable!r}")
return probleme
def pruefe_meldung(fall: Fall, out: str) -> list[str]:
roh = out.strip()
if not (roh.startswith("{") and roh.endswith("}")):
return [f"stdout ist kein JSON-Objekt: {out[:300]!r}"]
try:
daten = json.loads(roh)
except ValueError as exc:
return [f"stdout nicht parsebar: {exc}"]
if not isinstance(daten, dict) or set(daten) != {"hookSpecificOutput", "systemMessage"}:
art = sorted(daten) if isinstance(daten, dict) else type(daten).__name__
return [f"falsche Schluessel: {art}"]
innen = daten["hookSpecificOutput"]
system = daten["systemMessage"]
if not isinstance(innen, dict) or innen.get("hookEventName") != "PostToolUse":
return ["hookSpecificOutput.hookEventName ist nicht PostToolUse"]
kontext = innen.get("additionalContext")
if not isinstance(kontext, str) or not kontext:
return ["additionalContext fehlt oder ist leer"]
probleme: list[str] = []
if not isinstance(system, str) or not system or "\n" in system:
probleme.append(f"systemMessage ist keine einzelne Zeile: {system!r}")
elif fall.system_enthaelt not in system:
probleme.append(f"systemMessage ohne {fall.system_enthaelt!r}: {system!r}")
probleme += [f"Kontext ohne {t!r}" for t in fall.enthaelt if t not in kontext]
if len(kontext) > MAX_KONTEXT:
probleme.append(f"Kontext hat {len(kontext)} Zeichen, Grenze {MAX_KONTEXT}")
if probleme:
probleme.append("Kontext war:\n" + kontext)
return probleme
def pruefe(fall: Fall, v: Path, code: int, out: str, dauer: float) -> list[str]:
probleme: list[str] = []
if code != 0:
probleme.append(f"Exit {code} statt 0")
if dauer > fall.max_dauer:
probleme.append(f"Laufzeit {dauer:.1f} s ueber {fall.max_dauer:.0f} s")
aufruf = v / "aufruf.json"
if fall.erwartung == "still-ohne-lauf":
if out != "":
probleme.append(f"stdout nicht leer: {out[:300]!r}")
if aufruf.exists():
probleme.append("Check wurde gestartet, obwohl er nicht haette laufen duerfen")
elif fall.erwartung == "still-nach-lauf":
if out != "":
probleme.append(f"stdout nicht leer: {out[:300]!r}")
probleme += pruefe_aufruf(aufruf, v)
else:
probleme += pruefe_meldung(fall, out)
if fall.fake is not None:
probleme += pruefe_aufruf(aufruf, v)
return probleme
def lauf_fall(fall: Fall, arbeit: Path) -> tuple[bool, str]:
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
baue_vault(v, fall.fake)
# Seit der Nachbesserung Runde 2 bestimmt regelwerk_nach_edit.py seine
# Vault-Wurzel ausschliesslich aus dem eigenen Skriptpfad, das Feld cwd
# der Hook-Eingabe zaehlt nicht mehr. Nur eine Kopie im Wegwerf-Vault sorgt
# dafuer, dass diese Wurzel auch tatsaechlich v ist statt des echten
# Vaults, in dem das Original unter HOOK liegt.
ziel = v / ".vault-tools" / "session-hooks"
ziel.mkdir(parents=True, exist_ok=True)
skript = ziel / HOOK.name
shutil.copy2(HOOK, skript)
umgebung = fall.umgebung(v) if fall.umgebung is not None else None
code, out, err, dauer = fahre(skript, fall.stdin(v), arbeit, umgebung)
kopf = f"exit={code} {dauer:.2f}s"
probleme = pruefe(fall, v, code, out, dauer)
if not probleme:
return True, kopf
text = kopf + "\n" + "\n".join(" " + p for p in probleme)
if err.strip():
text += "\n stderr: " + err.strip()[:800]
return False, text
def ip_hook_wirft(arbeit: Path) -> tuple[bool, str]:
"""Fehlerpfad im Hook selbst, von aussen nicht ausloesbar: im Prozess."""
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
baue_vault(v, "befund")
# Kopie in v, wie in lauf_fall(): main() bestimmt die Wurzel seit Runde 2
# ausschliesslich aus dem eigenen Skriptpfad, der file_path unten zeigt
# aber in v hinein - ohne die Kopie faende main() keinen Geltungsbereich.
ziel = v / ".vault-tools" / "session-hooks"
ziel.mkdir(parents=True, exist_ok=True)
kopie = ziel / HOOK.name
shutil.copy2(HOOK, kopie)
spec = importlib.util.spec_from_file_location("hook_wirft", kopie)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
def wirft(wurzel: Path) -> None:
raise RuntimeError("Testfehler")
modul.fuehre_check = wirft
puffer = io.StringIO()
with contextlib.redirect_stdout(puffer):
code = modul.main(edit(v, str(v / "CLAUDE.md")))
erwartet = Fall("", "befund", lambda _: b"", "meldung",
["RuntimeError: Testfehler"], "regelwerk_nach_edit.py: RuntimeError")
probleme = pruefe_meldung(erwartet, puffer.getvalue())
if code != 0:
probleme.append(f"main gab {code} zurueck statt 0")
return not probleme, "\n".join(" " + p for p in probleme)
def echter_lauf() -> int:
vault = HIER.parent.parent
code, out, err, dauer = fahre(HOOK, edit(vault, str(vault / "CLAUDE.md")), HIER)
print(f"exit={code} Laufzeit {dauer:.2f} s")
if out.strip():
try:
print(json.dumps(json.loads(out), ensure_ascii=False, indent=2))
except ValueError:
print("FORMFEHLER, stdout ist kein JSON:")
print(out)
return 1
else:
print("(keine Ausgabe: Regelwerk-Check ohne Befund)")
if err.strip():
print("--- stderr ---")
print(err)
return 0 if code == 0 else 1
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="nach-edit-faelle-")).resolve()
try:
print("=" * 78)
print("GRUNDLINIE")
print("=" * 78)
for fall in GRUNDLINIE:
ok, text = lauf_fall(fall, arbeit)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {fall.name} {text}")
if not ok:
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 2
print()
print("=" * 78)
print("ANGRIFFSLISTE")
print("=" * 78)
bestanden = 0
gesamt = 0
for fall in FAELLE:
gesamt += 1
try:
ok, text = lauf_fall(fall, 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}")
gesamt += 1
try:
ok, text = ip_hook_wirft(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} absturz | Hook selbst wirft (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())
@@ -0,0 +1,695 @@
"""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. Der Hook wird dafür immer zuerst in den Wegwerf-Vault kopiert:
seit der Nachbesserung Runde 2 bestimmt sitzungsstart.py seine Vault-Wurzel
ausschließlich aus dem eigenen Skriptpfad, das Feld cwd der Hook-Eingabe zählt
nicht mehr (Prüferbefund Runde 2, Koordinatorentscheidung). Nur eine Kopie im
Wegwerf-Vault sorgt dafür, dass diese Wurzel auch tatsächlich der Wegwerf-Vault
ist statt des echten Vaults, in dem das Original liegt. Vier 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 os
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 = 8.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: "
WA = "- Waisen außerhalb der Ausnahmen: "
FEHLERZEILE = "Sitzungsstart-Hook:"
WAISEN = HIER.parent / "linkcheck" / "waisen.py"
AUSNAHMEN = HIER.parent / "linkcheck" / "waisen-ausnahmen.txt"
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")
# waisen.py braucht einen Obsidian-Vault; ohne Filter hat die Grundlinie sechs Waisen.
schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": []}')
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)",
WA + "6",
]
def kopiere_hook(v: Path, linkcheck_kopieren: bool = True, waisen_kopieren: bool = True) -> Path:
"""Kopiert den Hook (und optional linkcheck.py) in den Wegwerf-Vault.
Seit der Nachbesserung Runde 2 bestimmt sitzungsstart.py seine Vault-
Wurzel ausschließlich aus dem eigenen Skriptpfad (zwei Ebenen darüber,
siehe bestimme_wurzel()). Nur eine Kopie im Wegwerf-Vault sorgt dafür,
dass diese Wurzel auch tatsächlich der Wegwerf-Vault ist statt des echten
Vaults, in dem das Original unter HIER liegt.
"""
ziel = v / ".vault-tools" / "session-hooks"
ziel.mkdir(parents=True, exist_ok=True)
skript = ziel / HOOK.name
shutil.copy2(HOOK, skript)
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
def ohne_pythonioencoding(_: Path) -> dict[str, str]:
"""Claude Code setzt PYTHONIOENCODING beim Hook-Aufruf nicht; nur die
globale Planvorgabe setzt sie vor dem Aufruf dieses Treibers selbst
(Prüferbefund Runde 2)."""
umgebung = dict(os.environ)
umgebung.pop("PYTHONIOENCODING", None)
return umgebung
def _baue_anderswo(v: Path) -> Path:
"""Ein echter, leerer Ordner neben v. Würde cwd doch noch gelesen, wäre
das am fehlenden Inhalt sofort sichtbar (Prüferbefund Runde 2)."""
anderswo = v.parent / "anderswo"
anderswo.mkdir(parents=True, exist_ok=True)
return anderswo
@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)
# Ueberschreibt die Standard-Eingabe (cwd auf den Wegwerf-Vault). Der Hook
# liest cwd seit Runde 2 nicht mehr; das Feld bleibt hier nur fuer Faelle,
# die gezielt eine kaputte oder ungewoehnliche stdin-Form pruefen wollen.
stdin: Callable[[Path], bytes] | None = None
linkcheck_kopieren: bool = True
waisen_kopieren: bool = True
# None: Subprozess erbt die Umgebung dieses Treibers unveraendert. Gesetzt:
# ersetzt die Umgebung vollstaendig (Pruefbefund Runde 2).
umgebung: Callable[[Path], dict[str, str]] | None = None
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("grenze | Frontmatter-Tag in Grossschreibung zaehlt",
lambda v: schreibe(v, "Notizen/frontmatter-gross.md", "---\ntags: [Needs-Review]\n---\n"),
[NR + "1 (Notizen/frontmatter-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("grenze | aehnliche review-Tags zaehlen nicht",
lambda v: schreibe(v, "Notizen/aehnlich-review.md",
"#reviewer abc#review #review/x\n"),
[RV + "0"]),
Fall("grenze | aehnlicher review-done-Tag zaehlt nicht als review-done",
lambda v: schreibe(v, "Notizen/aehnlich-review-done.md",
"#review\n\n#review-done-x\n"),
[RV + "1"]),
Fall("grenze | abc#review-done zaehlt nicht als review-done",
lambda v: schreibe(v, "Notizen/eingebettet-done.md", "#review\n\nabc#review-done\n"),
[RV + "1"]),
Fall("ausschluss | Punktdatei ausserhalb von Punktordnern",
lambda v: schreibe(v, "Notizen/.versteckt.md", "#needs-review\n"),
[NR + "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("ausschluss | Punktordner in der Inbox",
lambda v: schreibe(v, "Inbox/.trash/versteckt.md", "x\n"),
[IN + "1 (offen.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"],
verboten=[WA],
praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: kein Obsidian-Vault"]),
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("verweis | cwd zeigt auf einen anderen Ordner, Ergebnis bleibt der Skript-Vault",
nichts, None,
stdin=lambda v: json.dumps({"cwd": str(_baue_anderswo(v))}).encode("utf-8")),
Fall("umgebung | ohne PYTHONIOENCODING mit Nicht-ASCII-Inhalt bleibt gueltiges UTF-8-JSON",
lambda v: schreibe(v, "Notizen/Übersicht.md", "Prüfung #needs-review äöü 🚀\n"),
[NR + "1 (Notizen/Übersicht.md)"],
umgebung=ohne_pythonioencoding),
Fall("umgebung | linkcheck.py fehlt", nichts,
[DA, TN + "vorhanden", IN + "1 (offen.md)", PR + "0", SI + f"1 ({HEUTE}-10.md)"],
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),
]
def streng_utf8(roh: bytes) -> str:
# stdout muss gueltiges UTF-8 sein. Ein ungueltiges Byte darf nicht still
# ersetzt werden, sonst besteht der Umgebungsfall auch dann, wenn der Hook
# cp1252 schreibt (Pruefrunde 2, Befund zur Dekodierung).
try:
return roh.decode("utf-8")
except UnicodeDecodeError as exc:
return "UNGUELTIGES-UTF-8: %s" % exc
def fahre(
skript: Path,
eingabe: bytes,
arbeitsordner: Path,
umgebung: dict[str, str] | None = None,
) -> 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,
env=umgebung,
)
dauer = time.perf_counter() - start
return (
lauf.returncode,
streng_utf8(lauf.stdout),
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)
skript = kopiere_hook(v, fall.linkcheck_kopieren, fall.waisen_kopieren)
if fall.stdin is None:
eingabe = json.dumps({"session_id": "faelle", "cwd": str(v),
"hook_event_name": "SessionStart", "source": "startup"}).encode("utf-8")
else:
eingabe = fall.stdin(v)
umgebung = fall.umgebung(v) if fall.umgebung is not None else None
code, out, err, dauer = fahre(skript, eingabe, arbeit, umgebung)
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, skript_pfad: Path):
spec = importlib.util.spec_from_file_location(name, skript_pfad)
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]:
skript = kopiere_hook(v)
modul = lade_hook("hook_punkt_wirft", skript)
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]:
skript = kopiere_hook(v)
modul = lade_hook("hook_hauptablauf_wirft", skript)
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")
skript = kopiere_hook(v)
modul = lade_hook("hook_datei_gesperrt", skript)
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"
# 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)")
probleme = bewerte(zeilen, erwartet, [], [])
return not probleme, "\n".join(" " + p for p in probleme)
class _WalkPatch:
"""Ersetzt os.walk nur im Namensraum des geladenen Hook-Moduls (modul.os),
ohne das echte, global geteilte os-Modul anzufassen."""
def __init__(self, echt_os, fehler_pfad: Path) -> None:
self._echt = echt_os
self._fehler_pfad = fehler_pfad
def __getattr__(self, name):
return getattr(self._echt, name)
def walk(self, start, onerror=None, **kwargs):
if onerror is not None:
fehler = OSError(13, "Testsperre")
fehler.filename = str(self._fehler_pfad)
onerror(fehler)
yield from self._echt.walk(start, onerror=onerror, **kwargs)
def ip_ordner_gesperrt(v: Path) -> tuple[bool, str]:
"""Der onerror-Pfad von os.walk in durchlaufe(): von aussen nicht ohne
echte Rechteaenderung ausloesbar, deshalb im Prozess."""
skript = kopiere_hook(v)
modul = lade_hook("hook_ordner_gesperrt", skript)
fehler_pfad = v / "gesperrt"
modul.os = _WalkPatch(modul.os, fehler_pfad)
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
erwartet = grundzeilen(v) + [f"Sitzungsstart-Hook: 1 Ordner nicht lesbar (erster: {fehler_pfad})"]
probleme = bewerte(zeilen, erwartet, [], [])
return not probleme, "\n".join(" " + p for p in probleme)
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),
]
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)
skript = kopiere_hook(v)
eingabe = json.dumps({"cwd": str(v), "hook_event_name": "SessionStart"}).encode("utf-8")
code, out, err, dauer = fahre(skript, 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())
@@ -0,0 +1,233 @@
"""PostToolUse-Hook: startet den Regelwerk-Check nach einer Änderung an der Anweisungsschicht.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste)
GELTUNGSBEREICH
Ausgelöst wird nur, wenn tool_input.file_path innerhalb der Vault-Wurzel auf
CLAUDE.md, AI/Regelwerk/**, .claude/agents/**, .claude/skills/** oder
.claude/workflows/** zeigt.
Die Wurzel ist ausschließlich der Ordner zwei Ebenen über dieser Datei. Weder
die Umgebungsvariable CLAUDE_PROJECT_DIR noch das Feld cwd der Hook-Eingabe
werden gelesen: cwd folgt einem cd im Bash-Werkzeug und zeigt nach einem
Wechsel in einen Projektordner nicht mehr auf den Vault (Claude Code 2.1.263,
Prüferbefund Runde 2). 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). Verglichen wird ohne
Rücksicht auf Groß- und Kleinschreibung und auf die Richtung der
Schrägstriche, nach Auflösen von "." und "..". Ein relativer Pfad gilt
relativ zur Wurzel.
Still bleibt der Hook nur in zwei Lagen: Die Eingabe taugt nicht (leer, kein
JSON-Objekt, kein file_path als Zeichenkette), oder der Pfad liegt außerhalb
des Geltungsbereichs. Ein Check ohne Befund bleibt ebenfalls still. Eine
fehlende, abstürzende oder hängende Prüfung ist ein Befund.
BENANNTE LÜCKEN
* Ob tool_name Edit, Write oder MultiEdit ist, prüft der Matcher in
settings.json, nicht dieses Skript.
* Symlinks und Junctions werden nicht aufgelöst. Ein Link von außen auf eine
Regelwerk-Datei löst nichts aus.
* Still nach Exit 0 bleibt der Hook nur, solange regelwerk_check.py seine
Schlusszeile "Keine der N gelaufenen Prüfungen hat angeschlagen." behält.
Ändert sich der Wortlaut, meldet der Hook jeden Lauf, also laut statt still.
* Die Längengrenze für additionalContext ist in der Hook-Doku nicht belegt.
40 Zeilen und 4000 Zeichen sind eine Projektvorgabe.
DIESER HOOK IST EIN FANGNETZ, KEIN NACHWEIS. Er reicht nur weiter, was
regelwerk_check.py sieht, und das sind formale Widersprüche, keine inhaltlichen.
Exit ist immer 0. Nach dem Hilfetext von claude.exe 2.1.263 zeigt Exit 2 bei
PostToolUse stderr sofort dem Modell. Ob JSON bei einem anderen Exit-Code
gelesen wird, belegt der Hilfetext nicht; Exit 0 plus JSON ist die belegte
Form (ENTWURF.md, Frage 7).
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
SKRIPT = Path(__file__).resolve()
CHECK_REL = Path(".vault-tools") / "regelwerk-check" / "regelwerk_check.py"
# Unter dem timeout von 30 s, den der settings.json-Eintrag setzen soll.
TIMEOUT_S = 20
MAX_ZEILEN = 40
MAX_ZEICHEN = 4000
# Kleinbuchstaben und Schrägstrich vorwärts, verglichen gegen normiere().
GELTUNG_DATEIEN = ("claude.md",)
GELTUNG_ORDNER = ("ai/regelwerk/", ".claude/agents/", ".claude/skills/", ".claude/workflows/")
# Schlusszeile von regelwerk_check.py ohne Befund (dort Zeile 370).
_GRUEN = re.compile(r"^Keine der \d+ gelaufenen Prüfungen hat angeschlagen\.$")
def lies_stdin() -> bytes:
if sys.stdin is None or sys.stdin.isatty():
return b""
return sys.stdin.buffer.read()
def lies_eingabe(roh: bytes) -> object:
# errors=replace: ein kaputtes Byte im Pfad soll laut enden, nicht still.
text = roh.decode("utf-8-sig", errors="replace")
if not text.strip():
return None
try:
return json.loads(text)
except ValueError:
return None
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; weder CLAUDE_PROJECT_DIR noch das
# Feld cwd der Hook-Eingabe werden noch gelesen. cwd folgt einem cd im
# Bash-Werkzeug und zeigt nach einem Wechsel in einen Projektordner nicht
# mehr auf den Vault (Claude Code 2.1.263).
return SKRIPT.parents[2]
def normiere(pfad: str) -> str:
return os.path.normpath(os.path.abspath(pfad)).replace("\\", "/").lower().rstrip("/")
def im_geltungsbereich(wurzel: Path, datei: str) -> bool:
kandidat = datei.replace("\\", "/")
try:
# Relativ zur Wurzel auflösen, nie zum Arbeitsverzeichnis des Prozesses.
if not os.path.isabs(kandidat):
kandidat = os.path.join(str(wurzel), kandidat)
pfad = normiere(kandidat)
basis = normiere(str(wurzel))
except (ValueError, OSError):
return False
# Mit Trenner vergleichen, sonst träfe "<wurzel>-CLAUDE.md" neben dem Vault.
if not pfad.startswith(basis + "/"):
return False
rel = pfad[len(basis) + 1:]
return rel in GELTUNG_DATEIEN or rel.startswith(GELTUNG_ORDNER)
def dekodiere(roh: bytes | str | None) -> str:
if roh is None:
return ""
if isinstance(roh, str):
return roh
return roh.decode("utf-8", errors="replace")
def fuehre_check(wurzel: Path) -> tuple[str, str] | None:
"""None ohne Befund, sonst (systemMessage, Rumpf für additionalContext)."""
check = wurzel / CHECK_REL
if not check.is_file():
return (
f"Regelwerk-Check fehlt: {check}",
f"{check} fehlt. Die Prüfung ist nicht gelaufen; das ist ein Befund, keine Entwarnung.",
)
umgebung = dict(os.environ, PYTHONIOENCODING="utf-8")
try:
lauf = subprocess.run(
[sys.executable, str(check), "--vault", str(wurzel)],
cwd=str(wurzel),
env=umgebung,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=TIMEOUT_S,
)
except subprocess.TimeoutExpired as abgelaufen:
teil = (dekodiere(abgelaufen.stdout) + dekodiere(abgelaufen.stderr)).strip()
return (
f"Regelwerk-Check nach {TIMEOUT_S} s abgebrochen",
f"regelwerk_check.py lief länger als {TIMEOUT_S} s und wurde beendet. "
f"Bis dahin ausgegeben:\n{teil or '(nichts)'}",
)
except OSError as fehler:
return (
f"Regelwerk-Check nicht startbar: {type(fehler).__name__}",
f"regelwerk_check.py ließ sich nicht starten: {type(fehler).__name__}: {fehler}",
)
ausgabe = dekodiere(lauf.stdout)
fehlertext = dekodiere(lauf.stderr).strip()
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
gruen = any(_GRUEN.match(z) for z in zeilen)
achtung = any(z.startswith("ACHTUNG") for z in zeilen)
if lauf.returncode == 0 and gruen and not achtung:
return None
befundzeilen = sum(1 for z in zeilen if z.startswith("["))
rumpf = ausgabe.strip()
if fehlertext:
rumpf += "\n--- stderr ---\n" + fehlertext
return (
f"Regelwerk-Check: Exit {lauf.returncode}, {befundzeilen} Befundzeile(n)",
rumpf or "(keine Ausgabe)",
)
def kuerze(text: str) -> str:
zeilen = text.splitlines()
rest = len(zeilen) - MAX_ZEILEN
if rest > 0:
zeilen = zeilen[:MAX_ZEILEN] + [f"[... {rest} weitere Zeilen gekürzt]"]
ergebnis = "\n".join(zeilen)
if len(ergebnis) > MAX_ZEICHEN:
ergebnis = ergebnis[:MAX_ZEICHEN] + f"\n[... gekürzt auf {MAX_ZEICHEN} Zeichen]"
return ergebnis
def melde(system: str, kontext: str) -> None:
ausgabe = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": kontext,
},
"systemMessage": " ".join(system.split()),
}
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
sys.stdout.flush()
def main(roh: bytes | None = None) -> int:
try:
daten = lies_eingabe(lies_stdin() if roh is None else roh)
if not isinstance(daten, dict):
return 0
eingabe = daten.get("tool_input")
if not isinstance(eingabe, dict):
return 0
datei = eingabe.get("file_path")
if not isinstance(datei, str) or not datei.strip():
return 0
wurzel = bestimme_wurzel()
if not im_geltungsbereich(wurzel, datei):
return 0
ergebnis = fuehre_check(wurzel)
if ergebnis is None:
return 0
system, rumpf = ergebnis
melde(system, kuerze(f"Regelwerk-Check nach Änderung an {datei} (Vault: {wurzel}):\n{rumpf}"))
except Exception as fehler:
melde(
f"regelwerk_nach_edit.py: {type(fehler).__name__}",
f"regelwerk_nach_edit.py ist abgebrochen: {type(fehler).__name__}: {fehler}. "
"Der Regelwerk-Check ist damit nicht gelaufen.",
)
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)
+369
View File
@@ -0,0 +1,369 @@
"""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, 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
ü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 subprocess
import sys
from pathlib import Path
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
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)}"]
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),
]
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)