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:
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.
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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.
|
||||
+1040
File diff suppressed because it is too large.
Load diff
+792
@@ -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.
|
||||
+301
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user