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
c064c729ed
82 files changed
+13428
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user