Files
claude-regelwerk/.vault-tools/frontmatter-migration/migriere.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

319 lines
13 KiB
Python

#!/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())