diff --git a/.claude/settings.json b/.claude/settings.json index 1aa5cb2..27f6066 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,7 +12,9 @@ { "type": "command", "command": "python", - "args": ["D:/Claude/.vault-tools/session-hooks/sitzungsstart.py"], + "args": [ + "D:/Claude/.vault-tools/session-hooks/sitzungsstart.py" + ], "timeout": 30 } ] @@ -25,8 +27,18 @@ { "type": "command", "command": "python", - "args": ["D:/Claude/.vault-tools/session-hooks/regelwerk_nach_edit.py"], + "args": [ + "D:/Claude/.vault-tools/session-hooks/regelwerk_nach_edit.py" + ], "timeout": 30 + }, + { + "type": "command", + "command": "python", + "args": [ + "D:/Claude/.vault-tools/auto-push/auto_push.py" + ], + "timeout": 60 } ] } diff --git a/.gitignore b/.gitignore index 709eee3..4007eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ !/.vault-tools/ # Niemals, egal wo +# Laufzeitsperre des Auto-Push-Hooks. Sie entsteht bei jedem Lauf und +# hat im Repo nichts zu suchen (Fallkatalog vom 2026-09-17). +.vault-tools/auto-push/.sperre __pycache__/ *.pyc *.pyo diff --git a/.vault-tools/auto-push/askpass.py b/.vault-tools/auto-push/askpass.py new file mode 100644 index 0000000..731e55a --- /dev/null +++ b/.vault-tools/auto-push/askpass.py @@ -0,0 +1,38 @@ +"""GIT_ASKPASS-Helfer: gibt das Gitea-Token aus der Umgebung an git weiter. + +Ohne diesen Helfer landet das Token entweder in .git/config (Remote-URL mit +Zugangsdaten) oder in einer Kommandozeile (http.extraheader) und ist dort fuer +jeden Prozess der Maschine lesbar. git ruft dieses Skript mit der Eingabe- +aufforderung als Argument auf und liest die Antwort von stdout. + +Eine fehlende Variable ist ein Befund: das Skript endet mit Exit 1 und einer +Meldung auf stderr, statt eine leere Zeile auszugeben. Sonst haengt git an +einer stillen Fehlanmeldung. +""" + +from __future__ import annotations + +import os +import sys + +BENUTZER_VARIABLE = "GITEA_BENUTZER" +TOKEN_VARIABLE = "GITEA_TOKEN" + + +def main(argumente: list[str]) -> int: + frage = " ".join(argumente).lower() + if "username" in frage or "benutzer" in frage: + name = "GITEA_BENUTZER" + wert = os.environ.get(BENUTZER_VARIABLE, "").strip() + else: + name = "GITEA_TOKEN" + wert = os.environ.get(TOKEN_VARIABLE, "").strip() + if not wert: + sys.stderr.write(f"{name} ist nicht gesetzt oder leer.\n") + return 1 + sys.stdout.write(wert + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.vault-tools/auto-push/askpass.sh b/.vault-tools/auto-push/askpass.sh new file mode 100644 index 0000000..2ff453d --- /dev/null +++ b/.vault-tools/auto-push/askpass.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# Von git als GIT_ASKPASS aufgerufen. Reicht die Frage an askpass.py weiter. +exec python "D:/Claude/.vault-tools/auto-push/askpass.py" "$@" diff --git a/.vault-tools/auto-push/auto_push.py b/.vault-tools/auto-push/auto_push.py new file mode 100644 index 0000000..73f2318 --- /dev/null +++ b/.vault-tools/auto-push/auto_push.py @@ -0,0 +1,349 @@ +"""PostToolUse-Hook: committet und pusht jede Änderung an der veröffentlichten Anweisungsschicht. + +Ersteller: Pascal Michel +Erstellt: 2026-09-17 + +GELTUNGSBEREICH + Ausgelöst wird nur, wenn tool_input.file_path innerhalb der Vault-Wurzel auf + CLAUDE.md, README.md, AI/Regelwerk/**, .claude/agents/**, .claude/skills/**, + .claude/settings.json oder .vault-tools/** zeigt. Die Wurzel ist + ausschließlich der Ordner zwei Ebenen über dieser Datei; weder + CLAUDE_PROJECT_DIR noch das Feld cwd der Hook-Eingabe werden gelesen, weil + cwd einem cd im Bash-Werkzeug folgt (dieselbe Begründung wie in + regelwerk_nach_edit.py). + + Was tatsächlich gepusht wird, entscheidet aber nicht diese Liste, sondern die + .gitignore der Vault-Wurzel. Sie arbeitet als Positivliste: ausgeschlossen + ist alles, eingeschlossen nur, was dort ausdrücklich wieder aufgenommen ist. + Die Liste hier bestimmt nur, ob der Hook überhaupt anläuft — sie ist eine + Abkürzung, kein zweiter Filter. Wer den Umfang ändern will, ändert die + .gitignore. + + Still bleibt der Hook in drei Lagen: die Eingabe taugt nicht (leer, kein + JSON-Objekt, kein file_path als Zeichenkette); der Pfad liegt außerhalb des + Geltungsbereichs; oder es gibt nichts zu committen. Jede andere Lage — + fehlendes Token, fehlendes Remote, gescheiterter Commit, gescheiterter Push, + Zeitüberschreitung — ist ein Befund und wird gemeldet. + +VERHALTEN BEI GESCHEITERTEM PUSH + Der Commit bleibt lokal stehen und der Befund geht in den Kontext. Der + nächste Lauf schiebt ihn mit, weil `git push` alles bis HEAD überträgt. Ein + gescheiterter Push verliert also keine Arbeit, er verzögert sie. + +NEBENLÄUFIGKEIT + Zwei schnell aufeinanderfolgende Edits starten zwei Hook-Läufe. Ohne Sperre + kollidieren sie an der index.lock von git. Deshalb eine eigene Sperrdatei mit + O_EXCL; wer sie nicht bekommt, wartet bis SPERRE_WARTE_S und läuft danach + selbst. Findet er nichts mehr zu committen, bleibt er still — der andere Lauf + hat seine Änderung dann bereits mitgenommen. + +BENANNTE LÜCKEN + * Ob tool_name Edit, Write oder MultiEdit ist, prüft der Matcher in + settings.json, nicht dieses Skript. + * Der Hook prüft den Inhalt nicht. Er hat keinen Geheimnis-Scan und keine + Freigabe. Was in einer eingeschlossenen Datei steht, geht heraus, sobald + sie geschrieben wird — auch halbfertig. Das ist die ausdrückliche Vorgabe + (Pascal, 2026-09-17), und es ist der Grund, warum die .gitignore eine + Positivliste ist und keine Ausschlussliste. + * Pfade werden ueber realpath aufgeloest. Ein Symlink von ausserhalb auf + eine eingeschlossene Datei loest den Hook deshalb aus, ein Symlink von + innen nach aussen nicht. + * Ein Push wird nicht wiederholt. Nachgeholt wird er beim nächsten Lauf. + +Exit ist immer 0. Gemeldet wird über additionalContext plus systemMessage, wie +beim Nachbar-Hook regelwerk_nach_edit.py. +""" + +from __future__ import annotations + +import errno +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +SKRIPT = Path(__file__).resolve() + +# Unter dem timeout von 60 s, den der settings.json-Eintrag setzen soll. +TIMEOUT_GIT_S = 15 +TIMEOUT_PUSH_S = 40 +SPERRE_WARTE_S = 25 +SPERRE_TAKT_S = 0.4 +MAX_ZEILEN = 30 +MAX_ZEICHEN = 3000 +MAX_DATEIEN_IN_MELDUNG = 12 + +# Kleinbuchstaben und Schrägstrich vorwärts, verglichen gegen normiere(). +GELTUNG_DATEIEN = ("claude.md", "readme.md", ".gitignore", ".gitattributes") +GELTUNG_ORDNER = ( + "ai/regelwerk/", + ".claude/agents/", + ".claude/skills/", + ".vault-tools/", +) +GELTUNG_EINZELN = (".claude/settings.json",) + +ASKPASS_REL = Path(".vault-tools") / "auto-push" / "askpass.sh" +SPERRE_REL = Path(".vault-tools") / "auto-push" / ".sperre" + + +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: + return SKRIPT.parents[2] + + +def normiere(pfad: str) -> str: + # realpath, nicht abspath: Windows liefert denselben Ort mal als + # "C:/Users/Pascal Michel/..." und mal als 8.3-Kurznamen + # "C:/Users/PASCAL~1/...". Ein Vergleich mit abspath haelt die beiden fuer + # verschiedene Pfade, und der Hook bleibt dann still statt zu pushen -- + # im Fallkatalog vom 2026-09-17 sind daran sieben Faelle gescheitert. + return os.path.normpath(os.path.realpath(pfad)).replace("\\", "/").lower().rstrip("/") + + +def im_geltungsbereich(wurzel: Path, datei: str) -> bool: + kandidat = datei.replace("\\", "/") + try: + 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 "-CLAUDE.md" neben dem Vault. + if not pfad.startswith(basis + "/"): + return False + rel = pfad[len(basis) + 1:] + return ( + rel in GELTUNG_DATEIEN + or rel in GELTUNG_EINZELN + 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") + + +class GitFehler(Exception): + """Ein git-Aufruf ist gescheitert; die Meldung ist der Befund.""" + + def __init__(self, kopf: str, rumpf: str) -> None: + super().__init__(kopf) + self.kopf = kopf + self.rumpf = rumpf + + +def git(wurzel: Path, *argumente: str, timeout: int = TIMEOUT_GIT_S) -> str: + umgebung = dict( + os.environ, + PYTHONIOENCODING="utf-8", + GIT_TERMINAL_PROMPT="0", + GIT_ASKPASS=str(wurzel / ASKPASS_REL), + GIT_CONFIG_NOSYSTEM="0", + ) + befehl = ["git", "-C", str(wurzel), *argumente] + try: + lauf = subprocess.run( + befehl, + env=umgebung, + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as abgelaufen: + teil = (dekodiere(abgelaufen.stdout) + dekodiere(abgelaufen.stderr)).strip() + raise GitFehler( + f"git {argumente[0]} nach {timeout} s abgebrochen", + f"`git {' '.join(argumente)}` lief länger als {timeout} s.\n" + f"Bis dahin ausgegeben:\n{teil or '(nichts)'}", + ) from abgelaufen + except OSError as fehler: + raise GitFehler( + f"git nicht startbar: {type(fehler).__name__}", + f"`git {' '.join(argumente)}` ließ sich nicht starten: " + f"{type(fehler).__name__}: {fehler}", + ) from fehler + + if lauf.returncode != 0: + ausgabe = (dekodiere(lauf.stdout) + "\n" + dekodiere(lauf.stderr)).strip() + raise GitFehler( + f"git {argumente[0]}: Exit {lauf.returncode}", + f"`git {' '.join(argumente)}` endete mit Exit {lauf.returncode}.\n" + f"{ausgabe or '(keine Ausgabe)'}", + ) + return dekodiere(lauf.stdout) + + +def hole_sperre(sperre: Path) -> int | None: + """Gibt den Dateideskriptor zurück, oder None, wenn die Sperre belegt bleibt.""" + frist = time.monotonic() + SPERRE_WARTE_S + while True: + try: + return os.open(str(sperre), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except OSError as fehler: + if fehler.errno not in (errno.EEXIST, errno.EACCES): + raise + # Eine Sperre, die älter ist als die Wartezeit, stammt aus einem + # abgestürzten Lauf. Sie wird entfernt, nicht ewig respektiert. + try: + if time.time() - sperre.stat().st_mtime > SPERRE_WARTE_S * 2: + sperre.unlink(missing_ok=True) + continue + except OSError: + pass + if time.monotonic() >= frist: + return None + time.sleep(SPERRE_TAKT_S) + + +def gib_sperre_frei(deskriptor: int, sperre: Path) -> None: + try: + os.close(deskriptor) + except OSError: + pass + try: + sperre.unlink(missing_ok=True) + except OSError: + pass + + +def baue_meldung(dateien: list[str]) -> str: + rest = len(dateien) - MAX_DATEIEN_IN_MELDUNG + sichtbar = dateien[:MAX_DATEIEN_IN_MELDUNG] + kopf = ( + f"Automatischer Push: {len(dateien)} Datei(en) geändert" + if len(dateien) != 1 + else f"Automatischer Push: {dateien[0]}" + ) + rumpf = "\n".join(f"- {d}" for d in sichtbar) + if rest > 0: + rumpf += f"\n- … und {rest} weitere" + return ( + f"{kopf}\n\n{rumpf}\n\n" + "Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.\n" + "\n" + "Co-Authored-By: Claude Opus 5 \n" + ) + + +def arbeite(wurzel: Path) -> tuple[str, str] | None: + """None, wenn nichts zu tun war; sonst (systemMessage, Rumpf).""" + if not (wurzel / ".git").exists(): + raise GitFehler( + "Auto-Push: kein Git-Repo", + f"{wurzel} ist kein Git-Repository. Der Hook ist eingetragen, kann " + "aber nichts pushen. Das ist ein Befund, keine Entwarnung.", + ) + if not (wurzel / ASKPASS_REL).is_file(): + raise GitFehler( + "Auto-Push: askpass-Helfer fehlt", + f"{wurzel / ASKPASS_REL} fehlt. Ohne ihn fragt git nach einem " + "Passwort und der Push hängt oder scheitert.", + ) + if not os.environ.get("GITEA_TOKEN", "").strip(): + raise GitFehler( + "Auto-Push: GITEA_TOKEN nicht gesetzt", + "Die Umgebungsvariable GITEA_TOKEN ist leer oder fehlt. Der Commit " + "wird trotzdem gemacht, der Push scheitert. Variable setzen und " + "eine beliebige Regelwerk-Datei erneut speichern.", + ) + + git(wurzel, "add", "-A") + gestaged = [z for z in git(wurzel, "diff", "--cached", "--name-only").splitlines() if z.strip()] + if not gestaged: + return None + + git(wurzel, "commit", "-m", baue_meldung(gestaged)) + git(wurzel, "push", "origin", "HEAD", timeout=TIMEOUT_PUSH_S) + return None + + +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": kuerze(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 + + sperre = wurzel / SPERRE_REL + deskriptor = hole_sperre(sperre) + if deskriptor is None: + # Ein anderer Lauf arbeitet noch. Er nimmt diese Änderung mit, + # weil er `git add -A` über den ganzen Baum fährt. + return 0 + try: + ergebnis = arbeite(wurzel) + finally: + gib_sperre_frei(deskriptor, sperre) + if ergebnis is not None: + melde(*ergebnis) + return 0 + except GitFehler as fehler: + melde(fehler.kopf, fehler.rumpf) + return 0 + except Exception as fehler: # noqa: BLE001 - ein Absturz ist selbst ein Befund + melde( + f"Auto-Push abgebrochen: {type(fehler).__name__}", + f"Der Auto-Push-Hook ist unerwartet abgebrochen: " + f"{type(fehler).__name__}: {fehler}", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.vault-tools/auto-push/faelle_auto_push.py b/.vault-tools/auto-push/faelle_auto_push.py new file mode 100644 index 0000000..ebeda9e --- /dev/null +++ b/.vault-tools/auto-push/faelle_auto_push.py @@ -0,0 +1,338 @@ +"""Fährt die Angriffsliste gegen auto_push.py, je Fall auf einem Wegwerf-Vault. + +Ersteller: Pascal Michel +Erstellt: 2026-09-17 + +Jeder Fall bekommt einen eigenen Wegwerf-Vault: ein echtes Git-Repo mit einem +zweiten Repo als bare-Remote daneben. Damit ist der Push echt — es wird +wirklich übertragen, nur eben in ein Verzeichnis statt über das Netz. Ein +Auto-Push, der nur gegen eine Attrappe geprüft wurde, belegt nichts über den +Fall, der zählt: dass die Änderung nachher drüben liegt. + +Der Hook wird für jeden Fall in den Wegwerf-Vault kopiert, weil er seine +Vault-Wurzel ausschließlich aus dem eigenen Skriptpfad bestimmt. + +Grundlinie zuerst: eine geänderte Datei im Geltungsbereich muss nach dem Lauf +im Remote liegen. Läuft die Grundlinie nicht, misst der Rest nichts. + +Aufruf: + python .vault-tools/auto-push/faelle_auto_push.py +""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +HIER = Path(__file__).resolve().parent +HOOK = HIER / "auto_push.py" +ASKPASS_PY = HIER / "askpass.py" + +# Die .gitignore des echten Vaults, gekürzt auf das, was die Fälle brauchen. +GITIGNORE = """/* +!/.gitignore +!/CLAUDE.md +!/AI/ +/AI/* +!/AI/Regelwerk/ +!/.vault-tools/ +.vault-tools/auto-push/.sperre +__pycache__/ +*.pyc +""" + + +def raeume(pfad: Path) -> None: + """rmtree, das auch schreibgeschuetzte git-Objektdateien wegbekommt.""" + + def erzwinge(funktion, ziel, _fehler): + try: + os.chmod(ziel, stat.S_IWRITE) + funktion(ziel) + except OSError: + pass + + shutil.rmtree(pfad, onexc=erzwinge) + + +def git(wo: Path, *argumente: str) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + ["git", "-C", str(wo), *argumente], + capture_output=True, + stdin=subprocess.DEVNULL, + check=False, + ) + + +def baue_vault(basis: Path) -> tuple[Path, Path]: + """Legt Arbeits-Vault und bare-Remote an und macht den ersten Commit.""" + vault = basis / "vault" + remote = basis / "remote.git" + (vault / "AI" / "Regelwerk").mkdir(parents=True) + (vault / ".vault-tools" / "auto-push").mkdir(parents=True) + (vault / "Inbox").mkdir() + + (vault / ".gitignore").write_text(GITIGNORE, encoding="utf-8") + (vault / "CLAUDE.md").write_text("# Stamm\n", encoding="utf-8") + (vault / "AI" / "Regelwerk" / "probe.md").write_text("# Probe\n", encoding="utf-8") + # Eine Datei, die niemals herausgehen darf. + (vault / "Inbox" / "geheim.md").write_text("Kundendaten\n", encoding="utf-8") + + shutil.copy2(HOOK, vault / ".vault-tools" / "auto-push" / "auto_push.py") + shutil.copy2(ASKPASS_PY, vault / ".vault-tools" / "auto-push" / "askpass.py") + shim = vault / ".vault-tools" / "auto-push" / "askpass.sh" + shim.write_bytes(b"#!/bin/sh\necho unbenutzt\n") + + subprocess.run(["git", "init", "-q", "--bare", str(remote)], check=True) + git(vault, "init", "-q") + git(vault, "symbolic-ref", "HEAD", "refs/heads/main") + git(vault, "config", "user.name", "Fall") + git(vault, "config", "user.email", "fall@example.invalid") + git(vault, "config", "commit.gpgsign", "false") + git(vault, "remote", "add", "origin", remote.as_uri()) + git(vault, "add", "-A") + git(vault, "commit", "-q", "-m", "Grundstand") + git(vault, "push", "-q", "-u", "origin", "main") + return vault, remote + + +def lauf_hook(vault: Path, eingabe: dict, umgebung_extra: dict | None = None) -> tuple[int, str, str]: + umgebung = dict(os.environ, PYTHONIOENCODING="utf-8", GITEA_TOKEN="egal") + umgebung.update(umgebung_extra or {}) + lauf = subprocess.run( + [sys.executable, str(vault / ".vault-tools" / "auto-push" / "auto_push.py")], + input=json.dumps(eingabe).encode("utf-8"), + capture_output=True, + env=umgebung, + timeout=90, + check=False, + ) + return ( + lauf.returncode, + lauf.stdout.decode("utf-8", errors="replace"), + lauf.stderr.decode("utf-8", errors="replace"), + ) + + +def remote_dateien(remote: Path) -> set[str]: + lauf = git(remote, "ls-tree", "-r", "--name-only", "main") + if lauf.returncode != 0: + return set() + return {z for z in lauf.stdout.decode("utf-8", errors="replace").splitlines() if z} + + +def remote_commits(remote: Path) -> int: + lauf = git(remote, "rev-list", "--count", "main") + if lauf.returncode != 0: + return -1 + return int(lauf.stdout.decode().strip() or "0") + + +@dataclass +class Fall: + name: str + beschreibung: str + + +def main() -> int: + basis = Path(tempfile.mkdtemp(prefix="auto-push-faelle-")) + gesamt = 0 + bestanden = 0 + + def pruefe(name: str, ok: bool, text: str) -> None: + nonlocal gesamt, bestanden + gesamt += 1 + bestanden += int(ok) + print(f"{'BESTANDEN' if ok else 'FEHLER':10} {name:34} {text}") + + try: + # --- Grundlinie ------------------------------------------------- + vault, remote = baue_vault(basis / "grundlinie") + vorher = remote_commits(remote) + (vault / "AI" / "Regelwerk" / "probe.md").write_text("# Probe geaendert\n", encoding="utf-8") + code, aus, fehler = lauf_hook(vault, {"tool_input": {"file_path": str(vault / "AI" / "Regelwerk" / "probe.md")}}) + nachher = remote_commits(remote) + pruefe( + "grundlinie", + code == 0 and nachher == vorher + 1 and not aus.strip(), + f"Exit {code}, Commits {vorher}->{nachher}, Ausgabe {len(aus)} Z. {fehler[:80]}", + ) + + # --- Ausgeschlossene Datei geht nie heraus ---------------------- + drueben = remote_dateien(remote) + pruefe( + "positivliste haelt", + not any(d.startswith("Inbox/") for d in drueben), + f"Remote fuehrt {sorted(drueben)}", + ) + pruefe( + "sperrdatei bleibt drinnen", + not any(d.endswith(".sperre") for d in drueben), + f"Remote fuehrt {sorted(drueben)}", + ) + + # --- Lauf ohne Aenderung bleibt still --------------------------- + vorher = remote_commits(remote) + code, aus, _ = lauf_hook(vault, {"tool_input": {"file_path": str(vault / "CLAUDE.md")}}) + pruefe( + "nichts geaendert -> still", + code == 0 and not aus.strip() and remote_commits(remote) == vorher, + f"Exit {code}, Ausgabe {len(aus)} Z., Commits {remote_commits(remote)}", + ) + + # --- Eingaben, die nichts ausloesen duerfen --------------------- + stille_faelle = [ + ("eingabe leer", b""), + ("eingabe kein json", b"{kaputt"), + ("eingabe kein objekt", b"[1,2,3]"), + ("eingabe null", b"null"), + ("kein tool_input", b'{"tool_name":"Edit"}'), + ("tool_input keine map", b'{"tool_input":"x"}'), + ("kein file_path", b'{"tool_input":{"content":"x"}}'), + ("file_path keine zeichenkette", b'{"tool_input":{"file_path":42}}'), + ("file_path leer", b'{"tool_input":{"file_path":" "}}'), + ] + for name, roh in stille_faelle: + (vault / "CLAUDE.md").write_text(f"# Stamm {name}\n", encoding="utf-8") + vorher = remote_commits(remote) + lauf = subprocess.run( + [sys.executable, str(vault / ".vault-tools" / "auto-push" / "auto_push.py")], + input=roh, + capture_output=True, + env=dict(os.environ, PYTHONIOENCODING="utf-8", GITEA_TOKEN="egal"), + timeout=60, + check=False, + ) + aus = lauf.stdout.decode("utf-8", errors="replace") + pruefe( + name, + lauf.returncode == 0 and not aus.strip() and remote_commits(remote) == vorher, + f"Exit {lauf.returncode}, Ausgabe {len(aus)} Z.", + ) + git(vault, "checkout", "--", "CLAUDE.md") + + # --- Pfade ausserhalb des Geltungsbereichs ---------------------- + aussen = [ + ("pfad ausserhalb vault", str(basis / "woanders.md")), + ("nachbarordner mit praefix", str(vault) + "-anders/CLAUDE.md"), + ("inbox-notiz loest nicht aus", str(vault / "Inbox" / "geheim.md")), + ] + for name, pfad in aussen: + (vault / "CLAUDE.md").write_text(f"# Stamm {name}\n", encoding="utf-8") + vorher = remote_commits(remote) + code, aus, _ = lauf_hook(vault, {"tool_input": {"file_path": pfad}}) + pruefe( + name, + code == 0 and not aus.strip() and remote_commits(remote) == vorher, + f"Exit {code}, Commits unveraendert: {remote_commits(remote) == vorher}", + ) + git(vault, "checkout", "--", "CLAUDE.md") + + # --- Pfadformen, die ausloesen muessen -------------------------- + formen = [ + ("relativer pfad", "AI/Regelwerk/probe.md"), + ("backslash-pfad", str(vault / "AI" / "Regelwerk" / "probe.md").replace("/", "\\")), + ("pfad mit punktsegment", str(vault / "AI" / ".." / "AI" / "Regelwerk" / "probe.md")), + ("grossschreibung", str(vault / "AI" / "Regelwerk" / "probe.md").upper()), + ("vault-tools zaehlt mit", str(vault / ".vault-tools" / "auto-push" / "notiz.md")), + ] + for nummer, (name, pfad) in enumerate(formen): + ziel = vault / "AI" / "Regelwerk" / "probe.md" + ziel.write_text(f"# Probe Form {nummer}\n", encoding="utf-8") + vorher = remote_commits(remote) + code, aus, fehler = lauf_hook(vault, {"tool_input": {"file_path": pfad}}) + pruefe( + name, + code == 0 and remote_commits(remote) == vorher + 1, + f"Exit {code}, Commits {vorher}->{remote_commits(remote)} {aus[:60]}", + ) + + # --- Reihenfolge: erste und letzte Datei einer Sammeländerung --- + for position in ("erste", "letzte"): + vault2, remote2 = baue_vault(basis / f"reihenfolge-{position}") + for i in range(5): + (vault2 / "AI" / "Regelwerk" / f"a{i}.md").write_text(f"# {i}\n", encoding="utf-8") + ausloeser = "a0.md" if position == "erste" else "a4.md" + code, aus, _ = lauf_hook(vault2, {"tool_input": {"file_path": str(vault2 / "AI" / "Regelwerk" / ausloeser)}}) + dateien = remote_dateien(remote2) + fehlt = [f"AI/Regelwerk/a{i}.md" for i in range(5) if f"AI/Regelwerk/a{i}.md" not in dateien] + pruefe( + f"reihenfolge {position}", + code == 0 and not fehlt, + f"Exit {code}, fehlend im Remote: {fehlt or 'nichts'}", + ) + + # --- Fehlende Voraussetzungen sind Befunde ---------------------- + vault3, remote3 = baue_vault(basis / "ohne-token") + (vault3 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8") + code, aus, _ = lauf_hook(vault3, {"tool_input": {"file_path": str(vault3 / "CLAUDE.md")}}, {"GITEA_TOKEN": ""}) + pruefe( + "kein token -> befund", + code == 0 and "GITEA_TOKEN" in aus and remote_commits(remote3) == 1, + f"Exit {code}, meldet: {'ja' if 'GITEA_TOKEN' in aus else 'NEIN'}", + ) + + vault4, remote4 = baue_vault(basis / "ohne-askpass") + (vault4 / ".vault-tools" / "auto-push" / "askpass.sh").unlink() + (vault4 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8") + code, aus, _ = lauf_hook(vault4, {"tool_input": {"file_path": str(vault4 / "CLAUDE.md")}}) + pruefe( + "kein askpass -> befund", + code == 0 and "askpass" in aus.lower(), + f"Exit {code}, meldet: {'ja' if 'askpass' in aus.lower() else 'NEIN'}", + ) + + vault5, _ = baue_vault(basis / "ohne-git") + raeume(vault5 / ".git") + (vault5 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8") + code, aus, _ = lauf_hook(vault5, {"tool_input": {"file_path": str(vault5 / "CLAUDE.md")}}) + pruefe( + "kein repo -> befund", + code == 0 and "Git-Repo" in aus, + f"Exit {code}, meldet: {'ja' if 'Git-Repo' in aus else 'NEIN'}", + ) + + vault6, remote6 = baue_vault(basis / "remote-weg") + raeume(remote6) + (vault6 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8") + code, aus, _ = lauf_hook(vault6, {"tool_input": {"file_path": str(vault6 / "CLAUDE.md")}}) + lokal = git(vault6, "rev-list", "--count", "HEAD").stdout.decode().strip() + pruefe( + "push scheitert -> befund, commit bleibt", + code == 0 and "push" in aus.lower() and lokal == "2", + f"Exit {code}, lokale Commits {lokal}, meldet: {'ja' if 'push' in aus.lower() else 'NEIN'}", + ) + + # --- Belegte Sperre bleibt still -------------------------------- + vault7, remote7 = baue_vault(basis / "sperre") + sperre = vault7 / ".vault-tools" / "auto-push" / ".sperre" + sperre.write_bytes(b"") + (vault7 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8") + vorher = remote_commits(remote7) + code, aus, _ = lauf_hook(vault7, {"tool_input": {"file_path": str(vault7 / "CLAUDE.md")}}) + pruefe( + "belegte sperre -> still", + code == 0 and not aus.strip() and remote_commits(remote7) == vorher, + f"Exit {code}, Ausgabe {len(aus)} Z.", + ) + + print() + print(f"{bestanden} von {gesamt} Faellen bestanden.") + return 0 if bestanden == gesamt else 1 + finally: + shutil.rmtree(basis, ignore_errors=True) + + +if __name__ == "__main__": + try: + sys.stdout.reconfigure(encoding="utf-8") + except AttributeError: + pass + raise SystemExit(main()) diff --git a/AI/Regelwerk/veroeffentlichung.md b/AI/Regelwerk/veroeffentlichung.md new file mode 100644 index 0000000..47fc05a --- /dev/null +++ b/AI/Regelwerk/veroeffentlichung.md @@ -0,0 +1,58 @@ +--- +type: resource +status: reference +title: "Veröffentlichung der Anweisungsschicht" +summary: "Welcher Teil des Vaults in das Gitea-Repo claude-regelwerk geht, wie die Positivliste funktioniert und was der automatische Push tut. Laden, bevor der veröffentlichte Umfang geändert wird oder wenn ein Auto-Push-Befund im Kontext steht." +tags: [regelwerk, git, veroeffentlichung, werkzeuge] +created: 2026-09-17 +updated: 2026-09-17 +--- + +# Veröffentlichung der Anweisungsschicht + +Angeordnet am 2026-09-17. Die Anweisungsschicht des Vaults liegt seitdem in einem eigenen Git-Repo und wird nach jeder Änderung automatisch gepusht. Laden, wenn der Umfang geändert werden soll oder wenn eine Auto-Push-Meldung im Kontext steht. + +**Der Vault-Root ist jetzt ein Git-Repo.** Das war er vorher nicht, und es widerspricht nicht [[AI/Regelwerk/projekte|projekte.md]]: Die Projekte unter `Code Projekte/` behalten ihre eigenen Repos, die von diesem hier nicht berührt werden. + +| | | +| --- | --- | +| Remote | `https://git.nexhorizons.de/Nexhorizons/claude-regelwerk.git` | +| Zweig | `main` | +| Sichtbarkeit | Repo auf public, aber die Organisation `Nexhorizons` ist privat und die Instanz verlangt für jede Ansicht eine Anmeldung. **Faktisch ist es damit nur für angemeldete Nutzer der Instanz sichtbar.** | + +## Was hinausgeht — und warum die Liste so herum steht + +Die `.gitignore` im Vault-Root ist eine **Positivliste**: Sie schließt mit `/*` erst alles aus und nimmt dann einzeln wieder auf. Eine Ausschlussliste wäre hier falsch herum — sie müsste jede künftige Notiz, jeden neuen Kundenordner und jede Depotdatei vorhersehen, und die erste vergessene Zeile ist eine Veröffentlichung. + +Eingeschlossen sind: `CLAUDE.md`, `README.md`, `AI/Regelwerk/`, `.claude/agents/`, `.claude/skills/`, `.claude/settings.json`, `.vault-tools/`. + +Draußen bleibt damit alles andere, insbesondere `Inbox/`, `Code Projekte/`, `Security - Compliance Projekte/`, `Finanzen/`, `IT Workspace/`, `AI/sessions/`, `AI/secondbrain/`, `Bookstack/`, `Import/` und `.claude/settings.local.json`. + +**Wer den Umfang ändern will, ändert die `.gitignore`** — nicht die Pfadliste im Hook. Die Liste dort entscheidet nur, ob der Hook überhaupt anläuft. + +## Was vor der Aufnahme einer Datei geprüft wird + +Vor jeder Erweiterung der Positivliste, von Hand: + +1. **Kundennamen, Domains, IP-Adressen, Snippet- und Ticket-IDs.** In `seo.md` stand ein Kundenname im Klartext; er ist am 2026-09-17 durch eine neutrale Bezeichnung ersetzt worden. Die Fallgeschichte gehört ins Harness-Gedächtnis, nicht ins Regelwerk. +2. **Zugangsdaten.** Skripte lesen Zugänge aus Umgebungsvariablen, nie aus einer Datei im Repo. Gilt auch für Standardwerte und Beispielaufrufe in Kommentaren. +3. **`__pycache__` und Laufzeitdateien.** `.pyc` trägt kompilierte Zeichenketten, unter anderem URLs, und ist bereits einmal aufgefallen. + +## Der automatische Push + +`.vault-tools/auto-push/auto_push.py`, eingetragen als PostToolUse-Hook auf `Edit|Write|MultiEdit`. Nach jeder Änderung an einer eingeschlossenen Datei: `git add -A`, Commit, Push. Das Token kommt über `.vault-tools/auto-push/askpass.py` aus der Umgebungsvariable `GITEA_TOKEN`; in `.git/config` steht nur der Benutzername. + +**Es gibt keine Freigabe und keinen Geheimnis-Scan.** Was in einer eingeschlossenen Datei steht, geht heraus, sobald sie geschrieben wird — auch ein halbfertiger Zwischenstand. Das ist so angeordnet und der Grund, warum die Positivliste die einzige Schranke ist. + +**Eine fehlende Voraussetzung ist ein Befund, kein Grund zu schweigen.** Fehlendes Token, fehlendes Remote, gescheiterter Push: Der Hook meldet das in den Kontext. Ein gescheiterter Push verliert nichts — der Commit bleibt lokal, der nächste Lauf schiebt ihn mit. + +**Wenn eine Auto-Push-Meldung kommt**, ist sie zu lesen und nicht wegzuklicken. Der häufigste Fall wird ein Push-Fehler bei fehlendem Netz sein; dann genügt es, später eine beliebige eingeschlossene Datei erneut zu speichern. + +Der Fallkatalog liegt daneben: `python .vault-tools/auto-push/faelle_auto_push.py` fährt 28 Fälle gegen ein echtes Wegwerf-Repo mit echtem Remote. **Er ist ein Fangnetz, kein Nachweis** — er sieht, ob der Hook auslöst und ob die Positivliste hält, und nicht, ob eine eingeschlossene Datei etwas enthält, das niemand veröffentlichen wollte. + +## Referenzen + +- [[CLAUDE|↑ Claude.md (Stamm)]] +- [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]]: Angriffsliste und „eine fehlende Voraussetzung ist ein Befund" +- [[AI/Regelwerk/windows-shell|windows-shell.md]]: Geheimnisse nur als Länge ausgeben +- [[AI/Regelwerk/projekte|projekte.md]]: die eigenen Repos der Code-Projekte diff --git a/CLAUDE.md b/CLAUDE.md index e5783dd..8681e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,7 @@ Jede Datei triggert nur den betreffenden Teil — nicht bei jeder Aufgabe alles - Projekt in Code Projekte / Security-Compliance anlegen oder bearbeiten → [[AI/Regelwerk/projekte|projekte.md]] - Mechanische Prüfung, Prüfskript, Hook oder Gate-Erweiterung bauen → [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]] — **vor der ersten Zeile Code laden.** - Skript oder Shellbefehl unter Windows (PowerShell, Python, Git-Bash, WSL, ssh) → [[AI/Regelwerk/windows-shell|windows-shell.md]] +- Veröffentlichten Umfang ändern, `.gitignore` im Vault-Root anfassen oder eine Auto-Push-Meldung auflösen → [[AI/Regelwerk/veroeffentlichung|veroeffentlichung.md]] - Dokumentenset ab etwa 20 Dateien über Subagenten, oder voneinander abhängige Dokumente → [[AI/Regelwerk/dokumentations-grossprojekte|dokumentations-grossprojekte.md]] - Suchen, Links prüfen, `.vault-tools/`, Obsidian-CLI und -Skills → [[AI/Regelwerk/navigation|navigation.md]] - Frontmatter schreiben → [[AI/Regelwerk/frontmatter|frontmatter.md]] @@ -91,6 +92,14 @@ Jede Datei triggert nur den betreffenden Teil — nicht bei jeder Aufgabe alles - Auftrag an `planer` oder `pruefer` (Opus 5) schreiben → [[AI/Regelwerk/opus-5|opus-5.md]]; an `executer` oder Recherche (Sonnet 5) → [[AI/Regelwerk/sonnet-5|sonnet-5.md]]; Subagenten-Auftrag, Agent- oder Skill-Datei, System-Prompt allgemein → [[AI/Regelwerk/prompting|prompting.md]]. Verbindlich für Fable 5.1 als Auftraggeber, andere Modelle entscheiden selbst. - Code gegen die Claude Messages API (eigener Agent, Harness, Werkzeug) → [[AI/Regelwerk/api-mechanik|api-mechanik.md]] — nicht für die Claude-Code-Sitzung selbst. +## Veröffentlichung + +Der Vault-Root ist ein Git-Repo und pusht nach **jeder** Änderung automatisch nach `https://git.nexhorizons.de/Nexhorizons/claude-regelwerk.git`. Veröffentlicht wird nur die Anweisungsschicht: `CLAUDE.md`, `README.md`, `AI/Regelwerk/`, `.claude/agents/`, `.claude/skills/`, `.claude/settings.json` und `.vault-tools/`. + +Die `.gitignore` im Root ist eine **Positivliste** — sie schließt erst alles aus und nimmt dann einzeln wieder auf. Notizen, Projekte, Finanzen, Sitzungen und `settings.local.json` bleiben damit draußen, auch wenn morgen ein neuer Ordner dazukommt. + +**Es gibt keine Freigabe vor dem Push.** Was in einer eingeschlossenen Datei steht, ist nach dem Speichern draußen — auch ein Zwischenstand. Wer eine Datei neu in die Positivliste aufnimmt, prüft sie vorher auf Kundennamen, Domains und Zugangsdaten. Alles Weitere, auch die Fehlerfälle des Hooks: [[AI/Regelwerk/veroeffentlichung|veroeffentlichung.md]]. + ## Selbstpflege des Regelwerks Du darfst Dateien unter `AI/Regelwerk/` eigenständig anpassen, wenn du wiederkehrende Fehler, Lücken oder Verbesserungen erkennst — z. B. aus der Lessons-Learned-Schleife im Codeworkflow. Vorgehen: