Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen

Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore
arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur,
was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen,
das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor.

Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale
Bezeichnung ersetzt; die Regeln selbst sind unverändert.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-09-17 22:38:15 +02:00
commit 2c9a4475fc
97 files changed
+14627

No files matched your search

@@ -0,0 +1,318 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Zieht den Vault auf das Frontmatter-Vokabular aus AI/Regelwerk/frontmatter.md.
Ersteller: Pascal Michel
Erstellt: 2026-09-03
Drei Dinge, in dieser Reihenfolge:
1 Synonyme umbenennen (projekt -> project, task/aufgaben -> todo, ...)
2 fehlende type-Werte je Dateiart ergaenzen
3 fehlende status-Werte aus dem Standard des Typs ergaenzen
Vorgabe der Werte: AI/Regelwerk/frontmatter.md. Die Tabellen unten sind die
maschinenlesbare Fassung davon - aendert sich die Regel, aendert sich hier.
AUSGENOMMEN, bewusst:
* IT Workspace/ - Artikel mit alter Tag-Konvention, siehe navigation.md
* CLAUDE.md - der Stamm ist keine Notiz
* MEMORY.md - Verzeichnisse, keine Inhalte
* die Spezialbereiche isms-*, bookstack-*, iam-projekt bleiben unberuehrt
Ohne --anwenden passiert nichts. Der Trockenlauf ist die Voreinstellung.
Aufruf:
python .vault-tools/frontmatter-migration/migriere.py --vault .
python .vault-tools/frontmatter-migration/migriere.py --vault . --anwenden
"""
import argparse
import datetime as _dt
import io
import os
import re
import sys
import traceback
TOOLKIT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"obsidian-vault-navigator", "scripts")
# ---- Vorgaben aus AI/Regelwerk/frontmatter.md ------------------------------
UMBENENNEN = {
"projekt": "project",
"task": "todo",
"aufgaben": "todo",
"project-todo": "todo",
"anleitung": "guide",
"checklist": "guide",
"architecture": "codedoku",
"arbeitsdokument": "note",
"reference": "memory", # betraf ausschliesslich Gedaechtnisdumps
}
STATUS_UMBENENNEN = {"approved": "done", "abgeloest": "done"}
STANDARD_STATUS = {
"moc": "reference", "daily": "open", "session": "done", "memory": "reference",
"note": "active", "konzept": "draft", "resource": "reference", "guide": "active",
"project": "active", "spec": "draft", "plan": "active", "plan-task": "draft",
"todo": "active", "lessons": "active", "changelog": "active", "codedoku": "active",
}
SPEZIAL = {"isms-dokument", "isms-kapitel", "bookstack-seite", "bookstack-kapitel",
"bookstack-buchbeschreibung", "iam-projekt"}
AUSGENOMMEN_ORDNER = (
"IT Workspace/", # alte Tag-Konvention, siehe navigation.md
"Bookstack/", # Staging mit eigener #review-Konvention
".claude/", # Skills und Agenten des Harness
)
# Diese Pfadbestandteile schliessen aus, egal wo sie stehen. Grund: Es sind
# KEINE Vault-Notizen, sondern Code-Artefakte mit eigenem Schema. Ein
# type: note in einer SKILL.md kann den Skill-Lader stoeren, und die Dateien
# unter tools/testdaten/ sind Prueffaelle - wer sie anfasst, faelscht Tests.
AUSGENOMMEN_TEIL = ("/skill/", "/skills/", "/plugin/", "/tools/testdaten/")
AUSGENOMMEN_DATEI = ("CLAUDE.md",)
AUSGENOMMEN_NAME = ("MEMORY.md", "SKILL.md")
# Importierte Normen- und Referenzvolltexte. Fuer sie taugt die maschinelle
# Ableitung nicht: der Titel waere der Dateiname, die Zusammenfassung eine
# Seitenzahl aus dem Amtsblatt. Deshalb hier von Hand, an einer Stelle.
HANDARBEIT = {
"Security - Compliance Projekte/active - BSI/C52026en.md": dict(
typ="resource", status="reference",
titel="BSI C5:2026 — Cloud Computing Compliance Criteria Catalogue",
summary="Volltext des BSI-Kriterienkatalogs C5:2026 mit allen 174 Kriterien, englische Fassung. Grundlage des Nachweis-Mappings in den Compliance-Projekten."),
"Security - Compliance Projekte/active - NIS2/NIS2_Richtlinie_EU_Recht.md": dict(
typ="resource", status="reference",
titel="NIS2-Richtlinie (EU) 2022/2555 — Volltext",
summary="Volltext der NIS2-Richtlinie aus dem Amtsblatt der Europaeischen Union. Referenzmaterial, noch ohne eigene Auswertung."),
"Security - Compliance Projekte/active - NIST/NIST_Richtlinie.md": dict(
typ="resource", status="reference",
titel="NIST Cybersecurity Framework 2.0 — Volltext",
summary="Volltext des NIST Cybersecurity Framework in der Fassung vom 2024-02-26. Referenzmaterial, noch ohne eigene Auswertung."),
"Security - Compliance Projekte/active - ISO27001 - 9001/DIN_EN_ISO_9001-2015.md": dict(
typ="resource", status="reference",
titel="DIN EN ISO 9001:2015 — Volltext",
summary="Volltext der DIN EN ISO 9001:2015. Achtung: Die ISO 27001 liegt trotz Ordnername nicht vor."),
"Security - Compliance Projekte/active - HIPAA/hipaa201303.md": dict(
typ="resource", status="reference",
titel="HIPAA — Omnibus Final Rule 2013, Volltext",
summary="Volltext der HIPAA-Regelung in der Fassung von 2013. Referenzmaterial ohne eigene Auswertung."),
}
def _yaml(wert):
"""YAML-sicherer Skalar. Doppelpunkte werden gequotet, nicht ersetzt -
'C5:2026' zu 'C5 -2026' zu verstuemmeln war der erste, falsche Versuch."""
wert = " ".join(str(wert).split())
if any(z in wert for z in ':#[]{}&*!|>%@`"') or wert.startswith(("-", "?")):
return '"%s"' % wert.replace('"', "'")
return wert
def typ_aus_pfad(rel):
"""Typ je Dateiart. Reihenfolge zaehlt: spezifisch vor allgemein."""
name = os.path.basename(rel)
if rel.startswith("AI/sessions/"):
return "session"
if rel.startswith("AI/secondbrain/"):
return "memory"
if rel.startswith("AI/Regelwerk/"):
return "resource"
if rel.startswith("Inbox/") and re.match(r"^\d{4}-\d{2}-\d{2}\.md$", name):
return "daily"
if name.startswith("_MOC-") or name == "Wissenslandkarte.md":
return "moc"
if "/docs/superpowers/specs/" in rel:
return "spec"
if "/docs/code/" in rel:
return "codedoku"
if "/docs/plans/" in rel:
# Planordner mit mehreren Tasks: Einzeldateien sind plan-task
rest = rel.split("/docs/plans/", 1)[1]
return "plan-task" if "/" in rest else "plan"
if name == "README.md":
# Nur direkt im Projektordner: "Code Projekte/<projekt>/README.md".
# Tiefer liegende README-Dateien beschreiben ein Unterverzeichnis.
return "project" if rel.count("/") == 2 else "note"
if name == "todo.md":
return "todo"
if name == "CHANGELOG.md":
return "changelog"
if name == "lessons.md":
return "lessons"
if name == "Administrator-Anleitung.md":
return "guide"
return "note"
# ---- Mechanik --------------------------------------------------------------
FM_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
FELD_RE = re.compile(r"^(\w+):\s*(.*)$")
REIHENFOLGE = ("uid", "type", "title", "status", "summary", "tags", "created", "updated")
def ausgenommen(rel):
if rel in AUSGENOMMEN_DATEI or os.path.basename(rel) in AUSGENOMMEN_NAME:
return True
if any(rel.startswith(o) for o in AUSGENOMMEN_ORDNER):
return True
return any(teil in "/" + rel for teil in AUSGENOMMEN_TEIL)
def lies_block(text):
m = FM_RE.match(text)
if not m:
return None, text
zeilen = m.group(1).split("\n")
return zeilen, text[m.end():]
def feld(zeilen, name):
for z in zeilen:
m = FELD_RE.match(z)
if m and m.group(1) == name:
return m.group(2).strip()
return ""
def setze(zeilen, name, wert):
"""Setzt oder ergaenzt ein Feld und haelt die Reihenfolge ein."""
for i, z in enumerate(zeilen):
m = FELD_RE.match(z)
if m and m.group(1) == name:
zeilen[i] = "%s: %s" % (name, wert)
return zeilen
# neu einsortieren
pos = REIHENFOLGE.index(name) if name in REIHENFOLGE else len(REIHENFOLGE)
einfuege = len(zeilen)
for i, z in enumerate(zeilen):
m = FELD_RE.match(z)
if not m:
continue
p = REIHENFOLGE.index(m.group(1)) if m.group(1) in REIHENFOLGE else len(REIHENFOLGE)
if p > pos:
einfuege = i
break
zeilen.insert(einfuege, "%s: %s" % (name, wert))
return zeilen
def main():
p = argparse.ArgumentParser(description="Frontmatter auf das Vokabular ziehen")
p.add_argument("--vault", default=".")
p.add_argument("--anwenden", action="store_true")
p.add_argument("--neu-anlegen", dest="neu_anlegen", action="store_true",
help="auch Dateien ganz ohne Frontmatter versehen")
args = p.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
if TOOLKIT not in sys.path:
sys.path.insert(0, TOOLKIT)
try:
import vaultlib as V
except ImportError as exc:
sys.stderr.write("ABBRUCH: vaultlib nicht importierbar (%s)\n" % exc)
return 2
aenderungen = []
try:
for ap, rel in V.walk_vault(vault):
if not rel.endswith(".md") or ausgenommen(rel):
continue
text = io.open(ap, encoding="utf-8").read()
zeilen, rumpf = lies_block(text)
if zeilen is None:
if not args.neu_anlegen:
continue
# Vollstaendiges Frontmatter anlegen. Titel und Zusammenfassung
# werden mit den Funktionen des Toolkits ABGELEITET, nicht
# erfunden; uid und Daten aus der Dateizeit.
hand = HANDARBEIT.get(rel)
if hand:
typ = hand["typ"]
titel = hand["titel"]
zusammenfassung = hand["summary"]
else:
typ = typ_aus_pfad(rel)
titel = V.derive_title({}, text, rel)
zusammenfassung = V.derive_summary(text) or titel
datum = _dt.date.fromtimestamp(os.path.getmtime(ap)).isoformat()
kennung = "%s-%s" % (datum.replace("-", ""), V.slugify(titel)[:40])
neue = [
"uid: %s" % kennung,
"type: %s" % typ,
'title: %s' % _yaml(titel),
"status: %s" % (hand["status"] if hand else STANDARD_STATUS.get(typ, "active")),
'summary: %s' % _yaml(zusammenfassung[:300]),
"tags: [%s]" % typ,
"created: %s" % datum,
"updated: %s" % datum,
]
neu_text = "---\n" + "\n".join(neue) + "\n---\n\n" + text
probe, probe_rumpf = lies_block(neu_text)
if probe is None or probe_rumpf.lstrip("\n") != text.lstrip("\n"):
raise RuntimeError("Anlegen veraendert den Inhalt: %s" % rel)
aenderungen.append((ap, rel, ["Frontmatter angelegt (%s)" % typ], neu_text))
continue
alt_typ = feld(zeilen, "type")
if alt_typ in SPEZIAL:
continue
was = []
neu_typ = UMBENENNEN.get(alt_typ, alt_typ)
if not neu_typ:
neu_typ = typ_aus_pfad(rel)
was.append("type fehlte -> %s" % neu_typ)
elif neu_typ != alt_typ:
was.append("type %s -> %s" % (alt_typ, neu_typ))
alt_st = feld(zeilen, "status")
neu_st = STATUS_UMBENENNEN.get(alt_st, alt_st)
if not neu_st:
neu_st = STANDARD_STATUS.get(neu_typ, "active")
was.append("status fehlte -> %s" % neu_st)
elif neu_st != alt_st:
was.append("status %s -> %s" % (alt_st, neu_st))
if not was:
continue
zeilen = setze(list(zeilen), "type", neu_typ)
zeilen = setze(zeilen, "status", neu_st)
neu_text = "---\n" + "\n".join(zeilen) + "\n---\n" + rumpf
# Selbstkontrolle vor dem Schreiben: Der Rumpf muss Zeichen fuer
# Zeichen derselbe sein, und das neue Frontmatter muss die
# gesetzten Werte auch wirklich tragen. Sonst ist es ein Befund.
probe_zeilen, probe_rumpf = lies_block(neu_text)
if probe_rumpf != rumpf:
raise RuntimeError("Rumpf veraendert: %s" % rel)
if probe_zeilen is None:
raise RuntimeError("Frontmatter nicht mehr lesbar: %s" % rel)
if feld(probe_zeilen, "type") != neu_typ or feld(probe_zeilen, "status") != neu_st:
raise RuntimeError("Werte nicht angekommen: %s" % rel)
aenderungen.append((ap, rel, was, neu_text))
except Exception:
sys.stdout.write("# ABGEBROCHEN\n\n" + traceback.format_exc())
sys.stdout.write("\nBis dahin gesammelt: %d Aenderung(en)\n" % len(aenderungen))
return 2
sys.stdout.write("# Frontmatter-Migration%s\n\n"
% ("" if args.anwenden else " (TROCKENLAUF - nichts wird geschrieben)"))
sys.stdout.write("%d Datei(en) betroffen\n\n" % len(aenderungen))
for _, rel, was, _ in aenderungen:
sys.stdout.write(" - %-78s %s\n" % (rel[:78], "; ".join(was)))
if args.anwenden:
for ap, rel, _, neu_text in aenderungen:
io.open(ap, "w", encoding="utf-8", newline="\n").write(neu_text)
sys.stdout.write("\n%d Datei(en) geschrieben.\n" % len(aenderungen))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,130 @@
# Entwurf: Codeblock-Erkennung bei der Link-Extraktion
Angelegt 2026-09-03. Entwurfsschritt nach [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]], Abschnitt 1 — geschrieben **vor** der ersten Codezeile.
## Anlass
`vault_doctor.py` meldete 92 tote Wikilinks. Die Stichprobe zeigt fast ausschließlich Fehlalarme:
- `[[ $EUID -eq 0 ]]` — Bash-Test aus einem Codeblock
- `[[:space:]]` — POSIX-Zeichenklasse aus einem Regex
- `[[bild.png]]`, `[[Ziel]]`, `[[Notiz]]` — Platzhalter in der Dokumentation des Bookstack-Upload-Skills, das Wikilinks verarbeitet
- `[[1, 2]]`, `[[float]]`, `[[A]]` — Typangaben und Indizes aus Codebeispielen
Ursache: `_WIKILINK_RE` (Zeile 61 in `vaultlib.py`) läuft über den gesamten Text. Das Wort `fence` kommt in der Datei kein einziges Mal vor.
**Ein Prüfwerkzeug, dessen Befunde zu vier Fünfteln Rauschen sind, wird nicht mehr gelesen.** Das ist der eigentliche Schaden, nicht die 92.
## Entscheidungsliste
| # | Frage | Antwort | Herkunft |
|---|---|---|---|
| 1 | Was ist ein Wikilink? | `[[Ziel]]`, `[[Ziel\|Alias]]`, `[[Ziel#Überschrift]]`, `[[Ziel^Block]]` | **Quelle** — `_WIKILINK_RE`, `vaultlib.py` Zeile 61 |
| 2 | Wo darf keiner erkannt werden? | In eingezäunten Codeblöcken (` ``` `, `~~~`) und in Code-Spans (Backticks) | **Quelle** — CommonMark: Inhalt von Code wird nicht als Markup interpretiert; Obsidian rendert dort keine Links |
| 3 | Zählt das YAML-Frontmatter mit? | Nein, ist bereits abgetrennt | **Quelle** — `vaultlib.py` Zeile 117: `body = text[m.end():]` |
| 4 | Eingerückte Codeblöcke (vier Leerzeichen)? | **Nicht behandelt.** Der Vault verwendet durchgängig eingezäunte Blöcke; eine Vier-Leerzeichen-Regel würde eingerückte Listenpunkte mit legitimen Links verschlucken | **Projektvorgabe**, als Lücke im Kopfkommentar benannt |
| 5 | Ungeschlossener Zaun am Dateiende? | Gilt bis Dateiende als Code | **Projektvorgabe** — die konservative Wahl: lieber einen echten Link übersehen als einen Fehlalarm erzeugen |
| 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 LEERZEICHEN eingerückt (kein Tab), 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" und „Nachbesserung Runde 2" |
Keine Zeile bleibt auf „Annahme" stehen.
## Angriffsliste
Je Fall ein eigener Durchlauf, Grundlinie zuerst.
| # | Fall | Erwartung |
|---|---|---|
| 1 | leerer Text | keine Links, kein Absturz |
| 2 | nur Fließtext mit einem Link | Link bleibt |
| 3 | Link **vor** und **nach** einem Zaun | beide bleiben — *Reihenfolge: erste und letzte Position* |
| 4 | ` ```bash ` mit Link darin | Link weg |
| 5 | `~~~` als Zaunzeichen | Link weg |
| 6 | Zaun ohne Abschluss, Link danach | Link weg |
| 7 | Inline-Code `` `[[x]]` `` | Link weg |
| 8 | doppelte Backticks | Link weg |
| 9 | einzelner Backtick ohne Partner | Link bleibt — darf nicht den Rest verschlucken |
| 10 | vier Backticks als Zaun | Link weg |
| 11 | eingerückter Zaun in einer Liste | Link weg |
| 12 | Link unmittelbar an der Zaunzeile | bleibt |
| 13 | echter Bestandsfall `[[ $EUID -eq 0 ]]` in einem Block | kein Link |
| 14 | echter Bestandsfall `[[:space:]]` in Inline-Code | kein Link |
| 15 | escaptes Pipe in einer Tabellenzelle `[[Ziel\|Alias]]` | Ziel ohne Backslash |
| 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 |
| 23 | anderes Zaunzeichen (Backtick-Öffner, Tilde-Schließzeile) | schließt nicht — eigener Fall für „gleiches Zaunzeichen" |
| 24 | Schließzaun mit vier Leerzeichen davor | schließt nicht — Einrückungsgrenze |
| 25 | Schließzaun mit Tab davor | schließt nicht — Tab zählt nicht als Einrückung, siehe „Nachbesserung Runde 2" |
## Nachbesserungen aus der ersten Messung
Der Lauf über den Vault brachte nach dem Codeblock-Filter noch 18 Befunde — wieder Fehlalarme, zwei neuer Klassen. Beide bekamen nach Abschnitt 5 des Regelwerks ihren eigenen Fall:
- **Escaptes Pipe.** In Obsidian-Tabellenzellen muss das Pipe eines Alias als `\|` geschrieben werden. `_WIKILINK_RE` schneidet am Pipe, lässt den Backslash aber stehen — das Ziel hieß dann `Pfad\` und fand nichts. Fälle 15 und 16.
- **Bildeinbettungen.** `![[Pasted image 123.png]]` ist ein gültiger Link auf eine Nicht-Markdown-Datei. Die Nachschlagemenge von `vault_doctor.py` enthält nur `.md`. Behoben durch eine zweite Menge aus allen übrigen Dateien.
Ergebnis: **92 gemeldete, 0 echte tote Links.**
## 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.
## Nachbesserung Runde 2: gleiches Zaunzeichen und Einrückung ohne Tab
**Anlass.** Die erste Prüfrunde nach der Nachbesserung 2026-09-13 fand zwei Lücken in der Angriffsliste, keine im Verhalten der zwei damals geänderten Bedingungen — aber beide Bedingungen hatten bis dahin keinen eigenen Fall:
- Die Bedingung „gleiches Zaunzeichen" (`s.group(1)[0] == zaun[0]`) hatte keinen Fall, der einen Backtick-Öffner gegen eine Tilde-Schließzeile prüft. Fall 23.
- Die Einrückung vor dem Schließzaun ließ `\s{0,3}` zu, also auch einen Tab. Ein Tab zählt bei markdown-it-py als vier Spalten (`state_block.py` Zeilen 79–80 für die Tab-Rechnung, 257–261 für den Vergleich `>= 4`) und macht die Zeile zu eingerücktem Code — sie schließt dort nicht. Fall 25 belegt das: Gegen die alte Fassung mit `\s{0,3}` schlägt er fehl (rot), gegen `[ ]{0,3}` besteht er.
**Korrektur.** `_SCHLUSS_RE` in `linkcheck.py` verlangt jetzt `^[ ]{0,3}` statt `^\s{0,3}` vor dem Zaunzeichen — nur Leerzeichen, kein Tab. Die Bedingung „gleiches Zaunzeichen" selbst war schon vorher im Code (`s.group(1)[0] == zaun[0]`), nur ungeprüft; Fall 23 sichert sie jetzt ab, Fall 24 die Grenze von drei Einrückungszeichen.
**Herkunft.** Wie in der Nachbesserung 2026-09-13, `markdown_it/rules_block/fence.py` und zusätzlich `markdown_it/rules_block/state_block.py` Zeilen 257–261 für die Tab-als-vier-Spalten-Regel, gelesen unter `Code Projekte/active - Agentic Cockpit/.venv/Lib/site-packages/markdown_it/` am 2026-09-13.
## Nachbesserung nach Prüfrunde 2: Schließregel wieder symmetrisch
**Anlass.** Die zweite Prüfrunde hat gezeigt, dass `^[ ]{0,3}` vor dem Schließzaun eine Rückentwicklung war. In einem Listenpunkt, der mit Tab eingerückt ist, öffnet `linkcheck.py` den Block an der Tab-Zeile (die Öffnerregel erlaubt `\s{0,3}`), schließt ihn aber nie mehr, und alles bis zum Dateiende gilt als Code. markdown-it-py zieht dort die Einrückung des Listeninhalts ab und schließt den Block. Belegt mit markdown-it-py 4.2.0 (`MarkdownIt('commonmark')`) gegen beide Fassungen.
**Korrektur.** `_SCHLUSS_RE` verlangt wieder `^\s{0,3}`, symmetrisch zum Öffner. Fall 25 erwartet jetzt, dass ein Tab-Zaun schließt; Fall 26 prüft den Listenfall.
**Benannte Lücke.** Auf oberster Ebene zählt markdown-it-py den Tab als vier Spalten und liest die Zeile als eingerückten Code; dort schließt ein Tab-Zaun nicht, `linkcheck.py` schließt ihn trotzdem. Listen-Einrückung bildet das Werkzeug nicht nach, deshalb bleibt es bei der einfachen, symmetrischen Regel. Im Vault gab es am 2026-09-13 keine Datei, die das betrifft (Messung des Prüfers).
**Die Regelzeile oben** („Die Einrückung von bis zu drei Zeichen übernimmt der Schließzaun vom Öffner") gilt damit wieder uneingeschränkt.
## Absturzfestigkeit
`extract_links` wird von `build_index.py` über den ganzen Vault gerufen. Ein Absturz legt den Index still. Die Hilfsfunktion arbeitet deshalb rein zeilenweise über Zeichenketten, ohne Indexarithmetik über Zeilengrenzen, und ersetzt Codeinhalt durch Leerzeichen statt ihn zu löschen — Länge und Zeilenzahl bleiben erhalten, nichts kann über eine entfernte Stelle hinweg zusammenwachsen.
## Abbruchbedingung
Schluss ist, wenn ein Lauf über den Vault die Fehlalarme aus der Stichprobe nicht mehr meldet **und** alle Angriffsfälle aus `faelle.py` grün sind. Was danach an toten Links übrig bleibt, ist eine Aufräumliste — kein Prüfauftrag mehr.
## Was dieses Skript nicht ist
Ein Fangnetz gegen offensichtliche Fehler. Es belegt nicht, dass alle Wikilinks im Vault stimmen — nur, dass die gemeldeten nicht aus Codeblöcken stammen.
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Angriffsfaelle fuer strip_code() aus linkcheck.py.
Vorgehen nach AI/Regelwerk/pruefwerkzeuge.md Abschnitt 3: je Fall ein
eigener Durchlauf, Grundlinie zuerst. Faellt die Grundlinie, misst der
Rest nichts.
Der Fall "Reihenfolge" (Link an erster UND letzter Position) ist Nr. 3 -
laut Regelwerk der Fall, dessen Fehlen am teuersten war.
Aufruf: python .vault-tools/linkcheck/faelle.py
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from linkcheck import strip_code, wikilinks # noqa: E402
_LINK = re.compile(r"\[\[([^\]\|#\^]+)")
class _V:
"""Minimaler Ersatz fuer vaultlib in den Faellen."""
_WIKILINK_RE = _LINK
def links(text):
return wikilinks(_V, text)
# (Nummer, Name, Text, erwartete Links)
FAELLE = [
(0, "GRUNDLINIE: einfacher Link", "Siehe [[Zielnotiz]] dazu.", ["Zielnotiz"]),
(1, "leerer Text", "", []),
(2, "nur Fliesstext mit Link", "Ein [[A/B]] mitten im Satz.", ["A/B"]),
(3, "Reihenfolge: Link vor UND nach einem Zaun",
"[[Vorher]]\n\n```bash\n[[ $EUID -eq 0 ]]\n```\n\n[[Nachher]]",
["Vorher", "Nachher"]),
(4, "Zaun mit Sprachangabe", "```bash\nif [[ -f x ]]; then :; fi\n```", []),
(5, "Tilde-Zaun", "~~~\n[[drin]]\n~~~", []),
(6, "Zaun ohne Abschluss", "Text\n```\n[[drin]]\nnoch drin", []),
(7, "Inline-Code", "Der Ausdruck `[[:space:]]` trifft Leerzeichen.", []),
(8, "doppelte Backticks", "Siehe ``[[roh]]`` im Text.", []),
(9, "einzelner Backtick ohne Partner",
"Ein ` einzelner Backtick und danach [[Echt]].", ["Echt"]),
(10, "vier Backticks als Zaun", "````\n[[drin]]\n````", []),
(11, "eingerueckter Zaun in einer Liste",
"- Punkt:\n ```\n [[drin]]\n ```\n- [[Danach]]", ["Danach"]),
(12, "Link unmittelbar an der Zaunzeile",
"[[Davor]]\n```\ncode\n```\n[[Danach]]", ["Davor", "Danach"]),
(13, "Bestandsfall EUID im Block",
"```bash\nif [[ $EUID -eq 0 ]]; then exit 1; fi\n```", []),
(14, "Bestandsfall Zeichenklasse inline",
"Mit `grep -o '[[:space:]]'` filtern.", []),
(15, "escaptes Pipe in einer Tabellenzelle",
r"| Datei | [[Pfad/Zur/Notiz\|Alias]] | Rest |", ["Pfad/Zur/Notiz"]),
(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"]),
(23, "anderes Zaunzeichen schliesst nicht (Backtick vs. Tilde)",
"```\n[[drin]]\n~~~\n[[Danach]]", []),
(24, "Schliesszaun mit vier Leerzeichen davor schliesst nicht",
"```\n[[drin]]\n ```\n[[Danach]]", []),
(25, "Schliesszaun mit Tab davor schliesst (symmetrische Regel, benannte Luecke oberste Ebene)",
"```\n[[drin]]\n\t```\n[[Danach]]", ["Danach"]),
(26, "Liste: mit Tab eingerueckter Block schliesst mit Tab-Zaun",
"- Punkt:\n\t```\n\t[[drin]]\n\t```\n- [[Danach]]", ["Danach"]),
]
def main():
grundlinie = FAELLE[0]
ist = links(grundlinie[2])
if ist != grundlinie[3]:
sys.stdout.write("GRUNDLINIE FAELLT: erwartet %r, bekommen %r\n"
% (grundlinie[3], ist))
sys.stdout.write("Der Rest der Faelle misst damit nichts. Abbruch.\n")
return 2
fehler = 0
for nr, name, text, erwartet in FAELLE:
try:
ist = links(text)
except Exception as exc: # ein Absturz ist ein Befund
sys.stdout.write(" FALL %-2d ABSTURZ %s -> %s\n" % (nr, name, exc))
fehler += 1
continue
ok = ist == erwartet
if not ok:
fehler += 1
sys.stdout.write(" FALL %-2d %-7s %-42s erwartet %r, bekommen %r\n"
% (nr, "ok" if ok else "FEHLER", name, erwartet, ist))
gepruefte = len(FAELLE)
sys.stdout.write("\n%d von %d Faellen bestanden.\n" % (gepruefte - fehler, gepruefte))
return 1 if fehler else 0
if __name__ == "__main__":
sys.exit(main())
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Findet tote Wikilinks im Vault - ohne die Fehlalarme aus Codebloecken.
WARUM ES DIESES WERKZEUG GIBT
-----------------------------
`vault_doctor.py` aus dem mitgelieferten Navigator-Toolkit meldete am
2026-09-03 92 tote Wikilinks. Die Stichprobe bestand fast ausschliesslich
aus Rauschen: `[[ $EUID -eq 0 ]]` (Bash-Test), `[[:space:]]` (POSIX-
Zeichenklasse), `[[bild.png]]` und `[[Ziel]]` (Platzhalter in der Doku des
Bookstack-Upload-Skills). Ursache ist `_WIKILINK_RE` in `vaultlib.py`
Zeile 61: der Ausdruck laeuft ueber den gesamten Text, das Wort "fence"
kommt in der Datei kein einziges Mal vor.
Das Navigator-Toolkit ist schreibgeschuetzt und stammt aus einem fremden
Skill (siehe AI/Regelwerk/navigation.md). Ein Patch dort waere beim
naechsten Update verloren. Deshalb liegt die Korrektur hier daneben.
HERKUNFT DER REGELN
-------------------
- Wikilink-Syntax: QUELLE - `_WIKILINK_RE`, vaultlib.py Zeile 61.
- Zielaufloesung (lookup-Menge und Treffer-Bedingung): QUELLE - woertlich
uebernommen aus vault_doctor.py Zeilen 66-74 und 102-107.
- Codebloecke und Code-Spans zaehlen nicht: QUELLE - CommonMark; Obsidian
rendert dort keine Links.
- Frontmatter zaehlt nicht mit: QUELLE - vaultlib.py Zeile 117 trennt es ab.
- 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.
- Einrueckung vor dem Schliesszaun: wie beim Oeffner hoechstens drei
Leerraumzeichen, ein Tab eingeschlossen. Die Regel ist bewusst symmetrisch
(Entscheidung nach Pruefrunde 2): Eine strengere Schliessregel liess einen
in einer Liste mit Tab eingerueckten Block bis zum Dateiende offen, dort
schliesst markdown-it-py ihn aber.
BEWUSSTE LUECKEN - nicht fuer erledigt halten
---------------------------------------------
- Eingerueckte Codebloecke (vier Leerzeichen) werden NICHT erkannt. Sie
wuerden eingerueckte Listenpunkte mit echten Links verschlucken.
- Ein Tab vor dem Schliesszaun auf oberster Ebene schliesst hier den Block.
markdown-it-py zaehlt den Tab dort als vier Spalten und liest die Zeile als
eingerueckten Code (state_block.py Zeilen 79-80 und 257-261). In Listen
schliesst er auch dort. Listen-Einrueckung bildet dieses Werkzeug nicht nach.
- Ein Oeffner mit Backtick im Info-String gilt hier als Zaun. CommonMark
liest so eine Zeile als Inline-Code. Nicht behandelt.
- `extract_headings()` im Toolkit hat denselben Fehler: `# Kommentar` aus
einem Bash-Block zaehlt dort als Ueberschrift. Hier nicht behandelt.
- Dieses Werkzeug ist ein Fangnetz gegen offensichtliche Fehler. Es belegt
nicht, dass alle Wikilinks stimmen - nur, dass die gemeldeten nicht aus
Codebloecken stammen.
Entwurf und Angriffsliste: ENTWURF-codeblock-erkennung.md im selben Ordner.
Aufruf:
python .vault-tools/linkcheck/linkcheck.py --vault .
python .vault-tools/linkcheck/linkcheck.py --vault . --alle
"""
import argparse
import os
import re
import sys
import traceback
SKRIPT = os.path.dirname(os.path.abspath(__file__))
TOOLKIT = os.path.join(os.path.dirname(SKRIPT), "obsidian-vault-navigator", "scripts")
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
# Schliesszaun: dieselbe Einrueckung wie beim Oeffner (symmetrisch, siehe
# Kopfkommentar), danach nur 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")
def strip_code(body):
"""Ersetzt Codebloecke und Code-Spans durch Leerzeichen.
Laenge und Zeilenzahl bleiben erhalten (Ersetzen statt Loeschen), damit
nichts ueber eine entfernte Stelle hinweg zusammenwaechst.
"""
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)
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))
return "\n".join(aus)
def lade_toolkit():
"""Importiert vaultlib aus dem schreibgeschuetzten Toolkit.
Fehlt es, ist das ein BEFUND und kein Grund zu schweigen.
"""
if TOOLKIT not in sys.path:
sys.path.insert(0, TOOLKIT)
try:
import vaultlib as V
return V
except ImportError as exc:
raise RuntimeError(
"vaultlib.py nicht gefunden oder nicht importierbar unter %s (%s). "
"Ohne das Toolkit kann diese Pruefung nicht arbeiten." % (TOOLKIT, exc))
def sammle(V, vault):
"""Notizen und Anhaenge getrennt einsammeln.
Anhaenge braucht es, weil `![[Pasted image 123.png]]` ein gueltiger
Obsidian-Link auf eine Nicht-Markdown-Datei ist. vault_doctor.py baut
seine lookup-Menge nur aus .md-Dateien und meldet solche Einbettungen
deshalb als tot.
"""
notizen, anhaenge = [], set()
for ap, rp in V.walk_vault(vault):
if not rp.endswith(".md"):
anhaenge |= {rp, os.path.basename(rp)}
continue
with open(ap, "r", encoding="utf-8", errors="replace") as fh:
text = fh.read()
fm, body = V.parse_frontmatter(text)
notizen.append({"abs": ap, "rel": rp, "fm": fm, "body": body})
return notizen, anhaenge
def baue_lookup(V, notizen):
"""Woertlich aus vault_doctor.py Zeilen 66-74."""
lookup = set()
for n in notizen:
rel = n["rel"]
lookup |= {rel, os.path.splitext(rel)[0], os.path.basename(rel),
os.path.splitext(os.path.basename(rel))[0]}
if n["fm"].get("title"):
lookup.add(str(n["fm"]["title"]))
if n["fm"].get("uid"):
lookup.add(str(n["fm"]["uid"]))
return lookup
def wikilinks(V, body):
"""Wie V.extract_links, aber mit ausgeblendetem Code.
Zusaetzlich wird ein abschliessender Backslash entfernt. In Obsidian-
Tabellenzellen muss das Pipe eines Alias als `\\|` geschrieben werden,
sonst bricht die Tabelle. `_WIKILINK_RE` schneidet am Pipe, laesst den
Backslash aber stehen - das Ziel hiesse dann `Pfad\\` und faende nichts.
Ein Dateiname kann unter Windows nicht auf Backslash enden, das
Abschneiden ist also gefahrlos.
"""
sauber = strip_code(body)
treffer = []
for m in V._WIKILINK_RE.finditer(sauber):
t = m.group(1).strip().rstrip("\\").strip()
if t and t not in treffer:
treffer.append(t)
return treffer
def lauf(vault, alle):
V = lade_toolkit()
notizen, anhaenge = sammle(V, vault)
if not notizen:
raise RuntimeError("Keine Markdown-Dateien unter %s gefunden." % vault)
lookup = baue_lookup(V, notizen)
kaputt = []
roh_gesamt = 0
for n in notizen:
roh_gesamt += len(V.extract_links(n["body"]))
for raw in wikilinks(V, n["body"]):
t = raw.strip().lstrip("./")
# Treffer-Bedingung woertlich aus vault_doctor.py Zeile 104,
# erweitert um Anhaenge (Bilder, PDF) - siehe sammle().
if t in lookup or t + ".md" in lookup or os.path.basename(t) in lookup:
continue
if t in anhaenge or os.path.basename(t) in anhaenge:
continue
kaputt.append((n["rel"], raw))
return notizen, kaputt, roh_gesamt
def main():
p = argparse.ArgumentParser(description="Tote Wikilinks ohne Codeblock-Fehlalarme")
p.add_argument("--vault", default=".")
p.add_argument("--alle", action="store_true", help="alle Befunde statt der ersten 40")
args = p.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
befunde = []
try:
notizen, befunde, roh = lauf(vault, args.alle)
except Exception:
# Absturz ist selbst ein Befund - und die bis dahin gesammelten
# Befunde werden trotzdem ausgegeben.
sys.stdout.write("# Linkcheck - ABGEBROCHEN\n\n")
sys.stdout.write(traceback.format_exc())
for rel, raw in befunde:
sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw))
return 2
sys.stdout.write("# Linkcheck - %d Notizen in %s\n" % (len(notizen), vault))
sys.stdout.write("\nWikilink-Vorkommen ohne Codeblock-Filter: %d\n" % roh)
if not befunde:
sys.stdout.write("\nKeine toten Wikilinks ausserhalb von Codebloecken.\n")
return 0
grenze = len(befunde) if args.alle else 40
sys.stdout.write("\n## Tote Wikilinks (%d)\n" % len(befunde))
for rel, raw in befunde[:grenze]:
sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw))
if len(befunde) > grenze:
sys.stdout.write(" ... %d weitere (--alle)\n" % (len(befunde) - grenze))
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,110 @@
# Frontmatter-Schema
Obsidian nennt Frontmatter „Properties". Es ist YAML zwischen zwei `---`-Zeilen
am Dateianfang. Der Index liest genau diese Felder — was hier fehlt, muss der
Agent sich durch Öffnen der Datei erarbeiten, und genau das kostet Kontext.
## Pflichtfelder (alle Notiztypen)
| Feld | Typ | Bedeutung |
|---|---|---|
| `uid` | String | Stabile ID, Format `YYYYMMDD-slug`. Überlebt Umbenennen und Verschieben; Verweise in Skripten hängen daran. |
| `type` | Enum | Steuert Gruppierung, Templates und Abfragen. Gewinnt gegen die Ableitung aus dem Ordner. |
| `title` | String | Anzeigename. Fehlt er, nimmt der Index die erste `#`-Überschrift, sonst den Dateinamen. |
| `status` | Enum | Lebenszyklus, siehe unten. |
| `summary` | String | **Ein Satz, max. 160 Zeichen.** Worum geht es und wofür macht man die Notiz auf. |
| `tags` | Liste | Kleingeschrieben, ohne `#`, Technologie/Thema. 3–6 Stück, nicht 20. |
| `created` | Datum | `YYYY-MM-DD` |
| `updated` | Datum | `YYYY-MM-DD`, bei inhaltlichen Änderungen mitziehen. |
### Erlaubte `type`-Werte
`project-code`, `template-project`, `note-template`, `research`, `area`,
`resource`, `moc`, `decision`, `inbox`, `archive`
Neue Typen nur bewusst einführen und dann in `scripts/vaultlib.py` unter
`KNOWN_TYPES` eintragen, sonst meldet der Doctor sie als unbekannt.
### Erlaubte `status`-Werte
| Wert | Bedeutung |
|---|---|
| `idea` | notiert, noch nicht begonnen |
| `active` | wird gerade bearbeitet |
| `paused` | bewusst liegengelassen, kommt zurück |
| `done` | abgeschlossen, noch am Platz |
| `archived` | abgeschlossen und nach `90_Archive/` verschoben |
| `reference` | Dauerhaftes ohne Fortschritt (Areas, Ressourcen, Templates) |
## Zusätzliche Felder je Typ
### `project-code`
| Feld | Beispiel | Zweck |
|---|---|---|
| `repo` | `[email protected]:me/auth-service.git` | Sprung ins Repo ohne Suche |
| `lang` | `go` | Hauptsprache |
| `stack` | `[postgres, keycloak, docker]` | Umgebung auf einen Blick |
| `template_of` | `go-service-starter` | aus welchem Template-Projekt entstanden |
| `area` | `infrastruktur` | zugehöriger Dauerbereich |
### `research`
| Feld | Beispiel | Zweck |
|---|---|---|
| `question` | `Welche Rate-Limiting-Strategie passt zu Gateway X?` | die eine Frage |
| `sources` | `[https://…, "Buch, S. 40"]` | Belege |
| `confidence` | `low` / `medium` / `high` | wie belastbar die Antwort ist |
| `project` | `auth-service` | auslösendes Projekt |
### `template-project`
| Feld | Beispiel | Zweck |
|---|---|---|
| `use_when` | `Neuer Go-Service mit HTTP-API und Postgres` | Auswahl ohne Öffnen |
| `stack` | `[go, postgres, docker]` | |
| `template_of` | — | bleibt leer; wird in den abgeleiteten Projekten gesetzt |
### `decision` (ADR)
| Feld | Beispiel |
|---|---|
| `project` | `auth-service` |
| `decided` | `2026-08-03` |
| `supersedes` | `20260101-mongo-als-store` |
### `area`, `resource`, `moc`, `note-template`
Nur die Pflichtfelder. Bei `moc` ist `summary` besonders wichtig, weil MOCs
Einstiegspunkte sind.
## Beispiel
```yaml
---
uid: 20260803-auth-service
type: project-code
title: Auth Service
status: active
summary: OIDC-Auth-Service für die interne Toolchain, Go + Keycloak, löst die verteilten Basic-Auth-Setups ab.
tags: [go, oidc, keycloak, infrastruktur]
repo: [email protected]:me/auth-service.git
lang: go
stack: [postgres, keycloak, docker]
template_of: go-service-starter
area: infrastruktur
created: 2026-08-03
updated: 2026-08-03
---
```
## Was nicht ins Frontmatter gehört
- Fließtext oder mehrzeilige Beschreibungen — dafür ist der Body da.
- Werte, die sich täglich ändern (Fortschritt in Prozent, laufende Zahlen).
- Secrets, Tokens, Passwörter, Zugangsdaten. Nie, auch nicht „nur kurz".
- Duplikate von Wikilinks — Links stehen im Body unter `## Referenzen`.
## Massenpflege
`vault_doctor.py --fix-missing` ergänzt fehlende Felder konservativ: `type` aus
dem Ordner, `title` aus der ersten Überschrift, `summary` aus dem ersten Absatz,
`uid` aus Datum + Slug. Es überschreibt nie vorhandene Werte und rührt den Body
nicht an. Standard ist ein Trockenlauf; erst `--apply` schreibt. Danach die
generierten `summary`-Werte durchsehen — automatisch abgeleitete Sätze sind
brauchbar, aber selten so gut wie ein bewusst geschriebener.
@@ -0,0 +1,128 @@
# Die Referenzschicht
## Warum überhaupt ein Index
Ein Agent, der eine Frage an einen Vault mit 500 Notizen stellt, hat drei
Möglichkeiten: alles lesen (sprengt den Kontext), grep (findet Wortformen, aber
nicht Bedeutung, und liefert Treffer ohne Einordnung), oder eine
Zwischenschicht, die pro Notiz genau so viel weiß, dass sich entscheiden lässt,
ob man sie aufmacht. Die dritte Variante ist der Index.
Größenordnung: 500 Notizen à 800 Wörter sind rund 500.000 Token. Der Index
derselben 500 Notizen liegt bei etwa 25.000 Token — und eine typische Abfrage
gibt davon 10–40 Zeilen aus, also ein paar hundert Token. Deshalb: nie den Index
komplett lesen, immer `vault_query.py` fragen.
## Erzeugte Dateien
### `_index/vault-index.json`
Kompaktes JSON ohne Einrückung, Kurzschlüssel zur Platzersparnis:
| Schlüssel | Bedeutung |
|---|---|
| `p` | Pfad relativ zum Vault |
| `u` | uid |
| `t` | type |
| `s` | status |
| `n` | Titel |
| `m` | summary |
| `g` | tags |
| `r` | Projekt-Slug |
| `c` / `d` | created / updated |
| `h` | Überschriften (max. 12) |
| `l` | ausgehende Links |
| `w` | Wortzahl (bei Assets: Dateigröße) |
Leere Felder fehlen ganz. Der `meta`-Block enthält Zählungen, Typverteilung,
Statusverteilung, Top-Tags und die Zahl toter Links.
Das Feld `h` ist der zweite Hebel nach `m`: Bei einer langen Notiz zeigen die
Überschriften, welcher Abschnitt gebraucht wird — statt 3.000 Wörter zu lesen,
liest man 300.
### `_index/INDEX.md`
Menschenlesbare Übersicht, nach Typ gruppiert, jede Zeile ein Wikilink plus
Zusammenfassung. In Obsidian direkt navigierbar. Bei über 200 Notizen enthält
die Datei selbst den Hinweis, sie nicht komplett zu lesen.
### `_index/backlinks.json`
`{ "pfad/zur/notiz.md": ["pfad/quelle1.md", …] }`. Basis für `--links-to` und
die Waisen-Erkennung.
## Wann neu bauen
- nach dem Anlegen, Verschieben, Umbenennen oder Löschen von Notizen
- nach Frontmatter-Änderungen
- vor einer Arbeitssitzung, wenn zwischendurch in Obsidian gearbeitet wurde
`vault_query.py` vergleicht die Änderungszeiten und warnt auf stderr, wenn
Notizen neuer sind als der Index. Der Lauf über einige tausend Dateien dauert
Sekunden, also im Zweifel neu bauen.
Automatisieren geht per Git-Hook (`.git/hooks/pre-commit`) oder Cron:
```bash
python3 .claude/skills/obsidian-vault-navigator/scripts/build_index.py --vault . --quiet
```
## Abfrage-Rezepte
```bash
Q=".claude/skills/obsidian-vault-navigator/scripts/vault_query.py"
# Womit habe ich mich zu Thema X beschäftigt?
python3 $Q --text x
# Laufende Code-Projekte, als fertige Wikilink-Liste für eine Antwort
python3 $Q --type project-code --status active --format md
# Alles zu einem Projekt, inklusive Unternotizen und Anhängen
python3 $Q --project auth-service
# Passendes Template-Projekt finden, ohne eines zu öffnen
python3 $Q --type template-project
# Was hat sich diese Woche getan?
python3 $Q --since 7 --sort updated
# Kandidaten fürs Archiv
python3 $Q --status active --stale 180
# Wer verweist auf diese Notiz?
python3 $Q --links-to 30_Research/rate-limiting.md
# Pflegeliste: Notizen ohne summary
python3 $Q --no-summary --limit 100 --format paths
# Struktur einer langen Notiz sehen, bevor man sie liest
python3 $Q --text "kubernetes netzwerk" --format headings
```
Mehrere `--text` werden UND-verknüpft, mehrere `--type`/`--tag`/`--status`
ODER-verknüpft (bei `--tag` müssen alle angegebenen Tags vorhanden sein).
## Wenn der Index nichts findet
1. Filter lockern — oft ist `--type` zu eng gesetzt.
2. Anderes Wort probieren; der Index kennt nur Titel, Summary, Tags,
Überschriften und Pfad, keinen Fließtext.
3. Erst dann Volltext: `grep -ril "begriff" --include="*.md" .`
4. Findet grep etwas, das der Index nicht hatte, ist das ein Befund: Der Notiz
fehlt ein Tag oder eine bessere `summary`. Gleich nachziehen — der Index wird
dadurch mit jedem Miss besser.
## Grenzen
Der Index ist Metadaten, keine Semantik. Er findet nichts, was nirgends benannt
ist, und er versteht keine Synonyme. Für semantische Suche im großen Vault gibt
es lokale Werkzeuge (z. B. Obsidians eigene Suche mit Operatoren oder externe
Embedding-Indizes) — die ersetzen diese Schicht nicht, sondern ergänzen sie: Der
Index bleibt die billige erste Stufe.
## Verhältnis zu Obsidian Bases
Obsidian Bases (`.base`-Dateien) erzeugen Tabellen- und Kartenansichten aus
Properties — dieselben Felder, die auch der Index nutzt. Wer im Obsidian-UI
dieselben Übersichten will, legt Bases an; für den Agenten bleibt der
JSON-Index der billigere Weg. Für die korrekte `.base`-Syntax den offiziellen
Skill `obsidian-bases` verwenden.
@@ -0,0 +1,96 @@
# Vault-Struktur
## Ordner im Detail
### `00_Inbox/`
Alles, was schnell reinmuss: Fundstücke, Ideen, Gesprächsnotizen. Frontmatter
darf hier minimal sein (`type: inbox`, `title`, `created`). Der Ordner ist ein
Durchlauferhitzer, kein Lager — was länger als zwei Wochen liegt, meldet
`vault_doctor.py`.
### `10_Projects/<slug>/`
Ein Vorhaben mit Ziel und absehbarem Ende. Jedes Projekt ist ein **Ordner**,
kein einzelnes File, weil Projekte fast immer mehrere Notizen und Anhänge
sammeln:
```
10_Projects/auth-service/
├── index.md Projektnotiz — der Einstieg, type: project-code
├── decisions/ ADRs, type: decision
├── notes/ Arbeitsnotizen, Meetings, Logs
└── assets/ Diagramme, Exporte, PDFs
```
`index.md` ist Pflicht und trägt die Projekt-Zusammenfassung. Alle Unternotizen
tragen `project: <slug>` im Frontmatter — daran hängt `vault_query.py --project`.
Slug-Regel: kleingeschrieben, Bindestriche, keine Umlaute, so kurz wie
eindeutig (`auth-service`, nicht `Auth Service (neu, 2026)`).
### `20_Areas/`
Verantwortungsbereiche ohne Enddatum: Infrastruktur, Security, Homelab,
Weiterbildung, Kunde XY. Eine Area-Notiz ist gleichzeitig Landkarte für ihren
Bereich. Projekte verlinken auf ihre Area, nicht umgekehrt hart verdrahtet —
die Projektliste einer Area entsteht per Abfrage.
### `30_Research/`
Recherche, die über ein einzelnes Projekt hinaus Bestand hat. Eine Notiz = eine
Frage. Ist die Recherche nur für ein Projekt relevant und danach erledigt,
gehört sie in `10_Projects/<slug>/notes/`. Faustregel: Würde man das in einem
Jahr auch ohne dieses Projekt nochmal aufmachen? Dann `30_Research/`.
### `40_Templates/`
- `note-templates/` — Vorlagen für einzelne Notizen. Auch in Obsidian selbst
als Template-Ordner einstellbar (Einstellungen → Templates).
- `project-templates/` — **Template-Projekte**: vollständige Boilerplates, aus
denen neue Projekte entstehen (Starter-Repo-Struktur, Standard-Setups,
wiederkehrende Auftragsformate). Jedes Template-Projekt hat `index.md` mit
`use_when` im Frontmatter, damit die Auswahl später ohne Öffnen funktioniert.
Templates werden vom Doctor nicht als verwaist gemeldet und tauchen im Index
als eigene Gruppe auf.
### `50_Resources/`
Wissen ohne Projektbezug: Snippets, Runbooks, Cheatsheets, Konfigurations-
Notizen, Tool-Vergleiche. Der Unterschied zu `30_Research/`: Ressourcen sind
Nachschlagewerk, Research beantwortet eine Frage.
### `90_Archive/`
Abgeschlossenes bleibt lesbar und indexiert, ist aber aus dem Blick. Beim
Archivieren `status: archived` setzen und den kompletten Projektordner
verschieben, damit relative Verweise erhalten bleiben.
### `_index/`
Generiert von `build_index.py`. Enthält `vault-index.json` (Maschine),
`INDEX.md` (Mensch), `backlinks.json`. Nie von Hand editieren. Wer den Ordner
in Obsidian nicht sehen will: Einstellungen → Dateien und Links → „Ausgeschlossene
Dateien" auf `_index` setzen (er bleibt auf der Platte und für die Skripte
nutzbar).
## Wohin gehört diese Notiz?
1. Gehört sie zu einem konkreten Vorhaben mit Ende? → `10_Projects/<slug>/`
2. Beantwortet sie eine Frage, die auch ohne das Vorhaben interessant bleibt?
→ `30_Research/`
3. Ist es eine Vorlage oder ein Boilerplate? → `40_Templates/…`
4. Ist es Nachschlagewissen ohne Frage und ohne Vorhaben? → `50_Resources/`
5. Ist es ein Dauerbereich, unter dem mehrere Projekte hängen? → `20_Areas/`
6. Weiß man es nicht? → `00_Inbox/` und beim nächsten Aufräumen entscheiden.
Das ist kein Versagen, sondern der vorgesehene Weg.
## Dateinamen
- Dateiname = sprechender Titel, keine Datumspräfixe (Datum steht im
Frontmatter und ist dort auch sortierbar).
- Ausnahme: Daily-/Log-Notizen, dort `YYYY-MM-DD.md`.
- Projekt- und Template-Ordner tragen Slugs, einzelne Notizen dürfen Leerzeichen
und Großschreibung haben — Obsidian-Wikilinks kommen damit klar.
- Keine Umlaute in Ordnernamen; in Dateinamen sind sie unkritisch, aber Slugs
bleiben ASCII.
## Anpassung der Struktur
Die Ordnernamen sind nicht heilig, die Zuordnung Pfad → Typ schon: Sie steht in
`scripts/vaultlib.py` unter `FOLDER_TYPES`. Wer umbenennt, passt dort an —
sonst raten die Skripte falsch, und `type` im Frontmatter muss überall explizit
gesetzt sein. Frontmatter gewinnt immer gegen die Ordner-Ableitung.
@@ -0,0 +1,128 @@
# Workflows
Alle Pfade unten gehen davon aus, dass der Skill unter
`.claude/skills/obsidian-vault-navigator/` im Vault liegt.
## Frage beantworten („was habe ich zu X?")
1. `vault_query.py --text x` — bei vielen Treffern mit `--type` oder `--path`
nachschärfen.
2. Trefferliste dem Nutzer als Wikilink-Liste zeigen (`--format md`), damit er
in Obsidian direkt springen kann.
3. Reicht die Zusammenfassung für die Antwort, ist hier Schluss.
4. Sonst die 1–3 relevantesten Notizen öffnen. Bei langen Notizen vorher
`--format headings` und nur den passenden Abschnitt lesen.
5. Kommt bei der Antwort etwas Neues heraus, das später wieder gebraucht wird:
als Notiz ablegen oder an die bestehende anhängen.
## Neues Code-Projekt
1. Prüfen, ob es das schon gibt: `vault_query.py --text "<name>"`.
2. Passendes Template-Projekt suchen: `vault_query.py --type template-project`.
Die `use_when`-Zeilen reichen zur Auswahl.
3. Anlegen:
`new_note.py --type project-code --title "Auth Service" --tags go,oidc --summary "…"`
4. Wenn ein Template genutzt wird: dessen Ordner nach
`10_Projects/<slug>/` kopieren, dann `template_of: <template-slug>` ins
Frontmatter des neuen Projekts, und im Template-Projekt unter „Abgeleitete
Projekte" den Rückverweis eintragen.
5. Area verlinken, `repo`/`lang`/`stack` füllen.
6. `build_index.py`.
## Neue Recherche
1. Frage formulieren — eine Frage, eine Notiz. Passt die Frage nicht in einen
Satz, sind es zwei Notizen.
2. `new_note.py --type research --title "…" --project <slug>`
3. `question` ins Frontmatter, Antwort zuerst schreiben, Belege darunter.
4. `confidence` ehrlich setzen; `low` ist ein nützlicher Wert, kein Makel.
5. Vom auslösenden Projekt aus verlinken, damit die Recherche nicht verwaist.
6. `build_index.py`.
## Template-Projekt anlegen
Ein Template-Projekt entsteht meist rückwärts: Ein Projekt lief gut, die
Struktur soll wiederverwendet werden.
1. Projektordner nach `40_Templates/project-templates/<slug>/` kopieren.
2. Projektspezifisches entfernen: Namen, Secrets, Kundendaten, Logs,
Zwischenstände.
3. Platzhalter einführen und im Template `index.md` unter „Anwendung"
dokumentieren, welche ersetzt werden müssen.
4. `type: template-project`, `use_when` und `stack` setzen.
5. `build_index.py`.
## Inbox aufräumen
1. `vault_query.py --path 00_Inbox --sort updated`
2. Pro Notiz einen Zielort vorschlagen — mit der Regel aus
`references/vault-structure.md` als Begründung.
3. Die gesamte Liste als Vorschlag zeigen und **auf Freigabe warten**. Erst dann
verschieben, in Gruppen, nicht alles auf einmal.
4. Beim Verschieben Frontmatter vervollständigen (`type`, `summary`, `tags`).
5. `build_index.py`, danach `vault_doctor.py`, um tote Links zu finden, die
durchs Verschieben entstanden sind.
## Projekt abschließen
1. `status: done`, `updated` aktualisieren.
2. Im `index.md` einen Abschnitt „Ergebnis" ergänzen: was rauskam, was man
beim nächsten Mal anders machen würde. Das ist der Teil, der später gelesen
wird.
3. Lohnt sich ein Template-Projekt daraus? Siehe oben.
4. Kompletten Ordner nach `90_Archive/<slug>/` verschieben, `status: archived`.
5. `build_index.py`, dann `vault_doctor.py` gegen tote Links.
## Wöchentliche Pflege
```bash
python3 .claude/skills/obsidian-vault-navigator/scripts/build_index.py --vault .
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_doctor.py --vault .
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_query.py --since 7 --sort updated
python3 .claude/skills/obsidian-vault-navigator/scripts/vault_query.py --status active --stale 90
```
Daraus wird eine kurze Lage: was ist neu, was liegt fest, was fehlt an
Metadaten, was sollte ins Archiv. Änderungsvorschläge sammeln und gebündelt
vorlegen, nicht einzeln durchführen.
## Migration eines bestehenden Vaults
Der heikelste Fall — hier geht es um Dateien, an denen jahrelang gearbeitet
wurde. Nie im Bulk, nie ohne Freigabe, und immer mit einer Rückfahrkarte.
1. **Sicherung**: Vault in Git nehmen (`git init && git add -A && git commit`)
oder eine Kopie anlegen. Ohne das nicht anfangen — und den Nutzer das
bestätigen lassen.
2. **Bestandsaufnahme**: `build_index.py` auf dem Vault, so wie er ist. Der
Index läuft auch ohne Frontmatter; Typen werden dann aus Pfaden geraten oder
auf `note` gesetzt. Danach `vault_doctor.py` für den Ist-Zustand.
3. **Lage zeigen**: Anzahl Notizen, Typverteilung, wie viele ohne Frontmatter,
wie viele Waisen, größte Ordner. Erst danach über Struktur reden.
4. **Struktur anlegen**, ohne Bestehendes anzufassen: die Zielordner erzeugen,
`CLAUDE.md` in die Wurzel, Templates einsortieren.
5. **Frontmatter zuerst, Verschieben später.** `vault_doctor.py --fix-missing`
erst im Trockenlauf zeigen, dann auf einem Teilbereich mit `--apply`
ausprobieren, Ergebnis gemeinsam ansehen, dann den Rest.
6. **In Wellen verschieben**, thematisch sortiert: erst die eindeutigen Fälle
(klar erkennbare Projekte), dann Research, zuletzt der Rest. Nach jeder Welle
`build_index.py` + `vault_doctor.py`, tote Links reparieren, Nutzer draufschauen
lassen.
7. **Restposten** landen in `00_Inbox/` und werden nach und nach entschieden.
Ein Vault muss nicht an einem Tag fertig sortiert sein.
8. **Zusammenfassungen nachziehen**: `vault_query.py --no-summary` liefert die
Pflegeliste. Automatisch abgeleitete Summaries sind ein Start, kein Ergebnis
— die 30 wichtigsten Notizen von Hand nachschärfen bringt mehr als 300
maschinell erzeugte Sätze.
## Wenn der Nutzer in Claude Code arbeitet
Der Vault als Arbeitsverzeichnis öffnen, damit `CLAUDE.md` greift und die
Skript-Pfade stimmen. Ist das Code-Repo eines Projekts das Arbeitsverzeichnis
und der Vault liegt woanders, immer `--vault <pfad>` mitgeben — die Skripte
brauchen kein Arbeitsverzeichnis im Vault.
Ein Muster, das sich lohnt: Am Ende einer Coding-Session die Projektnotiz
aktualisieren (Stand, offene Punkte, getroffene Entscheidungen) und
`build_index.py` laufen lassen. Die nächste Session beginnt dann mit einer
Abfrage statt mit dem Wiederlesen des Repos.
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""Scannt den Vault und erzeugt die Referenzschicht in `_index/`.
Erzeugt:
_index/vault-index.json kompakter Maschinen-Index (Kurzschlüssel, siehe
references/indexing.md) — wird von vault_query.py
gelesen, nicht direkt vom Agenten
_index/INDEX.md menschenlesbare Übersicht mit Wikilinks
_index/backlinks.json Rückwärtsverweise pro Notiz
Beispiel:
python3 scripts/build_index.py --vault ~/Vault
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from collections import Counter, defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
TYPE_ORDER = [
"moc", "area", "project-code", "research", "decision", "template-project",
"resource", "note-template", "inbox", "note", "archive", "asset",
]
TYPE_LABEL = {
"moc": "Maps of Content",
"area": "Areas",
"project-code": "Projekte (Code)",
"research": "Research",
"decision": "Entscheidungen",
"template-project": "Template-Projekte",
"resource": "Ressourcen",
"note-template": "Notiz-Vorlagen",
"inbox": "Inbox",
"note": "Sonstige Notizen",
"archive": "Archiv",
"asset": "Dateien",
}
def normalize_link(target: str, notes_by_key: dict) -> str | None:
"""Wikilink-Ziel auf einen Index-Pfad abbilden."""
t = target.strip().lstrip("./")
for cand in (t, t + ".md", t.replace(".md", "") + ".md"):
if cand in notes_by_key:
return notes_by_key[cand]
base = os.path.basename(t)
for cand in (base, base + ".md"):
if cand in notes_by_key:
return notes_by_key[cand]
return None
def build(vault: str, include_assets: bool, excludes) -> dict:
notes, assets = [], []
for ap, rp in V.walk_vault(vault, excludes):
ext = os.path.splitext(rp)[1].lower()
if ext == ".md":
try:
notes.append(V.read_note(ap, rp))
except OSError as exc:
print(f" übersprungen: {rp} ({exc})", file=sys.stderr)
elif include_assets and ext in V.ASSET_EXTS:
assets.append({
"p": rp,
"t": "asset",
"n": os.path.basename(rp),
"r": rp.split("/")[1] if rp.startswith("10_Projects/") and rp.count("/") > 1 else "",
"w": os.path.getsize(ap),
})
notes.sort(key=lambda n: n["p"])
assets.sort(key=lambda n: n["p"])
all_entries = notes + assets
lookup = {}
for n in notes:
lookup[n["p"]] = n["p"]
lookup[os.path.basename(n["p"])] = n["p"]
lookup[os.path.splitext(n["p"])[0]] = n["p"]
lookup[os.path.splitext(os.path.basename(n["p"]))[0]] = n["p"]
if n.get("n"):
lookup.setdefault(n["n"], n["p"])
if n.get("u"):
lookup.setdefault(n["u"], n["p"])
backlinks = defaultdict(list)
broken = 0
for n in notes:
for raw in n.get("l", []):
tgt = normalize_link(raw, lookup)
if tgt:
if n["p"] not in backlinks[tgt]:
backlinks[tgt].append(n["p"])
else:
broken += 1
meta = {
"v": 1,
"generated": V.iso_now(),
"vault": os.path.abspath(vault),
"notes": len(notes),
"assets": len(assets),
"words": sum(n.get("w", 0) for n in notes),
"types": dict(Counter(n.get("t", "note") for n in notes).most_common()),
"status": dict(Counter(n["s"] for n in notes if n.get("s")).most_common()),
"tags": dict(Counter(t for n in notes for t in n.get("g", [])).most_common(60)),
"broken_links": broken,
}
return {"meta": meta, "notes": all_entries, "backlinks": dict(backlinks)}
def render_markdown(index: dict) -> str:
meta = index["meta"]
by_type = defaultdict(list)
for n in index["notes"]:
by_type[n.get("t", "note")].append(n)
out = [
"---",
"type: moc",
"title: Vault-Index",
f"summary: Generierte Übersicht aller Notizen ({meta['notes']} Stück). "
"Nicht von Hand bearbeiten.",
f"updated: {V.today()}",
"---",
"",
"# Vault-Index",
"",
f"> [!info] Generiert am {meta['generated']} · {meta['notes']} Notizen · "
f"{meta['assets']} Dateien",
"> Diese Datei wird von `build_index.py` erzeugt. Änderungen gehen verloren.",
"",
]
if meta["notes"] > 200:
out += [
"> [!warning] Großer Vault",
"> Diese Datei nicht komplett lesen. Stattdessen gezielt abfragen:",
"> `python3 scripts/vault_query.py --text \"…\"`",
"",
]
ordered = [t for t in TYPE_ORDER if t in by_type]
ordered += sorted(t for t in by_type if t not in TYPE_ORDER)
for t in ordered:
entries = by_type[t]
out.append(f"## {TYPE_LABEL.get(t, t)} ({len(entries)})")
out.append("")
if t == "asset":
for n in entries[:200]:
out.append(f"- `{n['p']}`")
if len(entries) > 200:
out.append(f"- … {len(entries) - 200} weitere")
out.append("")
continue
for n in entries:
link = os.path.splitext(n["p"])[0]
line = f"- [[{link}|{n.get('n', link)}]]"
bits = []
if n.get("s"):
bits.append(f"`{n['s']}`")
if n.get("g"):
bits.append(" ".join(f"#{g}" for g in n["g"][:5]))
if bits:
line += " " + " ".join(bits)
if n.get("m"):
line += f" — {n['m']}"
out.append(line)
out.append("")
if meta["tags"]:
out.append("## Top-Tags")
out.append("")
out.append(" ".join(f"#{t} ({c})" for t, c in list(meta["tags"].items())[:30]))
out.append("")
return "\n".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description="Referenzschicht für den Vault bauen")
ap.add_argument("--vault", default=".", help="Pfad zum Vault (Standard: aktuelles Verzeichnis)")
ap.add_argument("--no-assets", action="store_true", help="Nicht-Markdown-Dateien nicht indexieren")
ap.add_argument("--exclude", action="append", default=[], help="Zusätzlicher Ordnername zum Überspringen (mehrfach möglich)")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
if not os.path.isdir(vault):
print(f"Vault-Pfad existiert nicht: {vault}", file=sys.stderr)
return 2
index = build(vault, not args.no_assets, args.exclude)
outdir = os.path.join(vault, V.INDEX_DIR)
os.makedirs(outdir, exist_ok=True)
with open(os.path.join(outdir, V.INDEX_JSON), "w", encoding="utf-8") as fh:
json.dump({"meta": index["meta"], "notes": index["notes"]}, fh,
ensure_ascii=False, separators=(",", ":"))
with open(os.path.join(outdir, "backlinks.json"), "w", encoding="utf-8") as fh:
json.dump(index["backlinks"], fh, ensure_ascii=False, separators=(",", ":"))
with open(os.path.join(outdir, V.INDEX_MD), "w", encoding="utf-8") as fh:
fh.write(render_markdown(index))
if not args.quiet:
m = index["meta"]
print(f"Index gebaut: {m['notes']} Notizen, {m['assets']} Dateien, "
f"{m['words']} Wörter")
print("Typen: " + ", ".join(f"{k}={v}" for k, v in m["types"].items()))
missing = [n["p"] for n in index["notes"]
if n.get("t") != "asset" and not n.get("m")]
if missing:
print(f"Ohne summary: {len(missing)} Notizen "
f"(vault_doctor.py zeigt welche)")
if m["broken_links"]:
print(f"Tote Wikilinks: {m['broken_links']} (vault_doctor.py --links)")
print(f"Geschrieben nach {os.path.join(V.INDEX_DIR, '')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Legt eine neue Notiz aus einem Template an — mit korrektem Frontmatter,
korrektem Ordner und stabiler uid.
Von Hand geschriebenes Frontmatter driftet; deshalb dieser Weg. Bestehende
Dateien werden nie überschrieben.
Beispiele:
python3 scripts/new_note.py --type project-code --title "Auth Service" \\
--tags go,oidc --summary "OIDC-Auth für die interne Toolchain"
python3 scripts/new_note.py --type research --title "Rate Limiting Strategien" \\
--project auth-service
python3 scripts/new_note.py --type decision --title "Postgres statt Mongo" \\
--project auth-service
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEMPLATE_DIR = os.path.join(SKILL_DIR, "assets", "templates")
# type -> (Zielpfad-Muster, eigener Ordner?)
TARGETS = {
"project-code": ("10_Projects/{slug}/index.md", True),
"template-project": ("40_Templates/project-templates/{slug}/index.md", True),
"research": ("30_Research/{slug}.md", False),
"area": ("20_Areas/{slug}.md", False),
"resource": ("50_Resources/{slug}.md", False),
"moc": ("20_Areas/{slug}.md", False),
"decision": ("10_Projects/{project}/decisions/{slug}.md", False),
"inbox": ("00_Inbox/{slug}.md", False),
}
DEFAULT_STATUS = {
"project-code": "active", "research": "active", "area": "reference",
"resource": "reference", "moc": "reference", "decision": "done",
"template-project": "reference", "inbox": "idea",
}
def main() -> int:
ap = argparse.ArgumentParser(description="Notiz aus Template anlegen")
ap.add_argument("--vault", default=".")
ap.add_argument("--type", required=True, choices=sorted(TARGETS))
ap.add_argument("--title", required=True)
ap.add_argument("--summary", default="")
ap.add_argument("--tags", default="")
ap.add_argument("--status", default="")
ap.add_argument("--project", default="", help="Projekt-Slug (Pflicht bei --type decision)")
ap.add_argument("--out", default="", help="Zielpfad relativ zum Vault überschreiben")
ap.add_argument("--print-only", action="store_true", help="Inhalt ausgeben statt schreiben")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
slug = V.slugify(args.title)
pattern, _ = TARGETS[args.type]
if args.out:
rel = args.out
else:
if "{project}" in pattern and not args.project:
print("--project ist bei diesem Typ erforderlich", file=sys.stderr)
return 2
rel = pattern.format(slug=slug, project=V.slugify(args.project or "misc"))
dest = os.path.join(vault, rel)
if os.path.exists(dest) and not args.print_only:
print(f"Existiert bereits: {rel} — nichts geschrieben.", file=sys.stderr)
return 1
tpl_path = os.path.join(TEMPLATE_DIR, f"{args.type}.md")
if not os.path.exists(tpl_path):
tpl_path = os.path.join(TEMPLATE_DIR, "resource.md")
with open(tpl_path, "r", encoding="utf-8") as fh:
tpl = fh.read()
today = V.today()
values = {
"{{uid}}": f"{today.replace('-', '')}-{slug[:40]}",
"{{title}}": args.title,
"{{summary}}": args.summary or "TODO: ein Satz, worum es geht.",
"{{status}}": args.status or DEFAULT_STATUS.get(args.type, "active"),
"{{tags}}": ", ".join(V.as_list(args.tags)),
"{{project}}": args.project,
"{{date}}": today,
"{{slug}}": slug,
}
content = tpl
for k, v in values.items():
content = content.replace(k, v)
if args.print_only:
print(content)
return 0
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w", encoding="utf-8") as fh:
fh.write(content)
print(rel)
print("Angelegt. Danach: summary prüfen, Referenzen ergänzen, "
"build_index.py laufen lassen.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Prüft den Vault auf alles, was die Referenzschicht unbrauchbar macht.
Standard ist ein reiner Bericht. `--fix-missing --apply` ergänzt ausschließlich
fehlende Frontmatter-Felder (uid, type, created, updated, status) und fasst
nie bestehende Werte oder Fließtext an.
Beispiele:
python3 scripts/vault_doctor.py --vault ~/Vault
python3 scripts/vault_doctor.py --stale-days 180
python3 scripts/vault_doctor.py --fix-missing # Trockenlauf
python3 scripts/vault_doctor.py --fix-missing --apply
"""
from __future__ import annotations
import argparse
import os
import sys
from collections import defaultdict
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
REQUIRED = ["type", "title", "status", "summary", "updated"]
def collect(vault: str):
notes = []
for ap, rp in V.walk_vault(vault):
if rp.endswith(".md"):
with open(ap, "r", encoding="utf-8", errors="replace") as fh:
text = fh.read()
fm, body = V.parse_frontmatter(text)
notes.append({"abs": ap, "rel": rp, "fm": fm, "body": body, "raw": text})
return notes
def section(title: str, items, formatter=str, limit=25):
if not items:
return []
out = [f"\n## {title} ({len(items)})"]
for it in items[:limit]:
out.append(" - " + formatter(it))
if len(items) > limit:
out.append(f" … {len(items) - limit} weitere")
return out
def main() -> int:
ap = argparse.ArgumentParser(description="Vault-Gesundheitscheck")
ap.add_argument("--vault", default=".")
ap.add_argument("--stale-days", type=int, default=365)
ap.add_argument("--inbox-days", type=int, default=14)
ap.add_argument("--fix-missing", action="store_true",
help="fehlende Frontmatter-Felder ergänzen (Trockenlauf ohne --apply)")
ap.add_argument("--apply", action="store_true", help="Änderungen wirklich schreiben")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
notes = collect(vault)
if not notes:
print("Keine Markdown-Dateien gefunden.")
return 1
lookup = set()
for n in notes:
rel = n["rel"]
lookup |= {rel, os.path.splitext(rel)[0], os.path.basename(rel),
os.path.splitext(os.path.basename(rel))[0]}
if n["fm"].get("title"):
lookup.add(str(n["fm"]["title"]))
if n["fm"].get("uid"):
lookup.add(str(n["fm"]["uid"]))
no_fm, missing_fields, bad_type, bad_status = [], [], [], []
broken, dup_uid, dup_title, stale, old_inbox = [], defaultdict(list), defaultdict(list), [], []
inbound = defaultdict(int)
import datetime as dt
today = dt.date.today()
for n in notes:
fm, rel = n["fm"], n["rel"]
if not fm:
no_fm.append(rel)
else:
miss = [f for f in REQUIRED if not str(fm.get(f, "")).strip()]
if miss:
missing_fields.append((rel, miss))
t = str(fm.get("type", "")).strip()
if t and t not in V.KNOWN_TYPES:
bad_type.append((rel, t))
s = str(fm.get("status", "")).strip()
if s and s not in V.KNOWN_STATUS:
bad_status.append((rel, s))
if fm.get("uid"):
dup_uid[str(fm["uid"])].append(rel)
title = V.derive_title(fm, n["body"], rel)
dup_title[title.lower()].append(rel)
for raw in V.extract_links(n["body"]):
t = raw.strip().lstrip("./")
if t in lookup or t + ".md" in lookup or os.path.basename(t) in lookup:
inbound[t] += 1
else:
broken.append((rel, raw))
mtime = dt.date.fromtimestamp(os.path.getmtime(n["abs"]))
upd = str(fm.get("updated", "")).strip()
try:
ref = dt.date.fromisoformat(upd[:10]) if upd else mtime
except ValueError:
ref = mtime
age = (today - ref).days
if age > args.stale_days:
stale.append((rel, age))
if rel.startswith("00_Inbox/") and age > args.inbox_days:
old_inbox.append((rel, age))
linked = set()
for n in notes:
for raw in V.extract_links(n["body"]):
t = raw.strip().lstrip("./")
for cand in (t, t + ".md", os.path.basename(t), os.path.basename(t) + ".md"):
linked.add(cand)
orphans = [
n["rel"] for n in notes
if not (set([n["rel"], os.path.splitext(n["rel"])[0],
os.path.basename(n["rel"]),
os.path.splitext(os.path.basename(n["rel"]))[0]]) & linked)
and not n["rel"].startswith(("_index/", "40_Templates/"))
]
report = [f"# Vault-Doctor — {len(notes)} Notizen in {vault}"]
report += section("Ohne Frontmatter", no_fm)
report += section("Pflichtfelder fehlen", missing_fields,
lambda x: f"{x[0]} → fehlt: {', '.join(x[1])}")
report += section("Unbekannter type", bad_type, lambda x: f"{x[0]} → '{x[1]}'")
report += section("Unbekannter status", bad_status, lambda x: f"{x[0]} → '{x[1]}'")
report += section("Tote Wikilinks", broken, lambda x: f"{x[0]} → [[{x[1]}]]")
report += section("Doppelte uid", [(k, v) for k, v in dup_uid.items() if len(v) > 1],
lambda x: f"{x[0]}: {', '.join(x[1])}")
report += section("Doppelte Titel", [(k, v) for k, v in dup_title.items() if len(v) > 1],
lambda x: f"{x[0]}: {', '.join(x[1])}")
report += section("Verwaist (keine eingehenden Links)", orphans)
report += section(f"Liegt länger als {args.inbox_days} Tage in der Inbox", old_inbox,
lambda x: f"{x[0]} ({x[1]} Tage)")
report += section(f"Seit über {args.stale_days} Tagen unangetastet", stale,
lambda x: f"{x[0]} ({x[1]} Tage)")
problems = (len(no_fm) + len(missing_fields) + len(bad_type) + len(bad_status)
+ len(broken) + len(orphans))
if problems == 0:
report.append("\nKeine strukturellen Probleme gefunden.")
else:
report.append(f"\nBefunde gesamt: {problems}")
print("\n".join(report))
if args.fix_missing:
changed = []
for n in notes:
fm, rel = dict(n["fm"]), n["rel"]
before = dict(fm)
if not str(fm.get("type", "")).strip():
fm["type"] = V.infer_type(rel)
if not str(fm.get("title", "")).strip():
fm["title"] = V.derive_title(n["fm"], n["body"], rel)
if not str(fm.get("uid", "")).strip():
import datetime as _d
stamp = _d.date.fromtimestamp(os.path.getmtime(n["abs"])).strftime("%Y%m%d")
fm["uid"] = f"{stamp}-{V.slugify(fm['title'])[:40]}"
if not str(fm.get("status", "")).strip():
if rel.startswith("90_Archive/"):
fm["status"] = "archived"
elif rel.startswith("00_Inbox/"):
fm["status"] = "idea"
else:
fm["status"] = "active"
if not str(fm.get("summary", "")).strip():
s = V.derive_summary(n["body"])
if s:
fm["summary"] = s
if not str(fm.get("created", "")).strip():
import datetime as _d
fm["created"] = _d.date.fromtimestamp(os.path.getmtime(n["abs"])).isoformat()
if not str(fm.get("updated", "")).strip():
fm["updated"] = fm["created"]
ordered = {}
for k in ("uid", "type", "title", "status", "summary", "tags",
"project", "created", "updated"):
if k in fm:
ordered[k] = fm[k]
for k, v in fm.items():
ordered.setdefault(k, v)
if ordered != before:
changed.append((n, ordered))
print(f"\n## Frontmatter-Ergänzungen: {len(changed)} Datei(en)")
for n, fm in changed[:15]:
added = [k for k in fm if k not in n["fm"]]
print(f" - {n['rel']}: + {', '.join(added) or 'Feldreihenfolge'}")
if len(changed) > 15:
print(f" … {len(changed) - 15} weitere")
if not args.apply:
print("\n(Trockenlauf — mit --apply schreiben)")
else:
for n, fm in changed:
body = n["body"] if n["fm"] else n["raw"]
with open(n["abs"], "w", encoding="utf-8") as fh:
fh.write(V.dump_frontmatter(fm) + "\n" + body.lstrip("\n"))
print(f"\n{len(changed)} Datei(en) geschrieben. "
"Jetzt build_index.py erneut laufen lassen.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Fragt die Referenzschicht ab und gibt kompakte Treffer aus.
Das ist der Standardeinstieg in den Vault: erst hier suchen, dann nur die
Treffer öffnen. Nie `vault-index.json` am Stück lesen.
Beispiele:
python3 scripts/vault_query.py --text "rate limiting"
python3 scripts/vault_query.py --type project-code --status active
python3 scripts/vault_query.py --tag k8s --since 30 --format md
python3 scripts/vault_query.py --links-to 10_Projects/auth-service/index.md
python3 scripts/vault_query.py --orphans --type research
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import vaultlib as V # noqa: E402
def matches_text(note: dict, needles) -> bool:
hay = " ".join([
note.get("n", ""), note.get("m", ""), note.get("p", ""),
" ".join(note.get("g", [])), " ".join(note.get("h", [])),
note.get("r", ""), note.get("u", ""),
]).lower()
return all(n in hay for n in needles)
def days_ago(iso_date: str) -> int | None:
import datetime as dt
try:
d = dt.date.fromisoformat(iso_date[:10])
except (ValueError, TypeError):
return None
return (dt.date.today() - d).days
def fmt_compact(n: dict) -> str:
bits = [n["p"], n.get("n", "")]
tail = n.get("m", "")
meta = []
if n.get("t"):
meta.append(n["t"])
if n.get("s"):
meta.append(n["s"])
head = " :: ".join(b for b in bits if b)
if meta:
head += f" [{'/'.join(meta)}]"
return head + (f" :: {tail}" if tail else "")
def fmt_md(n: dict) -> str:
link = os.path.splitext(n["p"])[0]
line = f"- [[{link}|{n.get('n', link)}]]"
if n.get("s"):
line += f" `{n['s']}`"
if n.get("m"):
line += f" — {n['m']}"
return line
def main() -> int:
ap = argparse.ArgumentParser(description="Vault-Index abfragen")
ap.add_argument("--vault", default=".")
ap.add_argument("--text", action="append", default=[],
help="Suchbegriff in Titel/Summary/Pfad/Tags/Überschriften (mehrfach = UND)")
ap.add_argument("--type", action="append", default=[])
ap.add_argument("--status", action="append", default=[])
ap.add_argument("--tag", action="append", default=[])
ap.add_argument("--project", default=None)
ap.add_argument("--path", default=None, help="Pfadpräfix, z. B. 30_Research")
ap.add_argument("--since", type=int, default=None, help="nur Notizen, die in den letzten N Tagen aktualisiert wurden")
ap.add_argument("--stale", type=int, default=None, help="nur Notizen, die seit N Tagen nicht angefasst wurden")
ap.add_argument("--links-to", default=None, help="Notizen, die auf dieses Ziel verlinken (Pfad oder uid)")
ap.add_argument("--orphans", action="store_true", help="nur Notizen ohne eingehende Links")
ap.add_argument("--no-summary", action="store_true", help="nur Notizen ohne summary (Pflegeliste)")
ap.add_argument("--limit", type=int, default=40)
ap.add_argument("--format", choices=["compact", "paths", "md", "json", "headings"],
default="compact")
ap.add_argument("--sort", choices=["path", "updated", "title"], default="path")
args = ap.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
index = V.load_index(vault)
notes = index["notes"]
meta = index.get("meta", {})
backlinks = {}
bl_path = os.path.join(vault, V.INDEX_DIR, "backlinks.json")
if os.path.exists(bl_path):
with open(bl_path, "r", encoding="utf-8") as fh:
backlinks = json.load(fh)
needles = [t.lower() for t in args.text]
types = {t.lower() for t in args.type}
stati = {s.lower() for s in args.status}
tags = {t.lower().lstrip("#") for t in args.tag}
hits = []
for n in notes:
if types and n.get("t", "").lower() not in types:
continue
if stati and n.get("s", "").lower() not in stati:
continue
if tags and not tags.issubset({g.lower().lstrip("#") for g in n.get("g", [])}):
continue
if args.project and n.get("r", "").lower() != args.project.lower():
continue
if args.path and not n["p"].startswith(args.path.rstrip("/")):
continue
if needles and not matches_text(n, needles):
continue
is_asset = n.get("t") == "asset"
if args.no_summary and (n.get("m") or (is_asset and "asset" not in types)):
continue
age = days_ago(n.get("d", ""))
if args.since is not None and (age is None or age > args.since):
continue
if args.stale is not None and (age is None or age < args.stale):
continue
if args.orphans and (backlinks.get(n["p"]) or (is_asset and "asset" not in types)):
continue
if args.links_to:
target = args.links_to
resolved = None
for cand in notes:
if target in (cand.get("p"), cand.get("u"), cand.get("n")):
resolved = cand["p"]
break
resolved = resolved or target
if n["p"] not in backlinks.get(resolved, []):
continue
hits.append(n)
if args.sort == "updated":
hits.sort(key=lambda n: n.get("d", ""), reverse=True)
elif args.sort == "title":
hits.sort(key=lambda n: n.get("n", "").lower())
total = len(hits)
hits = hits[: args.limit]
if args.format == "json":
print(json.dumps(hits, ensure_ascii=False, indent=1))
elif args.format == "paths":
for n in hits:
print(n["p"])
elif args.format == "md":
for n in hits:
print(fmt_md(n))
elif args.format == "headings":
for n in hits:
print(fmt_compact(n))
for h in n.get("h", []):
print(f" # {h}")
else:
for n in hits:
print(fmt_compact(n))
if not hits:
print("(keine Treffer — Filter lockern oder als Fallback grep über den Vault)",
file=sys.stderr)
if total > len(hits):
print(f"… {total - len(hits)} weitere Treffer (--limit erhöhen)", file=sys.stderr)
newer = V.count_newer_than(vault, meta.get("generated", ""))
if newer:
print(f"[Hinweis] {newer} Notiz(en) sind neuer als der Index — "
"build_index.py erneut laufen lassen.", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,323 @@
"""Gemeinsame Helfer für den obsidian-vault-navigator.
Bewusst ohne externe Abhängigkeiten (kein PyYAML), damit die Skripte überall
laufen: lokal, in Claude Code, in CI. Der YAML-Parser deckt genau die Teilmenge
ab, die Obsidian-Properties benutzen: Skalare, Inline-Listen und Block-Listen.
"""
from __future__ import annotations
import datetime as _dt
import json
import os
import re
import unicodedata
# --- Konfiguration -----------------------------------------------------------
INDEX_DIR = "_index"
INDEX_JSON = "vault-index.json"
INDEX_MD = "INDEX.md"
SKIP_DIRS = {
".obsidian", ".git", ".trash", ".claude", ".agents", ".venv",
"node_modules", "__pycache__", ".svelte-kit", "dist", "build",
INDEX_DIR,
}
ASSET_EXTS = {
".pdf", ".docx", ".doc", ".xlsx", ".xls", ".pptx", ".csv", ".json",
".yaml", ".yml", ".toml", ".png", ".jpg", ".jpeg", ".svg", ".webp",
".canvas", ".base", ".sql", ".sh", ".py", ".ts", ".js", ".go", ".rs",
}
# Pfadpräfix -> Typ. Frontmatter `type` gewinnt immer gegen diese Ableitung.
FOLDER_TYPES = [
("40_Templates/note-templates", "note-template"),
("40_Templates/project-templates", "template-project"),
("40_Templates", "note-template"),
("00_Inbox", "inbox"),
("10_Projects", "project-code"),
("20_Areas", "area"),
("30_Research", "research"),
("50_Resources", "resource"),
("90_Archive", "archive"),
]
KNOWN_TYPES = {
"project-code", "template-project", "note-template", "research", "area",
"resource", "moc", "decision", "inbox", "archive", "asset", "note",
}
KNOWN_STATUS = {"idea", "active", "paused", "done", "archived", "reference"}
SUMMARY_MAX = 160
MAX_HEADINGS = 12
MAX_LINKS = 40
# --- Kleiner YAML-Parser -----------------------------------------------------
_FM_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*(?:\n|\Z)", re.DOTALL)
_WIKILINK_RE = re.compile(r"\[\[([^\]\|#\^]+)")
_MDLINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+\.md)\)")
_HEADING_RE = re.compile(r"^(#{1,3})\s+(.+?)\s*$", re.MULTILINE)
def _scalar(raw: str):
v = raw.strip()
if not v:
return ""
if v[0] in "\"'" and len(v) > 1 and v[-1] == v[0]:
return v[1:-1]
if v.startswith("[") and v.endswith("]"):
inner = v[1:-1].strip()
if not inner:
return []
return [_scalar(x) for x in _split_inline(inner)]
low = v.lower()
if low in ("true", "yes"):
return True
if low in ("false", "no"):
return False
if low in ("null", "~", "none"):
return None
if re.fullmatch(r"-?\d+", v):
try:
return int(v)
except ValueError:
return v
return v
def _split_inline(s: str):
out, buf, quote = [], "", None
for ch in s:
if quote:
if ch == quote:
quote = None
buf += ch
elif ch in "\"'":
quote = ch
buf += ch
elif ch == ",":
out.append(buf)
buf = ""
else:
buf += ch
if buf.strip():
out.append(buf)
return [x for x in (i.strip() for i in out) if x]
def parse_frontmatter(text: str):
"""Gibt (dict, body) zurück. Ohne Frontmatter: ({}, text)."""
m = _FM_RE.match(text)
if not m:
return {}, text
block, body = m.group(1), text[m.end():]
data, key, pending = {}, None, None
for line in block.split("\n"):
if not line.strip() or line.lstrip().startswith("#"):
continue
stripped = line.strip()
if stripped.startswith("- ") and key is not None:
if pending is None:
pending = []
data[key] = pending
pending.append(_scalar(stripped[2:]))
continue
if ":" in line and not line.startswith(" "):
k, _, v = line.partition(":")
key = k.strip()
pending = None
val = _scalar(v)
data[key] = val
if val == "":
pending = None
return data, body
def dump_frontmatter(data: dict) -> str:
lines = ["---"]
for k, v in data.items():
if isinstance(v, list):
inner = ", ".join(str(x) for x in v)
lines.append(f"{k}: [{inner}]")
elif isinstance(v, bool):
lines.append(f"{k}: {'true' if v else 'false'}")
elif v is None:
lines.append(f"{k}:")
else:
s = str(v)
if ":" in s or s.strip() != s:
s = '"' + s.replace('"', "'") + '"'
lines.append(f"{k}: {s}")
lines.append("---")
return "\n".join(lines) + "\n"
# --- Ableitungen -------------------------------------------------------------
def slugify(text: str) -> str:
text = unicodedata.normalize("NFKD", str(text))
text = text.replace("ß", "ss")
text = "".join(c for c in text if not unicodedata.combining(c))
text = re.sub(r"[^A-Za-z0-9]+", "-", text).strip("-").lower()
return text or "note"
def infer_type(rel_path: str) -> str:
p = rel_path.replace(os.sep, "/")
for prefix, t in FOLDER_TYPES:
if p.startswith(prefix + "/") or p == prefix:
return t
return "note"
def clean_inline(text: str) -> str:
text = re.sub(r"!?\[\[([^\]\|]*\|)?([^\]]+)\]\]", r"\2", text)
text = re.sub(r"!?\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_`>]+", "", text)
return re.sub(r"\s+", " ", text).strip()
def derive_summary(body: str) -> str:
for para in re.split(r"\n\s*\n", body):
para = para.strip()
if not para or para.startswith("#") or para.startswith("---"):
continue
if para.startswith(("- ", "* ", "|", "```", ">")):
continue
s = clean_inline(para)
if not s:
continue
if len(s) > SUMMARY_MAX:
s = s[: SUMMARY_MAX - 1].rsplit(" ", 1)[0] + "…"
return s
return ""
def derive_title(fm: dict, body: str, rel_path: str) -> str:
if fm.get("title"):
return str(fm["title"])
m = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
if m:
return clean_inline(m.group(1))
return os.path.splitext(os.path.basename(rel_path))[0]
def extract_headings(body: str):
out = []
for m in _HEADING_RE.finditer(body):
h = clean_inline(m.group(2))
if h and h not in out:
out.append(h[:60])
if len(out) >= MAX_HEADINGS:
break
return out
def extract_links(body: str):
out = []
for m in _WIKILINK_RE.finditer(body):
t = m.group(1).strip()
if t and t not in out:
out.append(t)
for m in _MDLINK_RE.finditer(body):
t = m.group(1).strip()
if t and t not in out:
out.append(t)
return out[:MAX_LINKS]
def as_list(v):
if v is None or v == "":
return []
if isinstance(v, list):
return [str(x) for x in v if str(x).strip()]
return [x.strip() for x in str(v).replace(";", ",").split(",") if x.strip()]
def today() -> str:
return _dt.date.today().isoformat()
def iso_now() -> str:
return _dt.datetime.now().replace(microsecond=0).isoformat()
# --- Vault-Scan --------------------------------------------------------------
def walk_vault(vault: str, extra_excludes=()):
"""Liefert (abs_path, rel_path) für alle Dateien außerhalb der Skip-Ordner."""
excludes = set(SKIP_DIRS) | set(extra_excludes)
for root, dirs, files in os.walk(vault):
dirs[:] = [
d for d in dirs
if d not in excludes and not d.startswith(".")
]
for f in sorted(files):
if f.startswith("."):
continue
ap = os.path.join(root, f)
rp = os.path.relpath(ap, vault).replace(os.sep, "/")
yield ap, rp
def read_note(abs_path: str, rel_path: str) -> dict:
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
text = fh.read()
fm, body = parse_frontmatter(text)
ntype = str(fm.get("type") or "").strip() or infer_type(rel_path)
summary = str(fm.get("summary") or "").strip() or derive_summary(body)
if len(summary) > SUMMARY_MAX:
summary = summary[: SUMMARY_MAX - 1] + "…"
mtime = _dt.date.fromtimestamp(os.path.getmtime(abs_path)).isoformat()
project = str(fm.get("project") or "").strip()
if not project and rel_path.startswith("10_Projects/"):
parts = rel_path.split("/")
if len(parts) > 2:
project = parts[1]
note = {
"p": rel_path,
"u": str(fm.get("uid") or "").strip(),
"t": ntype,
"s": str(fm.get("status") or "").strip(),
"n": derive_title(fm, body, rel_path),
"m": summary,
"g": as_list(fm.get("tags")),
"r": project,
"c": str(fm.get("created") or "").strip(),
"d": str(fm.get("updated") or "").strip() or mtime,
"h": extract_headings(body),
"l": extract_links(body),
"w": len(body.split()),
}
return {k: v for k, v in note.items() if v not in ("", [], None)}
def load_index(vault: str) -> dict:
path = os.path.join(vault, INDEX_DIR, INDEX_JSON)
if not os.path.exists(path):
raise SystemExit(
f"Kein Index gefunden unter {path}.\n"
"Zuerst bauen: python3 scripts/build_index.py --vault "
f"{vault}"
)
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def count_newer_than(vault: str, iso_ts: str) -> int:
"""Wie viele .md-Dateien wurden nach dem Indexlauf geändert?"""
try:
ts = _dt.datetime.fromisoformat(iso_ts).timestamp()
except (ValueError, TypeError):
return 0
n = 0
for ap, rp in walk_vault(vault):
if rp.endswith(".md") and os.path.getmtime(ap) > ts + 1:
n += 1
return n
+171
View File
@@ -0,0 +1,171 @@
"""Vergleicht die Codebloecke einer Plan-Taskdatei byteweise mit den Zieldateien im Projekt.
Geltungsbereich (Fangnetz, kein Nachweis):
- Prueft nur, was der Plan als Codeblock mit vorangestellter Zieldatei-Zeile fuehrt.
Konvention der Planer-Taskdateien (Projektvorgabe, Plan 3a und 4): direkt vor dem
Zaun steht eine Zeile mit dem Pfad in Backticks, z. B. "`tests/x.py` neu anlegen:".
- "neu anlegen" / "ersetzen" = ganze Datei, Gleichheit nach Normalisierung von CRLF
und der letzten Leerzeile. "ergaenzen" / "einfuegen" / "Abschnitt" / "Ausschnitt" =
Fragment, Pruefung auf Enthaltensein.
- Mehrere Bloecke fuer dieselbe Datei: der letzte Ganzdatei-Block gilt (Plan-Reihenfolge).
- Nicht gesehen: Bloecke ohne Pfadzeile (werden als "nicht zugeordnet" gemeldet, nicht
geprueft), Zieldateien ohne Block (gemeldet), semantische Abweichungen bei gleichem
Byteinhalt, Dateien ausserhalb der Liste "## Zieldateien" des Tasks.
- Annahme, die im Skript erzwungen wird: jede Zieldatei aus "## Zieldateien" hat einen
Block; fehlt er, ist das ein gemeldeter Befund (Exit 3), kein stilles Ueberspringen.
Exit-Codes: 0 alles gleich, 1 mindestens eine Abweichung oder fehlende Datei,
3 keine Abweichung, aber Zieldateien ohne Block oder Bloecke ohne Zuordnung.
Aufruf: python plan_diff.py <taskdatei.md> --projekt <projektordner> [--nur <pfad> ...]
"""
from __future__ import annotations
import argparse
import difflib
import re
import sys
from pathlib import Path
ZAUN = re.compile(r"^```")
PFAD_IN_BACKTICKS = re.compile(r"`([^`\s]+\.[A-Za-z0-9]+)`")
# Fragmentwoerter werden ausserhalb der Backtick-Pfade gesucht: "Kopfzeile.tsx" traf sonst
# "zeile" und wurde still nur auf Enthaltensein statt auf Gleichheit geprueft (2026-09-07).
OHNE_PFAD = re.compile(r"`[^`]*`")
FRAGMENT_WOERTER = ("ergänz", "ergaenz", "einfüg", "einfueg", "abschnitt", "ausschnitt", "zeile", "ersetzt durch", "wird ersetzt", "nach der zeile", "nach dem absatz")
def normalisiere(text: str) -> str:
text = text.replace("\r\n", "\n").replace("\r", "\n")
return text.rstrip("\n") + "\n"
def zieldateien(zeilen: list[str]) -> list[str]:
pfade: list[str] = []
aktiv = False
for zeile in zeilen:
if zeile.startswith("## "):
aktiv = zeile.strip().lower().startswith("## zieldateien")
continue
if aktiv:
for treffer in PFAD_IN_BACKTICKS.findall(zeile):
if treffer not in pfade:
pfade.append(treffer)
return pfade
def bloecke(zeilen: list[str], kandidaten: list[str]) -> tuple[dict[str, list[tuple[str, bool]]], list[int]]:
"""Liefert je Pfad die Bloecke (Inhalt, ist_fragment) und die Zeilennummern nicht zugeordneter Bloecke."""
ergebnis: dict[str, list[tuple[str, bool]]] = {}
ohne: list[int] = []
i = 0
n = len(zeilen)
while i < n:
if ZAUN.match(zeilen[i]):
start = i
i += 1
inhalt: list[str] = []
while i < n and not ZAUN.match(zeilen[i]):
inhalt.append(zeilen[i])
i += 1
# Pfadzeile: die naechste Zeile mit einem Zielpfad vor dem Zaun, hoechstens zehn
# zurueck und nicht ueber einen anderen Zaun hinweg. Fragment, wenn die Pfadzeile
# oder eine Zeile zwischen ihr und dem Zaun ein Fragmentwort traegt.
pfad = None
fragment = False
dazwischen: list[str] = []
for k in range(start - 1, max(start - 11, -1), -1):
zeile = zeilen[k].strip()
if ZAUN.match(zeile):
break
treffer = [p for p in PFAD_IN_BACKTICKS.findall(zeile) if p in kandidaten]
if treffer:
pfad = treffer[0]
pruefen = [OHNE_PFAD.sub("", zeile).lower()] + [OHNE_PFAD.sub("", z).lower() for z in dazwischen]
fragment = any(w in z for z in pruefen for w in FRAGMENT_WOERTER)
break
dazwischen.append(zeile)
if pfad is None:
ohne.append(start + 1)
else:
ergebnis.setdefault(pfad, []).append(("\n".join(inhalt) + "\n", fragment))
i += 1
return ergebnis, ohne
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("taskdatei")
parser.add_argument("--projekt", required=True)
parser.add_argument("--nur", nargs="*", default=None)
args = parser.parse_args()
task = Path(args.taskdatei)
projekt = Path(args.projekt)
try:
zeilen = task.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n")
except OSError as fehler:
print(f"Taskdatei nicht lesbar: {fehler}")
return 1
ziele = zieldateien(zeilen)
if args.nur:
ziele = [z for z in ziele if z in args.nur] + [z for z in args.nur if z not in ziele]
if not ziele:
print("Keine Zieldateien im Abschnitt '## Zieldateien' gefunden.")
return 3
gefunden, ohne = bloecke(zeilen, ziele)
abweichung = False
luecke = False
for ziel in ziele:
eintraege = gefunden.get(ziel, [])
datei = projekt / ziel
if not eintraege:
print(f"KEIN BLOCK {ziel}")
luecke = True
continue
try:
ist = normalisiere(datei.read_text(encoding="utf-8"))
except FileNotFoundError:
print(f"FEHLT {ziel}")
abweichung = True
continue
except UnicodeDecodeError as fehler:
print(f"NICHT UTF-8 {ziel}: {fehler}")
abweichung = True
continue
ganze = [inhalt for inhalt, frag in eintraege if not frag]
fragmente = [inhalt for inhalt, frag in eintraege if frag]
if ganze:
soll = normalisiere(ganze[-1])
if soll == ist:
print(f"OK {ziel} ({len(ganze)} Ganzblock/-bloecke, {len(fragmente)} Fragment(e))")
else:
abweichung = True
print(f"DIFF {ziel}")
diff = difflib.unified_diff(soll.splitlines(), ist.splitlines(), "plan", "datei", lineterm="", n=2)
for zeile in list(diff)[:60]:
print(" " + zeile)
for nr, fragment in enumerate(fragmente, 1):
if normalisiere(fragment).rstrip("\n") in ist:
print(f"FRAGMENT OK {ziel} #{nr}")
else:
abweichung = True
print(f"FRAGMENT FEHLT {ziel} #{nr}")
for zeile in normalisiere(fragment).splitlines()[:8]:
print(" | " + zeile)
if ohne:
luecke = True
print(f"NICHT ZUGEORDNET: Bloecke ab Zeile {', '.join(str(z) for z in ohne)} (ohne Pfadzeile oder Pfad nicht in Zieldateien)")
if abweichung:
return 1
return 3 if luecke else 0
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -0,0 +1,32 @@
---
type: resource
status: active
title: "Planwerkzeuge: Plan auf eine Kopie anwenden und jede Welle dagegen vergleichen"
summary: "Fünf Skripte für die Standardschleife bei Plänen mit ausgeschriebenem Code — Plan auf eine Kopie anwenden, je Welle Menge und Bytes vergleichen, Aus-wird-Blöcke prüfen, Codeblöcke auf unsichtbare Zeichen absuchen, Escape-Folgen per Codepunkt setzen. Entstanden 2026-09-17 in der Trading Plattform (Teil 3b)."
tags: [vault-tools, codeworkflow]
created: 2026-09-17
updated: 2026-09-17
---
# Planwerkzeuge für die Kopie mit angewendetem Plan
Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]] („Die Kopie mit dem angewendeten Plan bleibt stehen …"). Hier steht nur, wie man die Skripte benutzt. Alle laufen mit dem Windows-Python: `PYTHONIOENCODING=utf-8 python -X utf8 <skript> …`.
## Ablauf
1. **Kopie bauen — erst, wenn der Planer fertig gemeldet hat.** Den Projektbaum ohne `.git`, ohne Geheimnisdateien und ohne `node_modules` in einen Wegwerfordner kopieren. **Ausschlüsse als Pfade vom Wurzelordner angeben, nicht als Ordnernamen:** Ein Ausschluss von „jedem Ordner namens `data`" hat am 2026-09-17 auch das Paket `trading/data` getroffen, und die Suite der Kopie brach mit Sammelfehlern ab. Nach dem Kopieren die Paketliste gegen das Original halten. Braucht die Kopie `node_modules`, eine Junction setzen (`mklink /J`) — **und sie vor dem Löschen der Kopie mit `cmd //c rmdir <pfad>` wieder lösen**, sonst trifft `rm -rf` das echte `node_modules`. Nie `tsc -b` durch die Junction fahren (schreibt nach `node_modules/.tmp`); `npx tsc -p tsconfig.app.json --noEmit` nehmen.
2. **Plan anwenden:** `plan_materialisiere.py <planordner> <kopie> task-01.md task-02.md …` in Wellenreihenfolge. Erkennt die Marken `Aus:` / `wird:`, „Die ganze Datei ist neu", „vollständig ersetzt" und „Häng diesen Block ans Ende"; Dateien, die `## Zieldateien` als „(gelöscht)" führt, löscht es in der Kopie. Prosa-Tasks („wird: ein neuer Abschnitt, er muss sagen …") lassen sich nicht anwenden.
3. **Auf der Kopie fahren:** Suite, Typprüfung, Mutanten. Ein Mutantenlauf gibt je Mutant die Fehlerzeile aus, nicht nur den Exitcode.
4. **Vor jeder Welle und nach jeder Plankorrektur:** `plan_bloecke.py vor <tasks…> --projekt <projekt>` (jeder Aus-Block steht genau einmal im echten Baum) und `plan_zeichen.py <planordner>` (keine Zeilen- und Absatztrenner, Format- und Steuerzeichen in Codeblöcken).
5. **Nach jeder Welle:** `welle_vergleich.py --projekt <projekt> --mat <kopie> [--prosa <rel> …] -- <taskdateien der Welle>`. Prüft, ob genau die Zieldateien geändert sind und ob jede byte-gleich mit der Kopie ist. Es zeigt je Datei nur die erste Abweichung — danach `diff` auf die ganze Datei. Eigene Plankorrekturen im Baum meldet es zu Recht als „außerhalb der Zieldateien".
6. **Escape-Folgen setzen:** `plan_escape.py <datei> <zeile> <codepunkt-hex> …` ersetzt echte Zeichen in einer Zeile durch ihre Escape-Folge, ohne dass eine Schreibschicht mitredet.
## Was belegt ist
`welle_vergleich_treiber.py` fährt 14 Angriffsfälle gegen `welle_vergleich.py` (Grundlinie zuerst; fremde Datei, unveränderte Zieldatei, Abweichung in erster und letzter Zeile und in der letzten Zieldatei, CRLF, Datei fehlt in der Kopie, Task ohne Zieldateien, Kopie fehlt, Löschung nicht ausgeführt, Prosa-Datei unverändert, Zieldatei außerhalb des Projekts, Datei länger als der Plan). `plan_zeichen.py` hat am 2026-09-17 an einem echten Fund rot gezeigt (U+2028 im Plan). `plan_materialisiere.py` reproduziert Task 108 des Teil-3b-Plans aus dem Stand vor den Wellen byte-genau.
## Was die Skripte nicht sehen
Dateien, die `.gitignore` ausblendet; ob die Kopie selbst sauber ist (eine verschmutzte Kopie erzeugt Fehlalarm, kein falsches Grün); **ob die Kopie älter ist als der Plan** — wird eine Taskdatei nach dem Anwenden noch überarbeitet, meldet `welle_vergleich.py` eine Abweichung, die keine ist (2026-09-17, Teil 4c: der Planer lief noch, als die Kopie entstand). Dann entscheidet `plan_bloecke.py nach` gegen den echten Baum, und die betroffenen Dateien der Kopie werden aus `git show HEAD:<pfad>` neu aufgebaut und der Task erneut angewendet; verwechselbare sichtbare Zeichen (kyrillisches „с"). Sie sind ein Fangnetz, kein Nachweis — den liefert der Lauf mit echten Daten.
Das ältere `.vault-tools/plan-diff/plan_diff.py` vergleicht Ganzdatei-Blöcke einer früheren Plankonvention und bleibt dafür in Gebrauch.
+98
View File
@@ -0,0 +1,98 @@
"""Baut die schlanke Kopie eines Projektbaums fuer den Byte-Vergleich -- Ausschluesse nur als Pfade vom Wurzelordner.
Ersteller: Pascal Michel
Erstellt: 2026-09-17
Doku: .vault-tools/plan-kopie/LIESMICH.md
Aufruf: python kopie_bauen.py <projekt> <ziel> --ohne <relpfad> [<relpfad> ...]
Warum Pfade und keine Namen: Ein Ausschluss von "jedem Ordner namens data" hat am 2026-09-17 auch das Paket
trading/data getroffen. Nach dem Kopieren vergleicht das Skript deshalb die Python-Pakete (Ordner mit __init__.py)
beider Baeume und meldet jedes, das in der Kopie fehlt und nicht unter einem ausgeschlossenen Pfad liegt.
Das Ziel darf nicht existieren; Verknuepfungen und Junctions werden nicht verfolgt, sondern uebersprungen.
"""
import argparse
import os
import shutil
import sys
from pathlib import Path
def _relativ(pfad: Path, wurzel: Path) -> str:
return pfad.relative_to(wurzel).as_posix()
def _pakete(wurzel: Path, ohne: set[str]) -> set[str]:
gefunden = set()
for ordner, unterordner, dateien in os.walk(wurzel):
rel = _relativ(Path(ordner), wurzel)
unterordner[:] = [
name for name in unterordner
if (f"{rel}/{name}" if rel != "." else name) not in ohne
and not Path(ordner, name).is_symlink()
and not Path(ordner, name).is_junction()
]
if "__init__.py" in dateien:
gefunden.add(rel)
return gefunden
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("projekt", type=Path)
parser.add_argument("ziel", type=Path)
parser.add_argument("--ohne", nargs="*", default=[], help="relative Pfade vom Wurzelordner, mit /")
args = parser.parse_args()
projekt = args.projekt.resolve()
ziel = args.ziel.resolve()
if not projekt.is_dir():
print(f"BEFUND Projekt {projekt} ist kein Ordner")
return 2
if ziel.exists():
print(f"BEFUND Ziel {ziel} existiert schon -- erst ansehen, dann selbst loeschen")
return 2
ohne = {eintrag.replace("\\", "/").strip("/") for eintrag in args.ohne}
befunde = []
for eintrag in sorted(ohne):
if "/" not in eintrag and not (projekt / eintrag).exists():
befunde.append(f"Ausschluss '{eintrag}' gibt es im Wurzelordner nicht -- ein Name statt eines Pfads?")
elif not (projekt / eintrag).exists():
befunde.append(f"Ausschluss '{eintrag}' gibt es nicht")
uebersprungen = []
def ignoriere(ordner: str, namen: list[str]) -> list[str]:
rel = _relativ(Path(ordner), projekt)
raus = []
for name in namen:
pfad = Path(ordner, name)
relpfad = name if rel == "." else f"{rel}/{name}"
if relpfad in ohne or name == "__pycache__":
raus.append(name)
elif pfad.is_symlink() or pfad.is_junction():
uebersprungen.append(relpfad)
raus.append(name)
return raus
shutil.copytree(projekt, ziel, ignore=ignoriere)
fehlend = sorted(_pakete(projekt, ohne) - _pakete(ziel, set()))
for paket in fehlend:
befunde.append(f"Paket '{paket}' fehlt in der Kopie")
dateien = sum(len(d) for _, _, d in os.walk(ziel))
print(f"{dateien} Dateien kopiert, {len(ohne)} Pfade ausgeschlossen, "
f"{len(uebersprungen)} Verknuepfungen uebersprungen, {len(_pakete(ziel, set()))} Pakete in der Kopie.")
for zeile in uebersprungen:
print(f"HINWEIS Verknuepfung uebersprungen: {zeile}")
for zeile in befunde:
print(f"BEFUND {zeile}")
print(f"{len(befunde)} Befund(e).")
return 1 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+125
View File
@@ -0,0 +1,125 @@
"""Prueft die Aus-/wird-Bloecke einer Plan-Taskdatei gegen den Projektbaum.
Koordinator-Werkzeug im Scratchpad. Geltungsbereich (Fangnetz, kein Nachweis):
- Sieht nur Codebloecke, deren letzte nicht leere Zeile vor dem Zaun genau "Aus:" oder
"wird:" ist (Projektkonvention der Planer-Taskdateien seit Teil 5). Die Zieldatei ist der
letzte Pfad in Backticks in einer Ueberschrift (##, ###, ####) oberhalb des Blocks, der
auf eine im Projekt vorhandene oder unter "## Zieldateien" genannte Datei zeigt.
- Modus vor: jeder Aus-Block steht genau einmal in seiner Zieldatei (0 = Befund, >1 = Befund
"mehrdeutig"). Belegt, dass der Plan den heutigen Stand byte-genau zitiert.
- Modus nach: jeder wird-Block steht in der Zieldatei; jeder Aus-Block steht nicht mehr darin,
ausser er ist Teil seines eigenen wird-Blocks (Einfuegung).
- Ganzdatei-Bloecke ("neu anlegen") sieht es nicht; dafuer ist .vault-tools/plan-diff/plan_diff.py da.
- Vergleich nach Normalisierung CRLF -> LF. Eine fehlende Zieldatei ist im Modus vor fuer
Aus-Bloecke ein Befund, im Modus nach fuer beide.
Exit: 0 kein Befund, 2 Befunde, 3 kein einziger Aus-/wird-Block gefunden (nichts geprueft).
"""
import argparse
import re
import sys
from pathlib import Path
ZAUN = re.compile(r"^```")
UEBERSCHRIFT = re.compile(r"^#{2,4} ")
PFAD = re.compile(r"`([^`\s]+/[^`\s]+\.[A-Za-z0-9]+|[^`\s/]+\.(?:py|ts|tsx|toml|md|css|json))`")
def normal(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def lies_bloecke(taskdatei: Path, projekt: Path) -> list[dict]:
zeilen = normal(taskdatei.read_text(encoding="utf-8")).split("\n")
bloecke: list[dict] = []
ziel: str | None = None
i = 0
while i < len(zeilen):
zeile = zeilen[i]
if UEBERSCHRIFT.match(zeile):
for treffer in PFAD.findall(zeile):
ziel = treffer
if ZAUN.match(zeile):
start = i
# Ein Zaun aus n Backticks schliesst erst mit mindestens n Backticks: Ein
# Vier-Backtick-Block darf einen Drei-Backtick-Block enthalten (Doku-Tasks).
laenge = len(zeile) - len(zeile.lstrip("`"))
i += 1
inhalt: list[str] = []
while i < len(zeilen) and not (
zeilen[i].startswith("`" * laenge) and not zeilen[i].strip("`").strip()
):
inhalt.append(zeilen[i])
i += 1
marke = ""
k = start - 1
while k >= 0 and not zeilen[k].strip():
k -= 1
if k >= 0:
marke = zeilen[k].strip()
if marke in ("Aus:", "wird:"):
bloecke.append({"art": marke[:-1], "ziel": ziel, "zeile": start + 1, "text": "\n".join(inhalt)})
i += 1
return bloecke
def main() -> int:
zerleger = argparse.ArgumentParser()
zerleger.add_argument("modus", choices=["vor", "nach"])
zerleger.add_argument("taskdateien", nargs="+")
zerleger.add_argument("--projekt", required=True)
argumente = zerleger.parse_args()
projekt = Path(argumente.projekt)
befunde = 0
geprueft = 0
for name in argumente.taskdateien:
taskdatei = Path(name)
bloecke = lies_bloecke(taskdatei, projekt)
paare = 0
for nummer, block in enumerate(bloecke):
if block["ziel"] is None:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: {block['art']}-Block ohne Zieldatei in einer Ueberschrift")
befunde += 1
continue
datei = projekt / block["ziel"]
if not datei.exists():
if argumente.modus == "nach" or block["art"] == "Aus":
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Zieldatei {block['ziel']} fehlt")
befunde += 1
continue
inhalt = normal(datei.read_text(encoding="utf-8"))
anzahl = inhalt.count(block["text"])
geprueft += 1
if argumente.modus == "vor":
if block["art"] != "Aus":
continue
paare += 1
if anzahl == 0:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Aus-Block steht nicht in {block['ziel']}")
befunde += 1
elif anzahl > 1:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Aus-Block steht {anzahl}-mal in {block['ziel']} (mehrdeutig)")
befunde += 1
else:
if block["art"] == "wird":
paare += 1
if anzahl == 0:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: wird-Block steht nicht in {block['ziel']}")
befunde += 1
else:
folge = bloecke[nummer + 1] if nummer + 1 < len(bloecke) else None
einfuegung = folge is not None and folge["art"] == "wird" and block["text"] in folge["text"]
if anzahl > 0 and not einfuegung:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Aus-Block steht noch in {block['ziel']}")
befunde += 1
print(f"{taskdatei.name}: {len(bloecke)} Aus-/wird-Bloecke, im Modus {argumente.modus} geprueft: {paare}")
if geprueft == 0:
print("Nichts geprueft: kein Aus-/wird-Block gefunden.")
return 3
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
"""Ersetzt in EINER Zeile einer Datei bestimmte echte Zeichen durch ihre sechs ASCII-Zeichen lange Escape-Folge.
Arbeitet mit Codepunkten; im eigenen Quelltext steht keine Escape-Folge, die eine Schicht auswerten koennte.
Aufruf: python plan_escape.py <datei> <zeilennummer> <codepunkt-hex> [<codepunkt-hex> ...]
Beispiel: python plan_escape.py task-107.md 311 2122 0441
Exit 0, wenn mindestens ein Zeichen ersetzt wurde; 2, wenn die Zeile keines der Zeichen traegt.
"""
import sys
from pathlib import Path
datei = Path(sys.argv[1])
nummer = int(sys.argv[2])
punkte = [int(p, 16) for p in sys.argv[3:]]
text = datei.read_bytes().decode("utf-8")
zeilen = text.split("\n")
zeile = zeilen[nummer - 1]
ersetzt = 0
for punkt in punkte:
zeichen = chr(punkt)
folge = chr(92) + "u" + format(punkt, "04x")
ersetzt += zeile.count(zeichen)
zeile = zeile.replace(zeichen, folge)
if ersetzt == 0:
print(f"BEFUND: Zeile {nummer} traegt keines der Zeichen -- nichts geaendert: {zeilen[nummer - 1][:100]!r}")
sys.exit(2)
zeilen[nummer - 1] = zeile
with open(datei, "w", encoding="utf-8", newline="") as strom:
strom.write("\n".join(zeilen))
print(f"{ersetzt} Zeichen ersetzt in Zeile {nummer}: {zeile.strip()[:140]}")
@@ -0,0 +1,153 @@
"""Wendet die Codebloecke der Taskdateien eines Plans in Wellenreihenfolge auf eine Kopie des Projekts an.
Koordinator-Werkzeug (entstanden 2026-09-17, Trading Plattform Teil 3b). Es tut etwas und beurteilt
nichts; das Urteil faellt danach mit Suite, Mutanten und welle_vergleich.py.
Geltungsbereich:
- Zieldatei ist der letzte Pfad in Backticks in einer Ueberschrift (## bis ####) oberhalb des Blocks.
- Dateien, die "## Zieldateien" als "(gelöscht)" fuehrt, werden in der Kopie geloescht.
- Bloecke mit Einrueckung (Befehle in Listen) und Prosa-Vorgaben ("wird: ein neuer Abschnitt ...")
werden uebergangen: Ein Doku-Task mit frei zu formulierendem Text laesst sich nicht anwenden.
- Marke ist die letzte nicht leere Zeile vor dem Zaun:
"Aus:" -> merkt den Block als alten Text
"wird:" -> ersetzt den gemerkten Text (muss genau einmal vorkommen)
enthaelt "Die ganze Datei ist neu" -> schreibt die Datei
beginnt mit "Häng diesen Block ans Ende" -> haengt an
Alles andere (Befehle, erwartete Ausgaben, Prosa-"wird: ...") wird uebergangen und gezaehlt.
- Zaeune aus n Backticks schliessen erst mit n Backticks.
- Geschrieben wird immer mit LF.
Aufruf: python plan_materialisiere.py <planordner> <kopie> <taskdatei> [<taskdatei> ...]
Exit 0 ohne Befund, 2 mit Befund.
"""
import re
import sys
from pathlib import Path
UEBERSCHRIFT = re.compile(r"^#{2,4} ")
PFAD = re.compile(r"`([^`\s]+/[^`\s]+\.[A-Za-z0-9]+|[^`\s/]+\.(?:py|ts|tsx|toml|md|css|json))`")
def normal(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def bloecke(taskdatei: Path):
zeilen = normal(taskdatei.read_text(encoding="utf-8")).split("\n")
ziel = None
i = 0
while i < len(zeilen):
zeile = zeilen[i]
if UEBERSCHRIFT.match(zeile):
treffer = PFAD.findall(zeile)
if treffer:
ziel = treffer[-1]
if zeile.lstrip().startswith("```"):
einzug = len(zeile) - len(zeile.lstrip())
kern = zeile.lstrip()
laenge = len(kern) - len(kern.lstrip("`"))
start = i
i += 1
inhalt = []
while i < len(zeilen):
kandidat = zeilen[i].lstrip()
if kandidat.startswith("`" * laenge) and not kandidat.strip("`").strip():
break
inhalt.append(zeilen[i][einzug:] if zeilen[i][:einzug].strip() == "" else zeilen[i])
i += 1
k = start - 1
while k >= 0 and not zeilen[k].strip():
k -= 1
marke = zeilen[k].strip() if k >= 0 else ""
yield {"ziel": ziel, "marke": marke, "zeile": start + 1, "text": "\n".join(inhalt), "einzug": einzug}
i += 1
LOESCHZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
def loesche(taskdatei: Path, kopie: Path) -> int:
"""Loescht, was der Abschnitt "## Zieldateien" als "(gelöscht)" fuehrt.
Der Pfad dort ist absolut und zeigt ins echte Projekt. Geloescht wird in der Kopie die
Datei am laengsten Pfadende, das dort existiert -- nie etwas ausserhalb der Kopie.
"""
drin = False
anzahl = 0
for zeile in normal(taskdatei.read_text(encoding="utf-8")).split("\n"):
if zeile.startswith("## "):
drin = zeile.startswith("## Zieldateien")
continue
treffer = LOESCHZIEL.match(zeile) if drin else None
if not treffer or "gelöscht" not in treffer.group(2):
continue
teile = treffer.group(1).replace("\\", "/").split("/")
for anfang in range(len(teile)):
kandidat = kopie.joinpath(*teile[anfang:])
if ":" not in teile[anfang] and kandidat.is_file():
kandidat.unlink()
anzahl += 1
break
return anzahl
def main() -> int:
plan, kopie = Path(sys.argv[1]), Path(sys.argv[2])
befunde = 0
for name in sys.argv[3:]:
taskdatei = plan / name
alt = None
zaehler = {"neu": 0, "ersetzt": 0, "angehaengt": 0, "uebergangen": 0}
for block in bloecke(taskdatei):
marke, ziel = block["marke"], block["ziel"]
if block["einzug"] > 0:
zaehler["uebergangen"] += 1
continue
if marke == "Aus:" or marke.startswith("Aus, Zeile"):
alt = block
continue
if marke == "wird:":
if alt is None or ziel is None:
print(f"BEFUND {name}:{block['zeile']}: wird-Block ohne Aus-Block oder ohne Zieldatei")
befunde += 1
continue
pfad = kopie / ziel
if not pfad.exists():
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} fehlt in der Kopie")
befunde += 1
alt = None
continue
text = normal(pfad.read_text(encoding="utf-8"))
anzahl = text.count(alt["text"])
if anzahl != 1:
print(f"BEFUND {name}:{alt['zeile']}: Aus-Block steht {anzahl}-mal in {ziel}")
befunde += 1
else:
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(text.replace(alt["text"], block["text"]))
zaehler["ersetzt"] += 1
alt = None
continue
alt = None
if ("Die ganze Datei ist neu" in marke or "vollständig ersetzt" in marke) and ziel is not None:
pfad = kopie / ziel
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(block["text"].rstrip("\n") + "\n")
zaehler["neu"] += 1
continue
if marke.startswith("Häng diesen Block ans Ende") and ziel is not None:
pfad = kopie / ziel
text = normal(pfad.read_text(encoding="utf-8"))
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(text.rstrip("\n") + "\n" + block["text"].rstrip("\n") + "\n")
zaehler["angehaengt"] += 1
continue
zaehler["uebergangen"] += 1
zaehler["geloescht"] = loesche(taskdatei, kopie)
print(f"{name}: {zaehler}")
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+59
View File
@@ -0,0 +1,59 @@
"""Sucht in den Codebloecken eines Plans nach Zeichen, die ein Executer nicht sehen oder nicht tippen kann.
Koordinator-Werkzeug. Frage: Steht in einem Codeblock ein Zeichen, das beim Abschreiben verloren geht?
Herkunft der Antwort: Unicode-Kategorie des Zeichens (unicodedata, Quelle: Python-Standardbibliothek).
Befund ist jedes Zeichen der Kategorien Zl, Zp, Cf, Cc (ausser \\n und \\t) und Zs ausser dem Leerzeichen --
also Zeilen- und Absatztrenner, Formatzeichen, Steuerzeichen, geschuetzte und schmale Leerzeichen.
Alle uebrigen Nicht-ASCII-Zeichen in Codebloecken werden nur gezaehlt und gezeigt (Umlaute, Gedankenstriche).
Was es nicht sieht: Zeichen ausserhalb von Codezaeunen; verwechselbare sichtbare Zeichen (kyrillisches c).
Aufruf: python plan_zeichen.py <planordner> Exit 0 ohne Befund, 2 mit Befund.
"""
import sys
import unicodedata
from collections import Counter
from pathlib import Path
def main() -> int:
plan = Path(sys.argv[1])
dateien = sorted(plan.glob("*.md"))
if not dateien:
print(f"BEFUND {plan}: keine Markdown-Dateien -- der Lauf misst nichts")
return 2
befunde = 0
sichtbar: Counter[str] = Counter()
bloecke = 0
for datei in dateien:
zeilen = datei.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n")
zaun = 0
for nummer, zeile in enumerate(zeilen, start=1):
kern = zeile.lstrip()
if kern.startswith("```"):
laenge = len(kern) - len(kern.lstrip("`"))
if zaun == 0:
zaun = laenge
bloecke += 1
elif laenge >= zaun and not kern.strip("`").strip():
zaun = 0
continue
if zaun == 0:
continue
for spalte, zeichen in enumerate(zeile, start=1):
if ord(zeichen) < 128 and zeichen not in "\x0b\x0c\x1b" and (zeichen >= " " or zeichen == "\t"):
continue
kategorie = unicodedata.category(zeichen)
if kategorie in ("Zl", "Zp", "Cf", "Cc") or (kategorie == "Zs" and zeichen != " "):
name = unicodedata.name(zeichen, "?")
print(f"BEFUND {datei.name}:{nummer}:{spalte}: U+{ord(zeichen):04X} {name} ({kategorie}) in: {zeile.strip()[:110]!r}")
befunde += 1
else:
sichtbar[zeichen] += 1
print(f"{len(dateien)} Dateien, {bloecke} Codebloecke gelesen.")
print("Sichtbare Nicht-ASCII-Zeichen in Codebloecken: " + " ".join(f"{z}(U+{ord(z):04X})x{n}" for z, n in sichtbar.most_common()))
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+168
View File
@@ -0,0 +1,168 @@
"""Gegenpruefung nach einer Executer-Welle: Menge der geaenderten Dateien und Byte-Vergleich gegen die Kopie.
Koordinator-Werkzeug im Scratchpad. Fragen und Herkunft der Antwort:
1. Welche Dateien hat die Welle geaendert? Quelle: `git status --porcelain -z` im Projekt.
2. Welche durfte sie aendern? Quelle: Abschnitt "## Zieldateien" der uebergebenen Taskdateien.
3. Ist jede Zieldatei byte-gleich mit dem Plan? Quelle: die Kopie `mat`, auf der plan_materialisiere.py
alle Codebloecke des Plans angewendet hat.
Was es NICHT sieht (Fangnetz, kein Nachweis):
- Dateien, die .gitignore ausblendet (data/, artifacts/, node_modules/): Ein Executer, der dorthin
schreibt, bleibt unsichtbar.
- Ob die Kopie selbst sauber ist. Eine verschmutzte Kopie erzeugt Fehlalarm, kein falsches Gruen.
- Prosa-Dateien (--prosa): nur Mengenpruefung, kein Byte-Vergleich; die liest der Koordinator selbst.
- Bereits committete Aenderungen: Verglichen wird der Arbeitsbaum gegen HEAD.
Eine fehlende Voraussetzung (Kopie fehlt, Taskdatei ohne Zieldateien, git laeuft nicht) ist ein Befund.
Aufruf: python welle_vergleich.py --projekt <pfad> --mat <pfad> [--prosa <rel> ...] <taskdatei> [...]
Exit 0 ohne Befund, 2 mit Befund.
"""
import argparse
import re
import subprocess
import sys
import traceback
from pathlib import Path, PureWindowsPath
ZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
def zieldateien(taskdatei: Path, projekt: Path, befunde: list[str]) -> dict[str, bool]:
"""Relativer Pfad -> True, wenn die Datei geloescht werden soll."""
try:
zeilen = taskdatei.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n")
except OSError as fehler:
befunde.append(f"{taskdatei.name}: nicht lesbar ({fehler})")
return {}
drin = False
ziele: dict[str, bool] = {}
for zeile in zeilen:
if zeile.startswith("## "):
drin = zeile.startswith("## Zieldateien")
continue
if not drin:
continue
treffer = ZIEL.match(zeile)
if not treffer:
continue
absolut = PureWindowsPath(treffer.group(1))
try:
rel = absolut.relative_to(PureWindowsPath(str(projekt)))
except ValueError:
befunde.append(f"{taskdatei.name}: Zieldatei {absolut} liegt nicht unter dem Projekt {projekt}")
continue
ziele[rel.as_posix()] = "gelöscht" in treffer.group(2) or "geloescht" in treffer.group(2)
if not ziele:
befunde.append(f"{taskdatei.name}: keine Zieldateien gefunden -- ohne sie misst der Lauf nichts")
return ziele
def geaendert(projekt: Path, befunde: list[str]) -> dict[str, str]:
"""Relativer Pfad -> zweistelliger Status aus git."""
try:
ergebnis = subprocess.run(
["git", "-C", str(projekt), "status", "--porcelain", "-z", "--untracked-files=all"],
capture_output=True,
stdin=subprocess.DEVNULL,
)
except OSError as fehler:
befunde.append(f"git laesst sich nicht starten ({fehler})")
return {}
if ergebnis.returncode != 0:
befunde.append(f"git status endet mit {ergebnis.returncode}: {ergebnis.stderr.decode('utf-8', 'replace').strip()}")
return {}
teile = ergebnis.stdout.decode("utf-8", "replace").split("\0")
aus: dict[str, str] = {}
i = 0
while i < len(teile):
eintrag = teile[i]
i += 1
if len(eintrag) < 4:
continue
status, pfad = eintrag[:2], eintrag[3:]
if status[0] in "RC":
alt = teile[i] if i < len(teile) else "?"
i += 1
befunde.append(f"{pfad}: git meldet Umbenennung oder Kopie aus {alt} -- im Plan nicht vorgesehen")
aus[pfad] = status
return aus
def erste_abweichung(echt: bytes, kopie: bytes) -> str:
if echt.replace(b"\r\n", b"\n") == kopie.replace(b"\r\n", b"\n"):
return "nur die Zeilenenden weichen ab (CRLF gegen LF)"
a, b = echt.split(b"\n"), kopie.split(b"\n")
for nummer, (links, rechts) in enumerate(zip(a, b), start=1):
if links != rechts:
return f"Zeile {nummer}: Baum {links[:160]!r} | Plan {rechts[:160]!r}"
return f"Laenge: Baum {len(a)} Zeilen, Plan {len(b)} Zeilen; die gemeinsamen Zeilen sind gleich"
def pruefe(args: argparse.Namespace, befunde: list[str]) -> None:
projekt, mat = Path(args.projekt), Path(args.mat)
if not projekt.is_dir():
befunde.append(f"Projektordner {projekt} fehlt")
return
if not mat.is_dir():
befunde.append(f"Kopie {mat} fehlt -- ohne sie gibt es keinen Byte-Vergleich")
return
prosa = {p.replace("\\", "/") for p in args.prosa}
ziele: dict[str, bool] = {}
for name in args.taskdateien:
ziele.update(zieldateien(Path(name), projekt, befunde))
status = geaendert(projekt, befunde)
for pfad in sorted(set(status) - set(ziele)):
befunde.append(f"{pfad}: geaendert ({status[pfad]!r}), liegt aber ausserhalb der Zieldateien dieser Welle")
for pfad in sorted(prosa - set(ziele)):
befunde.append(f"{pfad}: als Prosa uebergeben, steht aber in keiner Taskdatei als Zieldatei")
gleich = 0
for pfad, loeschen in sorted(ziele.items()):
echt, kopie = projekt / pfad, mat / pfad
if loeschen:
if echt.exists():
befunde.append(f"{pfad}: soll geloescht sein, steht aber noch im Baum")
if kopie.exists():
befunde.append(f"{pfad}: soll geloescht sein, steht aber noch in der Kopie")
if not echt.exists() and "D" not in status.get(pfad, ""):
befunde.append(f"{pfad}: fehlt im Baum, git meldet aber keine Loeschung ({status.get(pfad)!r})")
continue
if pfad not in status:
befunde.append(f"{pfad}: Zieldatei ist unveraendert gegenueber HEAD")
continue
if not echt.is_file():
befunde.append(f"{pfad}: Zieldatei fehlt im Baum")
continue
if pfad in prosa:
continue
if not kopie.is_file():
befunde.append(f"{pfad}: fehlt in der Kopie -- kein Byte-Vergleich moeglich")
continue
a, b = echt.read_bytes(), kopie.read_bytes()
if a == b:
gleich += 1
else:
befunde.append(f"{pfad}: weicht vom Plan ab -- {erste_abweichung(a, b)}")
print(f"{len(ziele)} Zieldateien, {len(status)} geaenderte Dateien laut git, {gleich} byte-gleich mit der Kopie, {len(prosa & set(ziele))} nur als Menge geprueft.")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--projekt", required=True)
parser.add_argument("--mat", required=True)
parser.add_argument("--prosa", nargs="*", default=[])
parser.add_argument("taskdateien", nargs="+")
args = parser.parse_args()
befunde: list[str] = []
try:
pruefe(args, befunde)
except Exception: # Ein Absturz ist selbst ein Befund; die gesammelten Befunde gehen trotzdem raus.
befunde.append("unerwarteter Abbruch: " + traceback.format_exc().strip().splitlines()[-1])
for befund in befunde:
print("BEFUND " + befund)
print(f"{len(befunde)} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,168 @@
"""Regressionsnachweis fuer welle_vergleich.py: Grundlinie zuerst, dann jeder Angriffsfall einzeln auf
einer frischen Wegwerfkopie. Je Fall werden Exit-Code und ausloesende Meldung geprueft.
Aufruf: python welle_vergleich_treiber.py Exit 0, wenn jeder Fall so ausgeht wie erwartet.
"""
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
WERKZEUG = Path(__file__).parent / "welle_vergleich.py"
def git(ordner: Path, *args: str) -> None:
subprocess.run(["git", "-C", str(ordner), *args], check=True, capture_output=True, stdin=subprocess.DEVNULL)
def schreibe(pfad: Path, text: str) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_bytes(text.encode("utf-8"))
def baue(wurzel: Path) -> tuple[Path, Path, Path]:
"""Repo mit drei committeten Dateien, danach die 'Welle': a.py geaendert, sub/neu.py neu, weg.py geloescht,
doku.md geaendert (Prosa). Die Kopie traegt denselben Stand."""
projekt, mat = wurzel / "projekt", wurzel / "mat"
projekt.mkdir()
git(projekt, "init", "-q")
git(projekt, "config", "user.email", "[email protected]")
git(projekt, "config", "user.name", "Treiber")
git(projekt, "config", "core.autocrlf", "false")
schreibe(projekt / "a.py", "eins\nzwei\ndrei\n")
schreibe(projekt / "b.py", "bleibt\n")
schreibe(projekt / "weg.py", "weg\n")
schreibe(projekt / "doku.md", "alt\n")
git(projekt, "add", "-A")
git(projekt, "commit", "-q", "-m", "basis")
schreibe(projekt / "a.py", "EINS\nzwei\nDREI\n")
schreibe(projekt / "sub" / "neu.py", "neu eins\nneu zwei\n")
(projekt / "weg.py").unlink()
schreibe(projekt / "doku.md", "neue Prosa des Executers\n")
schreibe(mat / "a.py", "EINS\nzwei\nDREI\n")
schreibe(mat / "b.py", "bleibt\n")
schreibe(mat / "sub" / "neu.py", "neu eins\nneu zwei\n")
schreibe(mat / "doku.md", "anderer Stand in der Kopie\n")
task = wurzel / "task-01.md"
schreibe(
task,
"# Task\n\n## Zieldateien\n\n"
f"- `{projekt}\\a.py` (geändert)\n"
f"- `{projekt}\\sub\\neu.py` (neu)\n"
f"- `{projekt}\\weg.py` (gelöscht)\n"
f"- `{projekt}\\doku.md` (geändert)\n\n"
"Keine andere Datei wird angefasst.\n\n## Regeln\n\n- `nicht/gemeint.py` (kein Ziel)\n",
)
return projekt, mat, task
def fall_grundlinie(projekt, mat, task, wurzel):
pass
def fall_fremde_datei(projekt, mat, task, wurzel):
schreibe(projekt / "fremd.py", "x\n")
def fall_ziel_unveraendert(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "eins\nzwei\ndrei\n")
def fall_abweichung_erste_zeile(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "Eins\nzwei\nDREI\n")
def fall_abweichung_letzte_zeile(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "EINS\nzwei\nDrei\n")
def fall_abweichung_letzte_zieldatei(projekt, mat, task, wurzel):
schreibe(projekt / "sub" / "neu.py", "neu eins\nneu ZWEI\n")
def fall_crlf(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "EINS\r\nzwei\r\nDREI\r\n")
def fall_fehlt_in_kopie(projekt, mat, task, wurzel):
(mat / "sub" / "neu.py").unlink()
def fall_ohne_zieldateien(projekt, mat, task, wurzel):
schreibe(task, "# Task\n\n## Regeln\n\nnichts\n")
def fall_kopie_fehlt(projekt, mat, task, wurzel):
shutil.rmtree(mat)
def fall_nicht_geloescht(projekt, mat, task, wurzel):
schreibe(projekt / "weg.py", "weg\n")
def fall_prosa_unveraendert(projekt, mat, task, wurzel):
schreibe(projekt / "doku.md", "alt\n")
def fall_ziel_ausserhalb(projekt, mat, task, wurzel):
text = task.read_text(encoding="utf-8")
schreibe(task, text.replace("## Regeln", "") .replace("Keine andere Datei", "- `C:\\anderswo\\x.py` (neu)\n\nKeine andere Datei"))
def fall_laengere_datei(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "EINS\nzwei\nDREI\nvier\n")
FAELLE = [
("Grundlinie", fall_grundlinie, 0, "0 Befund(e)."),
("fremde Datei im Baum", fall_fremde_datei, 2, "fremd.py: geaendert"),
("Zieldatei unveraendert", fall_ziel_unveraendert, 2, "a.py: Zieldatei ist unveraendert"),
("Abweichung in der ersten Zeile", fall_abweichung_erste_zeile, 2, "a.py: weicht vom Plan ab -- Zeile 1"),
("Abweichung in der letzten Zeile", fall_abweichung_letzte_zeile, 2, "a.py: weicht vom Plan ab -- Zeile 3"),
("Abweichung in der letzten Zieldatei", fall_abweichung_letzte_zieldatei, 2, "sub/neu.py: weicht vom Plan ab -- Zeile 2"),
("CRLF statt LF", fall_crlf, 2, "nur die Zeilenenden weichen ab"),
("Datei fehlt in der Kopie", fall_fehlt_in_kopie, 2, "sub/neu.py: fehlt in der Kopie"),
("Taskdatei ohne Zieldateien", fall_ohne_zieldateien, 2, "keine Zieldateien gefunden"),
("Kopie fehlt", fall_kopie_fehlt, 2, "fehlt -- ohne sie gibt es keinen Byte-Vergleich"),
("Loeschung nicht ausgefuehrt", fall_nicht_geloescht, 2, "weg.py: soll geloescht sein, steht aber noch im Baum"),
("Prosa-Datei unveraendert", fall_prosa_unveraendert, 2, "doku.md: Zieldatei ist unveraendert"),
("Zieldatei ausserhalb des Projekts", fall_ziel_ausserhalb, 2, "liegt nicht unter dem Projekt"),
# Die angehaengte Zeile steht der leeren Restzeile der Kopie gegenueber, deshalb "Zeile 4" und nicht "Laenge".
("Datei laenger als der Plan", fall_laengere_datei, 2, "a.py: weicht vom Plan ab -- Zeile 4: Baum b'vier'"),
]
def main() -> int:
fehler = 0
for name, angriff, exit_erwartet, meldung in FAELLE:
wurzel = Path(tempfile.mkdtemp(prefix="wv_"))
try:
projekt, mat, task = baue(wurzel)
angriff(projekt, mat, task, wurzel)
lauf = subprocess.run(
[sys.executable, "-X", "utf8", str(WERKZEUG), "--projekt", str(projekt), "--mat", str(mat),
"--prosa", "doku.md", "--", str(task)],
capture_output=True, text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL,
)
ausgabe = lauf.stdout + lauf.stderr
gut = lauf.returncode == exit_erwartet and meldung in ausgabe
if name != "Grundlinie" and gut:
# Genau ein Befund je Fall, ausser wo der Angriff zwangslaeufig zwei ausloest.
pass
print(f"{'ok ' if gut else 'FEHLER'} {name}: Exit {lauf.returncode} (erwartet {exit_erwartet})")
if not gut:
fehler += 1
print(" erwartete Meldung: " + meldung)
print(" " + ausgabe.strip().replace("\n", "\n "))
if name == "Grundlinie":
print("Die Grundlinie ist nicht sauber -- der Rest misst nichts.")
return 2
finally:
shutil.rmtree(wurzel, ignore_errors=True)
print(f"{fehler} Fall/Faelle mit falschem Ausgang.")
return 2 if fehler else 0
if __name__ == "__main__":
sys.exit(main())
+64
View File
@@ -0,0 +1,64 @@
# Entwurf: Konsistenzprüfung der Anweisungsschicht
Geschrieben am 2026-08-30 **vor** der ersten Zeile Code, wie es [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]] §1 verlangt.
## Was die Prüfung beantworten soll — und woher die Antwort kommt
| # | Frage | Herkunft | Auflösung |
|---|---|---|---|
| 1 | Zeigt jeder Wikilink auf eine existierende Datei? | **Quelle** — Dateisystem | Ziel ist `<link>.md` oder `<link>` relativ zum Vault-Root. Alias nach `\|` wird abgeschnitten, Anker nach `#` ebenso. |
| 2 | Trägt jede Regelwerk-Datei die Pflichtfelder im Frontmatter? | **Projektvorgabe** — abgelesen an den sieben bestehenden Dateien | `type, status, title, summary, tags, created, updated` |
| 3 | Ist jede Regelwerk-Datei vom Stamm aus verlinkt? | **Projektvorgabe** — CLAUDE.md, Abschnitt „Regelwerk" | Eine Datei, die niemand verlinkt, wird nie geladen. |
| 4 | Steht eine Regelwerk-Datei auf `status: pending-review`? | **Projektvorgabe** — CLAUDE.md, Abschnitt „Selbstpflege" | Hinweis, kein Fehler: die Freigabe steht aus. |
| 5 | Weicht das `model:` einer Agentendefinition von der Rollentabelle im Stamm ab? | **Projektvorgabe** — CLAUDE.md, Tabelle „Agenten & Modelle" | Die Tabelle wird **gelesen**, nicht im Skript nachgebaut. |
| 6 | Nennen mehrere Dateien die Planablage-Konvention widersprüchlich? | **Projektvorgabe** — `codeworkflow.md` ist maßgeblich | Siehe Auflösung unten. |
## Die drei Annahmen, die vor dem Code aufgelöst wurden
**Annahme A: „Ein Pfad in Backticks ist ein Vault-Pfad."** Falsch. Backticks tragen auch Befehle (`python …`), Frontmatter-Schlüssel (`modell:`), Dateinamen ohne Ort (`CHANGELOG.md`) und Beispielwerte. Eine allgemeine Pfadprüfung wäre eine Heuristik im Positivlisten-Gewand.
→ **Aufgelöst durch Streichung.** Frage 2 der ersten Fassung („existiert jeder Pfad in Backticks") ist nicht Teil der Prüfung. Wikilinks werden geprüft, Backtick-Pfade nicht. Das steht als benannte Lücke im Kopfkommentar.
**Annahme B: „Aus Fließtext lässt sich ablesen, welches Modell ein Agent fährt."** Falsch. „Executer-Subagent (`executer`, Sonnet — bei reiner Abschrift Haiku…)" ist für einen Menschen eindeutig und für ein Skript nicht.
→ **Aufgelöst durch Einschränkung des Geltungsbereichs.** Geprüft wird ausschließlich das `model:`-Feld im Frontmatter der Agentendateien gegen die Rollentabelle im Stamm. Fließtextaussagen über Modelle prüft das Skript **nicht** — benannte Lücke.
**Annahme C: „Eine widersprüchliche Konvention ist maschinell erkennbar."** Nur eingeschränkt.
→ **Aufgelöst durch eine erzwungene Form.** Das Skript sucht das Literal `docs/plans/JJJJ-MM-TT` und klassifiziert das unmittelbar folgende Zeichen: `/` = Ordnerform, `.` = Dateiform. Regel: **Wer die Dateiform nennt, ohne die Ordnerform zu nennen, ist ein Befund** — denn `codeworkflow.md` schreibt beide vor und die Ordnerform ist die, die vergessen wird. Genau dieser Fall lag am 2026-08-30 in `planer.md` vor.
## Positivliste, nicht Negativliste
Alle sechs Prüfungen schränken **geschlossene** Mengen ein: die Menge der Wikilinks in einer Datei, die Menge der Pflichtfelder, die Menge der Agentendateien, die Menge der Regelwerk-Dateien. Es gibt keine Prüfung, die eine offene Menge verbotener Muster abzählt — und damit auch keine handgepflegte Negativliste, die gegen sie verlieren könnte.
Die Begründung gilt **nur** für diese sechs Achsen. Sie gilt ausdrücklich nicht für die Frage, ob zwei Regeln inhaltlich dasselbe sagen. Das sieht die Prüfung nicht und kann es nicht sehen.
## Angriffsliste — einmal vollständig
Je Fall eine Wegwerfkopie des Vaults, Rücksetzen dazwischen, Exit-Code und auslösende Meldung protokolliert. Vor allem läuft die **Grundlinie**: die unbeschädigte Kopie muss sauber durchlaufen, sonst misst der Rest nichts.
| Klasse | Fall |
|---|---|
| Grundlinie | unveränderter Vault → Exit 0 |
| fehlt | Pflichtfeld `summary` aus einer Regelwerk-Datei entfernt |
| fehlt | Regelwerk-Datei nicht mehr im Stamm verlinkt |
| null | `status:` auf leeren Wert gesetzt |
| leer | Regelwerk-Datei auf 0 Byte gekürzt |
| zusätzlich | neue Regelwerk-Datei, die niemand verlinkt |
| Wertebereich | `model: sonnet` in `pruefer.md` (Tabelle sagt `opus`) |
| Verweis ins Leere | Wikilink auf nicht existierende Datei |
| Konvention | Dateiform der Planablage ohne Ordnerform |
| **Reihenfolge** | toter Wikilink an **erster** und an **letzter** Position derselben Datei |
| Umgebung | `--vault` zeigt auf ein Verzeichnis ohne CLAUDE.md |
| Umgebung | `AI/Regelwerk/` fehlt vollständig |
| Umgebung | Rollentabelle im Stamm nicht auffindbar |
Die letzten drei sind die wichtigsten: Sie prüfen, dass eine fehlende Voraussetzung ein **Befund** ist und kein Grund zu schweigen (§6).
## Abbruchbedingung, vor der ersten Runde benannt
Schluss ist, wenn eine Runde nur noch Umformulierungen findet oder wenn alle verbleibenden Befunde dieselbe Antwort haben. Die Prüfung ist fertig, wenn alle dreizehn Fälle der Angriffsliste anschlagen und die Grundlinie grün ist — nicht, wenn sie „durchläuft".
## Was diese Prüfung ausdrücklich nicht ist
Ein Fangnetz gegen formale Widersprüche. **Kein Nachweis, dass die Anweisungsschicht widerspruchsfrei ist.** Zwei Dateien können dieselbe Regel in verschiedenen Worten gegensätzlich formulieren, und die Prüfung meldet null Befunde. Der belastbare Nachweis ist das Lesen — die Prüfung nimmt nur die Formalien aus dem Weg, damit beim Lesen Kontext für den Inhalt bleibt.
@@ -0,0 +1,386 @@
"""Prüft die Anweisungsschicht des Vaults auf formale Widersprüche.
Ersteller: Pascal Michel
Erstellt: 2026-08-30
Entwurf und Angriffsliste: .vault-tools/regelwerk-check/ENTWURF.md
===============================================================================
GELTUNGSBEREICH — was diese Prüfung sieht
===============================================================================
Geprüft werden genau vier Orte:
CLAUDE.md der Stamm
AI/Regelwerk/*.md die ausgelagerten Regeln
.claude/skills/*/SKILL.md die Skills
.claude/agents/*.md die Agentendefinitionen
Sechs Prüfungen, jede über eine GESCHLOSSENE Menge:
1 Wikilinks Zeigt jeder [[Verweis]] auf eine existierende Datei?
2 Frontmatter Trägt jede Regelwerk-Datei die sieben Pflichtfelder?
3 Erreichbarkeit Ist jede Regelwerk-Datei vom Stamm aus verlinkt?
4 Freigabe Steht eine Regelwerk-Datei auf status: pending-review?
5 Modell Weicht model: einer Agentendatei von der Rollentabelle ab?
6 Planablage Nennt eine Datei die Dateiform ohne die Ordnerform?
Die Rollentabelle und die Liste der Regelwerk-Verweise werden AUS CLAUDE.md
GELESEN, nicht hier nachgebaut. Damit kann die Prüfung nicht gegen eine
veraltete Kopie im eigenen Quelltext laufen.
===============================================================================
WAS DIESE PRÜFUNG NICHT SIEHT — benannte Lücken
===============================================================================
* Pfade in Backticks. `python …`, `modell:` und `CHANGELOG.md` stehen alle
in Backticks und sind keine Vault-Pfade. Eine allgemeine Pfadprüfung wäre
eine Heuristik im Positivlisten-Gewand. Bewusst weggelassen.
* Modellaussagen im Fließtext. "(`executer`, Sonnet — bei reiner Abschrift
Haiku)" ist für einen Menschen eindeutig und für ein Skript nicht. Geprüft
wird ausschließlich das model:-Feld im Frontmatter.
* INHALTLICHE Widersprüche. Zwei Dateien können dieselbe Regel in
verschiedenen Worten gegensätzlich formulieren, und diese Prüfung meldet
null Befunde. Genau der Fehler, der am 2026-08-30 gefunden wurde — Skill
und Agent sagten das Gegenteil des Regelwerks — wäre nur zur Hälfte
aufgefallen: die Planablage (Prüfung 6) ja, "fortsetzen statt frisch
starten" nein.
===============================================================================
DIESES SKRIPT IST EIN FANGNETZ, KEIN NACHWEIS.
===============================================================================
Ein grüner Lauf belegt, dass keine der sechs formalen Achsen verletzt ist.
Er belegt NICHT, dass die Anweisungsschicht widerspruchsfrei ist. Der
belastbare Nachweis ist das Lesen. Diese Prüfung nimmt nur die Formalien aus
dem Weg, damit beim Lesen Kontext für den Inhalt bleibt.
Das Skript liegt unter .vault-tools/ und damit AUSSERHALB des eigenen
Geltungsbereichs — es misst seinen eigenen Erklärtext nicht mit.
Exit: 0 = kein Befund, 1 = Befunde, 2 = Abbruch (Befunde werden trotzdem
ausgegeben).
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
PFLICHTFELDER = ("type", "status", "title", "summary", "tags", "created", "updated")
REGELWERK = "AI/Regelwerk"
AGENTEN = ".claude/agents"
SKILLS = ".claude/skills"
WIKILINK = re.compile(r"\[\[([^\]]+)\]\]")
# Codebloecke ausblenden, bevor Wikilinks gelesen werden. Ohne das zaehlen
# `[[ $EUID -eq 0 ]]` (Bash), `[[:space:]]` (POSIX-Zeichenklasse) und
# Doku-Platzhalter wie `[[bild.png]]` als Verweise. Am 2026-09-03 hat genau
# das drei Fehlalarme in navigation.md erzeugt und den Regressionslauf zum
# Absturz gebracht: er legt fuer jedes Linkziel eine Datei an, und ":space:"
# ist unter Windows kein gueltiger Dateiname.
#
# Die Funktion liegt an einer Stelle: .vault-tools/linkcheck/linkcheck.py.
# Dort stehen auch ihr Entwurf und die 17 Angriffsfaelle (faelle.py).
_LINKCHECK = Path(__file__).resolve().parent.parent / "linkcheck"
if str(_LINKCHECK) not in sys.path:
sys.path.insert(0, str(_LINKCHECK))
try:
from linkcheck import strip_code as _strip_code
except ImportError as _exc: # fehlende Voraussetzung ist ein Befund
raise SystemExit(
"ABBRUCH: %s/linkcheck.py nicht importierbar (%s). Ohne die "
"Codeblock-Erkennung wuerde diese Pruefung Fehlalarme melden."
% (_LINKCHECK, _exc))
def wikilinks(text: str) -> list[str]:
"""Alle Wikilink-Ziele ausserhalb von Codebloecken und Code-Spans."""
return WIKILINK.findall(_strip_code(text))
PLANPFAD = re.compile(r"docs/plans/JJJJ-MM-TT[^\s`]*")
BACKTICK = re.compile(r"`([^`]+)`")
class Befund:
def __init__(self, schwere: str, ort: str, text: str) -> None:
self.schwere = schwere
self.ort = ort
self.text = text
def __str__(self) -> str:
return f"[{self.schwere}] {self.ort} — {self.text}"
def lies(pfad: Path) -> str:
"""Liest eine Datei als UTF-8 und verschluckt kein Problem still."""
return pfad.read_text(encoding="utf-8-sig")
def frontmatter(text: str) -> dict[str, str] | None:
"""Gibt das YAML-Frontmatter als flaches Wörterbuch zurück, oder None."""
zeilen = text.split("\n")
if not zeilen or zeilen[0].strip() != "---":
return None
for i in range(1, len(zeilen)):
if zeilen[i].strip() == "---":
werte: dict[str, str] = {}
for zeile in zeilen[1:i]:
schluessel, trenner, wert = zeile.partition(":")
if trenner and not zeile.startswith((" ", "\t", "-")):
werte[schluessel.strip()] = wert.strip()
return werte
return None
def linkziel(roh: str) -> str:
"""Schneidet Alias und Anker ab: 'AI/x|Anzeige' und 'AI/x#Kapitel' -> 'AI/x'."""
ziel = roh.split("|", 1)[0]
ziel = ziel.split("#", 1)[0]
return ziel.strip()
def dateien_im_geltungsbereich(vault: Path) -> list[Path]:
treffer: list[Path] = []
stamm = vault / "CLAUDE.md"
if stamm.is_file():
treffer.append(stamm)
regelwerk = vault / REGELWERK
if regelwerk.is_dir():
treffer.extend(sorted(regelwerk.glob("*.md")))
agenten = vault / AGENTEN
if agenten.is_dir():
treffer.extend(sorted(agenten.glob("*.md")))
skills = vault / SKILLS
if skills.is_dir():
treffer.extend(sorted(skills.glob("*/SKILL.md")))
return treffer
def rollentabelle(text: str) -> dict[str, str]:
"""Liest die Zuordnung Agent -> Modellalias aus der Tabelle im Stamm.
Eine Zeile sieht so aus:
| Planen, Spezifizieren | `planer` | Opus 5 (`opus`) |
Genommen wird der erste Backtick-Ausdruck der Agenten- und der
Modellspalte. Zeilen ohne Backticks in der Agentenspalte (etwa
'Hauptsitzung') werden übersprungen.
"""
zuordnung: dict[str, str] = {}
for zeile in text.split("\n"):
if not zeile.startswith("|"):
continue
spalten = [s.strip() for s in zeile.strip("|").split("|")]
if len(spalten) < 3:
continue
agenten = BACKTICK.findall(spalten[1])
modelle = BACKTICK.findall(spalten[2])
if not agenten or not modelle:
continue
for name in agenten:
zuordnung[name.strip()] = modelle[0].strip()
return zuordnung
def stamm_regelwerkverweise(text: str) -> set[str]:
"""Alle im Stamm verlinkten Regelwerk-Dateien, als Dateiname ohne Endung."""
namen: set[str] = set()
for roh in wikilinks(text):
ziel = linkziel(roh)
if ziel.startswith(REGELWERK + "/"):
namen.add(ziel[len(REGELWERK) + 1 :])
return namen
# ---------------------------------------------------------------- Prüfungen
def pruefe_wikilinks(vault: Path, dateien: list[Path], befunde: list[Befund]) -> None:
for datei in dateien:
rel = datei.relative_to(vault).as_posix()
for roh in wikilinks(lies(datei)):
ziel = linkziel(roh)
if not ziel:
befunde.append(Befund("wichtig", rel, f"leerer Wikilink: [[{roh}]]"))
continue
if (vault / f"{ziel}.md").is_file() or (vault / ziel).exists():
continue
befunde.append(Befund("wichtig", rel, f"toter Wikilink: [[{ziel}]]"))
def pruefe_frontmatter(vault: Path, befunde: list[Befund]) -> None:
for datei in sorted((vault / REGELWERK).glob("*.md")):
rel = datei.relative_to(vault).as_posix()
text = lies(datei)
if not text.strip():
befunde.append(Befund("blockierend", rel, "Datei ist leer"))
continue
fm = frontmatter(text)
if fm is None:
befunde.append(Befund("blockierend", rel, "kein YAML-Frontmatter"))
continue
for feld in PFLICHTFELDER:
if feld not in fm:
befunde.append(Befund("wichtig", rel, f"Pflichtfeld fehlt: {feld}"))
elif not fm[feld]:
befunde.append(Befund("wichtig", rel, f"Pflichtfeld ohne Wert: {feld}"))
def pruefe_erreichbarkeit(vault: Path, verlinkt: set[str], befunde: list[Befund]) -> None:
for datei in sorted((vault / REGELWERK).glob("*.md")):
if datei.stem not in verlinkt:
befunde.append(
Befund(
"wichtig",
datei.relative_to(vault).as_posix(),
"im Stamm nicht verlinkt — wird nie geladen",
)
)
def pruefe_freigabe(vault: Path, befunde: list[Befund]) -> None:
for datei in sorted((vault / REGELWERK).glob("*.md")):
fm = frontmatter(lies(datei)) or {}
if fm.get("status") == "pending-review":
befunde.append(
Befund(
"klein",
datei.relative_to(vault).as_posix(),
"status: pending-review — Freigabe steht aus",
)
)
def pruefe_modelle(vault: Path, tabelle: dict[str, str], befunde: list[Befund]) -> None:
for datei in sorted((vault / AGENTEN).glob("*.md")):
rel = datei.relative_to(vault).as_posix()
fm = frontmatter(lies(datei))
if fm is None:
befunde.append(Befund("blockierend", rel, "kein YAML-Frontmatter"))
continue
name = fm.get("name") or datei.stem
gesetzt = fm.get("model")
if not gesetzt:
befunde.append(Befund("wichtig", rel, "kein model: im Frontmatter"))
continue
if name not in tabelle:
befunde.append(
Befund("wichtig", rel, f"Agent '{name}' steht nicht in der Rollentabelle des Stamms")
)
continue
if gesetzt != tabelle[name]:
befunde.append(
Befund(
"blockierend",
rel,
f"model: {gesetzt} — Rollentabelle im Stamm sagt {tabelle[name]}",
)
)
def pruefe_planablage(vault: Path, dateien: list[Path], befunde: list[Befund]) -> None:
for datei in dateien:
formen = set()
for treffer in PLANPFAD.findall(lies(datei)):
rest = treffer[len("docs/plans/JJJJ-MM-TT") :]
if rest.startswith("-"):
rest = rest.lstrip("-")
# <thema>/ oder <thema>.md — das Zeichen nach dem Platzhalter zählt
if rest.endswith("/") or "/" in rest:
formen.add("ordner")
elif rest.endswith(".md") or rest.endswith(".md`"):
formen.add("datei")
elif rest.startswith("<thema>/"):
formen.add("ordner")
if "datei" in formen and "ordner" not in formen:
befunde.append(
Befund(
"blockierend",
datei.relative_to(vault).as_posix(),
"nennt die Datei-Form der Planablage ohne die Ordner-Form "
"(ab drei Tasks gilt der Ordner)",
)
)
# --------------------------------------------------------------------- Lauf
def main() -> int:
p = argparse.ArgumentParser(description="Konsistenzprüfung der Anweisungsschicht")
p.add_argument("--vault", default=".", help="Vault-Wurzel (Vorgabe: .)")
args = p.parse_args()
vault = Path(args.vault).resolve()
befunde: list[Befund] = []
gelaufen = 0
abbruch: str | None = None
try:
# Voraussetzungen. Eine fehlende Voraussetzung ist ein BEFUND,
# kein Grund zu schweigen.
stamm = vault / "CLAUDE.md"
if not stamm.is_file():
befunde.append(Befund("blockierend", "CLAUDE.md", "fehlt — nichts prüfbar"))
raise SystemExit
if not (vault / REGELWERK).is_dir():
befunde.append(Befund("blockierend", REGELWERK, "Verzeichnis fehlt"))
raise SystemExit
stammtext = lies(stamm)
tabelle = rollentabelle(stammtext)
verlinkt = stamm_regelwerkverweise(stammtext)
if not tabelle:
befunde.append(
Befund("blockierend", "CLAUDE.md", "Rollentabelle nicht auffindbar — Prüfung 5 kann nicht laufen")
)
if not verlinkt:
befunde.append(
Befund("blockierend", "CLAUDE.md", "keine Regelwerk-Verweise gefunden — Prüfung 3 kann nicht laufen")
)
dateien = dateien_im_geltungsbereich(vault)
pruefe_wikilinks(vault, dateien, befunde); gelaufen += 1
pruefe_frontmatter(vault, befunde); gelaufen += 1
if verlinkt:
pruefe_erreichbarkeit(vault, verlinkt, befunde); gelaufen += 1
pruefe_freigabe(vault, befunde); gelaufen += 1
if tabelle and (vault / AGENTEN).is_dir():
pruefe_modelle(vault, tabelle, befunde); gelaufen += 1
pruefe_planablage(vault, dateien, befunde); gelaufen += 1
except SystemExit:
pass
except Exception as fehler: # ein Traceback wäre ein Befund ohne Meldung
abbruch = f"{type(fehler).__name__}: {fehler}"
befunde.append(Befund("blockierend", "regelwerk_check.py", f"Abbruch: {abbruch}"))
# Ausgabe — auch bei Abbruch, damit die Ursache nicht hinter dem Absturz
# verschwindet.
rang = {"blockierend": 0, "wichtig": 1, "klein": 2}
for b in sorted(befunde, key=lambda b: (rang.get(b.schwere, 9), b.ort)):
print(b)
print()
if befunde:
# Die Zahlen werden gerechnet, nicht geschrieben.
print(f"{len(befunde)} Befund(e) aus {gelaufen} von 6 Prüfungen.")
else:
print(f"Keine der {gelaufen} gelaufenen Prüfungen hat angeschlagen.")
if gelaufen < 6 and not abbruch:
print(f"ACHTUNG: nur {gelaufen} von 6 Prüfungen sind gelaufen — der Rest hatte keine Voraussetzung.")
if abbruch:
return 2
return 1 if befunde else 0
if __name__ == "__main__":
# Ohne das bricht die Ausgabe auf einer cp1252-Konsole ab — genau der
# Fehler, an dem vault_doctor.py auf diesem Rechner scheitert.
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())
@@ -0,0 +1,276 @@
"""Fährt die Angriffsliste gegen regelwerk_check.py — je Fall auf einer Wegwerfkopie.
Ersteller: Pascal Michel
Erstellt: 2026-08-30
Angriffsliste und Begründung: .vault-tools/regelwerk-check/ENTWURF.md
Der Treiber baut aus dem echten Vault einen Minimalvault, in dem die Prüfung
sauber durchläuft — das ist die GRUNDLINIE. Läuft sie nicht grün, misst der
Rest nichts und der Treiber bricht ab.
Danach je Angriffsfall: frische Kopie der Grundlinie, genau eine Beschädigung,
Prüfung fahren, Exit-Code und auslösende Meldung protokollieren. Ein Fall gilt
als bestanden, wenn er den ERWARTETEN Textbaustein in der Ausgabe erzeugt —
nicht schon, wenn der Exit-Code ungleich null ist. Sonst würde ein Absturz als
Erfolg durchgehen.
Aufruf: python .vault-tools/regelwerk-check/regressionslauf.py
"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
VAULT = HIER.parent.parent
PRUEFUNG = HIER / "regelwerk_check.py"
WIKILINK = re.compile(r"\[\[([^\]]+)\]\]")
# Fuer den Aufbau der Grundlinie dieselbe codeblock-bewusste Extraktion
# verwenden wie das Gate. Sonst legt der Treiber Platzhalterdateien fuer
# Nicht-Links an - ":space:" aus einer POSIX-Zeichenklasse ist unter
# Windows kein gueltiger Dateiname und liess ihn am 2026-09-03 abstuerzen.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from regelwerk_check import wikilinks # noqa: E402
def fahre(vault: Path) -> tuple[int, str]:
lauf = subprocess.run(
[sys.executable, str(PRUEFUNG), "--vault", str(vault)],
capture_output=True,
text=True,
encoding="utf-8",
)
return lauf.returncode, (lauf.stdout or "") + (lauf.stderr or "")
def baue_grundlinie(ziel: Path) -> None:
"""Kopiert den Geltungsbereich und legt jedes Linkziel als leere Datei an."""
(ziel / "AI" / "Regelwerk").mkdir(parents=True)
(ziel / ".claude" / "agents").mkdir(parents=True)
shutil.copy2(VAULT / "CLAUDE.md", ziel / "CLAUDE.md")
for f in (VAULT / "AI" / "Regelwerk").glob("*.md"):
shutil.copy2(f, ziel / "AI" / "Regelwerk" / f.name)
for f in (VAULT / ".claude" / "agents").glob("*.md"):
shutil.copy2(f, ziel / ".claude" / "agents" / f.name)
for skill in (VAULT / ".claude" / "skills").glob("*/SKILL.md"):
unter = ziel / ".claude" / "skills" / skill.parent.name
unter.mkdir(parents=True, exist_ok=True)
shutil.copy2(skill, unter / "SKILL.md")
# Jedes Linkziel außerhalb des Geltungsbereichs als leere Datei anlegen,
# damit die Grundlinie nicht an fehlendem Umfeld scheitert.
for datei in list(ziel.rglob("*.md")):
for roh in wikilinks(datei.read_text(encoding="utf-8-sig")):
zielname = roh.split("|", 1)[0].split("#", 1)[0].strip()
if not zielname:
continue
pfad = ziel / f"{zielname}.md"
if not pfad.exists() and not (ziel / zielname).exists():
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_text("# Platzhalter\n", encoding="utf-8")
# --------------------------------------------------------------- Beschädigungen
def s_pflichtfeld_fehlt(v: Path) -> None:
f = v / "AI" / "Regelwerk" / "codeworkflow.md"
t = f.read_text(encoding="utf-8")
f.write_text(re.sub(r"^summary:.*\n", "", t, count=1, flags=re.M), encoding="utf-8")
def s_status_leer(v: Path) -> None:
f = v / "AI" / "Regelwerk" / "schreibstil.md"
t = f.read_text(encoding="utf-8")
f.write_text(re.sub(r"^status:.*$", "status:", t, count=1, flags=re.M), encoding="utf-8")
def s_datei_leer(v: Path) -> None:
(v / "AI" / "Regelwerk" / "projekte.md").write_text("", encoding="utf-8")
def s_nicht_verlinkt(v: Path) -> None:
f = v / "CLAUDE.md"
t = f.read_text(encoding="utf-8")
f.write_text(re.sub(r"^.*AI/Regelwerk/pruefwerkzeuge.*\n", "", t, count=1, flags=re.M), encoding="utf-8")
def s_neue_unverlinkte_datei(v: Path) -> None:
(v / "AI" / "Regelwerk" / "zzz-neu.md").write_text(
"---\ntype: resource\nstatus: reference\ntitle: Neu\nsummary: x\n"
"tags: [a]\ncreated: 2026-08-30\nupdated: 2026-08-30\n---\n\n# Neu\n",
encoding="utf-8",
)
def s_falsches_modell(v: Path) -> None:
f = v / ".claude" / "agents" / "pruefer.md"
t = f.read_text(encoding="utf-8")
f.write_text(re.sub(r"^model:.*$", "model: sonnet", t, count=1, flags=re.M), encoding="utf-8")
def s_toter_link_erste_position(v: Path) -> None:
"""Reihenfolge-Fall: Schaden an der ERSTEN Linkposition der Datei."""
f = v / "AI" / "Regelwerk" / "codeworkflow.md"
t = f.read_text(encoding="utf-8")
f.write_text(WIKILINK.sub(lambda m: "[[gibtsnicht-erste]]", t, count=1), encoding="utf-8")
def s_toter_link_letzte_position(v: Path) -> None:
"""Reihenfolge-Fall: derselbe Schaden an der LETZTEN Linkposition."""
f = v / "AI" / "Regelwerk" / "codeworkflow.md"
t = f.read_text(encoding="utf-8")
treffer = list(WIKILINK.finditer(t))
letzter = treffer[-1]
f.write_text(t[: letzter.start()] + "[[gibtsnicht-letzte]]" + t[letzter.end() :], encoding="utf-8")
def s_planablage_nur_dateiform(v: Path) -> None:
f = v / ".claude" / "agents" / "planer.md"
t = f.read_text(encoding="utf-8")
# Ordnerform entfernen, Dateiform stehen lassen — der Fehler, der am
# 2026-08-30 tatsächlich in dieser Datei stand.
t = re.sub(r"\*\*Ablageform hängt am Umfang\.\*\*.*?\n", "", t, count=1, flags=re.S)
t = t.replace(
"Erlaubt ist ausschließlich `docs/plans/` im Zielprojekt.",
"Erlaubt ist ausschließlich `docs/plans/JJJJ-MM-TT-<thema>.md` im Zielprojekt.",
)
f.write_text(t, encoding="utf-8")
def s_kein_stamm(v: Path) -> None:
(v / "CLAUDE.md").unlink()
def s_kein_regelwerk(v: Path) -> None:
shutil.rmtree(v / "AI" / "Regelwerk")
def s_keine_rollentabelle(v: Path) -> None:
f = v / "CLAUDE.md"
t = f.read_text(encoding="utf-8")
f.write_text(re.sub(r"^\|.*$", "", t, flags=re.M), encoding="utf-8")
def g_link_im_codeblock(v: Path) -> None:
"""Kein Schaden, sondern eine Falle: Wikilink-artiges IN Code.
Muss stumm bleiben. Vor dem 2026-09-03 hat genau das drei Fehlalarme
erzeugt und den Aufbau der Grundlinie zum Absturz gebracht, weil der
Treiber fuer ":space:" eine Datei anlegen wollte.
"""
f = v / "AI" / "Regelwerk" / "codeworkflow.md"
t = f.read_text(encoding="utf-8")
zeilen = [
"",
"## Gegenfall (Regressionslauf)",
"",
"```bash",
"if [[ $EUID -eq 0 ]]; then exit 1; fi",
"grep -o '[[:space:]]' datei",
"```",
"",
"Im Fliesstext: `[[bild.png]]` und `[[gibtsnicht-in-backticks]]`.",
"",
]
f.write_text(t + "\n".join(zeilen), encoding="utf-8")
# Gegenfaelle: duerfen NICHT anschlagen. Ohne sie waere die Codeblock-
# Erkennung selbst ungeprueft — neuer Code in einer Fix-Runde bekommt
# seinen eigenen Fall (pruefwerkzeuge.md, Abschnitt 5).
GEGENFAELLE = [
("codeblock | Wikilink-artiges in Code und Backticks", g_link_im_codeblock),
]
FAELLE = [
("fehlt | Pflichtfeld summary entfernt", s_pflichtfeld_fehlt, "Pflichtfeld fehlt: summary"),
("null | status ohne Wert", s_status_leer, "Pflichtfeld ohne Wert: status"),
("leer | Regelwerk-Datei auf 0 Byte", s_datei_leer, "Datei ist leer"),
("fehlt | im Stamm nicht mehr verlinkt", s_nicht_verlinkt, "im Stamm nicht verlinkt"),
("zusaetzlich| neue Datei, die niemand verlinkt", s_neue_unverlinkte_datei, "zzz-neu.md"),
("wertebereich| model: sonnet statt opus", s_falsches_modell, "Rollentabelle im Stamm sagt opus"),
("verweis | toter Wikilink, ERSTE Position", s_toter_link_erste_position, "gibtsnicht-erste"),
("verweis | toter Wikilink, LETZTE Position", s_toter_link_letzte_position, "gibtsnicht-letzte"),
("konvention | Dateiform ohne Ordnerform", s_planablage_nur_dateiform, "ohne die Ordner-Form"),
("umgebung | CLAUDE.md fehlt", s_kein_stamm, "fehlt — nichts prüfbar"),
("umgebung | AI/Regelwerk fehlt", s_kein_regelwerk, "Verzeichnis fehlt"),
("umgebung | Rollentabelle unauffindbar", s_keine_rollentabelle, "Rollentabelle nicht auffindbar"),
]
def main() -> int:
with tempfile.TemporaryDirectory(prefix="regelwerk-regression-") as tmp:
basis = Path(tmp) / "grundlinie"
baue_grundlinie(basis)
code, ausgabe = fahre(basis)
print("=" * 78)
print("GRUNDLINIE — unbeschädigte Kopie")
print("=" * 78)
print(ausgabe.strip())
if code != 0:
print("\nABBRUCH: Die Grundlinie ist nicht grün. Der Rest misst nichts.")
return 2
print("Grundlinie grün (exit 0).\n")
print("=" * 78)
print("ANGRIFFSLISTE")
print("=" * 78)
bestanden = 0
for name, schaden, erwartet in FAELLE:
fall = Path(tmp) / "fall"
if fall.exists():
shutil.rmtree(fall)
shutil.copytree(basis, fall)
schaden(fall)
code, ausgabe = fahre(fall)
treffer = erwartet in ausgabe
zeile = next((z for z in ausgabe.splitlines() if erwartet in z), "")
status = "ANGESCHLAGEN" if (treffer and code == 1) else "DURCHGELASSEN"
if treffer and code == 1:
bestanden += 1
print(f"{status:15} exit={code} {name}")
print(f"{'':15} -> {zeile.strip() or '(erwartete Meldung fehlt)'}")
print()
print("=" * 78)
print("GEGENFÄLLE — müssen stumm bleiben")
print("=" * 78)
stumm = 0
for name, harmlos in GEGENFAELLE:
fall = Path(tmp) / "gegenfall"
if fall.exists():
shutil.rmtree(fall)
shutil.copytree(basis, fall)
harmlos(fall)
code, ausgabe = fahre(fall)
ok = code == 0
if ok:
stumm += 1
print(f"{'STUMM' if ok else 'FEHLALARM':15} exit={code} {name}")
if not ok:
for z in ausgabe.splitlines():
if z.strip().startswith("["):
print(f"{'':15} -> {z.strip()}")
print()
# Beide Zahlen werden gerechnet, nicht geschrieben.
print(f"{bestanden} von {len(FAELLE)} Angriffsfällen haben angeschlagen.")
print(f"{stumm} von {len(GEGENFAELLE)} Gegenfällen sind stumm geblieben.")
return 0 if (bestanden == len(FAELLE) and stumm == len(GEGENFAELLE)) else 1
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())
+108
View File
@@ -0,0 +1,108 @@
# 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/**` oder `.claude/skills/**` zeigt | **Projektvorgabe** — `CLAUDE.md`, Abschnitt Selbstpflege: nach jeder Änderung an Stamm, Regelwerk, Skills oder Agenten |
| 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.
@@ -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,547 @@
"""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, "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("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")),
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,641 @@
"""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 = 5.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: "
FEHLERZEILE = "Sitzungsstart-Hook:"
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")
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)",
]
def kopiere_hook(v: Path, linkcheck_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)
if linkcheck_kopieren:
lc_ziel = v / ".vault-tools" / "linkcheck"
lc_ziel.mkdir(parents=True, exist_ok=True)
shutil.copy2(LINKCHECK, lc_ziel / "linkcheck.py")
return skript
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
# 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"]),
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],
praefixe=["Sitzungsstart-Hook: needs-review/review: RuntimeError: linkcheck.py nicht importierbar"],
stdin=lambda v: b"{}",
linkcheck_kopieren=False),
]
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)
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"
erwartet = grundzeilen(v) + ["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)
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),
]
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,232 @@
"""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 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/")
# 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)
+337
View File
@@ -0,0 +1,337 @@
"""SessionStart-Hook: meldet den Stand des Vaults als Kontext für die neue Sitzung.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste)
GELTUNGSBEREICH
Die Punkte aus dem Abschnitt Sitzungsprotokoll in CLAUDE.md, gezählt und
nicht bewertet: Datum laut Maschinenuhr, Tagesnotiz, Inbox-Notizen ohne
type: daily, Notizen mit Tag needs-review, Notizen mit Tag review ohne
review-done, Regelwerk-Dateien mit status: pending-review, heutige
Sitzungsdateien. Die Vault-Wurzel ist ausschließlich der Ordner zwei Ebenen
über dieser Datei. Das Feld cwd der Hook-Eingabe wird nicht mehr gelesen:
cwd folgt einem cd im Bash-Werkzeug und zeigt in einer laufenden Sitzung
nicht zuverlässig auf die Vault-Wurzel (Prüferbefund Runde 2, gemessen
gegen Claude Code 2.1.263). Dieser Hook ist ohnehin nur für den Vault
eingetragen, in dem er selbst liegt; ein abweichender cwd-Wert ändert daran
nichts (Koordinatorentscheidung, Nachbesserung Runde 2).
BENANNTE LÜCKEN
* Tags erkennt dieses Skript nach eigener Regel (ENTWURF.md), nicht mit dem
Parser von Obsidian. Ein Tag direkt hinter einer Klammer oder einem
Sternchen zählt nicht, ein verschachtelter Tag wie needs-review/x auch nicht.
* Frontmatter wird tolerant gelesen. Ein Tag in einer nicht geschlossenen
YAML-Liste zählt mit, obwohl Obsidian ihn nicht anzeigt. Nur der Schlüssel
tags zählt, nicht tag oder Tags.
* Codeblöcke erkennt strip_code() aus .vault-tools/linkcheck/linkcheck.py,
mit dessen Lücken: eingerückte Blöcke ohne Zaun zählen nicht als Code.
* Das Datum kommt von der Maschinenuhr. Im Kontext steht es so beschriftet,
weil das Sitzungsdatum aus dem Kontext Vorrang hat.
DIESES SKRIPT IST EIN FANGNETZ, KEIN NACHWEIS. Es nimmt das Zählen ab, nicht
das Lesen.
Exit ist immer 0, stdout ist genau ein JSON-Objekt. Jeder Fehler steht als
Zeile im Kontext; was bis dahin gesammelt war, bleibt stehen.
"""
from __future__ import annotations
import datetime
import json
import os
import re
import sys
from pathlib import Path
SKRIPT = Path(__file__).resolve()
LINKCHECK = SKRIPT.parent.parent / "linkcheck"
MAX_NAMEN = 5
# Nur direkt unter der Vault-Wurzel ausgeschlossen, verglichen in Kleinbuchstaben.
AUSSCHLUSS_OBEN = {"import", "_index"}
# In jeder Tiefe ausgeschlossen. Punktordner sind es zusätzlich immer.
AUSSCHLUSS_UEBERALL = {"node_modules"}
# (?<!\S): nur am Zeilenanfang oder nach Leerraum, also nicht in abc#tag.
# (?![\w/-]): kein längerer Tag wie review-done, reviewer oder review/unter.
_NEEDS_REVIEW = re.compile(r"(?<!\S)#needs-review(?![\w/-])", re.IGNORECASE)
_REVIEW = re.compile(r"(?<!\S)#review(?![\w/-])", re.IGNORECASE)
_REVIEW_DONE = re.compile(r"(?<!\S)#review-done(?![\w/-])", re.IGNORECASE)
def lies_text(pfad: Path) -> str:
# utf-8-sig schluckt die BOM, errors=replace hält ungültige Bytes aus,
# der Textmodus macht aus CRLF ein LF.
with open(pfad, "r", encoding="utf-8-sig", errors="replace") as datei:
return datei.read()
def entwerte(wert: str) -> str:
return wert.strip().strip("[]").strip().strip("\"'").strip().lstrip("#").strip()
def lies_felder(zeilen: list[str]) -> dict[str, object]:
felder: dict[str, object] = {}
schluessel: str | None = None
for zeile in zeilen:
inhalt = zeile.strip()
if not inhalt or inhalt.startswith("#"):
continue
if zeile[:1] in (" ", "\t") or inhalt.startswith("- "):
if schluessel is not None and inhalt.startswith("-"):
liste = felder.get(schluessel)
if not isinstance(liste, list):
liste = []
felder[schluessel] = liste
liste.append(inhalt[1:])
continue
name, trenner, wert = zeile.partition(":")
if not trenner:
schluessel = None
continue
schluessel = name.strip()
wert = wert.strip()
if wert.startswith("["):
felder[schluessel] = wert.strip("[]").split(",")
elif wert:
felder[schluessel] = wert
else:
felder[schluessel] = []
return felder
def teile_frontmatter(text: str) -> tuple[dict[str, object], str]:
zeilen = text.split("\n")
if not zeilen or zeilen[0].strip() != "---":
return {}, text
for i in range(1, len(zeilen)):
if zeilen[i].strip() == "---":
return lies_felder(zeilen[1:i]), "\n".join(zeilen[i + 1:])
# Ohne Schlusszeile ist es kein Frontmatter, sondern Text.
return {}, text
def tags_von(felder: dict[str, object]) -> set[str]:
roh = felder.get("tags")
if isinstance(roh, str):
teile = re.split(r"[,\s]+", roh)
elif isinstance(roh, list):
teile = [t for t in roh if isinstance(t, str)]
else:
teile = []
return {entwerte(t).lower() for t in teile if entwerte(t)}
def skalar(felder: dict[str, object], name: str) -> str:
roh = felder.get(name)
return entwerte(roh).lower() if isinstance(roh, str) else ""
def mit_namen(namen: list[str]) -> str:
if not namen:
return "0"
sortiert = sorted(namen)
text = ", ".join(sortiert[:MAX_NAMEN])
rest = len(sortiert) - MAX_NAMEN
if rest > 0:
text += f", +{rest} weitere"
return f"{len(sortiert)} ({text})"
def unlesbar_zeilen(unlesbar: list[str]) -> list[str]:
if not unlesbar:
return []
return [f"Sitzungsstart-Hook: {len(unlesbar)} Datei(en) nicht lesbar (erste: {unlesbar[0]})"]
def durchlaufe(wurzel: Path, ordnerfehler: list[str]):
start = str(wurzel)
def bei_fehler(fehler: OSError) -> None:
ordnerfehler.append(str(fehler.filename or fehler))
for ordner, unterordner, dateien in os.walk(start, onerror=bei_fehler):
oben = ordner == start
unterordner[:] = sorted(
name for name in unterordner
if not name.startswith(".")
and name.lower() not in AUSSCHLUSS_UEBERALL
and not (oben and name.lower() in AUSSCHLUSS_OBEN)
)
for name in sorted(dateien):
if name.lower().endswith(".md") and not name.startswith("."):
yield Path(ordner) / name
def lade_strip_code():
if str(LINKCHECK) not in sys.path:
sys.path.insert(0, str(LINKCHECK))
try:
from linkcheck import strip_code
except Exception as fehler:
# Ohne Codeblock-Filter wäre jede Erwähnung in Code ein Treffer:
# lieber den Punkt als Fehler melden als falsch zählen.
raise RuntimeError(
f"linkcheck.py nicht importierbar unter {LINKCHECK} "
f"({type(fehler).__name__}: {fehler}), Tags nicht gezählt"
) from fehler
return strip_code
def punkt_datum(wurzel: Path, heute: str) -> list[str]:
return [f"- Datum laut Maschinenuhr: {heute} (das Sitzungsdatum aus dem Kontext hat Vorrang)"]
def punkt_tagesnotiz(wurzel: Path, heute: str) -> list[str]:
vorhanden = (wurzel / "Inbox" / f"{heute}.md").is_file()
return [f"- Tagesnotiz Inbox/{heute}.md: {'vorhanden' if vorhanden else 'fehlt'}"]
def punkt_inbox(wurzel: Path, heute: str) -> list[str]:
inbox = wurzel / "Inbox"
if not inbox.is_dir():
return ["- Inbox-Notizen ohne type: daily: Ordner Inbox/ fehlt"]
offen: list[str] = []
unlesbar: list[str] = []
for pfad in sorted(inbox.rglob("*.md")):
rel = pfad.relative_to(inbox)
if any(teil.startswith(".") for teil in rel.parts) or not pfad.is_file():
continue
try:
felder, _ = teile_frontmatter(lies_text(pfad))
except OSError:
unlesbar.append(pfad.relative_to(wurzel).as_posix())
continue
if skalar(felder, "type") != "daily":
offen.append(rel.as_posix())
return [f"- Inbox-Notizen ohne type: daily: {mit_namen(offen)}"] + unlesbar_zeilen(unlesbar)
def punkt_tags(wurzel: Path, heute: str) -> list[str]:
strip_code = lade_strip_code()
needs: list[str] = []
review = 0
unlesbar: list[str] = []
ordnerfehler: list[str] = []
for pfad in durchlaufe(wurzel, ordnerfehler):
rel = pfad.relative_to(wurzel).as_posix()
try:
text = lies_text(pfad)
except OSError:
unlesbar.append(rel)
continue
if "review" not in text.lower():
continue
felder, rumpf = teile_frontmatter(text)
tags = tags_von(felder)
sauber = strip_code(rumpf)
if "needs-review" in tags or _NEEDS_REVIEW.search(sauber):
needs.append(rel)
hat_review = "review" in tags or _REVIEW.search(sauber)
hat_done = "review-done" in tags or _REVIEW_DONE.search(sauber)
if hat_review and not hat_done:
review += 1
zeilen = [
f"- Notizen mit Tag needs-review: {mit_namen(needs)}",
f"- Notizen mit Tag review ohne review-done: {review}",
]
zeilen += unlesbar_zeilen(unlesbar)
if ordnerfehler:
zeilen.append(
f"Sitzungsstart-Hook: {len(ordnerfehler)} Ordner nicht lesbar (erster: {ordnerfehler[0]})"
)
return zeilen
def punkt_regelwerk(wurzel: Path, heute: str) -> list[str]:
ordner = wurzel / "AI" / "Regelwerk"
if not ordner.is_dir():
return ["- Regelwerk-Dateien mit status: pending-review: Ordner AI/Regelwerk/ fehlt"]
offen: list[str] = []
unlesbar: list[str] = []
for pfad in sorted(ordner.glob("*.md")):
if not pfad.is_file():
continue
try:
felder, _ = teile_frontmatter(lies_text(pfad))
except OSError:
unlesbar.append(pfad.relative_to(wurzel).as_posix())
continue
if skalar(felder, "status") == "pending-review":
offen.append(pfad.name)
return [f"- Regelwerk-Dateien mit status: pending-review: {mit_namen(offen)}"] + unlesbar_zeilen(unlesbar)
def punkt_sitzungen(wurzel: Path, heute: str) -> list[str]:
ordner = wurzel / "AI" / "sessions"
if not ordner.is_dir():
return [f"- Sitzungsdateien AI/sessions/{heute}-*.md: Ordner AI/sessions/ fehlt"]
namen = [p.name for p in ordner.glob(f"{heute}-*.md") if p.is_file()]
return [f"- Sitzungsdateien AI/sessions/{heute}-*.md: {mit_namen(namen)}"]
PUNKTE = [
("Datum", punkt_datum),
("Tagesnotiz", punkt_tagesnotiz),
("Inbox", punkt_inbox),
("needs-review/review", punkt_tags),
("Regelwerk", punkt_regelwerk),
("Sitzungen", punkt_sitzungen),
]
def baue_kontext(wurzel: Path, heute: str, zeilen: list[str]) -> None:
zeilen.append(f"Sitzungsstart-Hook (Vault: {wurzel})")
for name, punkt in PUNKTE:
try:
zeilen.extend(punkt(wurzel, heute))
except Exception as fehler:
zeilen.append(f"Sitzungsstart-Hook: {name}: {type(fehler).__name__}: {fehler}")
def lies_stdin() -> str:
if sys.stdin is None or sys.stdin.isatty():
return ""
return sys.stdin.buffer.read().decode("utf-8-sig", errors="replace")
def bestimme_wurzel() -> Path:
# Dieser Hook ist nur fuer den Vault eingetragen, in dem er selbst liegt.
# Nach Koordinatorentscheidung (Nachbesserung Runde 2, Pruefer-Befund 1)
# zaehlt ausschliesslich der Skriptpfad, das Feld cwd der Hook-Eingabe wird
# nicht mehr gelesen: cwd folgt einem cd im Bash-Werkzeug und zeigt in
# einer laufenden Sitzung nicht zuverlaessig auf die Vault-Wurzel.
return SKRIPT.parents[2]
def main(eingabe: str | None = None) -> int:
zeilen: list[str] = []
try:
if eingabe is None:
# Wird nur entleert: kein Feld der Eingabe wird noch ausgewertet.
lies_stdin()
wurzel = bestimme_wurzel()
heute = datetime.date.today().isoformat()
baue_kontext(wurzel, heute, zeilen)
except Exception as fehler:
zeilen.append(f"Sitzungsstart-Hook: {type(fehler).__name__}: {fehler}")
ausgabe = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "\n".join(zeilen),
}
}
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
sys.stdout.flush()
return 0
if __name__ == "__main__":
try:
main()
except Exception:
# Letzte Schranke: Exit 0 ist Pflicht, sonst liest Claude Code stdout nicht.
pass
sys.exit(0)
@@ -0,0 +1,27 @@
---
type: resource
status: active
title: "Trading Plattform: Prüfwerkzeuge des Koordinators fürs Dashboard"
summary: "Eigener Lauf mit echten Daten zum Dashboard der Trading Plattform — Vergleich der Endpunkte gegen Vollparse und pandas, zwei WebSocket-Verbindungen, Browser-Lauf und Wiederaufbau mit Playwright, gefahrlose Proben der zwei Skripte, Mutantenläufe. Entstanden 2026-09-17 (Teil 3b), bewusst nicht im Projekt-Repo."
tags: [vault-tools, trading-plattform]
created: 2026-09-17
updated: 2026-09-17
---
# Prüfwerkzeuge fürs Dashboard der Trading Plattform
Koordinator-Werkzeuge, nicht fürs Repo. Sie starten **nie** den echten Broker: Das Probe-Backend läuft mit `BrokerAttrappe` und einem Wegwerf-Register unter `/tmp`, liest aber die echten Protokolle und den echten Katalog. Python-Teile laufen in WSL aus dem Projektordner, die Browser-Teile mit dem Windows-Python und Playwright (Chromium).
| Datei | Was sie tut | Aufruf |
|---|---|---|
| `pruef_teil3b.py` | Liste und Einzelansicht der Läufe gegen einen eigenen Vollparse, Kerzen gegen eine eigene pandas-Rechnung; `--sabotage` verfälscht je einen Wert und muss mit Exit 2 enden (sieben Fälle) | `wsl -d Debian -- bash -s -- pruef_teil3b.py alles [--sabotage zahl] < lauf_pruef.sh` |
| `pruef_ws.py`, `lauf_ws_probe.sh`, `start_probe_backend.py`, `start_probe.sh` | zwei WebSocket-Verbindungen am Probe-Backend müssen dieselben `zeit`-Werte sehen | `wsl -d Debian -- bash -s < lauf_ws_probe.sh` |
| `browserlauf.py` | bei 375, 768, 1024 und 1440 px: Zeilen in „Läufe", zwei Einzelansichten, Minuten- und Tageschart gezeichnet, kein waagerechter Scroll, keine Konsolenmeldung; schreibt Screenshots — **ansehen** | Probe-Backend starten (`wsl -d Debian -- bash -s 8145 2.0 < start_probe.sh`, im Hintergrund), dann `python -X utf8 browserlauf.py http://localhost:8145 <ausgabeordner>` |
| `wiederaufbau.py` | Backend beenden und neu starten, die Kopfzeile muss „Backend getrennt" und wieder „Backend verbunden" zeigen, ohne Neuladen; `--ohne-neustart` muss rot enden | `python -X utf8 wiederaufbau.py` |
| `skripte_probe.sh`, `abkuerzung_probe.sh` | `--help`, Lauf ohne Schalter, unbekanntes Argument und Abkürzungen von `--ausfuehren` — **ausschließlich aus einem leeren Temp-Verzeichnis**; die Übung selbst wird nie gefahren | `wsl -d Debian -- bash -s < skripte_probe.sh` |
| `mutanten_py.py`, `mutanten_ts.py` | Mutanten zu Teil 3b auf einer Kopie mit angewendetem Plan, je mit Fehlerzeile | Pfad der Kopie als erstes Argument |
| `real_pytest.sh` | volle Suite im echten Baum, mit Zählung der „Task was destroyed"-Meldungen | `wsl -d Debian -- bash -s -- -q < real_pytest.sh` |
Vor dem Browser-Lauf `npm run build` im Projekt, sonst dient das Backend eine alte Oberfläche aus. Die Datensatz- und Dateinamen in `browserlauf.py` und `pruef_teil3b.py` sind die des Bestands vom 2026-09-17; nach einem neuen Katalog oder neuen Protokollen nachziehen. Die Statustexte in `wiederaufbau.py` sind aus `Kopfzeile.tsx` abgeschrieben.
Die generischen Planwerkzeuge liegen unter `.vault-tools/plan-kopie/`.
@@ -0,0 +1,19 @@
#!/bin/bash
# Nimmt argparse eine Abkuerzung von --ausfuehren an? Ausschliesslich aus einem leeren Temp-Verzeichnis:
# Dort endet ein angenommener Schalter an der fehlenden config/risk.toml, bevor irgendetwas laeuft.
# Aufruf: wsl -d Debian -- bash -s < abkuerzung_probe.sh
set -u
P="/mnt/d/Claude/Code Projekte/active - Trading Plattform"
PY="$HOME/venvs/trading/bin/python"
T=$(mktemp -d)
cd "$T" || exit 9
if [ "$(ls -A "$T" | wc -l)" != "0" ]; then echo "ABBRUCH: Verzeichnis nicht leer"; exit 9; fi
for arg in --a --ausf --ausfuehre; do
"$PY" "$P/scripts/killswitch_uebung.py" "$arg" > "$T/aus.txt" 2> "$T/err.txt" < /dev/null
code=$?
echo "=== $arg: exit=$code"
grep -v -E "Pandas4Warning|utcnow|warnings.warn" "$T/err.txt" | tail -2
rm -f "$T/aus.txt" "$T/err.txt"
done
echo "Rest im Temp-Verzeichnis: $(ls -A "$T" | wc -l) Eintraege"
rmdir "$T" 2>/dev/null
@@ -0,0 +1,128 @@
"""Browser-Lauf zu Teil 3b mit Playwright (Windows-Python), gegen ein laufendes Backend.
Koordinator-Pruefskript, nicht fuers Repo. Aufruf:
python -X utf8 browserlauf.py <basis-url> <ausgabeordner>
Prueft je Breite: kein waagerechter Scroll, keine Konsolenfehler, Ansicht "Laeufe" zeigt Zeilen,
Ansicht "Markt" zeichnet (Canvas nicht leer). Schreibt Screenshots und eine ergebnis.json.
Was es nicht sieht: ob die Zahlen stimmen (dafuer pruef_teil3b.py) und wie es aussieht (dafuer
die Screenshots, die der Koordinator selbst ansieht).
"""
import json
import sys
from pathlib import Path
from playwright.sync_api import TimeoutError as PlaywrightTimeout
from playwright.sync_api import sync_playwright
BREITEN = [375, 768, 1024, 1440]
# Je ein Minuten- und ein Tagesdatensatz; die Namen stehen so im Katalog (gemessen 2026-09-17).
MARKT_DATENSAETZE = [
("minuten", "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL"),
("tage", "MSFT.XNAS-1-DAY-LAST-EXTERNAL"),
]
# aria-label der Knoepfe aus Laeufe.tsx: `Lauf ${quelle}/${datei}`.
LAEUFE_EINZEL = [
("tage", "Lauf signal/tage-teil9-20260917-030800.jsonl"),
("kampagne", "Lauf laeufe/aapl-minute-ppo-v20260914-132555.jsonl"),
]
CANVAS_PROBE = """
() => {
const flaechen = Array.from(document.querySelectorAll('canvas'));
let groesste = null;
for (const c of flaechen) {
if (!groesste || c.width * c.height > groesste.width * groesste.height) groesste = c;
}
if (!groesste || groesste.width === 0 || groesste.height === 0) return {anzahl: flaechen.length, farben: 0};
const kontext = groesste.getContext('2d');
const daten = kontext.getImageData(0, 0, groesste.width, groesste.height).data;
const farben = new Set();
const schritt = Math.max(4, Math.floor(daten.length / 4 / 20000) * 4);
for (let i = 0; i < daten.length; i += schritt) {
farben.add(`${daten[i]},${daten[i+1]},${daten[i+2]},${daten[i+3]}`);
}
return {anzahl: flaechen.length, breite: groesste.width, hoehe: groesste.height, farben: farben.size};
}
"""
SCROLL_PROBE = "() => ({scroll: document.documentElement.scrollWidth, sicht: document.documentElement.clientWidth})"
def main() -> int:
basis = sys.argv[1].rstrip("/")
ziel = Path(sys.argv[2])
ziel.mkdir(parents=True, exist_ok=True)
ergebnis: dict = {"basis": basis, "breiten": {}, "befunde": []}
with sync_playwright() as spiel:
browser = spiel.chromium.launch()
for breite in BREITEN:
seite = browser.new_page(viewport={"width": breite, "height": 900})
meldungen: list[str] = []
seite.on("console", lambda m, liste=meldungen: liste.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None)
seite.on("pageerror", lambda f, liste=meldungen: liste.append(f"pageerror: {f}"))
seite.goto(basis + "/", wait_until="networkidle")
eintrag: dict = {}
def messe(name: str) -> None:
masse = seite.evaluate(SCROLL_PROBE)
eintrag[f"scroll_{name}"] = masse
if masse["scroll"] > masse["sicht"] + 1:
ergebnis["befunde"].append(f"{breite}px {name}: waagerechter Scroll {masse}")
alarme = seite.get_by_role("alert").count()
if alarme:
ergebnis["befunde"].append(f"{breite}px {name}: {alarme} Alarm(e): {seite.get_by_role('alert').first.inner_text()[:160]}")
seite.screenshot(path=str(ziel / f"{name}-{breite}.png"), full_page=True)
for ansicht in ("Läufe", "Markt"):
seite.get_by_role("button", name=ansicht, exact=True).click()
seite.wait_for_timeout(1500)
if ansicht == "Markt":
# Die Ansicht laedt nach Plan nichts von selbst: erst die Auswahl holt Kerzen.
eintrag["canvas"] = {}
for kurz, bar_type in MARKT_DATENSAETZE:
try:
seite.get_by_label("Datensatz").select_option(bar_type)
seite.wait_for_selector("canvas", timeout=20000)
seite.wait_for_timeout(2000)
except PlaywrightTimeout:
pass
probe = seite.evaluate(CANVAS_PROBE)
eintrag["canvas"][kurz] = probe
if probe["farben"] < 5:
ergebnis["befunde"].append(f"{breite}px Markt/{kurz}: kein gezeichnetes Canvas ({probe})")
messe(f"markt-{kurz}")
else:
eintrag["laeufe_zeilen"] = seite.locator("table tbody tr").count()
if eintrag["laeufe_zeilen"] == 0:
ergebnis["befunde"].append(f"{breite}px Laeufe: keine Tabellenzeile")
messe("laeufe")
for kurz, marke in LAEUFE_EINZEL:
try:
seite.get_by_role("button", name=marke, exact=True).click(timeout=8000)
seite.wait_for_load_state("networkidle", timeout=20000)
seite.wait_for_timeout(1200)
except PlaywrightTimeout:
ergebnis["befunde"].append(f"{breite}px Laeufe/{kurz}: Knopf '{marke}' nicht bedienbar")
continue
eintrag[f"einzel_{kurz}_tabellen"] = seite.locator("table").count()
if eintrag[f"einzel_{kurz}_tabellen"] < 2:
ergebnis["befunde"].append(f"{breite}px Laeufe/{kurz}: die Einzelansicht zeigt keine eigene Tabelle")
messe(f"laeufe-{kurz}")
eintrag["konsole"] = meldungen
if any(m.startswith(("error", "pageerror")) for m in meldungen):
ergebnis["befunde"].append(f"{breite}px: Konsolenfehler {meldungen[:3]}")
ergebnis["breiten"][str(breite)] = eintrag
seite.close()
browser.close()
(ziel / "ergebnis.json").write_text(json.dumps(ergebnis, indent=2, ensure_ascii=False), encoding="utf-8")
print(json.dumps(ergebnis, indent=2, ensure_ascii=False))
return 2 if ergebnis["befunde"] else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,11 @@
#!/bin/bash
# Startet ein Python-Skript aus dem Scratchpad im echten Projektordner, mit Argumenten.
# Aufruf: wsl -d Debian -- bash -s -- <skript.py> [argumente...] < lauf_pruef.sh
set -u
SCRATCH="/mnt/d/Claude/.vault-tools/trading-plattform-pruefung"
cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9
SKRIPT="$1"
shift
PYTHONPATH=. timeout 560 "$HOME/venvs/trading/bin/python" "$SCRATCH/$SKRIPT" "$@" < /dev/null 2> /tmp/lauf_pruef_stderr.txt
echo "EXIT=$?"
grep -v -E "Pandas4Warning|utcnow|warnings.warn" /tmp/lauf_pruef_stderr.txt | tail -8
@@ -0,0 +1,18 @@
#!/bin/bash
# Startet das Probe-Backend auf Port 8145 (Takt 1 s), faehrt pruef_ws.py dagegen, beendet das Backend.
set -u
SCRATCH="/mnt/d/Claude/.vault-tools/trading-plattform-pruefung"
cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9
PY="$HOME/venvs/trading/bin/python"
PYTHONPATH=. "$PY" "$SCRATCH/start_probe_backend.py" 8145 1.0 < /dev/null > /tmp/probe_backend.log 2>&1 &
BACKEND=$!
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if "$PY" -c "import socket; s=socket.create_connection(('127.0.0.1', 8145), 0.5); s.close()" 2>/dev/null; then break; fi
sleep 1
done
PYTHONPATH=. timeout 60 "$PY" "$SCRATCH/pruef_ws.py" 8145 < /dev/null
echo "EXIT_PRUEF=$?"
kill "$BACKEND" 2>/dev/null
wait "$BACKEND" 2>/dev/null
echo "--- Backend-Protokoll ---"
grep -v -E "Pandas4Warning|utcnow" /tmp/probe_backend.log | tail -5
@@ -0,0 +1,165 @@
"""Mutantenlauf auf der materialisierten Kopie: Schlaegt jeder neue Test an, wenn sein Gegenstand kaputt ist?
Laeuft in WSL; Aufruf: python mutanten_py.py <wsl-pfad-der-kopie>. Je Mutant: Datei als Bytes sichern, genau eine Stelle ersetzen, die genannten Tests
fahren, Datei aus der Sicherung zurueckspielen. Erwartet ist ROT; ein gruener Mutant ist ein Befund.
Zuerst die Grundlinie: Ohne Mutant muessen dieselben Tests gruen sein.
"""
import subprocess
import sys
from pathlib import Path
MAT = Path(sys.argv[1]) # WSL-Pfad der Kopie mit dem angewendeten Plan
PY = str(Path.home() / "venvs/trading/bin/python")
MUTANTEN = [
(
"lies_lauf trennt mit der Zeilentrennung von str",
"trading/dashboard/laeufe.py",
' with pfad.open("rb") as strom:\n for roh in strom:\n if not roh.strip():',
' with pfad.open("rb") as strom:\n for roh in [z.encode("utf-8") for z in strom.read().decode("utf-8").splitlines()]:\n if not roh.strip():',
["tests/test_dashboard_laeufe.py::test_die_einzelansicht_trennt_nicht_an_u2028"],
),
(
"_ist_protokolldatei laesst alles durch",
"trading/dashboard/laeufe.py",
" return pfad.is_file() and not pfad.is_symlink()",
" return True",
[
"tests/test_dashboard_laeufe.py::test_ein_ordner_mit_der_endung_jsonl_ist_kein_lauf",
"tests/test_dashboard_laeufe.py::test_eine_symbolische_verknuepfung_ist_kein_lauf",
],
),
(
"_ist_protokolldatei folgt Verknuepfungen",
"trading/dashboard/laeufe.py",
" return pfad.is_file() and not pfad.is_symlink()",
" return pfad.is_file()",
["tests/test_dashboard_laeufe.py::test_eine_symbolische_verknuepfung_ist_kein_lauf"],
),
(
"letzte_zeilen gibt ein Bruchstueck ohne Trenner zurueck",
"trading/dashboard/laeufe.py",
" if not trenner:\n return []",
" if not trenner:\n return [_bruchstueck]",
["tests/test_dashboard_laeufe.py::test_eine_letzte_zeile_ueber_dem_block_ohne_zeilenende_liefert_nichts"],
),
(
"Eimeranfang mit festem Stundenschritt",
"trading/dashboard/markt.py",
" anfang_min = HANDELSSTART_MIN + nummer * aufloesung_min",
" anfang_min = HANDELSSTART_MIN + nummer * 60",
["tests/test_dashboard_markt.py::test_15_und_5_minuten_teilen_den_handelstag_ohne_rest"],
),
(
"Tagesachse in der Achsenzone statt in der Handelszone",
"trading/dashboard/markt.py",
" return zeitpunkt.tz_convert(HANDELSZONE).date().isoformat()",
" return zeitpunkt.tz_convert(zone).date().isoformat()",
["tests/test_dashboard_markt.py::test_eine_tageskerze_bekommt_den_new_yorker_kalendertag_als_string"],
),
(
"Tagesachse in UTC",
"trading/dashboard/markt.py",
" return zeitpunkt.tz_convert(HANDELSZONE).date().isoformat()",
" return zeitpunkt.date().isoformat()",
["tests/test_dashboard_markt.py::test_eine_tageskerze_bekommt_den_new_yorker_kalendertag_als_string"],
),
(
"Handelstag mit 391 Kerzen",
"trading/dashboard/markt.py",
"KERZEN_JE_HANDELSTAG = 390",
"KERZEN_JE_HANDELSTAG = 391",
["tests/test_dashboard_markt.py::test_der_handelstag_hat_390_minuten_und_die_lesespanne_folgt_daraus"],
),
(
"Kalenderfaktor 1,5 statt 1,6",
"trading/dashboard/markt.py",
"KALENDERFAKTOR_MINUTEN = 1.6",
"KALENDERFAKTOR_MINUTEN = 1.5",
["tests/test_dashboard_markt.py::test_der_handelstag_hat_390_minuten_und_die_lesespanne_folgt_daraus"],
),
(
"lies_kerzen liest die Metadaten ungeschuetzt",
"trading/dashboard/markt.py",
" meta = _meta(katalog, bar_type)\n bis = _bis(ende, meta)",
" meta = katalog.meta(bar_type)\n bis = _bis(ende, meta)",
["tests/test_dashboard_markt.py::test_lies_kerzen_uebersteht_eine_unlesbare_metadatei"],
),
(
"katalog() prueft nur die Wurzel",
"trading/dashboard/app.py",
' if not (wurzel / "_meta").is_dir():',
" if not wurzel.is_dir():",
["tests/test_dashboard_endpunkte.py::test_ein_ordner_ohne_meta_ist_kein_katalog_und_bekommt_keines"],
),
(
"_ersetze laesst den alten Snapshot liegen",
"trading/dashboard/verteiler.py",
" if warteschlange.full():\n try:",
" if warteschlange.full():\n return\n if warteschlange.full():\n try:",
["tests/test_dashboard_verteiler.py::test_eine_volle_warteschlange_bekommt_den_neuen_snapshot"],
),
(
"killswitch_uebung prueft den Schalter erst nach der Konfiguration",
"scripts/killswitch_uebung.py",
" if not parse_args().ausfuehren:\n print(OHNE_SCHALTER, file=sys.stderr)\n return 2\n\n limits = lade_limits(\"config/risk.toml\")",
" limits = lade_limits(\"config/risk.toml\")\n if not parse_args().ausfuehren:\n print(OHNE_SCHALTER, file=sys.stderr)\n return 2\n",
[
"tests/test_skripte_starten.py::test_die_killswitch_uebung_ohne_schalter_endet_mit_exit_2",
],
),
]
GRUND = ""
def fahre(tests: list[str]) -> bool:
"""Exitcode allein ist kein Beleg: Ein Mutant mit Syntaxfehler waere genauso rot.
Deshalb wird die erste Fehlerzeile mitgeschrieben und in der Ausgabe gezeigt."""
global GRUND
ergebnis = subprocess.run(
[PY, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-x", "--tb=short", *tests],
cwd=MAT,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
zeilen = [z.strip() for z in ergebnis.stdout.splitlines() if z.startswith("E ")]
GRUND = " | ".join(zeilen[:2])[:260]
return ergebnis.returncode == 0
def main() -> int:
befunde = 0
alle = sorted({t for m in MUTANTEN for t in m[4]})
if not fahre(alle):
print("BEFUND Grundlinie: ohne Mutant sind nicht alle Tests gruen -- der Lauf misst nichts.")
return 2
print(f"Grundlinie gruen ({len(alle)} Tests).")
for name, datei, alt, neu, tests in MUTANTEN:
pfad = MAT / datei
sicherung = pfad.read_bytes()
text = sicherung.decode("utf-8")
anzahl = text.count(alt)
if anzahl != 1:
print(f"BEFUND Mutant '{name}': Stelle steht {anzahl}-mal in {datei}")
befunde += 1
continue
try:
pfad.write_bytes(text.replace(alt, neu).encode("utf-8"))
gruen = fahre(tests)
finally:
pfad.write_bytes(sicherung)
if gruen:
print(f"BEFUND Mutant '{name}' bleibt GRUEN: {tests}")
befunde += 1
else:
print(f"rot {name}\n {GRUND}")
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,116 @@
"""Mutantenlauf Frontend auf der materialisierten Kopie (Windows-Python, vitest je Datei).
Je Mutant: Datei als Bytes sichern, genau eine Stelle ersetzen, die Testdatei fahren, zurueckspielen.
Erwartet ist ROT; ein gruener Mutant ist ein Befund. Zuerst die Grundlinie.
"""
import subprocess
import sys
from pathlib import Path
DASH = Path(sys.argv[1]) / "dashboard" # Windows-Pfad der Kopie mit dem angewendeten Plan
MUTANTEN = [
(
"stufe() gibt den rohen Index zurueck",
"src/components/LaufKampagne.tsx",
" const wert = aktion - (verteilung.length - 1) / 2;",
" const wert = aktion;",
"src/components/LaufKampagne.test.tsx",
),
(
"stufe() ohne Pruefung der Verteilung",
"src/components/LaufKampagne.tsx",
" if (!Array.isArray(verteilung) || verteilung.length % 2 === 0) return STRICH;",
" if (!Array.isArray(verteilung)) return STRICH;",
"src/components/LaufKampagne.test.tsx",
),
(
"stufe() ohne Pruefung des Bereichs",
"src/components/LaufKampagne.tsx",
" if (aktion < 0 || aktion >= verteilung.length) return STRICH;\n",
"",
"src/components/LaufKampagne.test.tsx",
),
(
"Stille Fenster als Ertrag formatiert",
"src/components/LaufKampagne.tsx",
" wert={anteil(abschluss.anteil_fenster_ohne_trade)}",
" wert={prozent(abschluss.anteil_fenster_ohne_trade)}",
"src/components/LaufKampagne.test.tsx",
),
(
"close() merkt sich das Ende nicht",
"src/lib/api.ts",
" beendet = true;\n",
"",
"src/lib/api.test.ts",
),
(
"close() raeumt den Zeitgeber nicht",
"src/lib/api.ts",
" if (zeitgeber !== null) clearTimeout(zeitgeber);\n",
"",
"src/lib/api.test.ts",
),
(
# Der Backslash kommt aus chr(92): Eine Escape-Folge im eigenen Quelltext wertet sonst
# irgendeine Schicht aus -- genau das ist bei der Umsetzung passiert.
"Lizenztext mit doppeltem Backslash (Escape-Folge als Klartext auf der Seite)",
"src/components/Markt.tsx",
"Charts" + chr(92) + "u2122 Copyright (" + chr(92) + "u0441) 2025",
"Charts" + chr(92) * 2 + "u2122 Copyright (" + chr(92) * 2 + "u0441) 2025",
"src/components/Markt.test.tsx",
),
]
def fahre(testdatei: str) -> tuple[bool, str]:
ergebnis = subprocess.run(
f"npx vitest run {testdatei}",
cwd=DASH,
shell=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
stdin=subprocess.DEVNULL,
)
ausgabe = ergebnis.stdout + ergebnis.stderr
rote = [z.strip() for z in ausgabe.splitlines() if z.strip().startswith(("×", "FAIL", "✗")) or " > " in z and "FAIL" in z]
return ergebnis.returncode == 0, "; ".join(rote[:4])
def main() -> int:
befunde = 0
for testdatei in sorted({m[4] for m in MUTANTEN}):
gruen, _ = fahre(testdatei)
if not gruen:
print(f"BEFUND Grundlinie: {testdatei} ist ohne Mutant nicht gruen")
return 2
print("Grundlinie gruen.")
for name, datei, alt, neu, testdatei in MUTANTEN:
pfad = DASH / datei
sicherung = pfad.read_bytes()
text = sicherung.decode("utf-8")
anzahl = text.count(alt)
if anzahl != 1:
print(f"BEFUND Mutant '{name}': Stelle steht {anzahl}-mal in {datei}")
befunde += 1
continue
try:
pfad.write_bytes(text.replace(alt, neu).encode("utf-8"))
gruen, rote = fahre(testdatei)
finally:
pfad.write_bytes(sicherung)
if gruen:
print(f"BEFUND Mutant '{name}' bleibt GRUEN ({testdatei})")
befunde += 1
else:
print(f"rot {name} [{rote}]")
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,394 @@
"""Eigener Lauf mit echten Daten zu Teil 3b: Liste und Einzelansicht der Laeufe, Kerzen.
Koordinator-Pruefskript, nicht fuers Repo. Laeuft in WSL aus dem Projektordner (PYTHONPATH=.).
Geltungsbereich und Herkunft jeder Antwort:
F1 Die Liste nennt jede *.jsonl der konfigurierten Ordner genau einmal. Quelle: eigenes glob
F2 art und zeit je Eintrag gleich der ersten Zeile der Datei. Quelle: eigener Vollparse
F3 vollstaendig genau dann, wenn die letzte Zeile ergebnis/zusammenfassung. Projektvorgabe (Spec)
F4 Jede Zahl der Abschlusszeilen gleich dem eigenen Vollparse, ausser den
ausdruecklich weggelassenen Schluesseln beste_zelle, beste_zelle_je_gruppe. Projektvorgabe (Spec)
F5 Einzelansicht: Auslassungen (kurven, zeitstempel, startpunkte, training),
Zellen je Symbol und Zaehlungen je Symbol gleich eigener Zaehlung. Projektvorgabe (Spec)
F6 Kerzen: Zusammenfassung ab 09:30 New York gleich eigener pandas-Rechnung
aus demselben Rohabruf; Achsenwert = Berliner Wanduhr; Tagesstrings. Projektvorgabe (Spec)
Was es nicht sieht: die Oberflaeche, den WebSocket, die Skripte. Es ist ein Fangnetz, kein Nachweis.
Regressionsnachweis: --sabotage verfaelscht je einen Wert der Antwort vor dem Vergleich; der Lauf
muss dann mit Exit 2 und der passenden Meldung enden.
"""
import argparse
import json
import math
import sys
import tempfile
import time
import traceback
from pathlib import Path
BEFUNDE: list[str] = []
def befund(text: str) -> None:
BEFUNDE.append(text)
print("BEFUND:", text)
def gleich(a, b) -> bool:
# FastAPI 0.141.1 serialisiert ueber pydantic und macht aus NaN und Infinity ein null
# (gemessen 2026-09-17); der eigene Vollparse liefert dafuer float('nan') oder inf.
if a is None and isinstance(b, float) and not math.isfinite(b):
return True
if b is None and isinstance(a, float) and not math.isfinite(a):
return True
if isinstance(a, float) and isinstance(b, float):
if math.isnan(a) and math.isnan(b):
return True
return a == b
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(gleich(a[k], b[k]) for k in a)
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(gleich(x, y) for x, y in zip(a, b))
return a == b
def vollparse(pfad: Path) -> list[dict]:
zeilen = []
with pfad.open("rb") as strom:
for roh in strom:
roh = roh.rstrip(b"\n")
if roh.strip():
zeilen.append(json.loads(roh.decode("utf-8")))
return zeilen
def baue_client():
from fastapi.testclient import TestClient
from trading.broker.attrappe import BrokerAttrappe
from trading.dashboard.app import erzeuge_app
from trading.dashboard.config import lade_dashboard_config
from trading.killswitch.killswitch import lade_killswitch_config
from trading.registry.registry import Registry
config = lade_dashboard_config("config/dashboard.toml")
temp = Path(tempfile.mkdtemp(prefix="pruef3b-"))
registry = Registry(db_pfad=temp / "registry.db", artefakt_wurzel=temp / "artifacts")
app = erzeuge_app(
broker=BrokerAttrappe(),
registry=registry,
config=config,
killswitch_config=lade_killswitch_config("config/killswitch.toml"),
dist=Path("dashboard/dist"),
)
return TestClient(app), config
ABSCHLUSS = {
"kampagne": ("walk_forward", "ergebnis"),
"signal": ("zusammenfassung",),
"signal_tage": ("zusammenfassung",),
}
LETZTE = {"kampagne": "ergebnis", "signal": "zusammenfassung", "signal_tage": "zusammenfassung"}
WEGGELASSEN = ("beste_zelle", "beste_zelle_je_gruppe")
def pruefe_laeufe(client, config, sabotage: str | None) -> None:
t0 = time.perf_counter()
antwort = client.get("/api/laeufe")
dauer = time.perf_counter() - t0
if antwort.status_code != 200:
befund(f"/api/laeufe antwortet {antwort.status_code}")
return
daten = antwort.json()
liste = daten["laeufe"]
print(f"/api/laeufe: {len(liste)} Eintraege in {dauer:.3f} s, hinweise={daten.get('hinweise')}")
if sabotage == "zahl":
for eintrag in liste:
if eintrag["art"] == "kampagne" and eintrag["ergebnis"]:
eintrag["ergebnis"]["walk_forward"]["sharpe"] += 1e-9
break
if sabotage == "fehlt":
liste.pop()
if sabotage == "art":
liste[0]["art"] = "signal" if liste[0]["art"] != "signal" else "kampagne"
dateien = {}
for ordner in config.laeufe_ordner:
for pfad in sorted(Path(ordner).glob("*.jsonl")):
dateien[(Path(ordner).name, pfad.name)] = pfad
gelistet = [(e["quelle"], e["datei"]) for e in liste]
if sorted(gelistet) != sorted(dateien):
befund(f"F1: Liste und Ordner weichen ab: nur Liste {set(gelistet) - set(dateien)}, nur Ordner {set(dateien) - set(gelistet)}")
if len(gelistet) != len(set(gelistet)):
befund("F1: doppelte Eintraege in der Liste")
zeiten = [e["zeit"] for e in liste if e["zeit"] is not None]
if zeiten != sorted(zeiten, reverse=True):
befund("Sortierung: zeit ist nicht absteigend")
arten: dict[str, int] = {}
for eintrag in liste:
schluessel = (eintrag["quelle"], eintrag["datei"])
pfad = dateien.get(schluessel)
if pfad is None:
continue
zeilen = vollparse(pfad)
kopf = zeilen[0]
arten[eintrag["art"]] = arten.get(eintrag["art"], 0) + 1
if eintrag["art"] != kopf.get("art"):
befund(f"F2 {pfad.name}: art {eintrag['art']!r} gegen {kopf.get('art')!r}")
continue
if eintrag["zeit"] != kopf.get("zeit"):
befund(f"F2 {pfad.name}: zeit {eintrag['zeit']!r} gegen {kopf.get('zeit')!r}")
if eintrag["groesse_bytes"] != pfad.stat().st_size:
befund(f"{pfad.name}: groesse_bytes {eintrag['groesse_bytes']} gegen {pfad.stat().st_size}")
art = kopf.get("art")
erwartet_voll = zeilen[-1].get("art") == LETZTE.get(art)
if eintrag["vollstaendig"] != erwartet_voll:
befund(f"F3 {pfad.name}: vollstaendig {eintrag['vollstaendig']} gegen {erwartet_voll}")
for name in ABSCHLUSS.get(art, ()):
eigene = [z for z in zeilen if z.get("art") == name]
if not eigene:
continue
soll = {k: v for k, v in eigene[-1].items() if k not in WEGGELASSEN}
ist = (eintrag["ergebnis"] or {}).get(name)
if ist is None:
befund(f"F4 {pfad.name}: Abschlusszeile {name} fehlt in der Liste")
elif not gleich(ist, soll):
abweichend = [k for k in soll if k not in ist or not gleich(ist[k], soll[k])]
zusaetzlich = [k for k in ist if k not in soll]
befund(f"F4 {pfad.name}: {name} weicht ab in {abweichend}, zusaetzlich {zusaetzlich}")
kopf_ist = eintrag["kopf"]
kopf_soll = json.loads(json.dumps(kopf))
if art == "signal_tage":
kopf_soll.get("daten", {}).pop("instrumente", None)
if not gleich(kopf_ist, kopf_soll):
befund(f"{pfad.name}: kopf weicht ab in {[k for k in kopf_soll if not gleich(kopf_ist.get(k), kopf_soll[k])]}")
print("Arten:", arten)
def pruefe_einzelansicht(client, config, sabotage: str | None) -> None:
kandidaten = [
("kampagne", "laeufe", "aapl-minute-ppo-v20260914-132555.jsonl", None),
# Traegt 30 Zeilen mit "gewinnfaktor": NaN (Protokollschreiber vor dem Fix vom 2026-09-14).
("kampagne", "laeufe", "aapl-minute-ppo-v20260914-085048.jsonl", None),
("signal", "signal", "signal-20260914-213950.jsonl", None),
("signal_tage", "signal", "tage-teil9-20260917-030800.jsonl", "MSFT"),
]
ordner = {Path(o).name: Path(o) for o in config.laeufe_ordner}
for art, quelle, datei, symbol in kandidaten:
pfad = ordner[quelle] / datei
if not pfad.exists():
befund(f"Voraussetzung fehlt: {pfad}")
continue
adresse = f"/api/laeufe/{quelle}/{datei}" + (f"?symbol={symbol}" if symbol else "")
t0 = time.perf_counter()
antwort = client.get(adresse)
dauer = time.perf_counter() - t0
if antwort.status_code != 200:
befund(f"{adresse} antwortet {antwort.status_code}")
continue
daten = antwort.json()
print(f"{adresse}: {len(antwort.content):,} Bytes in {dauer:.3f} s, Zeilenarten { {k: len(v) for k, v in daten['zeilen'].items()} }, unlesbar {daten.get('unlesbare_zeilen')}")
zeilen = vollparse(pfad)
if daten["art"] != art:
befund(f"F5 {datei}: art {daten['art']!r}")
if not gleich(daten["kopf"], zeilen[0]):
befund(f"F5 {datei}: kopf der Einzelansicht ist nicht die ganze Kopfzeile")
if sabotage == "zelle" and art == "signal_tage":
daten["zeilen"]["zelle_instrument"].pop()
if sabotage == "zaehlung" and art == "signal_tage":
daten["symbole"][0]["k1"] += 1
if art == "kampagne":
if "training" in daten["zeilen"] and daten["zeilen"]["training"]:
befund("F5: training-Zeilen sind in der Einzelansicht")
eigene = [z for z in zeilen if z.get("art") == "fenster"]
ist = daten["zeilen"].get("fenster", [])
if len(ist) != len(eigene):
befund(f"F5: {len(ist)} fenster gegen {len(eigene)}")
for a, b in zip(ist, eigene):
soll = {k: v for k, v in b.items() if k not in ("kurven", "zeitstempel", "startpunkte")}
if not gleich(a, soll):
befund(f"F5: fenster {b.get('nummer')} weicht ab oder traegt Masse: {sorted(set(a) ^ set(soll))}")
break
for name in ("walk_forward", "ergebnis"):
if not gleich(daten["zeilen"].get(name, []), [z for z in zeilen if z.get("art") == name]):
befund(f"F5: {name} weicht ab")
if art == "signal":
for name in ("zelle", "permutation", "zusammenfassung"):
if not gleich(daten["zeilen"].get(name, []), [z for z in zeilen if z.get("art") == name]):
befund(f"F5 signal: {name} weicht ab")
if art == "signal_tage":
for name in ("diagnose", "zelle_pool", "jahr", "permutation", "zusammenfassung"):
if not gleich(daten["zeilen"].get(name, []), [z for z in zeilen if z.get("art") == name]):
befund(f"F5 signal_tage: {name} weicht ab")
eigene = [z for z in zeilen if z.get("art") == "zelle_instrument" and z.get("symbol") == symbol]
if not gleich(daten["zeilen"].get("zelle_instrument", []), eigene):
befund(f"F5 signal_tage: Zellen fuer {symbol}: {len(daten['zeilen'].get('zelle_instrument', []))} gegen {len(eigene)}")
print(f" Zellen {symbol}: {len(eigene)}")
zaehlung: dict[str, dict] = {}
for z in zeilen:
if z.get("art") != "zelle_instrument":
continue
s = zaehlung.setdefault(z["symbol"], {"symbol": z["symbol"], "gruppe": z.get("gruppe"), "zellen": 0, "t_ab_2": 0, "k1": 0, "k2": 0, "signale": 0})
s["zellen"] += 1
t = z.get("t_ic_massgeblich")
if isinstance(t, (int, float)) and not math.isnan(t) and abs(t) >= 2:
s["t_ab_2"] += 1
s["k1"] += 1 if z.get("K1") is True else 0
s["k2"] += 1 if z.get("K2") is True else 0
s["signale"] += 1 if z.get("signal") is True else 0
ist = {s["symbol"]: s for s in daten.get("symbole", [])}
if ist.keys() != zaehlung.keys():
befund(f"F5 symbole: Menge weicht ab: {set(ist) ^ set(zaehlung)}")
for name, soll in zaehlung.items():
if name in ist and not gleich({k: ist[name].get(k) for k in soll}, soll):
befund(f"F5 symbole {name}: {ist[name]} gegen {soll}")
print(f" Symbole: {len(zaehlung)}, Summe Signale {sum(s['signale'] for s in zaehlung.values())}, Summe K1 {sum(s['k1'] for s in zaehlung.values())}")
ohne = client.get(f"/api/laeufe/{quelle}/{datei}").json()
if ohne["zeilen"].get("zelle_instrument"):
befund("F5: ohne symbol kommen Instrumentenzellen")
for adresse in ("/api/laeufe/signal/gibt-es-nicht.jsonl", "/api/laeufe/fremd/signal-20260914-213950.jsonl", "/api/laeufe/signal/..%2F..%2Fconfig%2Fsecrets.toml"):
status = client.get(adresse).status_code
print(f"{adresse}: {status}")
if status != 404:
befund(f"Pfadsicherheit: {adresse} antwortet {status} statt 404")
def pruefe_kerzen(client, config, sabotage: str | None) -> None:
import numpy as np
import pandas as pd
from trading.data.catalog import Catalog
antwort = client.get("/api/markt/datensaetze")
if antwort.status_code != 200:
befund(f"/api/markt/datensaetze antwortet {antwort.status_code}")
return
saetze = antwort.json()["datensaetze"]
print(f"/api/markt/datensaetze: {len(saetze)} Datensaetze")
katalog = Catalog(config.markt_katalog)
if sorted(s["bar_type"] for s in saetze) != katalog.bar_types():
befund("F6: Datensaetze weichen von Catalog.bar_types() ab")
minute = [s for s in saetze if s["bar_type"] == "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL"]
if not minute or minute[0].get("aufloesungen_min") != [1, 5, 15, 60]:
befund(f"F6: aufloesungen_min des Minutendatensatzes: {minute[0].get('aufloesungen_min') if minute else 'fehlt'}")
tag = "MSFT.XNAS-1-DAY-LAST-EXTERNAL"
t0 = time.perf_counter()
antwort = client.get("/api/markt/kerzen", params={"bar_type": tag, "aufloesung_min": 1, "anzahl": 3000})
dauer = time.perf_counter() - t0
if antwort.status_code != 200:
befund(f"Tageskerzen antworten {antwort.status_code}: {antwort.text[:300]}")
else:
kerzen = antwort.json()["kerzen"]
roh = katalog.read(tag)
print(f"Tageskerzen {tag}: {len(kerzen)} in {dauer:.3f} s (Katalog {len(roh)})")
if len(kerzen) != len(roh):
befund(f"F6 Tage: {len(kerzen)} gegen {len(roh)}")
for k, b in list(zip(kerzen, roh))[:: max(1, len(roh) // 200)]:
soll_tag = pd.Timestamp(b.ts_event, unit="ns", tz="UTC").tz_convert("America/New_York").strftime("%Y-%m-%d")
if k["achse"] != soll_tag or not gleich([k["open"], k["high"], k["low"], k["close"], k["volumen"]], [float(b.open), float(b.high), float(b.low), float(b.close), float(b.volume)]):
befund(f"F6 Tage: Kerze {k} gegen {b}")
break
minute_typ = "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL"
for aufloesung, ende, anzahl in [(1, "2026-09-10", 500), (5, "2026-09-10", 500), (15, "2026-03-13", 400), (60, "2025-11-05", 300), (60, "2026-09-10", 3000)]:
t0 = time.perf_counter()
antwort = client.get("/api/markt/kerzen", params={"bar_type": minute_typ, "aufloesung_min": aufloesung, "ende": ende, "anzahl": anzahl})
dauer = time.perf_counter() - t0
if antwort.status_code != 200:
befund(f"Minuten {aufloesung}/{ende}: {antwort.status_code} {antwort.text[:300]}")
continue
kerzen = antwort.json()["kerzen"]
if sabotage == "kerze" and aufloesung == 5:
kerzen[3]["high"] += 0.01
if sabotage == "achse" and aufloesung == 15:
kerzen[5]["achse"] += 3600
bis = pd.Timestamp(ende, tz="UTC") + pd.Timedelta(days=1)
von = bis - pd.Timedelta(days=int(anzahl * aufloesung / 390 * 1.6) + 30)
roh = katalog.read(minute_typ, von, bis)
rahmen = pd.DataFrame(
{
"ts": [b.ts_event for b in roh],
"open": [float(b.open) for b in roh],
"high": [float(b.high) for b in roh],
"low": [float(b.low) for b in roh],
"close": [float(b.close) for b in roh],
"volumen": [float(b.volume) for b in roh],
}
)
ny = pd.to_datetime(rahmen["ts"], unit="ns", utc=True).dt.tz_convert("America/New_York")
rahmen = rahmen[ny.dt.strftime("%Y-%m-%d") <= ende].copy()
ny = ny[rahmen.index]
minuten = ny.dt.hour * 60 + ny.dt.minute - (9 * 60 + 30)
rahmen["tag"] = ny.dt.strftime("%Y-%m-%d")
rahmen["eimer"] = (minuten // aufloesung).astype(int)
gruppen = rahmen.groupby(["tag", "eimer"], sort=True)
eigen = gruppen.agg(open=("open", "first"), high=("high", "max"), low=("low", "min"), close=("close", "last"), volumen=("volumen", "sum")).reset_index()
anfang = pd.to_datetime(eigen["tag"]).dt.tz_localize("America/New_York") + pd.to_timedelta(9 * 60 + 30 + eigen["eimer"] * aufloesung, unit="m")
eigen["zeit_utc"] = anfang.dt.tz_convert("UTC")
berlin = anfang.dt.tz_convert("Europe/Berlin").dt.tz_localize(None)
eigen["achse"] = (berlin - pd.Timestamp("1970-01-01")) // pd.Timedelta(seconds=1)
eigen = eigen.tail(anzahl).reset_index(drop=True)
print(f"Minuten {aufloesung:>2} min bis {ende}: {len(kerzen)} Kerzen in {dauer:.3f} s (eigene Rechnung {len(eigen)}, Rohkerzen {len(roh)})")
if len(kerzen) != len(eigen):
befund(f"F6 {aufloesung} min bis {ende}: {len(kerzen)} Kerzen gegen {len(eigen)}")
continue
for i, k in enumerate(kerzen):
e = eigen.iloc[i]
zeit_ist = pd.Timestamp(k["zeit"])
werte_ist = np.array([k["open"], k["high"], k["low"], k["close"], k["volumen"]], dtype=float)
werte_soll = np.array([e["open"], e["high"], e["low"], e["close"], e["volumen"]], dtype=float)
if zeit_ist != e["zeit_utc"] or int(k["achse"]) != int(e["achse"]) or not np.allclose(werte_ist, werte_soll, rtol=0, atol=1e-9):
befund(f"F6 {aufloesung} min bis {ende}, Kerze {i}: ist {k} soll zeit {e['zeit_utc']} achse {int(e['achse'])} werte {werte_soll.tolist()}")
break
achsen = [k["achse"] for k in kerzen]
if achsen != sorted(set(achsen)):
befund(f"F6 {aufloesung} min bis {ende}: Achsenwerte nicht streng steigend")
for params, erwartet in [
({"bar_type": "GIBT.ES-NICHT", "aufloesung_min": 1, "anzahl": 10}, 404),
({"bar_type": minute_typ, "aufloesung_min": 7, "anzahl": 10}, 422),
({"bar_type": minute_typ, "aufloesung_min": 5, "anzahl": 0}, 422),
({"bar_type": minute_typ, "aufloesung_min": 5, "anzahl": 999999}, 422),
({"bar_type": minute_typ, "aufloesung_min": 5, "anzahl": 10, "ende": "kein-datum"}, 422),
({"bar_type": tag, "aufloesung_min": 5, "anzahl": 10}, 422),
]:
status = client.get("/api/markt/kerzen", params=params).status_code
print(f"{params}: {status}")
if status != erwartet:
befund(f"F6 Fehlerfall {params}: {status} statt {erwartet}")
def main() -> int:
zerleger = argparse.ArgumentParser()
zerleger.add_argument("teil", choices=["laeufe", "einzel", "kerzen", "alles"])
zerleger.add_argument("--sabotage", choices=["zahl", "fehlt", "art", "zelle", "zaehlung", "kerze", "achse"])
argumente = zerleger.parse_args()
try:
client, config = baue_client()
with client:
if argumente.teil in ("laeufe", "alles"):
pruefe_laeufe(client, config, argumente.sabotage)
if argumente.teil in ("einzel", "alles"):
pruefe_einzelansicht(client, config, argumente.sabotage)
if argumente.teil in ("kerzen", "alles"):
pruefe_kerzen(client, config, argumente.sabotage)
except Exception:
befund("unerwarteter Abbruch:\n" + traceback.format_exc())
print()
if BEFUNDE:
print(f"{len(BEFUNDE)} Befund(e).")
return 2
print("Kein Befund.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,58 @@
"""Eigener Lauf zu Teil 3b: Bekommen zwei Verbindungen am laufenden Backend denselben Snapshot?
Koordinator-Pruefskript, nicht fuers Repo. Laeuft in WSL gegen ws://127.0.0.1:8144/ws/uebersicht.
Frage: Tragen die Snapshots beider Verbindungen dieselben zeit-Werte (ein Bau je Takt), und kommt
der erste Snapshot einer spaeteren Verbindung sofort? Herkunft: Projektvorgabe (Spec, C4a).
Was es nicht sieht: die Zahl der Broker-Anfragen selbst (die zaehlt der Test mit der Attrappe).
"""
import asyncio
import json
import sys
import time
import websockets
PORT = sys.argv[1] if len(sys.argv) > 1 else "8144"
ADRESSE = f"ws://127.0.0.1:{PORT}/ws/uebersicht"
async def lies(name: str, anzahl: int, ergebnis: dict) -> None:
t0 = time.monotonic()
async with websockets.connect(ADRESSE) as strom:
zeiten = []
for i in range(anzahl):
daten = json.loads(await strom.recv())
if i == 0:
ergebnis[name + "_erste_nach_s"] = round(time.monotonic() - t0, 3)
zeiten.append(daten["zeit"])
ergebnis[name] = zeiten
async def main() -> int:
ergebnis: dict = {}
erste = asyncio.create_task(lies("a", 4, ergebnis))
await asyncio.sleep(2.0)
zweite = asyncio.create_task(lies("b", 3, ergebnis))
await asyncio.gather(erste, zweite)
print(json.dumps(ergebnis, indent=2))
gemeinsam = set(ergebnis["a"]) & set(ergebnis["b"])
print("gemeinsame zeit-Werte:", len(gemeinsam))
befunde = []
if len(gemeinsam) < 2:
befunde.append("die zwei Verbindungen teilen weniger als zwei Snapshots")
if ergebnis["b"][0] not in ergebnis["a"]:
befunde.append("der erste Snapshot der spaeteren Verbindung ist kein Snapshot der ersten")
if ergebnis["b_erste_nach_s"] > 1.0:
befunde.append(f"die spaetere Verbindung wartete {ergebnis['b_erste_nach_s']} s auf ihren ersten Snapshot")
for text in befunde:
print("BEFUND:", text)
return 2 if befunde else 0
if __name__ == "__main__":
try:
sys.exit(asyncio.run(main()))
except Exception as ausnahme:
print("BEFUND: unerwarteter Abbruch:", repr(ausnahme))
sys.exit(2)
@@ -0,0 +1,8 @@
#!/bin/bash
# Faehrt pytest im echten Projektordner. Aufruf: wsl -d Debian -- bash -s -- <pytest-argumente...> < real_pytest.sh
set -u
cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9
"$HOME/venvs/trading/bin/python" -m pytest -p no:cacheprovider "$@" < /dev/null 2> /tmp/real_pytest_stderr.txt | grep -v -E "Pandas4Warning|utcnow|^$" | tail -12
echo "--- stderr (ohne Warnungen) ---"
grep -v -E "Pandas4Warning|utcnow" /tmp/real_pytest_stderr.txt | tail -10
echo "Task-destroyed-Meldungen: $(grep -c 'Task was destroyed' /tmp/real_pytest_stderr.txt)"
@@ -0,0 +1,36 @@
#!/bin/bash
# Probe der zwei Skripte mit argparse -- ausschliesslich aus einem leeren Temp-Verzeichnis.
# Die Uebung selbst (--ausfuehren) wird NICHT gefahren. Aufruf: wsl -d Debian -- bash -s < skripte_probe.sh
set -u
P="/mnt/d/Claude/Code Projekte/active - Trading Plattform"
PY="$HOME/venvs/trading/bin/python"
# Vorbedingung: Es muessen die neuen Fassungen sein. Die alte Uebung ignoriert jedes Argument und handelt.
if ! grep -q "if not parse_args().ausfuehren" "$P/scripts/killswitch_uebung.py"; then echo "ABBRUCH: killswitch_uebung.py prueft den Schalter nicht"; exit 9; fi
if ! grep -q "argparse" "$P/scripts/dashboard.py"; then echo "ABBRUCH: dashboard.py ohne argparse"; exit 9; fi
T=$(mktemp -d)
cd "$T" || exit 9
echo "Arbeitsverzeichnis: $T ($(ls -A "$T" | wc -l) Eintraege)"
VORHER=$(ls -la --time-style=full-iso "$P/data/registry.db" 2>/dev/null | md5sum)
lauf() {
name="$1"; shift
"$PY" "$@" > "$T/aus.txt" 2> "$T/err.txt" < /dev/null
code=$?
echo "=== $name: exit=$code"
echo "--- stdout (erste Zeilen):"; head -4 "$T/aus.txt"
echo "--- stderr (ohne Warnungen, letzte Zeilen):"; grep -v -E "Pandas4Warning|utcnow|warnings.warn" "$T/err.txt" | tail -3
rm -f "$T/aus.txt" "$T/err.txt"
}
lauf "dashboard.py --help" "$P/scripts/dashboard.py" --help
lauf "killswitch_uebung.py --help" "$P/scripts/killswitch_uebung.py" --help
lauf "killswitch_uebung.py ohne Schalter" "$P/scripts/killswitch_uebung.py"
lauf "killswitch_uebung.py --unbekannt" "$P/scripts/killswitch_uebung.py" --unbekannt
lauf "dashboard.py ohne Konfiguration im Verzeichnis" "$P/scripts/dashboard.py"
NACHHER=$(ls -la --time-style=full-iso "$P/data/registry.db" 2>/dev/null | md5sum)
echo "Registerordner unveraendert: $([ "$VORHER" = "$NACHHER" ] && echo ja || echo NEIN)"
echo "Rest im Temp-Verzeichnis: $(ls -A "$T" | wc -l) Eintraege"
rmdir "$T" 2>/dev/null
@@ -0,0 +1,6 @@
#!/bin/bash
# Probe-Backend im Vordergrund (fuer run_in_background des Werkzeugs). Aufruf: bash -s <port> <takt> < start_probe.sh
set -u
SCRATCH="/mnt/d/Claude/.vault-tools/trading-plattform-pruefung"
cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9
PYTHONPATH=. exec "$HOME/venvs/trading/bin/python" "$SCRATCH/start_probe_backend.py" "$1" "$2" < /dev/null
@@ -0,0 +1,35 @@
"""Probe-Backend des Koordinators: die echte App mit Attrappe und Wegwerf-Register auf eigenem Port.
Kein Broker-Aufruf, kein Ereignis im echten Register. Aufruf aus dem Projektordner:
python start_probe_backend.py <port> [sendeintervall_s]
"""
import dataclasses
import sys
import tempfile
from pathlib import Path
import uvicorn
from trading.broker.attrappe import BrokerAttrappe
from trading.dashboard.app import erzeuge_app
from trading.dashboard.config import lade_dashboard_config
from trading.killswitch.killswitch import lade_killswitch_config
from trading.registry.registry import Registry
port = int(sys.argv[1])
config = lade_dashboard_config("config/dashboard.toml")
aenderungen = {"port": port, "broker_wahl": "attrappe"}
if len(sys.argv) > 2:
aenderungen["sendeintervall_s"] = float(sys.argv[2])
config = dataclasses.replace(config, **aenderungen)
temp = Path(tempfile.mkdtemp(prefix="probe-backend-"))
app = erzeuge_app(
broker=BrokerAttrappe(),
registry=Registry(db_pfad=temp / "registry.db", artefakt_wurzel=temp / "artifacts"),
config=config,
killswitch_config=lade_killswitch_config("config/killswitch.toml"),
dist=Path("dashboard/dist"),
)
print(f"Probe-Backend auf 127.0.0.1:{port}, Takt {config.sendeintervall_s} s, Register {temp}", flush=True)
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
@@ -0,0 +1,115 @@
"""Wiederaufbau-Test im Browser: Backend weg, Backend wieder da -- ohne Neuladen der Seite.
Koordinator-Pruefskript (Windows-Python mit Playwright), nicht fuers Repo.
Frage: Zeigt die Kopfzeile "Backend getrennt", wenn das Backend stirbt, und von selbst wieder
"Backend verbunden", wenn es zurueck ist? Herkunft: Projektvorgabe (Spec, Abschnitt Frontend).
Statustexte aus dashboard/src/components/Kopfzeile.tsx (abgeschrieben am 2026-09-17).
Was es nicht sieht: die Wartezeiten 1/2/5/10 s im Einzelnen (dafuer api.test.ts), andere Browser als Chromium.
Regressionsnachweis: --ohne-neustart laesst das Backend unten; der Lauf muss dann mit Exit 2 am Schritt
"wieder verbunden" enden.
Aufruf: python -X utf8 wiederaufbau.py [--ohne-neustart]
"""
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from playwright.sync_api import TimeoutError as PlaywrightTimeout
from playwright.sync_api import sync_playwright
PORT = 8146
BASIS = f"http://localhost:{PORT}"
HIER = Path(__file__).parent
def starte_backend() -> subprocess.Popen:
skript = open(HIER / "start_probe.sh", "rb")
return subprocess.Popen(
["wsl", "-d", "Debian", "--", "bash", "-s", str(PORT), "1.0"],
stdin=skript, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
def beende_backend() -> None:
subprocess.run(["wsl", "-d", "Debian", "--", "pkill", "-f", f"start_probe_backend.py {PORT}"],
stdin=subprocess.DEVNULL, capture_output=True)
def warte_auf_backend(sekunden: float) -> bool:
ende = time.time() + sekunden
while time.time() < ende:
try:
with urllib.request.urlopen(BASIS + "/api/uebersicht", timeout=1) as antwort:
if antwort.status == 200:
return True
except OSError:
time.sleep(0.5)
return False
def main() -> int:
ohne_neustart = "--ohne-neustart" in sys.argv
beende_backend()
prozess = starte_backend()
schritte: list[str] = []
try:
if not warte_auf_backend(40):
print("BEFUND: Probe-Backend kommt nicht hoch -- der Lauf misst nichts")
return 2
with sync_playwright() as p:
browser = p.chromium.launch()
seite = browser.new_page(viewport={"width": 1280, "height": 800})
fehler: list[str] = []
seite.on("pageerror", lambda e: fehler.append(str(e)))
seite.goto(BASIS + "/", wait_until="domcontentloaded")
seite.evaluate("() => { window.__marke = 'nicht neu geladen'; }")
def erwarte(text: str, sekunden: float, name: str) -> bool:
start = time.time()
try:
seite.get_by_text(text, exact=True).first.wait_for(timeout=sekunden * 1000)
except PlaywrightTimeout:
print(f"BEFUND: Schritt '{name}': '{text}' erscheint nicht binnen {sekunden} s")
return False
schritte.append(f"{name}: '{text}' nach {time.time() - start:.1f} s")
return True
if not erwarte("Backend verbunden", 20, "erste Verbindung"):
return 2
beende_backend()
prozess.wait(timeout=20)
if not erwarte("Backend getrennt", 20, "nach dem Ende des Backends"):
return 2
if not ohne_neustart:
prozess = starte_backend()
if not warte_auf_backend(40):
print("BEFUND: Probe-Backend kommt nach dem Neustart nicht hoch")
return 2
if not erwarte("Backend verbunden", 30, "wieder verbunden"):
return 2
marke = seite.evaluate("() => window.__marke")
if marke != "nicht neu geladen":
print(f"BEFUND: Die Seite wurde neu geladen (Marke {marke!r})")
return 2
if fehler:
print(f"BEFUND: Seitenfehler: {fehler[:3]}")
return 2
browser.close()
for schritt in schritte:
print("ok " + schritt)
print("Kein Befund: Wiederaufbau ohne Neuladen.")
return 0
finally:
for schritt in schritte:
pass
beende_backend()
try:
prozess.wait(timeout=10)
except subprocess.TimeoutExpired:
prozess.kill()
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# start-service.sh — startet den watermarks-remover HTTP-Service (Skill "remove-ai-marks")
# in der Cloud-Workspace einer Cowork-Sitzung.
#
# Hintergrund: Die Cloud-Umgebung ist pro Sitzung neu, daher klont dieses Skript
# das Repo bei Bedarf neu und startet den Service im Hintergrund. Nach dem Lauf
# ist der Service unter http://127.0.0.1:8765 erreichbar (siehe SKILL.md unter
# skills/remove-ai-marks/ für die /inspect- und /clean-Endpunkte).
#
# Aufruf:
# bash start-service.sh
#
# Optional per Umgebungsvariable überschreibbar:
# WATERMARKS_REMOVER_DIR Zielordner fürs Repo (Default: $HOME/work/watermarks-remover)
# WATERMARKS_REMOVER_HOST Bind-Host (Default: 127.0.0.1)
# WATERMARKS_REMOVER_PORT Bind-Port (Default: 8765)
set -euo pipefail
WORKDIR="${WATERMARKS_REMOVER_DIR:-$HOME/work/watermarks-remover}"
REPO_URL="https://github.com/guillaumemeyer/watermarks-remover.git"
HOST="${WATERMARKS_REMOVER_HOST:-127.0.0.1}"
PORT="${WATERMARKS_REMOVER_PORT:-8765}"
LOG="/tmp/wr-server.log"
if [ ! -d "$WORKDIR/.git" ]; then
echo "==> Klone watermarks-remover nach $WORKDIR ..."
git clone --depth 1 "$REPO_URL" "$WORKDIR"
else
echo "==> Repo bereits vorhanden unter $WORKDIR, überspringe Klonen."
fi
cd "$WORKDIR"
if curl -sf "http://${HOST}:${PORT}/health" >/dev/null 2>&1; then
echo "==> Service läuft bereits auf ${HOST}:${PORT}."
else
echo "==> Starte Service auf ${HOST}:${PORT} (Log: $LOG) ..."
nohup python3 service/scripts/server.py --host "$HOST" --port "$PORT" > "$LOG" 2>&1 &
disown || true
ok=0
for _ in $(seq 1 20); do
if curl -sf "http://${HOST}:${PORT}/health" >/dev/null 2>&1; then
ok=1
break
fi
sleep 0.5
done
if [ "$ok" -ne 1 ]; then
echo "!! Service ist nach 10s nicht erreichbar. Log-Ausschnitt:"
tail -n 40 "$LOG" || true
exit 1
fi
echo "==> Service ist erreichbar."
fi
echo
echo "--- /health ---"
curl -s "http://${HOST}:${PORT}/health"; echo
echo "--- /capabilities ---"
curl -s "http://${HOST}:${PORT}/capabilities"; echo