From dd5e6124528a9757226a2567a618171747743efe Mon Sep 17 00:00:00 2001 From: Pascal Michel Date: Thu, 15 Oct 2026 04:00:03 +0200 Subject: [PATCH] Abgleich mit dem Vault-Stand 80a20f1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 Datei(en) geändert. Erzeugt von .vault-tools/auto-push/werkzeuge_spiegel.py. --- .../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 +++++ README.md | 33 +- 6 files changed, 1237 insertions(+), 54 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/README.md b/README.md index 773f0e0..fe5890b 100644 --- a/README.md +++ b/README.md @@ -2,34 +2,19 @@ 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 das jeweilige `LIESMICH.md` oder `ENTWURF.md` lesen. +- 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. -## `.vault-tools/` +## Aufbau -| Ordner | Zweck | -|---|---| -| `auto-push/` | Täglicher Push der Anweisungsschicht per Windows-Aufgabe, mit Positivliste und Prüffällen | -| `frontmatter-migration/` | Einmalige Migration des Frontmatters auf das aktuelle Schema | -| `linkcheck/` | Tote Wikilinks und Waisen (Dateien ohne eingehenden Link) finden | -| `obsidian-vault-navigator/` | Altes Index- und Abfrage-Toolkit; nicht mehr gepflegt | -| `plan-diff/` | Zwei Planfassungen vergleichen | -| `plan-kopie/` | Standardschleife: Wegwerfkopie bauen, Plan anwenden, Rot-Nachweis, Mutantenlauf, Wellenvergleich, Ersetzungslisten einsetzen | -| `quorum/` | Hilfswerkzeug für das Quorum am Meilenstein | -| `regelwerk-check/` | Formale Prüfungen des Regelwerks (Links, Frontmatter, Modell/Effort je Rolle, Steuerzeichen) | -| `session-hooks/` | SessionStart- und PostToolUse-Hooks für Claude Code | -| `workflow-pruefung/` | Prüffälle, Attrappe und Mutanten für die gespeicherten Workflows | - -## `.claude/` - -| Ordner | Inhalt | -|---|---| -| `agents/` | Rollen `planer`, `executer`, `pruefer`, `recherche`, `quorum-stimme` mit Modell und Effort | -| `skills/` | `anleitung-straffen`, `bookstack-upload`, `plan-executer-pruefung`, `plane-abgleich` | -| `workflows/` | `planung`, `plankritik`, `executer-welle`, `pruefung`, `welle-mit-pruefung`, `quorum`, `verbrauch` | -| `settings.json` | Hook-Verdrahtung der `session-hooks` | +- `.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 (BookStack, Plane, Gitea), lesen sie aus Umgebungsvariablen. +Keine im Repo. Skripte, die eine API brauchen, lesen sie aus Umgebungsvariablen.