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]>
387 lines
15 KiB
Python
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())
|