"""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/Effort Weichen model: oder effort: 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 werden ausschließlich die Felder model: und effort: im Frontmatter. * Ein YAML-Kommentar hinter dem Wert (effort: high # …) wird nicht abgeschnitten und ergibt einen Befund; für model: galt das schon vorher. * 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. * effort: bei model: haiku. Haiku 4.5 kennt keinen Effort; Claude Code sendet ihn dann nicht (Claude Code 2.1.280, Programmdatei, nachgelesen 2026-09-23). Die Rollentabelle führt keinen Agenten auf Haiku; die Abschrift-Stufe steht im Task-Frontmatter, nicht in einer Agentendatei. * Doppelter Schlüssel effort:. Der flache Frontmatter-Leser nimmt den letzten Wert, wie bei model:. * Ob Claude Code den Wert anwendet. Das zeigt nur das Feld effort im Transkript eines echten Laufs, und eine geänderte Agentendatei wirkt in der laufenden Sitzung nicht sofort. Belegt am 2026-09-23 in frischen Sitzungen über das Agent-Werkzeug und im Workflow. * effort in gespeicherten Workflows unter .claude/workflows/. Sie setzen den Effort mit agentType absichtlich nicht; eine Abweichung dort sieht diese Prüfung nicht. =============================================================================== 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"`([^`]+)`") # Die Stufen stammen aus Claude Code 2.1.280 (Programmdatei, nachgelesen # 2026-09-23). Claude Code selbst nimmt zusätzlich Großschreibung, "med" # und ganze Zahlen an; die Vorgabe hier ist bewusst enger. EFFORT_WERTE = ("low", "medium", "high", "xhigh", "max") 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.5 (`opus`) | `high` (Frontmatter) | 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 entquotet(wert: str) -> str: """Nimmt passende einfache oder doppelte Anführungszeichen ab: 'high' -> high.""" if len(wert) >= 2 and wert[0] == wert[-1] and wert[0] in ("'", '"'): return wert[1:-1] return wert def effort_spalte(text: str) -> int | None: """Findet den Index der Effort-Spalte in der Rollentabelle im Stamm. Gesucht wird die Tabellenzeile, deren Zellen (getrimmt) sowohl 'Agent' als auch 'Effort' enthalten — das ist die Kopfzeile. Der Index der Zelle 'Effort' ist die Effort-Spalte. """ for zeile in text.split("\n"): if not zeile.startswith("|"): continue spalten = [s.strip() for s in zeile.strip("|").split("|")] if "Agent" in spalten and "Effort" in spalten: return spalten.index("Effort") return None def rollentabelle_effort(text: str, spalte: int) -> dict[str, str]: """Liest den Effort je Agent aus der Rollentabelle im Stamm. Genommen wird der erste Backtick-Ausdruck der Effort-Spalte (`spalte`). Fehlt ein Backtick-Ausdruck, ist der Wert für diesen Agenten leer. """ 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) <= spalte: continue agenten = BACKTICK.findall(spalten[1]) if not agenten: continue effort_treffer = BACKTICK.findall(spalten[spalte]) wert = effort_treffer[0].strip() if effort_treffer else "" for name in agenten: zuordnung[name.strip()] = wert 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], effort_index: int | None, effort_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]}", ) ) # Effort: nur prüfbar, wenn die Effort-Spalte der Rollentabelle # gefunden wurde (siehe main() für den Befund am Stamm, falls nicht). if effort_index is None: continue soll = effort_tabelle.get(name, "") if not soll: befunde.append( Befund("wichtig", "CLAUDE.md", f"Rollentabelle nennt für '{name}' keinen Effort") ) effort_roh = fm.get("effort") if not effort_roh: befunde.append( Befund( "wichtig", rel, "kein effort: im Frontmatter — der Agent erbt den Effort der Hauptsitzung", ) ) continue effort_wert = entquotet(effort_roh) if not effort_wert: befunde.append( Befund( "wichtig", rel, "kein effort: im Frontmatter — der Agent erbt den Effort der Hauptsitzung", ) ) elif effort_wert not in EFFORT_WERTE: befunde.append( Befund( "blockierend", rel, f"effort: {effort_wert} ist nicht in der Vorgabe (erlaubt: low, medium, high, xhigh, max)", ) ) elif soll and effort_wert != soll: befunde.append( Befund("blockierend", rel, f"effort: {effort_wert} — Rollentabelle im Stamm sagt {soll}") ) 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("-") # / oder .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("/"): 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") ) effort_index = effort_spalte(stammtext) if tabelle else None if tabelle and effort_index is None: befunde.append( Befund( "blockierend", "CLAUDE.md", "Effort-Spalte der Rollentabelle nicht auffindbar — Prüfung 5 kann den Effort nicht prüfen", ) ) effort_tabelle = rollentabelle_effort(stammtext, effort_index) if effort_index is not None else {} 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, effort_index, effort_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())