Files
claude-regelwerk/.vault-tools/regelwerk-check/regelwerk_check.py
T
Pascal MichelandClaude Opus 5 c064c729ed 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]>
2026-09-17 22:38:15 +02:00

387 lines
15 KiB
Python

"""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())