From fe20d21d6efba6cf0adc7e33f1d390e65cac68f4 Mon Sep 17 00:00:00 2001 From: Pascal Michel Date: Wed, 14 Oct 2026 11:51:42 +0200 Subject: [PATCH] =?UTF-8?q?Auto-Push:=20Werkzeuge=20t=C3=A4glich=20nach=20?= =?UTF-8?q?claude-werkzeuge=20spiegeln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit taeglich_push.py ruft nach dem Vault-Push werkzeuge_spiegel.py auf: committeter Stand von .vault-tools/ und .claude/ ohne Trading-Werkzeuge und watermarks-remover, README aus werkzeuge-README.md. Fallkatalog 14 von 14, drei Mutanten erkannt. veroeffentlichung.md und Stamm nachgezogen; Gitea seit heute anonym lesbar. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-14-werkzeuge-spiegel.md | 687 ++++++++++++++++++ .../auto-push/faelle_werkzeuge_spiegel.py | 295 ++++++++ .vault-tools/auto-push/taeglich_push.py | 84 ++- .vault-tools/auto-push/werkzeuge-README.md | 20 + .vault-tools/auto-push/werkzeuge_spiegel.py | 172 +++++ AI/Regelwerk/veroeffentlichung.md | 16 +- CLAUDE.md | 2 +- 7 files changed, 1243 insertions(+), 33 deletions(-) create mode 100644 .vault-tools/auto-push/docs/plans/2026-10-14-werkzeuge-spiegel.md create mode 100644 .vault-tools/auto-push/faelle_werkzeuge_spiegel.py create mode 100644 .vault-tools/auto-push/werkzeuge-README.md create mode 100644 .vault-tools/auto-push/werkzeuge_spiegel.py diff --git a/.vault-tools/auto-push/docs/plans/2026-10-14-werkzeuge-spiegel.md b/.vault-tools/auto-push/docs/plans/2026-10-14-werkzeuge-spiegel.md new file mode 100644 index 0000000..4e7e3c8 --- /dev/null +++ b/.vault-tools/auto-push/docs/plans/2026-10-14-werkzeuge-spiegel.md @@ -0,0 +1,687 @@ +--- +type: plan-task +title: "Task 01 — Werkzeuge täglich nach claude-werkzeuge spiegeln" +status: draft +summary: "Neues Modul werkzeuge_spiegel.py mit Fallkatalog und README-Quelle; taeglich_push.py ruft es nach dem Vault-Push auf, damit der 04:00-Lauf auch das öffentliche Repo claude-werkzeuge nachzieht." +tags: [plan-task, vault-tools] +created: 2026-10-14 +updated: 2026-10-14 +modell: haiku +modell_begruendung: "Vier Dateien stehen wörtlich im Plan; der Executer schreibt ab, fährt den Fallkatalog und drei vorgegebene Mutanten. Typografische Zeichen in den Blöcken übernimmt Haiku 5.5 byte-genau." +--- +Hub: [[AI/Regelwerk/veroeffentlichung|veroeffentlichung.md]] + +# Task 01 — Werkzeuge täglich nach claude-werkzeuge spiegeln + +## Ziel + +Der tägliche Lauf `taeglich_push.py` pusht nach dem Vault auch den committeten Stand von `.vault-tools/` und `.claude/` (ohne `trading-plattform-pruefung` und `watermarks-remover`) in das öffentliche Repo `Nexhorizons-Public/claude-werkzeuge`. Der Klon dafür liegt außerhalb des Vaults unter `D:\ClaudeData\claude-werkzeuge`. + +## Zieldateien + +- `.vault-tools/auto-push/werkzeuge_spiegel.py` (neu) +- `.vault-tools/auto-push/faelle_werkzeuge_spiegel.py` (neu) +- `.vault-tools/auto-push/werkzeuge-README.md` (neu) +- `.vault-tools/auto-push/taeglich_push.py` (ganz ersetzen) + +Alle Pfade relativ zu `D:\Claude`. Keine andere Datei anfassen. Kein `git commit`, kein `git push`, `taeglich_push.py` **nicht** ausführen (das pusht echt). + +## Vorgehen + +1. Die vier Dateien byte-genau aus den Blöcken unten anlegen, Zeilenenden LF, UTF-8 ohne BOM. +2. Fallkatalog fahren, erwartet: letzte Zeile `14 von 14 Faellen bestanden.`, Exit 0. +3. Drei Mutanten, je einzeln auf einer **Kopie** von `werkzeuge_spiegel.py`: Kopie nach `werkzeuge_spiegel.py.orig` legen, Mutation setzen, Fallkatalog fahren, erwartete FEHLER-Zeile sehen, Original byte-gleich zurückkopieren, `.orig` löschen. + - M1: in `gehoert_dazu` den Teil `and not pfad.startswith(AUSSCHLUESSE)` streichen → erwartet `FEHLER` bei `grundlinie`. + - M2: in `spiegele` die Zeile `raeume(eintrag)` durch `pass` ersetzen → erwartet `FEHLER` bei `grundlinie` (veraltet.txt bleibt drüben). + - M3: in `bereite_klon` die Zeile `if vorhanden != adresse:` durch `if False:` ersetzen → erwartet `FEHLER` bei `fremder klon -> befund, nichts geleert`. +4. Danach den Fallkatalog noch einmal fahren: wieder 14 von 14. +5. `python -m py_compile` auf alle drei .py-Dateien. + +## Prüfen + +```bash +cd /d/Claude && python .vault-tools/auto-push/faelle_werkzeuge_spiegel.py; echo "Exit $?" +python .vault-tools/plan-diff/plan_diff.py .vault-tools/auto-push/docs/plans/2026-10-14-werkzeuge-spiegel.md --projekt /d/Claude +``` + +## Fertig heißt + +- Fallkatalog: `14 von 14 Faellen bestanden.`, Exit 0, im Bericht wörtlich zitiert. +- Jede der drei Mutationen hat die genannte FEHLER-Zeile erzeugt; Zeile je Mutant wörtlich im Bericht. +- `plan_diff.py` endet mit Exit 0. +- `git status --short` zeigt nur die vier Zieldateien und diesen Plan. + +## Code + +`.vault-tools/auto-push/werkzeuge_spiegel.py` neu anlegen: + +```python +"""Spiegelt die Werkzeuge des Vaults in das öffentliche Repo claude-werkzeuge. + +Ersteller: Pascal Michel +Erstellt: 2026-10-14 + +WAS GESPIEGELT WIRD + Der committete Stand (HEAD) des Vaults unter .vault-tools/ und .claude/, + ohne die Ordner in AUSSCHLUESSE, dazu werkzeuge-README.md als README.md. + Ungesicherte Änderungen im Arbeitsbaum gehen nicht mit. Was die + .gitignore des Vaults draußen hält (settings.local.json, __pycache__, + lauf.log), ist deshalb auch hier draußen. + + AUSSCHLUESSE ist eine Negativliste: Ein neuer Werkzeugordner geht beim + nächsten Lauf mit hinaus. So angeordnet (Pascal, 2026-10-14: künftig alle + Werkzeuge außer denen des Trading-Projekts). Ein neues Werkzeug des + Trading-Projekts gehört deshalb in AUSSCHLUESSE, bevor es committet wird. + +WOHIN + In einen Klon außerhalb des Vaults (KLON). Der Lauf holt den Remote-Stand, + leert den Klon bis auf .git, packt den Stand neu aus und committet und + pusht nur, wenn sich etwas geändert hat. Die Remote-Adresse entsteht aus + der origin-Adresse des Vaults, letzter Pfadteil ersetzt; Benutzername und + Host stehen so nirgends im Quelltext. Das Token kommt wie beim Vault-Push + über askpass.sh aus GITEA_TOKEN. + +SCHUTZ + Geleert wird nur ein Klon, dessen origin genau auf die erwartete Adresse + zeigt. Ein Klon im Vault, ein fremder Klon und ein nicht leerer Ordner + ohne .git sind Befunde, nichts wird gelöscht. + +BENANNTE LÜCKEN + * Kein Geheimnis-Scan. Was im Vault unter .vault-tools/ oder .claude/ + committet ist, geht hinaus, wie beim Vault-Push. + * Nur reguläre Dateien. Symlinks im Vault werden übersprungen. + * Eine Änderung direkt im Repo claude-werkzeuge überschreibt der nächste + Lauf. + * Ein gescheiterter Push lässt den Commit im Klon stehen; der nächste Lauf + setzt den Klon auf den Remote-Stand zurück und committet neu. +""" + +from __future__ import annotations + +import io +import os +import shutil +import stat +import subprocess +import tarfile +from pathlib import Path + +KLON = Path(r"D:\ClaudeData\claude-werkzeuge") +REPO_NAME = "claude-werkzeuge.git" +README_QUELLE = ".vault-tools/auto-push/werkzeuge-README.md" +ASKPASS_REL = Path(".vault-tools") / "auto-push" / "askpass.sh" + +QUELLEN = (".vault-tools/", ".claude/") +# Verankert ab der Vault-Wurzel, nie als bloße Ordnernamen. +AUSSCHLUESSE = ( + ".vault-tools/trading-plattform-pruefung/", + ".vault-tools/watermarks-remover/", +) + +TIMEOUT_GIT_S = 30 +TIMEOUT_NETZ_S = 90 + + +class SpiegelFehler(Exception): + """Ein Schritt ist gescheitert; die Meldung ist der Befund.""" + + +def git(wo: Path, wurzel: Path, *argumente: str, timeout: int = TIMEOUT_GIT_S) -> bytes: + umgebung = dict( + os.environ, + GIT_TERMINAL_PROMPT="0", + GIT_ASKPASS=str(wurzel / ASKPASS_REL), + ) + try: + lauf = subprocess.run( + ["git", "-C", str(wo), *argumente], + env=umgebung, + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as abgelaufen: + raise SpiegelFehler(f"git {argumente[0]} nach {timeout} s abgebrochen") from abgelaufen + except OSError as fehler: + raise SpiegelFehler(f"git nicht startbar: {type(fehler).__name__}: {fehler}") from fehler + if lauf.returncode != 0: + text = (lauf.stdout + b"\n" + lauf.stderr).decode("utf-8", errors="replace") + raise SpiegelFehler(f"git {argumente[0]}: Exit {lauf.returncode}: {' '.join(text.split())[:300]}") + return lauf.stdout + + +def gehoert_dazu(pfad: str) -> bool: + return pfad.startswith(QUELLEN) and not pfad.startswith(AUSSCHLUESSE) + + +def ziel_adresse(wurzel: Path) -> str: + adresse = git(wurzel, wurzel, "remote", "get-url", "origin").decode("utf-8").strip() + if "/" not in adresse: + raise SpiegelFehler(f"origin des Vaults ohne Pfad: {adresse}") + return adresse.rsplit("/", 1)[0] + "/" + REPO_NAME + + +def raeume(pfad: Path) -> None: + """Löscht Datei oder Ordner, auch schreibgeschützte Dateien.""" + + def erzwinge(funktion, ziel, _fehler): + os.chmod(ziel, stat.S_IWRITE) + funktion(ziel) + + if pfad.is_dir() and not pfad.is_symlink(): + shutil.rmtree(pfad, onexc=erzwinge) + else: + pfad.unlink() + + +def bereite_klon(wurzel: Path, klon: Path, adresse: str) -> None: + """Klont beim ersten Lauf, setzt danach auf den Remote-Stand zurück.""" + basis = wurzel.resolve() + ziel = klon.resolve() + if ziel == basis or basis in ziel.parents: + raise SpiegelFehler(f"Klon {klon} liegt im Vault; abgebrochen, nichts geleert") + if not (klon / ".git").is_dir(): + if klon.exists() and any(klon.iterdir()): + raise SpiegelFehler(f"{klon} ist kein Git-Klon und nicht leer; abgebrochen, nichts geleert") + klon.parent.mkdir(parents=True, exist_ok=True) + git(klon.parent, wurzel, "clone", "-q", "-c", "core.autocrlf=false", adresse, str(klon), + timeout=TIMEOUT_NETZ_S) + return + vorhanden = git(klon, wurzel, "remote", "get-url", "origin").decode("utf-8").strip() + if vorhanden != adresse: + raise SpiegelFehler( + f"origin des Klons zeigt auf {vorhanden}, erwartet {adresse}; abgebrochen, nichts geleert" + ) + git(klon, wurzel, "fetch", "-q", "origin", timeout=TIMEOUT_NETZ_S) + git(klon, wurzel, "reset", "-q", "--hard", "origin/main") + + +def spiegele(wurzel: Path, klon: Path = KLON) -> tuple[str, str]: + """Gibt ("gespiegelt" oder "unveraendert", Text) zurück. Wirft SpiegelFehler.""" + adresse = ziel_adresse(wurzel) + stand = git(wurzel, wurzel, "rev-parse", "--short", "HEAD").decode("utf-8").strip() + # Ganzer HEAD statt Pfadangaben: git archive bricht mit "pathspec did not match" + # ab, sobald eine Quelle leer ist. Gefiltert wird danach mit gehoert_dazu. + archiv = git(wurzel, wurzel, "archive", "--format=tar", "HEAD") + + with tarfile.open(fileobj=io.BytesIO(archiv)) as tar: + mitglieder = [m for m in tar.getmembers() if m.isfile() and gehoert_dazu(m.name)] + if README_QUELLE not in {m.name for m in mitglieder}: + raise SpiegelFehler(f"README-Quelle {README_QUELLE} fehlt im Vault-Stand {stand}") + + bereite_klon(wurzel, klon, adresse) + for eintrag in klon.iterdir(): + if eintrag.name != ".git": + raeume(eintrag) + for mitglied in mitglieder: + tar.extract(mitglied, klon, filter="data") + readme = tar.extractfile(README_QUELLE) + (klon / "README.md").write_bytes(readme.read()) + + git(klon, wurzel, "add", "-A") + roh = git(klon, wurzel, "diff", "--cached", "--name-only", "-z").decode("utf-8") + geaendert = [z for z in roh.split("\0") if z] + if not geaendert: + return "unveraendert", f"Vault-Stand {stand}" + git(klon, wurzel, "commit", "-q", "-m", + f"Abgleich mit dem Vault-Stand {stand}\n\n{len(geaendert)} Datei(en) geändert.\n" + "Erzeugt von .vault-tools/auto-push/werkzeuge_spiegel.py.") + git(klon, wurzel, "push", "-q", "origin", "HEAD:main", timeout=TIMEOUT_NETZ_S) + return "gespiegelt", f"{len(geaendert)} Datei(en), Vault-Stand {stand}" +``` + +`.vault-tools/auto-push/faelle_werkzeuge_spiegel.py` neu anlegen: + +```python +"""Fährt die Fälle gegen werkzeuge_spiegel.py, auf Wegwerf-Vaults mit echten bare-Remotes. + +Ersteller: Pascal Michel +Erstellt: 2026-10-14 + +Jeder Wegwerf-Vault hat als origin ein bare-Repo claude-regelwerk.git, daneben +liegt claude-werkzeuge.git mit einem veralteten Stand. Der Spiegel leitet seine +Adresse aus der des Vaults ab, wie im Betrieb. Geprüft wird, was nachher +drüben liegt, nicht was das Skript meldet. + +Aufruf: + python .vault-tools/auto-push/faelle_werkzeuge_spiegel.py +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent +SPIEGEL = HIER / "werkzeuge_spiegel.py" +README = HIER / "werkzeuge-README.md" + +CRLF_INHALT = b"zeile eins\r\nzeile zwei\r\n" + +ERWARTET = { + "README.md", + ".vault-tools/auto-push/werkzeuge_spiegel.py", + ".vault-tools/auto-push/werkzeuge-README.md", + ".vault-tools/linkcheck/a.py", + ".vault-tools/linkcheck/crlf.txt", + ".claude/agents/x.md", +} + + +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 lade_spiegel(): + spec = importlib.util.spec_from_file_location("werkzeuge_spiegel", SPIEGEL) + if spec is None or spec.loader is None: + raise RuntimeError(f"nicht ladbar: {SPIEGEL}") + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +def schreibe(pfad: Path, inhalt: bytes) -> None: + pfad.parent.mkdir(parents=True, exist_ok=True) + pfad.write_bytes(inhalt) + + +def richte_repo_ein(wo: Path, remote: Path) -> None: + git(wo, "init", "-q") + git(wo, "symbolic-ref", "HEAD", "refs/heads/main") + git(wo, "config", "user.name", "Fall") + git(wo, "config", "user.email", "fall@example.invalid") + git(wo, "config", "commit.gpgsign", "false") + git(wo, "config", "core.autocrlf", "false") + git(wo, "remote", "add", "origin", remote.as_uri()) + + +def committe(wo: Path, text: str) -> None: + git(wo, "add", "-A") + git(wo, "commit", "-q", "-m", text) + + +def baue(basis: Path) -> tuple[Path, Path]: + """Legt Vault, beide bare-Remotes und den veralteten Spiegelstand an.""" + vault = basis / "vault" + remotes = basis / "remotes" + regelwerk = remotes / "claude-regelwerk.git" + werkzeuge = remotes / "claude-werkzeuge.git" + remotes.mkdir(parents=True) + subprocess.run(["git", "init", "-q", "--bare", str(regelwerk)], check=True) + subprocess.run(["git", "init", "-q", "--bare", "--initial-branch=main", str(werkzeuge)], check=True) + + schreibe(vault / ".vault-tools" / "auto-push" / "werkzeuge_spiegel.py", SPIEGEL.read_bytes()) + schreibe(vault / ".vault-tools" / "auto-push" / "werkzeuge-README.md", README.read_bytes()) + schreibe(vault / ".vault-tools" / "linkcheck" / "a.py", b"print('a')\n") + schreibe(vault / ".vault-tools" / "linkcheck" / "crlf.txt", CRLF_INHALT) + schreibe(vault / ".vault-tools" / "trading-plattform-pruefung" / "t.py", b"trading\n") + schreibe(vault / ".vault-tools" / "watermarks-remover" / "w.sh", b"aus\n") + schreibe(vault / ".claude" / "agents" / "x.md", b"# x\n") + schreibe(vault / "Inbox" / "geheim.md", b"Kundendaten\n") + richte_repo_ein(vault, regelwerk) + committe(vault, "Grundstand") + # Ungesicherte Datei im Arbeitsbaum: darf nie hinueber. + schreibe(vault / ".vault-tools" / "linkcheck" / "lokal.tmp", b"nicht committet\n") + + alt = basis / "alt" + schreibe(alt / "README.md", b"alt\n") + schreibe(alt / "veraltet.txt", b"weg damit\n") + richte_repo_ein(alt, werkzeuge) + committe(alt, "Alter Stand") + git(alt, "push", "-q", "origin", "main") + return vault, werkzeuge + + +def 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 inhalt(remote: Path, pfad: str) -> bytes: + return git(remote, "cat-file", "blob", f"main:{pfad}").stdout + + +def 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") + + +def main() -> int: + spiegel = lade_spiegel() + basis = Path(tempfile.mkdtemp(prefix="werkzeuge-spiegel-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:42} {text}") + + def wirft(wurzel: Path, klon: Path) -> str | None: + try: + spiegel.spiegele(wurzel, klon) + except spiegel.SpiegelFehler as fehler: + return str(fehler) + return None + + try: + # --- Grundlinie ------------------------------------------------- + vault, werkzeuge = baue(basis / "a") + klon = basis / "a" / "klon" + status, text = spiegel.spiegele(vault, klon) + drueben = dateien(werkzeuge) + pruefe( + "grundlinie", + status == "gespiegelt" and drueben == ERWARTET, + f"{status}: {text}; zu viel {sorted(drueben - ERWARTET)}, fehlt {sorted(ERWARTET - drueben)}", + ) + pruefe( + "readme aus der quelle", + inhalt(werkzeuge, "README.md") == README.read_bytes(), + f"{len(inhalt(werkzeuge, 'README.md'))} Bytes drueben", + ) + pruefe( + "bytes bleiben (crlf)", + inhalt(werkzeuge, ".vault-tools/linkcheck/crlf.txt") == CRLF_INHALT, + repr(inhalt(werkzeuge, ".vault-tools/linkcheck/crlf.txt")), + ) + + # --- Zweiter Lauf ohne Aenderung -------------------------------- + vorher = commits(werkzeuge) + status, text = spiegel.spiegele(vault, klon) + pruefe( + "zweiter lauf -> unveraendert", + status == "unveraendert" and commits(werkzeuge) == vorher, + f"{status}: {text}; Commits {vorher}->{commits(werkzeuge)}", + ) + + # --- Committet geht mit, ungesichert nicht ---------------------- + a_py = vault / ".vault-tools" / "linkcheck" / "a.py" + schreibe(a_py, b"print('neu')\n") + committe(vault, "a geaendert") + schreibe(a_py, b"print('ungesichert')\n") + status, text = spiegel.spiegele(vault, klon) + pruefe( + "committete aenderung geht mit", + status == "gespiegelt" and inhalt(werkzeuge, ".vault-tools/linkcheck/a.py") == b"print('neu')\n", + f"{status}: {text}; drueben {inhalt(werkzeuge, '.vault-tools/linkcheck/a.py')!r}", + ) + + # --- Loeschung geht mit ----------------------------------------- + git(vault, "rm", "-q", ".claude/agents/x.md") + committe(vault, "x geloescht") + spiegel.spiegele(vault, klon) + pruefe( + "loeschung geht mit", + ".claude/agents/x.md" not in dateien(werkzeuge), + f"drueben {sorted(dateien(werkzeuge))}", + ) + + # --- Schutz: nichts leeren, was nicht unser Klon ist ------------ + fremd = basis / "fremd" + fremd.mkdir() + git(fremd, "init", "-q") + # Erreichbares Remote in anderer Schreibweise: ohne Schutz liefe der + # Abgleich durch und leerte den Ordner. + git(fremd, "remote", "add", "origin", str(werkzeuge)) + schreibe(fremd / "bleibt.txt", b"x\n") + meldung = wirft(vault, fremd) + pruefe( + "fremder klon -> befund, nichts geleert", + meldung is not None and (fremd / "bleibt.txt").is_file(), + str(meldung)[:100], + ) + + innen = vault / "klon-im-vault" + schreibe(innen / "bleibt.txt", b"x\n") + meldung = wirft(vault, innen) + pruefe( + "klon im vault -> befund, nichts geleert", + meldung is not None and (innen / "bleibt.txt").is_file(), + str(meldung)[:100], + ) + + ohne_git = basis / "ohne-git" + schreibe(ohne_git / "bleibt.txt", b"x\n") + meldung = wirft(vault, ohne_git) + pruefe( + "ordner ohne git -> befund, nichts geleert", + meldung is not None and (ohne_git / "bleibt.txt").is_file(), + str(meldung)[:100], + ) + + # --- Fehlende Voraussetzungen sind Befunde ---------------------- + vault2, werkzeuge2 = baue(basis / "b") + raeume(werkzeuge2) + meldung = wirft(vault2, basis / "b" / "klon") + pruefe("remote fehlt beim klonen -> befund", meldung is not None, str(meldung)[:100]) + + vault3, werkzeuge3 = baue(basis / "c") + git(vault3, "rm", "-q", ".vault-tools/auto-push/werkzeuge-README.md") + committe(vault3, "readme weg") + meldung = wirft(vault3, basis / "c" / "klon") + pruefe( + "readme-quelle fehlt -> befund, nichts gepusht", + meldung is not None and "README" in meldung and commits(werkzeuge3) == 1, + f"Commits {commits(werkzeuge3)}; {str(meldung)[:80]}", + ) + pruefe( + "readme-quelle fehlt -> kein klon angelegt", + not (basis / "c" / "klon").exists(), + f"klon vorhanden: {(basis / 'c' / 'klon').exists()}", + ) + + vault4, werkzeuge4 = baue(basis / "d") + klon4 = basis / "d" / "klon" + spiegel.spiegele(vault4, klon4) + raeume(werkzeuge4) + schreibe(vault4 / ".vault-tools" / "linkcheck" / "a.py", b"print('d')\n") + committe(vault4, "a geaendert") + meldung = wirft(vault4, klon4) + pruefe("remote weg bei bestehendem klon -> befund", meldung is not None, str(meldung)[:100]) + + vault5, _ = baue(basis / "e") + git(vault5, "remote", "remove", "origin") + meldung = wirft(vault5, basis / "e" / "klon") + pruefe("vault ohne origin -> befund", meldung is not None, str(meldung)[:100]) + + print() + print(f"{bestanden} von {gesamt} Faellen bestanden.") + return 0 if bestanden == gesamt else 1 + finally: + raeume(basis) + + +if __name__ == "__main__": + try: + sys.stdout.reconfigure(encoding="utf-8") + except AttributeError: + pass + raise SystemExit(main()) +``` + +`.vault-tools/auto-push/werkzeuge-README.md` neu anlegen: + +```markdown +# Claude-Werkzeuge + +Werkzeuge, Skills, Agenten und Workflows, die rund um Claude Code für einen Obsidian-Vault entstanden sind. Die Regeln dazu stehen im Schwester-Repo [claude-regelwerk](https://git.nexhorizons.de/Nexhorizons-Public/claude-regelwerk). + +- Dieses Repo ist ein Spiegel. Es wird täglich aus dem Vault neu geschrieben; Änderungen direkt hier gehen beim nächsten Lauf verloren. +- Die Ordnerstruktur entspricht der im Vault (`.vault-tools/`, `.claude/`). Pfade in Skripten und Dokumenten passen so ohne Anpassung. +- Viele Skripte setzen Vault-Konventionen voraus (Ordner, Frontmatter, `AI/Regelwerk/`). Vor dem Einsatz den Kopfkommentar und, wo vorhanden, `LIESMICH.md` oder `ENTWURF.md` im Werkzeugordner lesen. +- Nicht enthalten: die Prüfwerkzeuge des Trading-Projekts. + +## Aufbau + +- `.vault-tools/` — Skripte, je Werkzeug ein Ordner. +- `.claude/agents/` — Rollen mit Modell und Effort. +- `.claude/skills/` — Skills (Playbooks), je Skill ein Ordner mit `SKILL.md`. +- `.claude/workflows/` — gespeicherte Workflows für das Workflow-Werkzeug von Claude Code. +- `.claude/settings.json` — Hook-Verdrahtung. + +## Zugangsdaten + +Keine im Repo. Skripte, die eine API brauchen, lesen sie aus Umgebungsvariablen. +``` + +`.vault-tools/auto-push/taeglich_push.py` ganz ersetzen: + +```python +"""Täglicher Lauf: committet und pusht die Anweisungsschicht und spiegelt die Werkzeuge. + +Ersteller: Pascal Michel +Erstellt: 2026-09-17 +Geändert: 2026-10-14 (Werkzeug-Spiegel nach claude-werkzeuge) + +WARUM LOKAL UND NICHT ALS COWORK-ROUTINE + Eine Cowork-Routine läuft in Anthropics Cloud und hat weder Zugriff auf + D:\\Claude noch auf die Umgebungsvariable GITEA_TOKEN dieser Maschine (so + wörtlich in der Routinen-Doku, gelesen am 2026-09-17). Sie könnte hier + nichts committen. Deshalb die Windows-Aufgabenplanung. + +WAS ES TUT + 1. Genau dasselbe wie der frühere PostToolUse-Hook: die Arbeitsfunktion + aus auto_push.py, unter derselben Sperre. Der Umfang steht in der + .gitignore des Vault-Roots, nicht hier. + 2. Danach, noch unter der Sperre, spiegelt werkzeuge_spiegel.py den + committeten Stand von .vault-tools/ und .claude/ in das öffentliche + Repo claude-werkzeuge. Scheitert Schritt 1, entfällt Schritt 2. + +WAS ES MELDET + Jeder Lauf schreibt eine Zeile je Schritt nach lauf.log im selben Ordner, + auch ein Lauf ohne Änderung. Eine Protokolldatei, die nur bei Erfolg + schreibt, lässt sich von einer nicht gestarteten Aufgabe nicht + unterscheiden, und genau das war am 2026-09-07 beim Depotbericht das + Problem. + + Exit 0: gepusht oder nichts zu tun, in beiden Schritten. Exit 1: Befund in + einem Schritt, Text steht im Protokoll. Die Aufgabenplanung zeigt den Code + als „Letztes Ergebnis". +""" + +from __future__ import annotations + +import importlib.util +import sys +import traceback +from datetime import datetime +from pathlib import Path + +HIER = Path(__file__).resolve().parent +PROTOKOLL = HIER / "lauf.log" +MAX_PROTOKOLL_ZEILEN = 400 + + +def lade(name: str): + spec = importlib.util.spec_from_file_location(name, HIER / f"{name}.py") + if spec is None or spec.loader is None: + raise RuntimeError(f"{name}.py nicht ladbar: {HIER / f'{name}.py'}") + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +def schreibe(zeile: str) -> None: + stempel = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + text = f"{stempel} {zeile}\n" + try: + alt = PROTOKOLL.read_text(encoding="utf-8").splitlines(keepends=True) + except OSError: + alt = [] + alt.append(text) + PROTOKOLL.write_text("".join(alt[-MAX_PROTOKOLL_ZEILEN:]), encoding="utf-8") + sys.stdout.write(text) + + +def vault_push(hook, wurzel: Path) -> int: + try: + vorher = hook.git(wurzel, "rev-parse", "HEAD").strip() + hook.arbeite(wurzel) + nachher = hook.git(wurzel, "rev-parse", "HEAD").strip() + except hook.GitFehler as fehler: + schreibe(f"BEFUND {fehler.kopf} | {' '.join(fehler.rumpf.split())[:400]}") + return 1 + except Exception as fehler: # noqa: BLE001 + schreibe(f"BEFUND unerwarteter Abbruch: {type(fehler).__name__}: {fehler}") + schreibe(" " + " ".join(traceback.format_exc().split())[:400]) + return 1 + + if vorher == nachher: + schreibe(f"ok nichts zu tun, HEAD {nachher[:8]}") + else: + schreibe(f"ok gepusht, HEAD {vorher[:8]} -> {nachher[:8]}") + return 0 + + +def werkzeuge_spiegeln(wurzel: Path) -> int: + try: + spiegel = lade("werkzeuge_spiegel") + status, text = spiegel.spiegele(wurzel) + except Exception as fehler: # noqa: BLE001 - SpiegelFehler und jeder Absturz sind Befunde + schreibe(f"BEFUND werkzeuge: {type(fehler).__name__}: {' '.join(str(fehler).split())[:400]}") + return 1 + schreibe(f"ok werkzeuge {status}, {text}") + return 0 + + +def main() -> int: + try: + hook = lade("auto_push") + except Exception as fehler: # noqa: BLE001 + schreibe(f"BEFUND auto_push.py nicht ladbar: {type(fehler).__name__}: {fehler}") + return 1 + + wurzel = HIER.parents[1] + sperre = wurzel / hook.SPERRE_REL + deskriptor = hook.hole_sperre(sperre) + if deskriptor is None: + # Kein Befund: ein anderer Lauf arbeitet und nimmt alles mit. + schreibe("uebersprungen Sperre belegt, ein anderer Lauf arbeitet") + return 0 + + try: + if vault_push(hook, wurzel) != 0: + return 1 + return werkzeuge_spiegeln(wurzel) + finally: + hook.gib_sperre_frei(deskriptor, sperre) + + +if __name__ == "__main__": + try: + sys.stdout.reconfigure(encoding="utf-8") + except AttributeError: + pass + raise SystemExit(main()) +``` diff --git a/.vault-tools/auto-push/faelle_werkzeuge_spiegel.py b/.vault-tools/auto-push/faelle_werkzeuge_spiegel.py new file mode 100644 index 0000000..100938f --- /dev/null +++ b/.vault-tools/auto-push/faelle_werkzeuge_spiegel.py @@ -0,0 +1,295 @@ +"""Fährt die Fälle gegen werkzeuge_spiegel.py, auf Wegwerf-Vaults mit echten bare-Remotes. + +Ersteller: Pascal Michel +Erstellt: 2026-10-14 + +Jeder Wegwerf-Vault hat als origin ein bare-Repo claude-regelwerk.git, daneben +liegt claude-werkzeuge.git mit einem veralteten Stand. Der Spiegel leitet seine +Adresse aus der des Vaults ab, wie im Betrieb. Geprüft wird, was nachher +drüben liegt, nicht was das Skript meldet. + +Aufruf: + python .vault-tools/auto-push/faelle_werkzeuge_spiegel.py +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent +SPIEGEL = HIER / "werkzeuge_spiegel.py" +README = HIER / "werkzeuge-README.md" + +CRLF_INHALT = b"zeile eins\r\nzeile zwei\r\n" + +ERWARTET = { + "README.md", + ".vault-tools/auto-push/werkzeuge_spiegel.py", + ".vault-tools/auto-push/werkzeuge-README.md", + ".vault-tools/linkcheck/a.py", + ".vault-tools/linkcheck/crlf.txt", + ".claude/agents/x.md", +} + + +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 lade_spiegel(): + spec = importlib.util.spec_from_file_location("werkzeuge_spiegel", SPIEGEL) + if spec is None or spec.loader is None: + raise RuntimeError(f"nicht ladbar: {SPIEGEL}") + modul = importlib.util.module_from_spec(spec) + spec.loader.exec_module(modul) + return modul + + +def schreibe(pfad: Path, inhalt: bytes) -> None: + pfad.parent.mkdir(parents=True, exist_ok=True) + pfad.write_bytes(inhalt) + + +def richte_repo_ein(wo: Path, remote: Path) -> None: + git(wo, "init", "-q") + git(wo, "symbolic-ref", "HEAD", "refs/heads/main") + git(wo, "config", "user.name", "Fall") + git(wo, "config", "user.email", "fall@example.invalid") + git(wo, "config", "commit.gpgsign", "false") + git(wo, "config", "core.autocrlf", "false") + git(wo, "remote", "add", "origin", remote.as_uri()) + + +def committe(wo: Path, text: str) -> None: + git(wo, "add", "-A") + git(wo, "commit", "-q", "-m", text) + + +def baue(basis: Path) -> tuple[Path, Path]: + """Legt Vault, beide bare-Remotes und den veralteten Spiegelstand an.""" + vault = basis / "vault" + remotes = basis / "remotes" + regelwerk = remotes / "claude-regelwerk.git" + werkzeuge = remotes / "claude-werkzeuge.git" + remotes.mkdir(parents=True) + subprocess.run(["git", "init", "-q", "--bare", str(regelwerk)], check=True) + subprocess.run(["git", "init", "-q", "--bare", "--initial-branch=main", str(werkzeuge)], check=True) + + schreibe(vault / ".vault-tools" / "auto-push" / "werkzeuge_spiegel.py", SPIEGEL.read_bytes()) + schreibe(vault / ".vault-tools" / "auto-push" / "werkzeuge-README.md", README.read_bytes()) + schreibe(vault / ".vault-tools" / "linkcheck" / "a.py", b"print('a')\n") + schreibe(vault / ".vault-tools" / "linkcheck" / "crlf.txt", CRLF_INHALT) + schreibe(vault / ".vault-tools" / "trading-plattform-pruefung" / "t.py", b"trading\n") + schreibe(vault / ".vault-tools" / "watermarks-remover" / "w.sh", b"aus\n") + schreibe(vault / ".claude" / "agents" / "x.md", b"# x\n") + schreibe(vault / "Inbox" / "geheim.md", b"Kundendaten\n") + richte_repo_ein(vault, regelwerk) + committe(vault, "Grundstand") + # Ungesicherte Datei im Arbeitsbaum: darf nie hinueber. + schreibe(vault / ".vault-tools" / "linkcheck" / "lokal.tmp", b"nicht committet\n") + + alt = basis / "alt" + schreibe(alt / "README.md", b"alt\n") + schreibe(alt / "veraltet.txt", b"weg damit\n") + richte_repo_ein(alt, werkzeuge) + committe(alt, "Alter Stand") + git(alt, "push", "-q", "origin", "main") + return vault, werkzeuge + + +def 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 inhalt(remote: Path, pfad: str) -> bytes: + return git(remote, "cat-file", "blob", f"main:{pfad}").stdout + + +def 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") + + +def main() -> int: + spiegel = lade_spiegel() + basis = Path(tempfile.mkdtemp(prefix="werkzeuge-spiegel-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:42} {text}") + + def wirft(wurzel: Path, klon: Path) -> str | None: + try: + spiegel.spiegele(wurzel, klon) + except spiegel.SpiegelFehler as fehler: + return str(fehler) + return None + + try: + # --- Grundlinie ------------------------------------------------- + vault, werkzeuge = baue(basis / "a") + klon = basis / "a" / "klon" + status, text = spiegel.spiegele(vault, klon) + drueben = dateien(werkzeuge) + pruefe( + "grundlinie", + status == "gespiegelt" and drueben == ERWARTET, + f"{status}: {text}; zu viel {sorted(drueben - ERWARTET)}, fehlt {sorted(ERWARTET - drueben)}", + ) + pruefe( + "readme aus der quelle", + inhalt(werkzeuge, "README.md") == README.read_bytes(), + f"{len(inhalt(werkzeuge, 'README.md'))} Bytes drueben", + ) + pruefe( + "bytes bleiben (crlf)", + inhalt(werkzeuge, ".vault-tools/linkcheck/crlf.txt") == CRLF_INHALT, + repr(inhalt(werkzeuge, ".vault-tools/linkcheck/crlf.txt")), + ) + + # --- Zweiter Lauf ohne Aenderung -------------------------------- + vorher = commits(werkzeuge) + status, text = spiegel.spiegele(vault, klon) + pruefe( + "zweiter lauf -> unveraendert", + status == "unveraendert" and commits(werkzeuge) == vorher, + f"{status}: {text}; Commits {vorher}->{commits(werkzeuge)}", + ) + + # --- Committet geht mit, ungesichert nicht ---------------------- + a_py = vault / ".vault-tools" / "linkcheck" / "a.py" + schreibe(a_py, b"print('neu')\n") + committe(vault, "a geaendert") + schreibe(a_py, b"print('ungesichert')\n") + status, text = spiegel.spiegele(vault, klon) + pruefe( + "committete aenderung geht mit", + status == "gespiegelt" and inhalt(werkzeuge, ".vault-tools/linkcheck/a.py") == b"print('neu')\n", + f"{status}: {text}; drueben {inhalt(werkzeuge, '.vault-tools/linkcheck/a.py')!r}", + ) + + # --- Loeschung geht mit ----------------------------------------- + git(vault, "rm", "-q", ".claude/agents/x.md") + committe(vault, "x geloescht") + spiegel.spiegele(vault, klon) + pruefe( + "loeschung geht mit", + ".claude/agents/x.md" not in dateien(werkzeuge), + f"drueben {sorted(dateien(werkzeuge))}", + ) + + # --- Schutz: nichts leeren, was nicht unser Klon ist ------------ + fremd = basis / "fremd" + fremd.mkdir() + git(fremd, "init", "-q") + # Erreichbares Remote in anderer Schreibweise: ohne Schutz liefe der + # Abgleich durch und leerte den Ordner. + git(fremd, "remote", "add", "origin", str(werkzeuge)) + schreibe(fremd / "bleibt.txt", b"x\n") + meldung = wirft(vault, fremd) + pruefe( + "fremder klon -> befund, nichts geleert", + meldung is not None and (fremd / "bleibt.txt").is_file(), + str(meldung)[:100], + ) + + innen = vault / "klon-im-vault" + schreibe(innen / "bleibt.txt", b"x\n") + meldung = wirft(vault, innen) + pruefe( + "klon im vault -> befund, nichts geleert", + meldung is not None and (innen / "bleibt.txt").is_file(), + str(meldung)[:100], + ) + + ohne_git = basis / "ohne-git" + schreibe(ohne_git / "bleibt.txt", b"x\n") + meldung = wirft(vault, ohne_git) + pruefe( + "ordner ohne git -> befund, nichts geleert", + meldung is not None and (ohne_git / "bleibt.txt").is_file(), + str(meldung)[:100], + ) + + # --- Fehlende Voraussetzungen sind Befunde ---------------------- + vault2, werkzeuge2 = baue(basis / "b") + raeume(werkzeuge2) + meldung = wirft(vault2, basis / "b" / "klon") + pruefe("remote fehlt beim klonen -> befund", meldung is not None, str(meldung)[:100]) + + vault3, werkzeuge3 = baue(basis / "c") + git(vault3, "rm", "-q", ".vault-tools/auto-push/werkzeuge-README.md") + committe(vault3, "readme weg") + meldung = wirft(vault3, basis / "c" / "klon") + pruefe( + "readme-quelle fehlt -> befund, nichts gepusht", + meldung is not None and "README" in meldung and commits(werkzeuge3) == 1, + f"Commits {commits(werkzeuge3)}; {str(meldung)[:80]}", + ) + pruefe( + "readme-quelle fehlt -> kein klon angelegt", + not (basis / "c" / "klon").exists(), + f"klon vorhanden: {(basis / 'c' / 'klon').exists()}", + ) + + vault4, werkzeuge4 = baue(basis / "d") + klon4 = basis / "d" / "klon" + spiegel.spiegele(vault4, klon4) + raeume(werkzeuge4) + schreibe(vault4 / ".vault-tools" / "linkcheck" / "a.py", b"print('d')\n") + committe(vault4, "a geaendert") + meldung = wirft(vault4, klon4) + pruefe("remote weg bei bestehendem klon -> befund", meldung is not None, str(meldung)[:100]) + + vault5, _ = baue(basis / "e") + git(vault5, "remote", "remove", "origin") + meldung = wirft(vault5, basis / "e" / "klon") + pruefe("vault ohne origin -> befund", meldung is not None, str(meldung)[:100]) + + print() + print(f"{bestanden} von {gesamt} Faellen bestanden.") + return 0 if bestanden == gesamt else 1 + finally: + raeume(basis) + + +if __name__ == "__main__": + try: + sys.stdout.reconfigure(encoding="utf-8") + except AttributeError: + pass + raise SystemExit(main()) diff --git a/.vault-tools/auto-push/taeglich_push.py b/.vault-tools/auto-push/taeglich_push.py index 0ccdd0d..64b7c1a 100644 --- a/.vault-tools/auto-push/taeglich_push.py +++ b/.vault-tools/auto-push/taeglich_push.py @@ -1,7 +1,8 @@ -"""Täglicher Lauf: committet und pusht die Anweisungsschicht, für die Windows-Aufgabenplanung. +"""Täglicher Lauf: committet und pusht die Anweisungsschicht und spiegelt die Werkzeuge. Ersteller: Pascal Michel Erstellt: 2026-09-17 +Geändert: 2026-10-14 (Werkzeug-Spiegel nach claude-werkzeuge) WARUM LOKAL UND NICHT ALS COWORK-ROUTINE Eine Cowork-Routine läuft in Anthropics Cloud und hat weder Zugriff auf @@ -10,18 +11,23 @@ WARUM LOKAL UND NICHT ALS COWORK-ROUTINE nichts committen. Deshalb die Windows-Aufgabenplanung. WAS ES TUT - Genau dasselbe wie der PostToolUse-Hook: die Arbeitsfunktion aus - auto_push.py, unter derselben Sperre. Der Umfang steht in der .gitignore - des Vault-Roots, nicht hier. + 1. Genau dasselbe wie der frühere PostToolUse-Hook: die Arbeitsfunktion + aus auto_push.py, unter derselben Sperre. Der Umfang steht in der + .gitignore des Vault-Roots, nicht hier. + 2. Danach, noch unter der Sperre, spiegelt werkzeuge_spiegel.py den + committeten Stand von .vault-tools/ und .claude/ in das öffentliche + Repo claude-werkzeuge. Scheitert Schritt 1, entfällt Schritt 2. WAS ES MELDET - Jeder Lauf schreibt eine Zeile nach lauf.log im selben Ordner — auch ein - Lauf ohne Änderung. Eine Protokolldatei, die nur bei Erfolg schreibt, - lässt sich von einer nicht gestarteten Aufgabe nicht unterscheiden, und - genau das war am 2026-09-07 beim Depotbericht das Problem. + Jeder Lauf schreibt eine Zeile je Schritt nach lauf.log im selben Ordner, + auch ein Lauf ohne Änderung. Eine Protokolldatei, die nur bei Erfolg + schreibt, lässt sich von einer nicht gestarteten Aufgabe nicht + unterscheiden, und genau das war am 2026-09-07 beim Depotbericht das + Problem. - Exit 0: gepusht oder nichts zu tun. Exit 1: Befund, Text steht im - Protokoll. Die Aufgabenplanung zeigt den Code als „Letztes Ergebnis". + Exit 0: gepusht oder nichts zu tun, in beiden Schritten. Exit 1: Befund in + einem Schritt, Text steht im Protokoll. Die Aufgabenplanung zeigt den Code + als „Letztes Ergebnis". """ from __future__ import annotations @@ -37,10 +43,10 @@ PROTOKOLL = HIER / "lauf.log" MAX_PROTOKOLL_ZEILEN = 400 -def lade_hook(): - spec = importlib.util.spec_from_file_location("auto_push", HIER / "auto_push.py") +def lade(name: str): + spec = importlib.util.spec_from_file_location(name, HIER / f"{name}.py") if spec is None or spec.loader is None: - raise RuntimeError(f"auto_push.py nicht ladbar: {HIER / 'auto_push.py'}") + raise RuntimeError(f"{name}.py nicht ladbar: {HIER / f'{name}.py'}") modul = importlib.util.module_from_spec(spec) spec.loader.exec_module(modul) return modul @@ -58,9 +64,40 @@ def schreibe(zeile: str) -> None: sys.stdout.write(text) +def vault_push(hook, wurzel: Path) -> int: + try: + vorher = hook.git(wurzel, "rev-parse", "HEAD").strip() + hook.arbeite(wurzel) + nachher = hook.git(wurzel, "rev-parse", "HEAD").strip() + except hook.GitFehler as fehler: + schreibe(f"BEFUND {fehler.kopf} | {' '.join(fehler.rumpf.split())[:400]}") + return 1 + except Exception as fehler: # noqa: BLE001 + schreibe(f"BEFUND unerwarteter Abbruch: {type(fehler).__name__}: {fehler}") + schreibe(" " + " ".join(traceback.format_exc().split())[:400]) + return 1 + + if vorher == nachher: + schreibe(f"ok nichts zu tun, HEAD {nachher[:8]}") + else: + schreibe(f"ok gepusht, HEAD {vorher[:8]} -> {nachher[:8]}") + return 0 + + +def werkzeuge_spiegeln(wurzel: Path) -> int: + try: + spiegel = lade("werkzeuge_spiegel") + status, text = spiegel.spiegele(wurzel) + except Exception as fehler: # noqa: BLE001 - SpiegelFehler und jeder Absturz sind Befunde + schreibe(f"BEFUND werkzeuge: {type(fehler).__name__}: {' '.join(str(fehler).split())[:400]}") + return 1 + schreibe(f"ok werkzeuge {status}, {text}") + return 0 + + def main() -> int: try: - hook = lade_hook() + hook = lade("auto_push") except Exception as fehler: # noqa: BLE001 schreibe(f"BEFUND auto_push.py nicht ladbar: {type(fehler).__name__}: {fehler}") return 1 @@ -74,25 +111,12 @@ def main() -> int: return 0 try: - vorher = hook.git(wurzel, "rev-parse", "HEAD").strip() - hook.arbeite(wurzel) - nachher = hook.git(wurzel, "rev-parse", "HEAD").strip() - except hook.GitFehler as fehler: - schreibe(f"BEFUND {fehler.kopf} | {' '.join(fehler.rumpf.split())[:400]}") - return 1 - except Exception as fehler: # noqa: BLE001 - schreibe(f"BEFUND unerwarteter Abbruch: {type(fehler).__name__}: {fehler}") - schreibe(" " + " ".join(traceback.format_exc().split())[:400]) - return 1 + if vault_push(hook, wurzel) != 0: + return 1 + return werkzeuge_spiegeln(wurzel) finally: hook.gib_sperre_frei(deskriptor, sperre) - if vorher == nachher: - schreibe(f"ok nichts zu tun, HEAD {nachher[:8]}") - else: - schreibe(f"ok gepusht, HEAD {vorher[:8]} -> {nachher[:8]}") - return 0 - if __name__ == "__main__": try: diff --git a/.vault-tools/auto-push/werkzeuge-README.md b/.vault-tools/auto-push/werkzeuge-README.md new file mode 100644 index 0000000..fe5890b --- /dev/null +++ b/.vault-tools/auto-push/werkzeuge-README.md @@ -0,0 +1,20 @@ +# Claude-Werkzeuge + +Werkzeuge, Skills, Agenten und Workflows, die rund um Claude Code für einen Obsidian-Vault entstanden sind. Die Regeln dazu stehen im Schwester-Repo [claude-regelwerk](https://git.nexhorizons.de/Nexhorizons-Public/claude-regelwerk). + +- Dieses Repo ist ein Spiegel. Es wird täglich aus dem Vault neu geschrieben; Änderungen direkt hier gehen beim nächsten Lauf verloren. +- Die Ordnerstruktur entspricht der im Vault (`.vault-tools/`, `.claude/`). Pfade in Skripten und Dokumenten passen so ohne Anpassung. +- Viele Skripte setzen Vault-Konventionen voraus (Ordner, Frontmatter, `AI/Regelwerk/`). Vor dem Einsatz den Kopfkommentar und, wo vorhanden, `LIESMICH.md` oder `ENTWURF.md` im Werkzeugordner lesen. +- Nicht enthalten: die Prüfwerkzeuge des Trading-Projekts. + +## Aufbau + +- `.vault-tools/` — Skripte, je Werkzeug ein Ordner. +- `.claude/agents/` — Rollen mit Modell und Effort. +- `.claude/skills/` — Skills (Playbooks), je Skill ein Ordner mit `SKILL.md`. +- `.claude/workflows/` — gespeicherte Workflows für das Workflow-Werkzeug von Claude Code. +- `.claude/settings.json` — Hook-Verdrahtung. + +## Zugangsdaten + +Keine im Repo. Skripte, die eine API brauchen, lesen sie aus Umgebungsvariablen. diff --git a/.vault-tools/auto-push/werkzeuge_spiegel.py b/.vault-tools/auto-push/werkzeuge_spiegel.py new file mode 100644 index 0000000..c6a962b --- /dev/null +++ b/.vault-tools/auto-push/werkzeuge_spiegel.py @@ -0,0 +1,172 @@ +"""Spiegelt die Werkzeuge des Vaults in das öffentliche Repo claude-werkzeuge. + +Ersteller: Pascal Michel +Erstellt: 2026-10-14 + +WAS GESPIEGELT WIRD + Der committete Stand (HEAD) des Vaults unter .vault-tools/ und .claude/, + ohne die Ordner in AUSSCHLUESSE, dazu werkzeuge-README.md als README.md. + Ungesicherte Änderungen im Arbeitsbaum gehen nicht mit. Was die + .gitignore des Vaults draußen hält (settings.local.json, __pycache__, + lauf.log), ist deshalb auch hier draußen. + + AUSSCHLUESSE ist eine Negativliste: Ein neuer Werkzeugordner geht beim + nächsten Lauf mit hinaus. So angeordnet (Pascal, 2026-10-14: künftig alle + Werkzeuge außer denen des Trading-Projekts). Ein neues Werkzeug des + Trading-Projekts gehört deshalb in AUSSCHLUESSE, bevor es committet wird. + +WOHIN + In einen Klon außerhalb des Vaults (KLON). Der Lauf holt den Remote-Stand, + leert den Klon bis auf .git, packt den Stand neu aus und committet und + pusht nur, wenn sich etwas geändert hat. Die Remote-Adresse entsteht aus + der origin-Adresse des Vaults, letzter Pfadteil ersetzt; Benutzername und + Host stehen so nirgends im Quelltext. Das Token kommt wie beim Vault-Push + über askpass.sh aus GITEA_TOKEN. + +SCHUTZ + Geleert wird nur ein Klon, dessen origin genau auf die erwartete Adresse + zeigt. Ein Klon im Vault, ein fremder Klon und ein nicht leerer Ordner + ohne .git sind Befunde, nichts wird gelöscht. + +BENANNTE LÜCKEN + * Kein Geheimnis-Scan. Was im Vault unter .vault-tools/ oder .claude/ + committet ist, geht hinaus, wie beim Vault-Push. + * Nur reguläre Dateien. Symlinks im Vault werden übersprungen. + * Eine Änderung direkt im Repo claude-werkzeuge überschreibt der nächste + Lauf. + * Ein gescheiterter Push lässt den Commit im Klon stehen; der nächste Lauf + setzt den Klon auf den Remote-Stand zurück und committet neu. +""" + +from __future__ import annotations + +import io +import os +import shutil +import stat +import subprocess +import tarfile +from pathlib import Path + +KLON = Path(r"D:\ClaudeData\claude-werkzeuge") +REPO_NAME = "claude-werkzeuge.git" +README_QUELLE = ".vault-tools/auto-push/werkzeuge-README.md" +ASKPASS_REL = Path(".vault-tools") / "auto-push" / "askpass.sh" + +QUELLEN = (".vault-tools/", ".claude/") +# Verankert ab der Vault-Wurzel, nie als bloße Ordnernamen. +AUSSCHLUESSE = ( + ".vault-tools/trading-plattform-pruefung/", + ".vault-tools/watermarks-remover/", +) + +TIMEOUT_GIT_S = 30 +TIMEOUT_NETZ_S = 90 + + +class SpiegelFehler(Exception): + """Ein Schritt ist gescheitert; die Meldung ist der Befund.""" + + +def git(wo: Path, wurzel: Path, *argumente: str, timeout: int = TIMEOUT_GIT_S) -> bytes: + umgebung = dict( + os.environ, + GIT_TERMINAL_PROMPT="0", + GIT_ASKPASS=str(wurzel / ASKPASS_REL), + ) + try: + lauf = subprocess.run( + ["git", "-C", str(wo), *argumente], + env=umgebung, + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as abgelaufen: + raise SpiegelFehler(f"git {argumente[0]} nach {timeout} s abgebrochen") from abgelaufen + except OSError as fehler: + raise SpiegelFehler(f"git nicht startbar: {type(fehler).__name__}: {fehler}") from fehler + if lauf.returncode != 0: + text = (lauf.stdout + b"\n" + lauf.stderr).decode("utf-8", errors="replace") + raise SpiegelFehler(f"git {argumente[0]}: Exit {lauf.returncode}: {' '.join(text.split())[:300]}") + return lauf.stdout + + +def gehoert_dazu(pfad: str) -> bool: + return pfad.startswith(QUELLEN) and not pfad.startswith(AUSSCHLUESSE) + + +def ziel_adresse(wurzel: Path) -> str: + adresse = git(wurzel, wurzel, "remote", "get-url", "origin").decode("utf-8").strip() + if "/" not in adresse: + raise SpiegelFehler(f"origin des Vaults ohne Pfad: {adresse}") + return adresse.rsplit("/", 1)[0] + "/" + REPO_NAME + + +def raeume(pfad: Path) -> None: + """Löscht Datei oder Ordner, auch schreibgeschützte Dateien.""" + + def erzwinge(funktion, ziel, _fehler): + os.chmod(ziel, stat.S_IWRITE) + funktion(ziel) + + if pfad.is_dir() and not pfad.is_symlink(): + shutil.rmtree(pfad, onexc=erzwinge) + else: + pfad.unlink() + + +def bereite_klon(wurzel: Path, klon: Path, adresse: str) -> None: + """Klont beim ersten Lauf, setzt danach auf den Remote-Stand zurück.""" + basis = wurzel.resolve() + ziel = klon.resolve() + if ziel == basis or basis in ziel.parents: + raise SpiegelFehler(f"Klon {klon} liegt im Vault; abgebrochen, nichts geleert") + if not (klon / ".git").is_dir(): + if klon.exists() and any(klon.iterdir()): + raise SpiegelFehler(f"{klon} ist kein Git-Klon und nicht leer; abgebrochen, nichts geleert") + klon.parent.mkdir(parents=True, exist_ok=True) + git(klon.parent, wurzel, "clone", "-q", "-c", "core.autocrlf=false", adresse, str(klon), + timeout=TIMEOUT_NETZ_S) + return + vorhanden = git(klon, wurzel, "remote", "get-url", "origin").decode("utf-8").strip() + if vorhanden != adresse: + raise SpiegelFehler( + f"origin des Klons zeigt auf {vorhanden}, erwartet {adresse}; abgebrochen, nichts geleert" + ) + git(klon, wurzel, "fetch", "-q", "origin", timeout=TIMEOUT_NETZ_S) + git(klon, wurzel, "reset", "-q", "--hard", "origin/main") + + +def spiegele(wurzel: Path, klon: Path = KLON) -> tuple[str, str]: + """Gibt ("gespiegelt" oder "unveraendert", Text) zurück. Wirft SpiegelFehler.""" + adresse = ziel_adresse(wurzel) + stand = git(wurzel, wurzel, "rev-parse", "--short", "HEAD").decode("utf-8").strip() + # Ganzer HEAD statt Pfadangaben: git archive bricht mit "pathspec did not match" + # ab, sobald eine Quelle leer ist. Gefiltert wird danach mit gehoert_dazu. + archiv = git(wurzel, wurzel, "archive", "--format=tar", "HEAD") + + with tarfile.open(fileobj=io.BytesIO(archiv)) as tar: + mitglieder = [m for m in tar.getmembers() if m.isfile() and gehoert_dazu(m.name)] + if README_QUELLE not in {m.name for m in mitglieder}: + raise SpiegelFehler(f"README-Quelle {README_QUELLE} fehlt im Vault-Stand {stand}") + + bereite_klon(wurzel, klon, adresse) + for eintrag in klon.iterdir(): + if eintrag.name != ".git": + raeume(eintrag) + for mitglied in mitglieder: + tar.extract(mitglied, klon, filter="data") + readme = tar.extractfile(README_QUELLE) + (klon / "README.md").write_bytes(readme.read()) + + git(klon, wurzel, "add", "-A") + roh = git(klon, wurzel, "diff", "--cached", "--name-only", "-z").decode("utf-8") + geaendert = [z for z in roh.split("\0") if z] + if not geaendert: + return "unveraendert", f"Vault-Stand {stand}" + git(klon, wurzel, "commit", "-q", "-m", + f"Abgleich mit dem Vault-Stand {stand}\n\n{len(geaendert)} Datei(en) geändert.\n" + "Erzeugt von .vault-tools/auto-push/werkzeuge_spiegel.py.") + git(klon, wurzel, "push", "-q", "origin", "HEAD:main", timeout=TIMEOUT_NETZ_S) + return "gespiegelt", f"{len(geaendert)} Datei(en), Vault-Stand {stand}" diff --git a/AI/Regelwerk/veroeffentlichung.md b/AI/Regelwerk/veroeffentlichung.md index 49c9c31..f6d87b0 100644 --- a/AI/Regelwerk/veroeffentlichung.md +++ b/AI/Regelwerk/veroeffentlichung.md @@ -5,7 +5,7 @@ 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-23 +updated: 2026-10-14 --- # Veröffentlichung der Anweisungsschicht @@ -18,7 +18,8 @@ Angeordnet am 2026-09-17. Die Anweisungsschicht des Vaults liegt seitdem in eine | --- | --- | | Remote | `https://git.nexhorizons.de/Nexhorizons-Public/claude-regelwerk.git` | | Zweig | `main` | -| Sichtbarkeit | Organisation `Nexhorizons-Public` und Repo sind beide public. **Anonym trotzdem gesperrt:** Die Instanz verlangt für jede Ansicht eine Anmeldung (`REQUIRE_SIGNIN_VIEW`), gemessen am 2026-09-17 mit 403 auf der API und einer Weiterleitung auf den Login im Web. Das lässt sich nur in der `app.ini` am Server ändern, nicht über die API. | +| Sichtbarkeit | Organisation `Nexhorizons-Public` und Repo sind beide public und **ohne Anmeldung lesbar**, seit Pascal am 2026-10-14 `REQUIRE_SIGNIN_VIEW` in der `app.ini` geändert hat (gemessen: Web und API `200`, anonymer `git clone` geht). Vorher Weiterleitung auf den Login und `403` auf der API. Jedes public Repo der Instanz ist damit anonym lesbar. | +| Zweites Repo | `https://git.nexhorizons.de/Nexhorizons-Public/claude-werkzeuge.git`, Spiegel der Werkzeuge, siehe „Werkzeug-Spiegel" unten | ## Was hinausgeht — und warum die Liste so herum steht @@ -68,6 +69,17 @@ Belegt am 2026-09-17: Lauf aus der Aufgabenplanung heraus, Ergebnis `0x0`, Proto 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 ausgelöst wird und ob die Positivliste hält, und nicht, ob eine eingeschlossene Datei etwas enthält, das niemand veröffentlichen wollte. +## Werkzeug-Spiegel `claude-werkzeuge` + +Angeordnet am 2026-10-14. Nach dem Vault-Push spiegelt derselbe 04:00-Lauf die Werkzeuge in ein zweites öffentliches Repo. + +- **Inhalt:** der committete Stand von `.vault-tools/` und `.claude/`, ohne `.vault-tools/trading-plattform-pruefung/` und `.vault-tools/watermarks-remover/`. Dazu `README.md` aus `.vault-tools/auto-push/werkzeuge-README.md`. +- **Neue Werkzeuge gehen automatisch mit.** Die Ausschlüsse sind eine Negativliste in `werkzeuge_spiegel.py` (`AUSSCHLUESSE`), so angeordnet („alle Werkzeuge außer Trading"). Ein neues Werkzeug des Trading-Projekts unter `.vault-tools/` trägst du dort ein, **bevor** es committet wird. +- **Klon:** `D:\ClaudeData\claude-werkzeuge`, außerhalb des Vaults. Der Lauf leert ihn bis auf `.git` und packt neu aus. Er weigert sich, wenn `origin` nicht genau auf das Spiegel-Repo zeigt oder der Ordner im Vault liegt. +- **Das Repo ist ein Spiegel.** Änderungen direkt in `claude-werkzeuge` überschreibt der nächste Lauf. +- **Protokoll:** eine zweite Zeile je Lauf in `lauf.log` (`ok werkzeuge gespiegelt|unveraendert` oder `BEFUND werkzeuge: …`). Scheitert der Vault-Push, entfällt der Spiegel. +- **Fallkatalog:** `python .vault-tools/auto-push/faelle_werkzeuge_spiegel.py`, Wegwerf-Vaults mit echten bare-Remotes. Fangnetz, kein Nachweis. + ## Referenzen - [[CLAUDE|↑ Claude.md (Stamm)]] diff --git a/CLAUDE.md b/CLAUDE.md index ae1ad50..e3c3fce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ Jede Datei triggert nur den betreffenden Teil — nicht bei jeder Aufgabe alles ## Veröffentlichung -Der Vault-Root ist ein Git-Repo und pusht **täglich um 04:00** nach `https://git.nexhorizons.de/Nexhorizons-Public/claude-regelwerk.git` — über die Windows-Aufgabe `Claude-Vault Auto-Push`, nicht über eine Cloud-Routine. Veröffentlicht wird nur die Anweisungsschicht: `CLAUDE.md`, `README.md`, `AI/Regelwerk/`, `.claude/agents/`, `.claude/skills/`, `.claude/workflows/`, `.claude/settings.json` und `.vault-tools/`. +Der Vault-Root ist ein Git-Repo und pusht **täglich um 04:00** nach `https://git.nexhorizons.de/Nexhorizons-Public/claude-regelwerk.git` — über die Windows-Aufgabe `Claude-Vault Auto-Push`, nicht über eine Cloud-Routine. Veröffentlicht wird nur die Anweisungsschicht: `CLAUDE.md`, `README.md`, `AI/Regelwerk/`, `.claude/agents/`, `.claude/skills/`, `.claude/workflows/`, `.claude/settings.json` und `.vault-tools/`. Derselbe Lauf spiegelt danach `.vault-tools/` und `.claude/` ohne die Trading-Werkzeuge nach `claude-werkzeuge`. 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.