Erste Fassung: Claude-Werkzeuge aus dem Vault

.vault-tools (ohne trading-plattform-pruefung und watermarks-remover),
.claude/agents, .claude/skills, .claude/workflows, .claude/settings.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-14 11:27:59 +02:00
commit fa1c26b407
137 files changed
+43926

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
"""GIT_ASKPASS-Helfer: gibt das Gitea-Token aus der Umgebung an git weiter.
Ohne diesen Helfer landet das Token entweder in .git/config (Remote-URL mit
Zugangsdaten) oder in einer Kommandozeile (http.extraheader) und ist dort fuer
jeden Prozess der Maschine lesbar. git ruft dieses Skript mit der Eingabe-
aufforderung als Argument auf und liest die Antwort von stdout.
Eine fehlende Variable ist ein Befund: das Skript endet mit Exit 1 und einer
Meldung auf stderr, statt eine leere Zeile auszugeben. Sonst haengt git an
einer stillen Fehlanmeldung.
"""
from __future__ import annotations
import os
import sys
BENUTZER_VARIABLE = "GITEA_BENUTZER"
TOKEN_VARIABLE = "GITEA_TOKEN"
def main(argumente: list[str]) -> int:
frage = " ".join(argumente).lower()
if "username" in frage or "benutzer" in frage:
name = "GITEA_BENUTZER"
wert = os.environ.get(BENUTZER_VARIABLE, "").strip()
else:
name = "GITEA_TOKEN"
wert = os.environ.get(TOKEN_VARIABLE, "").strip()
if not wert:
sys.stderr.write(f"{name} ist nicht gesetzt oder leer.\n")
return 1
sys.stdout.write(wert + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# Von git als GIT_ASKPASS aufgerufen. Reicht die Frage an askpass.py weiter.
exec python "D:/Claude/.vault-tools/auto-push/askpass.py" "$@"
+349
View File
@@ -0,0 +1,349 @@
"""PostToolUse-Hook: committet und pusht jede Änderung an der veröffentlichten Anweisungsschicht.
Ersteller: Pascal Michel
Erstellt: 2026-09-17
GELTUNGSBEREICH
Ausgelöst wird nur, wenn tool_input.file_path innerhalb der Vault-Wurzel auf
CLAUDE.md, README.md, AI/Regelwerk/**, .claude/agents/**, .claude/skills/**,
.claude/settings.json oder .vault-tools/** zeigt. Die Wurzel ist
ausschließlich der Ordner zwei Ebenen über dieser Datei; weder
CLAUDE_PROJECT_DIR noch das Feld cwd der Hook-Eingabe werden gelesen, weil
cwd einem cd im Bash-Werkzeug folgt (dieselbe Begründung wie in
regelwerk_nach_edit.py).
Was tatsächlich gepusht wird, entscheidet aber nicht diese Liste, sondern die
.gitignore der Vault-Wurzel. Sie arbeitet als Positivliste: ausgeschlossen
ist alles, eingeschlossen nur, was dort ausdrücklich wieder aufgenommen ist.
Die Liste hier bestimmt nur, ob der Hook überhaupt anläuft — sie ist eine
Abkürzung, kein zweiter Filter. Wer den Umfang ändern will, ändert die
.gitignore.
Still bleibt der Hook in drei Lagen: die Eingabe taugt nicht (leer, kein
JSON-Objekt, kein file_path als Zeichenkette); der Pfad liegt außerhalb des
Geltungsbereichs; oder es gibt nichts zu committen. Jede andere Lage —
fehlendes Token, fehlendes Remote, gescheiterter Commit, gescheiterter Push,
Zeitüberschreitung — ist ein Befund und wird gemeldet.
VERHALTEN BEI GESCHEITERTEM PUSH
Der Commit bleibt lokal stehen und der Befund geht in den Kontext. Der
nächste Lauf schiebt ihn mit, weil `git push` alles bis HEAD überträgt. Ein
gescheiterter Push verliert also keine Arbeit, er verzögert sie.
NEBENLÄUFIGKEIT
Zwei schnell aufeinanderfolgende Edits starten zwei Hook-Läufe. Ohne Sperre
kollidieren sie an der index.lock von git. Deshalb eine eigene Sperrdatei mit
O_EXCL; wer sie nicht bekommt, wartet bis SPERRE_WARTE_S und läuft danach
selbst. Findet er nichts mehr zu committen, bleibt er still — der andere Lauf
hat seine Änderung dann bereits mitgenommen.
BENANNTE LÜCKEN
* Ob tool_name Edit, Write oder MultiEdit ist, prüft der Matcher in
settings.json, nicht dieses Skript.
* Der Hook prüft den Inhalt nicht. Er hat keinen Geheimnis-Scan und keine
Freigabe. Was in einer eingeschlossenen Datei steht, geht heraus, sobald
sie geschrieben wird — auch halbfertig. Das ist die ausdrückliche Vorgabe
(Pascal, 2026-09-17), und es ist der Grund, warum die .gitignore eine
Positivliste ist und keine Ausschlussliste.
* Pfade werden ueber realpath aufgeloest. Ein Symlink von ausserhalb auf
eine eingeschlossene Datei loest den Hook deshalb aus, ein Symlink von
innen nach aussen nicht.
* Ein Push wird nicht wiederholt. Nachgeholt wird er beim nächsten Lauf.
Exit ist immer 0. Gemeldet wird über additionalContext plus systemMessage, wie
beim Nachbar-Hook regelwerk_nach_edit.py.
"""
from __future__ import annotations
import errno
import json
import os
import subprocess
import sys
import time
from pathlib import Path
SKRIPT = Path(__file__).resolve()
# Unter dem timeout von 60 s, den der settings.json-Eintrag setzen soll.
TIMEOUT_GIT_S = 15
TIMEOUT_PUSH_S = 40
SPERRE_WARTE_S = 25
SPERRE_TAKT_S = 0.4
MAX_ZEILEN = 30
MAX_ZEICHEN = 3000
MAX_DATEIEN_IN_MELDUNG = 12
# Kleinbuchstaben und Schrägstrich vorwärts, verglichen gegen normiere().
GELTUNG_DATEIEN = ("claude.md", "readme.md", ".gitignore", ".gitattributes")
GELTUNG_ORDNER = (
"ai/regelwerk/",
".claude/agents/",
".claude/skills/",
".vault-tools/",
)
GELTUNG_EINZELN = (".claude/settings.json",)
ASKPASS_REL = Path(".vault-tools") / "auto-push" / "askpass.sh"
SPERRE_REL = Path(".vault-tools") / "auto-push" / ".sperre"
def lies_stdin() -> bytes:
if sys.stdin is None or sys.stdin.isatty():
return b""
return sys.stdin.buffer.read()
def lies_eingabe(roh: bytes) -> object:
# errors=replace: ein kaputtes Byte im Pfad soll laut enden, nicht still.
text = roh.decode("utf-8-sig", errors="replace")
if not text.strip():
return None
try:
return json.loads(text)
except ValueError:
return None
def bestimme_wurzel() -> Path:
return SKRIPT.parents[2]
def normiere(pfad: str) -> str:
# realpath, nicht abspath: Windows liefert denselben Ort mal als
# "C:/Users/Pascal Michel/..." und mal als 8.3-Kurznamen
# "C:/Users/PASCAL~1/...". Ein Vergleich mit abspath haelt die beiden fuer
# verschiedene Pfade, und der Hook bleibt dann still statt zu pushen --
# im Fallkatalog vom 2026-09-17 sind daran sieben Faelle gescheitert.
return os.path.normpath(os.path.realpath(pfad)).replace("\\", "/").lower().rstrip("/")
def im_geltungsbereich(wurzel: Path, datei: str) -> bool:
kandidat = datei.replace("\\", "/")
try:
if not os.path.isabs(kandidat):
kandidat = os.path.join(str(wurzel), kandidat)
pfad = normiere(kandidat)
basis = normiere(str(wurzel))
except (ValueError, OSError):
return False
# Mit Trenner vergleichen, sonst träfe "<wurzel>-CLAUDE.md" neben dem Vault.
if not pfad.startswith(basis + "/"):
return False
rel = pfad[len(basis) + 1:]
return (
rel in GELTUNG_DATEIEN
or rel in GELTUNG_EINZELN
or rel.startswith(GELTUNG_ORDNER)
)
def dekodiere(roh: bytes | str | None) -> str:
if roh is None:
return ""
if isinstance(roh, str):
return roh
return roh.decode("utf-8", errors="replace")
class GitFehler(Exception):
"""Ein git-Aufruf ist gescheitert; die Meldung ist der Befund."""
def __init__(self, kopf: str, rumpf: str) -> None:
super().__init__(kopf)
self.kopf = kopf
self.rumpf = rumpf
def git(wurzel: Path, *argumente: str, timeout: int = TIMEOUT_GIT_S) -> str:
umgebung = dict(
os.environ,
PYTHONIOENCODING="utf-8",
GIT_TERMINAL_PROMPT="0",
GIT_ASKPASS=str(wurzel / ASKPASS_REL),
GIT_CONFIG_NOSYSTEM="0",
)
befehl = ["git", "-C", str(wurzel), *argumente]
try:
lauf = subprocess.run(
befehl,
env=umgebung,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as abgelaufen:
teil = (dekodiere(abgelaufen.stdout) + dekodiere(abgelaufen.stderr)).strip()
raise GitFehler(
f"git {argumente[0]} nach {timeout} s abgebrochen",
f"`git {' '.join(argumente)}` lief länger als {timeout} s.\n"
f"Bis dahin ausgegeben:\n{teil or '(nichts)'}",
) from abgelaufen
except OSError as fehler:
raise GitFehler(
f"git nicht startbar: {type(fehler).__name__}",
f"`git {' '.join(argumente)}` ließ sich nicht starten: "
f"{type(fehler).__name__}: {fehler}",
) from fehler
if lauf.returncode != 0:
ausgabe = (dekodiere(lauf.stdout) + "\n" + dekodiere(lauf.stderr)).strip()
raise GitFehler(
f"git {argumente[0]}: Exit {lauf.returncode}",
f"`git {' '.join(argumente)}` endete mit Exit {lauf.returncode}.\n"
f"{ausgabe or '(keine Ausgabe)'}",
)
return dekodiere(lauf.stdout)
def hole_sperre(sperre: Path) -> int | None:
"""Gibt den Dateideskriptor zurück, oder None, wenn die Sperre belegt bleibt."""
frist = time.monotonic() + SPERRE_WARTE_S
while True:
try:
return os.open(str(sperre), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except OSError as fehler:
if fehler.errno not in (errno.EEXIST, errno.EACCES):
raise
# Eine Sperre, die älter ist als die Wartezeit, stammt aus einem
# abgestürzten Lauf. Sie wird entfernt, nicht ewig respektiert.
try:
if time.time() - sperre.stat().st_mtime > SPERRE_WARTE_S * 2:
sperre.unlink(missing_ok=True)
continue
except OSError:
pass
if time.monotonic() >= frist:
return None
time.sleep(SPERRE_TAKT_S)
def gib_sperre_frei(deskriptor: int, sperre: Path) -> None:
try:
os.close(deskriptor)
except OSError:
pass
try:
sperre.unlink(missing_ok=True)
except OSError:
pass
def baue_meldung(dateien: list[str]) -> str:
rest = len(dateien) - MAX_DATEIEN_IN_MELDUNG
sichtbar = dateien[:MAX_DATEIEN_IN_MELDUNG]
kopf = (
f"Automatischer Push: {len(dateien)} Datei(en) geändert"
if len(dateien) != 1
else f"Automatischer Push: {dateien[0]}"
)
rumpf = "\n".join(f"- {d}" for d in sichtbar)
if rest > 0:
rumpf += f"\n- … und {rest} weitere"
return (
f"{kopf}\n\n{rumpf}\n\n"
"Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.\n"
"\n"
"Co-Authored-By: Claude Opus 5 <[email protected]>\n"
)
def arbeite(wurzel: Path) -> tuple[str, str] | None:
"""None, wenn nichts zu tun war; sonst (systemMessage, Rumpf)."""
if not (wurzel / ".git").exists():
raise GitFehler(
"Auto-Push: kein Git-Repo",
f"{wurzel} ist kein Git-Repository. Der Hook ist eingetragen, kann "
"aber nichts pushen. Das ist ein Befund, keine Entwarnung.",
)
if not (wurzel / ASKPASS_REL).is_file():
raise GitFehler(
"Auto-Push: askpass-Helfer fehlt",
f"{wurzel / ASKPASS_REL} fehlt. Ohne ihn fragt git nach einem "
"Passwort und der Push hängt oder scheitert.",
)
if not os.environ.get("GITEA_TOKEN", "").strip():
raise GitFehler(
"Auto-Push: GITEA_TOKEN nicht gesetzt",
"Die Umgebungsvariable GITEA_TOKEN ist leer oder fehlt. Der Commit "
"wird trotzdem gemacht, der Push scheitert. Variable setzen und "
"eine beliebige Regelwerk-Datei erneut speichern.",
)
git(wurzel, "add", "-A")
gestaged = [z for z in git(wurzel, "diff", "--cached", "--name-only").splitlines() if z.strip()]
if not gestaged:
return None
git(wurzel, "commit", "-m", baue_meldung(gestaged))
git(wurzel, "push", "origin", "HEAD", timeout=TIMEOUT_PUSH_S)
return None
def kuerze(text: str) -> str:
zeilen = text.splitlines()
rest = len(zeilen) - MAX_ZEILEN
if rest > 0:
zeilen = zeilen[:MAX_ZEILEN] + [f"[... {rest} weitere Zeilen gekürzt]"]
ergebnis = "\n".join(zeilen)
if len(ergebnis) > MAX_ZEICHEN:
ergebnis = ergebnis[:MAX_ZEICHEN] + f"\n[... gekürzt auf {MAX_ZEICHEN} Zeichen]"
return ergebnis
def melde(system: str, kontext: str) -> None:
ausgabe = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": kuerze(kontext),
},
"systemMessage": " ".join(system.split()),
}
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
sys.stdout.flush()
def main(roh: bytes | None = None) -> int:
try:
daten = lies_eingabe(lies_stdin() if roh is None else roh)
if not isinstance(daten, dict):
return 0
eingabe = daten.get("tool_input")
if not isinstance(eingabe, dict):
return 0
datei = eingabe.get("file_path")
if not isinstance(datei, str) or not datei.strip():
return 0
wurzel = bestimme_wurzel()
if not im_geltungsbereich(wurzel, datei):
return 0
sperre = wurzel / SPERRE_REL
deskriptor = hole_sperre(sperre)
if deskriptor is None:
# Ein anderer Lauf arbeitet noch. Er nimmt diese Änderung mit,
# weil er `git add -A` über den ganzen Baum fährt.
return 0
try:
ergebnis = arbeite(wurzel)
finally:
gib_sperre_frei(deskriptor, sperre)
if ergebnis is not None:
melde(*ergebnis)
return 0
except GitFehler as fehler:
melde(fehler.kopf, fehler.rumpf)
return 0
except Exception as fehler: # noqa: BLE001 - ein Absturz ist selbst ein Befund
melde(
f"Auto-Push abgebrochen: {type(fehler).__name__}",
f"Der Auto-Push-Hook ist unerwartet abgebrochen: "
f"{type(fehler).__name__}: {fehler}",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+338
View File
@@ -0,0 +1,338 @@
"""Fährt die Angriffsliste gegen auto_push.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-17
Jeder Fall bekommt einen eigenen Wegwerf-Vault: ein echtes Git-Repo mit einem
zweiten Repo als bare-Remote daneben. Damit ist der Push echt — es wird
wirklich übertragen, nur eben in ein Verzeichnis statt über das Netz. Ein
Auto-Push, der nur gegen eine Attrappe geprüft wurde, belegt nichts über den
Fall, der zählt: dass die Änderung nachher drüben liegt.
Der Hook wird für jeden Fall in den Wegwerf-Vault kopiert, weil er seine
Vault-Wurzel ausschließlich aus dem eigenen Skriptpfad bestimmt.
Grundlinie zuerst: eine geänderte Datei im Geltungsbereich muss nach dem Lauf
im Remote liegen. Läuft die Grundlinie nicht, misst der Rest nichts.
Aufruf:
python .vault-tools/auto-push/faelle_auto_push.py
"""
from __future__ import annotations
import json
import os
import shutil
import stat
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
HIER = Path(__file__).resolve().parent
HOOK = HIER / "auto_push.py"
ASKPASS_PY = HIER / "askpass.py"
# Die .gitignore des echten Vaults, gekürzt auf das, was die Fälle brauchen.
GITIGNORE = """/*
!/.gitignore
!/CLAUDE.md
!/AI/
/AI/*
!/AI/Regelwerk/
!/.vault-tools/
.vault-tools/auto-push/.sperre
__pycache__/
*.pyc
"""
def raeume(pfad: Path) -> None:
"""rmtree, das auch schreibgeschuetzte git-Objektdateien wegbekommt."""
def erzwinge(funktion, ziel, _fehler):
try:
os.chmod(ziel, stat.S_IWRITE)
funktion(ziel)
except OSError:
pass
shutil.rmtree(pfad, onexc=erzwinge)
def git(wo: Path, *argumente: str) -> subprocess.CompletedProcess[bytes]:
return subprocess.run(
["git", "-C", str(wo), *argumente],
capture_output=True,
stdin=subprocess.DEVNULL,
check=False,
)
def baue_vault(basis: Path) -> tuple[Path, Path]:
"""Legt Arbeits-Vault und bare-Remote an und macht den ersten Commit."""
vault = basis / "vault"
remote = basis / "remote.git"
(vault / "AI" / "Regelwerk").mkdir(parents=True)
(vault / ".vault-tools" / "auto-push").mkdir(parents=True)
(vault / "Inbox").mkdir()
(vault / ".gitignore").write_text(GITIGNORE, encoding="utf-8")
(vault / "CLAUDE.md").write_text("# Stamm\n", encoding="utf-8")
(vault / "AI" / "Regelwerk" / "probe.md").write_text("# Probe\n", encoding="utf-8")
# Eine Datei, die niemals herausgehen darf.
(vault / "Inbox" / "geheim.md").write_text("Kundendaten\n", encoding="utf-8")
shutil.copy2(HOOK, vault / ".vault-tools" / "auto-push" / "auto_push.py")
shutil.copy2(ASKPASS_PY, vault / ".vault-tools" / "auto-push" / "askpass.py")
shim = vault / ".vault-tools" / "auto-push" / "askpass.sh"
shim.write_bytes(b"#!/bin/sh\necho unbenutzt\n")
subprocess.run(["git", "init", "-q", "--bare", str(remote)], check=True)
git(vault, "init", "-q")
git(vault, "symbolic-ref", "HEAD", "refs/heads/main")
git(vault, "config", "user.name", "Fall")
git(vault, "config", "user.email", "[email protected]")
git(vault, "config", "commit.gpgsign", "false")
git(vault, "remote", "add", "origin", remote.as_uri())
git(vault, "add", "-A")
git(vault, "commit", "-q", "-m", "Grundstand")
git(vault, "push", "-q", "-u", "origin", "main")
return vault, remote
def lauf_hook(vault: Path, eingabe: dict, umgebung_extra: dict | None = None) -> tuple[int, str, str]:
umgebung = dict(os.environ, PYTHONIOENCODING="utf-8", GITEA_TOKEN="egal")
umgebung.update(umgebung_extra or {})
lauf = subprocess.run(
[sys.executable, str(vault / ".vault-tools" / "auto-push" / "auto_push.py")],
input=json.dumps(eingabe).encode("utf-8"),
capture_output=True,
env=umgebung,
timeout=90,
check=False,
)
return (
lauf.returncode,
lauf.stdout.decode("utf-8", errors="replace"),
lauf.stderr.decode("utf-8", errors="replace"),
)
def remote_dateien(remote: Path) -> set[str]:
lauf = git(remote, "ls-tree", "-r", "--name-only", "main")
if lauf.returncode != 0:
return set()
return {z for z in lauf.stdout.decode("utf-8", errors="replace").splitlines() if z}
def remote_commits(remote: Path) -> int:
lauf = git(remote, "rev-list", "--count", "main")
if lauf.returncode != 0:
return -1
return int(lauf.stdout.decode().strip() or "0")
@dataclass
class Fall:
name: str
beschreibung: str
def main() -> int:
basis = Path(tempfile.mkdtemp(prefix="auto-push-faelle-"))
gesamt = 0
bestanden = 0
def pruefe(name: str, ok: bool, text: str) -> None:
nonlocal gesamt, bestanden
gesamt += 1
bestanden += int(ok)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {name:34} {text}")
try:
# --- Grundlinie -------------------------------------------------
vault, remote = baue_vault(basis / "grundlinie")
vorher = remote_commits(remote)
(vault / "AI" / "Regelwerk" / "probe.md").write_text("# Probe geaendert\n", encoding="utf-8")
code, aus, fehler = lauf_hook(vault, {"tool_input": {"file_path": str(vault / "AI" / "Regelwerk" / "probe.md")}})
nachher = remote_commits(remote)
pruefe(
"grundlinie",
code == 0 and nachher == vorher + 1 and not aus.strip(),
f"Exit {code}, Commits {vorher}->{nachher}, Ausgabe {len(aus)} Z. {fehler[:80]}",
)
# --- Ausgeschlossene Datei geht nie heraus ----------------------
drueben = remote_dateien(remote)
pruefe(
"positivliste haelt",
not any(d.startswith("Inbox/") for d in drueben),
f"Remote fuehrt {sorted(drueben)}",
)
pruefe(
"sperrdatei bleibt drinnen",
not any(d.endswith(".sperre") for d in drueben),
f"Remote fuehrt {sorted(drueben)}",
)
# --- Lauf ohne Aenderung bleibt still ---------------------------
vorher = remote_commits(remote)
code, aus, _ = lauf_hook(vault, {"tool_input": {"file_path": str(vault / "CLAUDE.md")}})
pruefe(
"nichts geaendert -> still",
code == 0 and not aus.strip() and remote_commits(remote) == vorher,
f"Exit {code}, Ausgabe {len(aus)} Z., Commits {remote_commits(remote)}",
)
# --- Eingaben, die nichts ausloesen duerfen ---------------------
stille_faelle = [
("eingabe leer", b""),
("eingabe kein json", b"{kaputt"),
("eingabe kein objekt", b"[1,2,3]"),
("eingabe null", b"null"),
("kein tool_input", b'{"tool_name":"Edit"}'),
("tool_input keine map", b'{"tool_input":"x"}'),
("kein file_path", b'{"tool_input":{"content":"x"}}'),
("file_path keine zeichenkette", b'{"tool_input":{"file_path":42}}'),
("file_path leer", b'{"tool_input":{"file_path":" "}}'),
]
for name, roh in stille_faelle:
(vault / "CLAUDE.md").write_text(f"# Stamm {name}\n", encoding="utf-8")
vorher = remote_commits(remote)
lauf = subprocess.run(
[sys.executable, str(vault / ".vault-tools" / "auto-push" / "auto_push.py")],
input=roh,
capture_output=True,
env=dict(os.environ, PYTHONIOENCODING="utf-8", GITEA_TOKEN="egal"),
timeout=60,
check=False,
)
aus = lauf.stdout.decode("utf-8", errors="replace")
pruefe(
name,
lauf.returncode == 0 and not aus.strip() and remote_commits(remote) == vorher,
f"Exit {lauf.returncode}, Ausgabe {len(aus)} Z.",
)
git(vault, "checkout", "--", "CLAUDE.md")
# --- Pfade ausserhalb des Geltungsbereichs ----------------------
aussen = [
("pfad ausserhalb vault", str(basis / "woanders.md")),
("nachbarordner mit praefix", str(vault) + "-anders/CLAUDE.md"),
("inbox-notiz loest nicht aus", str(vault / "Inbox" / "geheim.md")),
]
for name, pfad in aussen:
(vault / "CLAUDE.md").write_text(f"# Stamm {name}\n", encoding="utf-8")
vorher = remote_commits(remote)
code, aus, _ = lauf_hook(vault, {"tool_input": {"file_path": pfad}})
pruefe(
name,
code == 0 and not aus.strip() and remote_commits(remote) == vorher,
f"Exit {code}, Commits unveraendert: {remote_commits(remote) == vorher}",
)
git(vault, "checkout", "--", "CLAUDE.md")
# --- Pfadformen, die ausloesen muessen --------------------------
formen = [
("relativer pfad", "AI/Regelwerk/probe.md"),
("backslash-pfad", str(vault / "AI" / "Regelwerk" / "probe.md").replace("/", "\\")),
("pfad mit punktsegment", str(vault / "AI" / ".." / "AI" / "Regelwerk" / "probe.md")),
("grossschreibung", str(vault / "AI" / "Regelwerk" / "probe.md").upper()),
("vault-tools zaehlt mit", str(vault / ".vault-tools" / "auto-push" / "notiz.md")),
]
for nummer, (name, pfad) in enumerate(formen):
ziel = vault / "AI" / "Regelwerk" / "probe.md"
ziel.write_text(f"# Probe Form {nummer}\n", encoding="utf-8")
vorher = remote_commits(remote)
code, aus, fehler = lauf_hook(vault, {"tool_input": {"file_path": pfad}})
pruefe(
name,
code == 0 and remote_commits(remote) == vorher + 1,
f"Exit {code}, Commits {vorher}->{remote_commits(remote)} {aus[:60]}",
)
# --- Reihenfolge: erste und letzte Datei einer Sammeländerung ---
for position in ("erste", "letzte"):
vault2, remote2 = baue_vault(basis / f"reihenfolge-{position}")
for i in range(5):
(vault2 / "AI" / "Regelwerk" / f"a{i}.md").write_text(f"# {i}\n", encoding="utf-8")
ausloeser = "a0.md" if position == "erste" else "a4.md"
code, aus, _ = lauf_hook(vault2, {"tool_input": {"file_path": str(vault2 / "AI" / "Regelwerk" / ausloeser)}})
dateien = remote_dateien(remote2)
fehlt = [f"AI/Regelwerk/a{i}.md" for i in range(5) if f"AI/Regelwerk/a{i}.md" not in dateien]
pruefe(
f"reihenfolge {position}",
code == 0 and not fehlt,
f"Exit {code}, fehlend im Remote: {fehlt or 'nichts'}",
)
# --- Fehlende Voraussetzungen sind Befunde ----------------------
vault3, remote3 = baue_vault(basis / "ohne-token")
(vault3 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
code, aus, _ = lauf_hook(vault3, {"tool_input": {"file_path": str(vault3 / "CLAUDE.md")}}, {"GITEA_TOKEN": ""})
pruefe(
"kein token -> befund",
code == 0 and "GITEA_TOKEN" in aus and remote_commits(remote3) == 1,
f"Exit {code}, meldet: {'ja' if 'GITEA_TOKEN' in aus else 'NEIN'}",
)
vault4, remote4 = baue_vault(basis / "ohne-askpass")
(vault4 / ".vault-tools" / "auto-push" / "askpass.sh").unlink()
(vault4 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
code, aus, _ = lauf_hook(vault4, {"tool_input": {"file_path": str(vault4 / "CLAUDE.md")}})
pruefe(
"kein askpass -> befund",
code == 0 and "askpass" in aus.lower(),
f"Exit {code}, meldet: {'ja' if 'askpass' in aus.lower() else 'NEIN'}",
)
vault5, _ = baue_vault(basis / "ohne-git")
raeume(vault5 / ".git")
(vault5 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
code, aus, _ = lauf_hook(vault5, {"tool_input": {"file_path": str(vault5 / "CLAUDE.md")}})
pruefe(
"kein repo -> befund",
code == 0 and "Git-Repo" in aus,
f"Exit {code}, meldet: {'ja' if 'Git-Repo' in aus else 'NEIN'}",
)
vault6, remote6 = baue_vault(basis / "remote-weg")
raeume(remote6)
(vault6 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
code, aus, _ = lauf_hook(vault6, {"tool_input": {"file_path": str(vault6 / "CLAUDE.md")}})
lokal = git(vault6, "rev-list", "--count", "HEAD").stdout.decode().strip()
pruefe(
"push scheitert -> befund, commit bleibt",
code == 0 and "push" in aus.lower() and lokal == "2",
f"Exit {code}, lokale Commits {lokal}, meldet: {'ja' if 'push' in aus.lower() else 'NEIN'}",
)
# --- Belegte Sperre bleibt still --------------------------------
vault7, remote7 = baue_vault(basis / "sperre")
sperre = vault7 / ".vault-tools" / "auto-push" / ".sperre"
sperre.write_bytes(b"")
(vault7 / "CLAUDE.md").write_text("# geaendert\n", encoding="utf-8")
vorher = remote_commits(remote7)
code, aus, _ = lauf_hook(vault7, {"tool_input": {"file_path": str(vault7 / "CLAUDE.md")}})
pruefe(
"belegte sperre -> still",
code == 0 and not aus.strip() and remote_commits(remote7) == vorher,
f"Exit {code}, Ausgabe {len(aus)} Z.",
)
print()
print(f"{bestanden} von {gesamt} Faellen bestanden.")
return 0 if bestanden == gesamt else 1
finally:
shutil.rmtree(basis, ignore_errors=True)
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())
+102
View File
@@ -0,0 +1,102 @@
"""Täglicher Lauf: committet und pusht die Anweisungsschicht, für die Windows-Aufgabenplanung.
Ersteller: Pascal Michel
Erstellt: 2026-09-17
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
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.
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.
Exit 0: gepusht oder nichts zu tun. Exit 1: Befund, 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_hook():
spec = importlib.util.spec_from_file_location("auto_push", HIER / "auto_push.py")
if spec is None or spec.loader is None:
raise RuntimeError(f"auto_push.py nicht ladbar: {HIER / 'auto_push.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 main() -> int:
try:
hook = lade_hook()
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:
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
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:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())