Automatischer Push: 46 Datei(en) geändert

- .claude/agents/planer.md
- .claude/skills/plan-executer-pruefung/SKILL.md
- .claude/workflows/plankritik.js
- .claude/workflows/planung.js
- .claude/workflows/pruefung.js
- .claude/workflows/verbrauch.js
- .claude/workflows/welle-mit-pruefung.js
- .vault-tools/plan-kopie/LIESMICH.md
- .vault-tools/plan-kopie/plan_zeichen.py
- .vault-tools/plan-kopie/rot_nachweis.py
- .vault-tools/trading-plattform-pruefung/LIESMICH.md
- .vault-tools/trading-plattform-pruefung/abendkontrolle.py
- … und 34 weitere

Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-09-25 04:00:02 +02:00
1 parent 67d674cd0b
commit ea278dc24f
46 files changed
+8403 -80

No files matched your search

+4 -2
View File
@@ -5,7 +5,7 @@ title: "Planwerkzeuge: Plan auf eine Kopie anwenden und jede Welle dagegen vergl
summary: "Skripte für die Standardschleife bei Plänen mit ausgeschriebenem Code — schlanke Kopie bauen, Plan auf die Kopie anwenden, je Welle Menge und Bytes vergleichen, Aus-wird-Blöcke prüfen, Codeblöcke auf unsichtbare Zeichen absuchen, Escape-Folgen per Codepunkt setzen. Entstanden 2026-09-17 in der Trading Plattform (Teil 3b)."
tags: [vault-tools, codeworkflow]
created: 2026-09-17
updated: 2026-09-20
updated: 2026-09-24
---
# Planwerkzeuge für die Kopie mit angewendetem Plan
@@ -21,12 +21,14 @@ Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]] („Die Ko
5. **Nach jeder Welle:** `welle_vergleich.py --projekt <projekt> --mat <kopie> [--prosa <rel> …] -- <taskdateien der Welle>`. Prüft, ob genau die Zieldateien geändert sind und ob jede byte-gleich mit der Kopie ist. Es zeigt je Datei nur die erste Abweichung — danach `diff` auf die ganze Datei. Eigene Plankorrekturen im Baum meldet es zu Recht als „außerhalb der Zieldateien".
6. **Escape-Folgen setzen:** `plan_escape.py <datei> <zeile> <codepunkt-hex> …` ersetzt echte Zeichen in einer Zeile durch ihre Escape-Folge, ohne dass eine Schreibschicht mitredet.
7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test]`. `tests/conftest.py` gilt dabei als Produktivseite und wird zurückgesetzt; nur wenn die neue Fixture eines Tasks dort liegt, lässt `--conftest-als-test` sie stehen (Beleg unten, 2026-09-22). `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich.
7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test] [--venv <wsl-pfad>] [--frontend <ordner>] [--projektname <ordnername>]`. **Für ein anderes Projekt als die Trading Plattform braucht es die drei letzten Schalter (seit 2026-09-24):** Ihre Vorgaben sind die der Trading Plattform (`~/venvs/trading`, `dashboard`, `trading-plattform`). Jedes andere Projekt nennt seine Werte, für den Nexhorizons Hub `--venv '~/venvs/hub' --frontend frontend --projektname 'active - Nexhorizons Hub'`, das Venv in einfachen Anführungszeichen, sonst macht die Git-Bash aus `~` einen Windows-Pfad. Das Skript druckt die benutzten Werte in der ersten Zeile. Die Vorgabe `trading-plattform` passt nur auf Pläne mit Arbeitsbaum-Pfaden; ältere Trading-Pläne mit absoluten Pfaden unter `active - Trading Plattform` brauchen `--projektname 'active - Trading Plattform'`, sonst endet der Lauf mit einem `ValueError`. Weiter fest verdrahtet bleiben die Distribution `Debian`, die Ordner `tests/` und `tests/fixtures/`, die Endungen `.test.ts`/`.test.tsx` und `npx.cmd vitest`. **Vorbedingung:** Das Projekt ist im Venv nicht editierbar installiert (`pip install -e`); sonst löst der Finder von setuptools ein fehlendes Untermodul gegen das echte Projekt auf, und ein Test ohne Umsetzung läuft grün. `tests/conftest.py` gilt dabei als Produktivseite und wird zurückgesetzt; nur wenn die neue Fixture eines Tasks dort liegt, lässt `--conftest-als-test` sie stehen (Beleg unten, 2026-09-22). `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich.
## Was belegt ist
Seit 2026-09-18 nimmt `welle_vergleich.py` unter `## Zieldateien` auch Pfade relativ zum Projekt (ohne Laufwerk und ohne Wurzel), weil Pläne für einen Git-Worktree keine absoluten Pfade nennen; ein `..` darin ist ein Befund wie ein fremder absoluter Pfad. Jede Zeile braucht weiter die Klammer hinter dem Pfad (`(neu)`, `(geändert)`, `(gelöscht)`). `plan_materialisiere.py` erkennt eine Zieldatei ohne Schrägstrich nur mit den Endungen py, ts, tsx, toml, md, css und json — für `compose.yaml` in der Projektwurzel schreibt die Überschrift `./compose.yaml`. Der Treiber fährt dazu drei weitere Fälle (relative Grundlinie, relative Abweichung, `..` nach außen), zusammen 17.
Seit 2026-09-24 hat `rot_nachweis.py` die Schalter `--venv`, `--frontend` und `--projektname`; vorher waren `~/venvs/trading`, `dashboard` und `trading-plattform/` fest verdrahtet. Ein Hub-Lauf im Trading-Venv wäre für die Tasks mit `argon2-cffi` und `pyotp` rot aus einem fehlenden Paket gewesen, nicht aus der fehlenden Umsetzung. Fehlt das Venv in WSL, steht beim Task jetzt `VORAUSSETZUNG FEHLT` statt `ROT`, und die Schlusszeile zählt diese Läufe getrennt. Belegt am selben Tag: Der Vorgabe-Befehl ist byte-gleich mit der alten Fassung, falsche Schalterwerte enden mit Exit 3, und der erste echte Lauf über 38 Tasks des Hub meldete keinen grün ohne Umsetzung. Im selben Lauf zeigte sich eine zweite Lücke: Beim Zurücksetzen blieben Ordner stehen, die erst der Task angelegt hatte. Ein leeres `hub/auth/` liest Python als Namespace-Paket, und die Fehlerzeile nannte `hub.auth.passwort` statt `hub.auth`. Jetzt gehen solche Ordner mit, sobald sie leer sind; derselbe Lauf für die Tasks 7, 9 und 11 zeigt danach genau die Zeile aus dem Plan. Die unabhängige Prüfung desselben Tages fand eine dritte Lücke: Ein vergessenes `--venv` fiel still auf das Trading-Venv zurück, und Task 7 stand als `ROT … No module named 'argon2'` da. Seitdem wird eine erste Fehlerzeile, die ein Modul außerhalb des Projekts und außerhalb der Zieldateien vermisst, als `VORAUSSETZUNG FEHLT` gemeldet. Das trifft auch den Ende-zu-Ende-Task des Hub, dessen Tests `playwright` brauchen. Mit den Vorgaben lieferte das Skript für die sieben Tasks des Trading-Pakets K vor und nach allen Änderungen dieselben Zeilen. Zwei weitere Lücken desselben Tages, beide ein ROT, das nichts misst: **Ein Arbeitsordner mit Windows-Kurznamen** (`C:/Users/PASCAL~1/…`) ließ vitest dort starten, die Dateien aber über den langen Namen auflösen, und jede Oberflächen-Testdatei endete als `Cannot find module '/src/…'`. Das Skript löst den Arbeitsordner jetzt auf den langen Namen auf; danach zeigte Trading-Task 395 wieder `9 failed | 52 passed` statt `no tests`, und die Hub-Tasks 24, 25, 29 und 37 die Zeile aus dem Plan (`Error: Failed to resolve import "./kontrast" …`). Die erste `Error:`-Zeile steht seitdem mit in der Ergebniszeile, weil die `FAIL`-Zeile allein den Grund nicht zeigt. **Ein Test, der über Vite eine Datei aus `node_modules` lädt** (`?raw`), scheitert hinter der Junction mit `Denied ID`, weil Vite nichts außerhalb seines Wurzelordners ausliefert; das Skript meldet das als `VORAUSSETZUNG FEHLT`. Für solche Tasks läuft der Nachweis in einer Kopie mit echtem `node_modules` (242 MB, gut 20 Sekunden).
Seit 2026-09-22 behandelt `rot_nachweis.py` `tests/conftest.py` als Produktivseite. Vorher zählte alles unter `tests/` als Testdatei — lag die Korrektur eines Tasks selbst in `conftest.py` (Trading Plattform, Task 369: die Ersatzfunktionen der Netzsperre trugen eine andere Vorgabe als die Originale), wendete der Lauf „ohne Umsetzung" sie mit an und meldete `GRUEN pytest: 147 passed` — der eine Zustand, in dem der Nachweis nichts misst, sah aus wie ein sauberer Befund. Gemessen an derselben Basis-Kopie und demselben Task, altes gegen neues Skript: `GRUEN 147 passed` → `ROT 3 failed, 144 passed` mit der gemeinten Fehlerzeile (`assert ('', '') == ('probe-thema', '')`). Die Gegenprobe für den anderen Fall — eine neue Fixture des Tasks liegt in `conftest.py` (Task 368) — mit `--conftest-als-test` `ROT 1 failed, 11 passed` wie vor der Änderung, ohne Schalter `ROT 6 failed, 6 passed` mit `AttributeError: module 'conftest' has no attribute '_gesperrte_pfade'` als erster Fehlerzeile: rot aus dem falschen Grund, aber sichtbar, und das Skript sagt je Task, wie es `conftest.py` behandelt hat. Was es weiter nicht sieht: ob ein Task beides in `conftest.py` legt, Fixture und Korrektur — dann ist keine der beiden Einstellungen richtig, und der Nachweis läuft von Hand.
Seit 2026-09-20 quotet `rot_nachweis.py` den Arbeitsordner für den WSL-Aufruf (`shlex.quote`). Ein Temp-Pfad mit Leerzeichen — der Benutzername steckt im Scratchpad-Pfad — ließ vorher das `cd` scheitern: Alle sieben Tasks eines Plans der Trading Plattform standen als „ROT pytest: keine Summenzeile" da, ohne dass ein einziger Test lief. Nach der Korrektur derselbe Lauf mit echten Summenzeilen (`3 failed, 17 passed`, `1 error`, …). Wer „keine Summenzeile" bei **jedem** Task sieht, hat kein Rot gemessen, sondern ein kaputtes Werkzeug.
+3
View File
@@ -16,6 +16,9 @@ from pathlib import Path
def main() -> int:
# Die Windows-Konsole kodiert cp1252; ein sichtbares Zeichen wie U+2192 in der Zusammenfassung
# liess das Skript sonst mit UnicodeEncodeError abbrechen (2026-09-24, Plan AF der Trading Plattform).
sys.stdout.reconfigure(errors="backslashreplace")
plan = Path(sys.argv[1])
dateien = sorted(plan.glob("*.md"))
if not dateien:
+139 -33
View File
@@ -20,16 +20,29 @@ Geltungsbereich und Luecken:
Sammelfehler -- das ist ROT mit "fixture ... not found" in der ersten Fehlerzeile, also sichtbar.
Wer diesen Fall hat, ruft mit --conftest-als-test auf; dann gilt conftest.py wie frueher als Testdatei.
Das Skript sagt je Task, wie es conftest.py behandelt hat.
- Python-Tests laufen in WSL (Debian, ~/venvs/trading), Oberflaechentests unter Windows mit npx vitest;
dafuer setzt das Skript eine Junction auf das node_modules des Projekts und loest sie vor dem Loeschen.
- Python-Tests laufen in WSL (Debian, Venv aus --venv), Oberflaechentests unter Windows mit npx vitest im
Ordner aus --frontend; dafuer setzt das Skript eine Junction auf das node_modules des Projekts und loest
sie vor dem Loeschen. Die Vorgaben der drei Schalter sind die der Trading Plattform (~/venvs/trading,
dashboard, trading-plattform/); jedes andere Projekt nennt seine Werte (seit 2026-09-24, Nexhorizons Hub).
Fehlt das Venv in WSL oder der Frontend-Ordner in der Kopie, steht beim Task VORAUSSETZUNG FEHLT statt
ROT -- sonst saehe ein Lauf im falschen Venv aus wie ein sauberer Rot-Nachweis. Dasselbe gilt, wenn die
erste pytest-Fehlerzeile ein Modul vermisst, das weder im Projekt liegt noch vom Task angelegt wird
(vergessenes --venv, eine Abhaengigkeit, die nur der Stack hat). Das greift nur an der ersten
Fehlerzeile; ein fremdes Modul, das erst hinter einem Projektmodul fehlt, sieht das Skript nicht.
- Vorbedingung: Das Projekt ist im Venv NICHT editierbar installiert (pip install -e). Sonst loest der
Finder von setuptools ein fehlendes Untermodul gegen das echte Projekt auf, und ein Test ohne Umsetzung
liefe gruen (Pruefung 2026-09-24, setuptools editable_wheel.py).
- Erzeugte Fixtures (der Plan laesst sie mit altem Code erzeugen) holt --fixturen aus einem Ordner nach,
wenn sie nach dem Anwenden fehlen.
- Es sieht nicht, WELCHE Tests rot sein muessten; es zaehlt und zeigt die erste Fehlerzeile.
- Die Basis ist eine Kopie, die der Aufrufer VOR der ersten Welle gezogen hat; das Skript fasst das
Projekt selbst nie an.
Aufruf: python rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test]
Aufruf: python rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test] [--venv <wsl-pfad>] [--frontend <ordner>] [--projektname <ordnername>]
--node-modules nennt das node_modules des Projekts, wenn die Basis-Kopie keines traegt.
--venv nennt das Venv in WSL (Vorgabe ~/venvs/trading), --frontend den Oberflaechenordner relativ zum
Projekt (Vorgabe dashboard), --projektname den Ordnernamen, hinter dem absolute Zielpfade abgeschnitten
werden (Vorgabe trading-plattform).
--conftest-als-test laesst tests/conftest.py als Testdatei stehen (nur, wenn die neue Fixture des Tasks dort liegt).
spec.json: [{"task": "task-141-....md", "vor": ["task-...md", ...]}, ...]
"""
@@ -46,10 +59,16 @@ from pathlib import Path
HIER = Path(__file__).resolve().parent
ZIEL = re.compile(r"^- `(.+?)`")
PROJEKTNAME = "trading-plattform/"
VENV = "~/venvs/trading"
FRONTEND = "dashboard"
CONFTEST = "tests/conftest.py"
# Das Venv steht ungequotet im bash-Aufruf, damit ~ expandiert; deshalb nur diese Zeichen.
VENV_MUSTER = re.compile(r"^~?[A-Za-z0-9._/-]+\Z")
FRONTEND_MUSTER = re.compile(r"^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*\Z")
FEHLENDES_MODUL = re.compile(r"No module named '([^']+)'")
def zieldateien(taskdatei: Path) -> list[str]:
def zieldateien(taskdatei: Path, projektname: str = PROJEKTNAME) -> list[str]:
"""Zieldateien relativ zum Projekt. Absolute Pfade werden hinter dem Projektordner abgeschnitten."""
drin = False
ziele: list[str] = []
@@ -61,10 +80,10 @@ def zieldateien(taskdatei: Path) -> list[str]:
if treffer:
pfad = treffer.group(1).replace("\\", "/").removeprefix("./")
if re.match(r"^[A-Za-z]:/", pfad):
stelle = pfad.lower().find(PROJEKTNAME)
stelle = pfad.lower().find(projektname)
if stelle < 0:
raise ValueError(f"{taskdatei.name}: absoluter Pfad ausserhalb des Projekts: {pfad}")
pfad = pfad[stelle + len(PROJEKTNAME) :]
pfad = pfad[stelle + len(projektname) :]
ziele.append(pfad)
return ziele
@@ -92,13 +111,57 @@ def wsl_pfad(pfad: Path) -> str:
return f"/mnt/{text[0].lower()}{text[2:]}"
def loesche(ordner: Path) -> None:
junction = ordner / "dashboard" / "node_modules"
def loesche(ordner: Path, frontend: str = FRONTEND) -> None:
junction = ordner / frontend / "node_modules"
if junction.exists():
os.rmdir(junction) # loest nur die Verknuepfung, nie das Ziel
shutil.rmtree(ordner, ignore_errors=True)
def pytest_befehl(rot: Path, python_tests: list[str], venv: str = VENV) -> str:
# shlex.quote: Ein Arbeitsordner mit Leerzeichen (Benutzername im Temp-Pfad) liess das cd
# scheitern; jeder Task stand dann als "ROT pytest: keine Summenzeile" da, ohne dass ein Test
# lief (gefunden 2026-09-20).
return f"cd {shlex.quote(wsl_pfad(rot))} && {venv}/bin/python -m pytest {' '.join(python_tests)} -q -p no:cacheprovider 2>&1 | tail -60"
def venv_da(venv: str) -> bool:
lauf = subprocess.run(["wsl.exe", "-d", "Debian", "--", "bash", "-lc", f"test -x {venv}/bin/python && echo VENV_DA"],
capture_output=True, text=True, encoding="utf-8", errors="replace")
return "VENV_DA" in lauf.stdout
def fremdes_modul(fehlerzeile: str, ziele: list[str], kopie: Path) -> str | None:
"""Das fehlende Modul, wenn es weder im Projekt liegt noch vom Task angelegt wird.
Ein solcher Fehler misst das Venv, nicht die fehlende Umsetzung: ein vergessenes --venv
(No module named 'argon2' im Trading-Venv) oder eine Abhaengigkeit, die nur der Stack hat
(playwright). Gefunden 2026-09-24 in der Pruefung des Werkzeugs.
"""
treffer = FEHLENDES_MODUL.search(fehlerzeile)
if not treffer:
return None
oben = treffer.group(1).split(".")[0]
eigene = set()
for ziel in ziele:
eigene.update(Path(ziel).parts[:-1])
eigene.add(Path(ziel).stem)
if oben in eigene or any(kopie.rglob(f"{oben}.py")) or any(p.is_dir() for p in kopie.rglob(oben)):
return None
return treffer.group(1)
def nimm_wert(argumente: list[str], schalter: str) -> str | None:
if schalter not in argumente:
return None
stelle = argumente.index(schalter)
if stelle + 1 >= len(argumente) or argumente[stelle + 1].startswith("--"):
raise ValueError(f"{schalter} ohne Wert")
wert = argumente[stelle + 1]
del argumente[stelle : stelle + 2]
return wert
def erste_fehlerzeile(ausgabe: str) -> str:
for zeile in ausgabe.splitlines():
kern = zeile.strip()
@@ -109,16 +172,23 @@ def erste_fehlerzeile(ausgabe: str) -> str:
def main() -> int:
argumente = sys.argv[1:]
fixturen = None
if "--fixturen" in argumente:
stelle = argumente.index("--fixturen")
fixturen = Path(argumente[stelle + 1])
del argumente[stelle : stelle + 2]
node_modules_arg = None
if "--node-modules" in argumente:
stelle = argumente.index("--node-modules")
node_modules_arg = Path(argumente[stelle + 1])
del argumente[stelle : stelle + 2]
try:
fixturen_arg = nimm_wert(argumente, "--fixturen")
node_modules_wert = nimm_wert(argumente, "--node-modules")
venv = nimm_wert(argumente, "--venv") or VENV
frontend = (nimm_wert(argumente, "--frontend") or FRONTEND).replace("\\", "/").strip("/")
projektname = (nimm_wert(argumente, "--projektname") or PROJEKTNAME).replace("\\", "/").strip("/").lower() + "/"
except ValueError as fehler:
print(f"AUFRUF FALSCH: {fehler}")
return 3
fixturen = Path(fixturen_arg) if fixturen_arg else None
node_modules_arg = Path(node_modules_wert) if node_modules_wert else None
if not VENV_MUSTER.match(venv):
print(f"AUFRUF FALSCH: --venv {venv!r} -- erlaubt sind nur ~, Buchstaben, Ziffern und ._/-")
return 3
if not FRONTEND_MUSTER.match(frontend) or ".." in frontend.split("/"):
print(f"AUFRUF FALSCH: --frontend {frontend!r} -- ein Ordner relativ zum Projekt, ohne ..")
return 3
conftest_als_test = False
if "--conftest-als-test" in argumente:
conftest_als_test = True
@@ -126,23 +196,30 @@ def main() -> int:
if len(argumente) != 4:
print(__doc__)
return 3
print(f"Einstellungen: venv={venv} frontend={frontend} projektname={projektname}")
basis, planordner, arbeit, spec_pfad = (Path(a) for a in argumente)
if not basis.is_dir() or not planordner.is_dir() or not spec_pfad.is_file():
print("VORAUSSETZUNG FEHLT: Basis-Kopie, Planordner oder Spec")
return 3
arbeit.mkdir(parents=True, exist_ok=True)
# Den langen Namen erzwingen: Kommt der Arbeitsordner als Windows-Kurzname (PASCAL~1), startet
# vitest dort, loest die Dateien aber ueber den langen Namen auf und meldet "Cannot find module"
# fuer die eigene Testdatei -- ein ROT, das nichts misst (2026-09-24, Hub-Tasks 24, 25, 29, 37
# und Trading-Task 395).
arbeit = arbeit.resolve()
node_modules = None
for kandidat in (node_modules_arg, basis / "dashboard" / "node_modules"):
for kandidat in (node_modules_arg, basis / frontend / "node_modules"):
if kandidat is not None and kandidat.exists():
node_modules = kandidat
break
venv_geprueft: bool | None = None # erst pruefen, wenn ein Task Python-Tests hat
ergebnisse = []
for eintrag in json.loads(spec_pfad.read_text(encoding="utf-8")):
task, voraus = eintrag["task"], eintrag.get("vor", [])
name = task.split("-")[1] if "-" in task else task
vor, rot = arbeit / f"{name}_vor", arbeit / f"{name}_rot"
for ordner in (vor, rot):
loesche(ordner)
loesche(ordner, frontend)
shutil.copytree(basis, vor, ignore=shutil.ignore_patterns("node_modules", "__pycache__"))
fehler = wende_an(planordner, vor, voraus)
shutil.copytree(vor, rot)
@@ -151,7 +228,7 @@ def main() -> int:
ergebnisse.append((task, "ANWENDEN GESCHEITERT", fehler.replace("\n", " | ")))
print(f"{task}: ANWENDEN GESCHEITERT: {fehler[-300:]}")
continue
ziele = zieldateien(planordner / task)
ziele = zieldateien(planordner / task, projektname)
tests = [z for z in ziele if ist_test(z, conftest_als_test) and not z.startswith("tests/fixtures/")]
if CONFTEST in ziele:
# Sichtbar machen, was mit conftest.py geschah -- der Koordinator liest die erste Fehlerzeile
@@ -168,14 +245,22 @@ def main() -> int:
shutil.copyfile(vor / ziel, rot / ziel)
elif (rot / ziel).is_file():
(rot / ziel).unlink()
# Ordner, die erst der Task angelegt hat, gehen mit, sobald sie leer sind: Ein leeres
# hub/auth/ liest Python als Namespace-Paket, und die Fehlerzeile nannte dann das
# Untermodul statt des fehlenden Pakets (2026-09-24, Nexhorizons Hub, Tasks 7, 9, 11).
ordner = (rot / ziel).parent
while ordner != rot and not (vor / ordner.relative_to(rot)).exists() and not any(ordner.iterdir()):
ordner.rmdir()
ordner = ordner.parent
python_tests = [t for t in tests if t.endswith(".py")]
ober_tests = [t for t in tests if not t.endswith(".py")]
zeilen = []
if python_tests:
# shlex.quote: Ein Arbeitsordner mit Leerzeichen (Benutzername im Temp-Pfad) liess das cd
# scheitern; jeder Task stand dann als "ROT pytest: keine Summenzeile" da, ohne dass ein Test
# lief (gefunden 2026-09-20).
befehl = f"cd {shlex.quote(wsl_pfad(rot))} && ~/venvs/trading/bin/python -m pytest {' '.join(python_tests)} -q -p no:cacheprovider 2>&1 | tail -60"
if python_tests and venv_geprueft is None:
venv_geprueft = venv_da(venv)
if python_tests and not venv_geprueft:
zeilen.append(f"VORAUSSETZUNG FEHLT: {venv}/bin/python ist in WSL nicht ausfuehrbar")
elif python_tests:
befehl = pytest_befehl(rot, python_tests, venv)
lauf = subprocess.run(["wsl.exe", "-d", "Debian", "--", "bash", "-lc", befehl],
capture_output=True, text=True, encoding="utf-8", errors="replace")
ausgabe = lauf.stdout
@@ -184,15 +269,22 @@ def main() -> int:
# nicht gesammelt (etwa ein ImportError in tests/conftest.py) -- das ist rot, nicht gruen.
# Gefunden am 2026-09-18: Task 172 stand als GRUEN da, obwohl kein einziger Test lief.
rot_ja = bool(re.search(r"\d+ (failed|error)", summe)) or summe == "keine Summenzeile"
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" pytest: {summe} | {erste_fehlerzeile(ausgabe)}")
erste = erste_fehlerzeile(ausgabe)
fremd = fremdes_modul(erste, ziele, rot) if rot_ja else None
if fremd:
zeilen.append(f"VORAUSSETZUNG FEHLT: Modul {fremd!r} liegt weder im Projekt noch im Venv {venv} | pytest: {summe} | {erste}")
else:
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" pytest: {summe} | {erste}")
if ober_tests:
if node_modules is None:
zeilen.append("VORAUSSETZUNG FEHLT: node_modules in der Basis-Kopie")
elif not (rot / frontend).is_dir():
zeilen.append(f"VORAUSSETZUNG FEHLT: Frontend-Ordner {frontend} fehlt in der Kopie")
else:
import _winapi
_winapi.CreateJunction(str(node_modules.resolve()), str(rot / "dashboard" / "node_modules"))
relativ = [t.removeprefix("dashboard/") for t in ober_tests]
lauf = subprocess.run(["npx.cmd", "vitest", "run", *relativ], cwd=rot / "dashboard",
_winapi.CreateJunction(str(node_modules.resolve()), str(rot / frontend / "node_modules"))
relativ = [t.removeprefix(f"{frontend}/") for t in ober_tests]
lauf = subprocess.run(["npx.cmd", "vitest", "run", *relativ], cwd=rot / frontend,
capture_output=True, text=True, encoding="utf-8", errors="replace")
ausgabe = re.sub(r"\x1b\[[0-9;]*m", "", lauf.stdout + lauf.stderr)
summe = next((z.strip() for z in ausgabe.splitlines() if z.strip().startswith("Tests ")), "")
@@ -201,16 +293,30 @@ def main() -> int:
# das Ende der Ausgabe zeigen, statt "rot" zu behaupten, ohne zu wissen woran.
summe = "keine Summenzeile; Ende der Ausgabe: " + " / ".join(z.strip() for z in ausgabe.splitlines()[-6:] if z.strip())[:300]
rot_ja = "failed" in summe or lauf.returncode != 0
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" vitest: {summe} | {erste_fehlerzeile(ausgabe)}")
if "Denied ID" in ausgabe:
# Vite liefert keine Datei ausserhalb seines Wurzelordners, und durch die Junction
# liegt node_modules ausserhalb der Kopie: Ein Test mit "?raw" aus node_modules
# scheitert dann als ganze Datei (2026-09-24, Nexhorizons Hub, Task 42).
zeilen.append(f"VORAUSSETZUNG FEHLT: Vite verweigert eine Datei hinter der Junction auf node_modules; mit echter Kopie von node_modules fahren | vitest: {summe}")
else:
# Bei einer Datei, die schon am Import scheitert, steht vor der Fehlermeldung nur
# die FAIL-Zeile; die Meldung selbst zeigt erst, ob der Grund der gemeinte ist.
erste = erste_fehlerzeile(ausgabe)
meldung = next((z.strip()[:220] for z in ausgabe.splitlines()
if z.strip().startswith(("Error:", "AssertionError", "TypeError", "ReferenceError", "SyntaxError"))), "")
zusatz = f" | {meldung}" if meldung and meldung not in erste else ""
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" vitest: {summe} | {erste}{zusatz}")
if not tests:
zeilen.append("ohne Testdatei unter den Zieldateien")
for zeile in zeilen:
print(f"{task}: {zeile}")
ergebnisse.append((task, zeile[:5].strip(), zeile))
for ordner in (vor, rot):
loesche(ordner)
loesche(ordner, frontend)
gruen = [e for e in ergebnisse if e[1] == "GRUEN"]
print(f"\n{len(ergebnisse)} Laeufe, davon {len(gruen)} gruen ohne Umsetzung.")
ohne_lauf = [e for e in ergebnisse if e[1] in ("VORAU", "ANWENDEN GESCHEITERT")]
print(f"\n{len(ergebnisse)} Laeufe, davon {len(gruen)} gruen ohne Umsetzung und {len(ohne_lauf)} ohne Lauf "
"(Voraussetzung fehlt oder Anwenden gescheitert).")
return 0
@@ -5,7 +5,7 @@ title: "Trading Plattform: Prüfwerkzeuge des Koordinators fürs Dashboard"
summary: "Eigener Lauf mit echten Daten zum Dashboard der Trading Plattform — Vergleich der Endpunkte gegen Vollparse und pandas, zwei WebSocket-Verbindungen, Browser-Lauf und Wiederaufbau mit Playwright, gefahrlose Proben der zwei Skripte, Mutantenläufe. Entstanden 2026-09-17 (Teil 3b), erweitert 2026-09-18 (Teil 10: Anmeldung, Angriffsfälle, Beobachtung, Kerzenlatenz), bewusst nicht im Projekt-Repo."
tags: [vault-tools, trading-plattform]
created: 2026-09-17
updated: 2026-09-21
updated: 2026-09-24
---
# Prüfwerkzeuge fürs Dashboard der Trading Plattform
@@ -103,3 +103,43 @@ Die Werkzeuge nennen `config/secrets.toml` nicht mehr fest. Wer eine Projektklas
|---|---|---|
| `handelstag_auswertung.py` | seit Paket M mit mehreren Instrumenten: Instrumente aus dem Protokollkopf; je Instrument Entscheidungen, Orders und die aus der Folge erwartete Zahl (Stufen, Versatz, Entscheidungen, Schlussbein, ohne geteilte Beine), erreichte Positionen und Takte mit frischer Kerze; groesste Zahl von Orders in einem Takt, Ereignisse je Art, Positionen je Instrument am Ende, BEFUND fuer jede nicht gefuellte Orderzeile; das Taktfeld orders gilt als Orders dieses Takts; `--broker` und `--kerzen` fragen alle Symbole des Kopfs, jede Order gegen die Kerze ihres Symbols | wie bisher: `python handelstag_auswertung.py <protokoll> [--broker] [--kerzen]` aus dem Projektordner |
| `m_probeaufruf.py` | lesender Probeaufruf vor dem Einspielen: `GET /v2/assets` roh (Schluessel und Typen) und ueber `AlpacaBroker.anlage`, `trades/latest` mit allen Symbolen roh und ueber `letzte_kurse`, Kerzen gebuendelt gegen einzeln, Seitenverhalten mit kleiner Seite; Exit 0 nur, wenn alles wie angenommen ist und keine Aktie die Startpruefung einer Folge mit Short verfehlt | aus einem Arbeitsbaum mit Paket M in WSL: `python m_probeaufruf.py <handelstag> <kennung> ...` |
## Betrieb ohne täglichen Klick (2026-09-24): Abendkontrolle und Rückweg
Pascal am 2026-09-24: eine dauerhafte Abendkontrolle, die Neustarts übersteht und keine Updates von Claude Code oder Desktop blockiert, und zehn Aktien bis Mittwoch 2026-09-30. Beides läuft als Windows-Aufgabe mit `pythonw.exe`, ohne Claude-Prozess.
| Datei | Was sie tut | Aufruf |
|---|---|---|
| `abendkontrolle_wsl.py` | liest einen Handelstag und urteilt im Code: Handelstag laut `GET /v2/calendar`, Protokoll des scharfen Runners, letzte Zeile „Ende: …, Exit n" im Journal (jeder Exit ungleich 0 ist ein Befund), BEFUND-Zeilen von `handelstag_auswertung.py --broker --kerzen`, offene Positionen beim Broker, mindestens ein Takt `aktiv`, Tagesbericht, Dashboard und Zeitgeber, Killswitch nicht `gesperrt`; bei Befund eine Meldung mit hoher Priorität über den Melder des Projekts. Gibt eine JSON-Zeile aus, nie Beträge | in WSL: `python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung]`; nur eine Meldung: `--meldung TITEL TEXT` |
| `abendkontrolle.py` | Windows-Seite: prüft jeden Werktag seit 2026-09-24 ohne Ergebnisdatei (höchstens sieben Tage zurück, heute erst ab 22:30), legt das Ergebnis unter `data/paper/abendkontrollen/<tag>.json` im Projekt ab und schreibt eine Zeile in `Inbox/<tag>.md` unter „Agentenprotokoll", bei Befund mit `#needs-review` | `python abendkontrolle.py [--tag JJJJ-MM-TT]` |
| `abendkontrolle_einrichten.ps1` | Aufgabe „Trading Plattform - Abendkontrolle": werktags 22:45 und fünf Minuten nach jeder Anmeldung, nur angemeldet, verpasste Läufe beim nächsten Start | `powershell -NoProfile -File abendkontrolle_einrichten.ps1 [-Aktion entfernen]` |
| `abendkontrolle_angriffe.py`, `abendkontrolle_ersatz_auswertung.py` | 44 Fälle in WSL (Kalender, Protokoll, Ende je Lauf samt mittags abgebrochenem und abendlichem Lauf, Journal, Auswertung, Broker, Maske, Bericht, Dienste, Killswitch, getrennte Fragen, Meldeweg gegen `ntfy_probe_server.py` und gegen einen toten Port, Bedienfehler; Reihenfolge an zwei Positionen) und 20 unter Windows (Notiz, Überschrift mit Zusatz, CRLF, kein UTF-8, Ergebnisform, Abbruch wird wiederholt, keine Nachbardatei, fällige Tage), ohne Netz. Am 2026-09-24 nach der unabhängigen Prüfung: 0 Abweichungen | `python abendkontrolle_angriffe.py` einmal in WSL (Venv), einmal unter Windows |
| `rueckweg_aapl.py`, `rueckweg_aapl_einrichten.ps1` | einmal: `config/paper-aapl-register.toml` wird `config/paper.toml`, lokal committet (nicht gepusht) — nur nach 2026-09-30 22:05, bei stehendem Runner, sauberem Projektordner mit `master`, wenn dort noch die Konfiguration mit `quelle = "folge"` liegt und beide Dateien ausser `[agent]`, den Instrumenten und dem Tagesverlust dieselben Schluessel und Werte tragen, in beide Richtungen verglichen (sonst schaltete der Tausch etwa ein abgeschaltetes `[auto_fortsetzen]` still wieder ein); ein halb erledigter Tausch wird zu Ende committet. Sonst Befund in der Notiz und eine Meldung. Aufgabe „Trading Plattform - Rueckweg AAPL" am 2026-09-30 22:50, zweiter Versuch 2026-10-01 22:10 | `python rueckweg_aapl.py`; Einrichten wie oben |
| `rueckweg_aapl_angriffe.py` | 22 Fälle auf einem Wegwerf-Git-Repo (dazu halb getauscht, Abschnitt nur in `paper.toml`, Automatik nur dort abgeschaltet, Abschnitt nur dort gelöscht, anderer Wert, anderer Zweig); am 2026-09-24 0 Abweichungen | `python rueckweg_aapl_angriffe.py` unter Windows |
| `abendkontrolle_mutanten.py` | baut je einen Fehler in eine Wegwerfkopie und verlangt, dass der Angriffstreiber rot wird: 16 Mutanten von `abendkontrolle_wsl.py` (in WSL), 9 von `abendkontrolle.py` und 12 von `rueckweg_aapl.py` (unter Windows). Am 2026-09-24: 37 von 37 gefangen | `python abendkontrolle_mutanten.py` einmal in WSL, einmal unter Windows |
## Einspielen (2026-09-24): Werkzeuge aus dem Scratchpad hierher
| Datei | Was sie tut | Aufruf |
|---|---|---|
| `dashboard_neustart.sh` | `sudo systemctl restart trading-dashboard.service` nur bei stehendem Runner, danach Tor 6b (Journal der letzten Minute ohne `PermissionError`/`Traceback`, sonst Exit 27), `/api/gesund` und die Oberfläche. Pascals dauerhafte Freigabe gilt für genau diesen Neustart außerhalb der Handelszeit | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/dashboard_neustart.sh` |
| `geheimnis_scan.py` | vor jedem Push: Werte ab 12 Zeichen aus Geheimnis- und Zugangsdatei gegen `git log -p <von>..<bis>`, nur Schlüsselnamen und Trefferzahlen; dazu, ob eine der drei Geheimnisdateien verfolgt wird. Regressionsnachweis mit `SCAN_PROBE=<bekannter Text>` (am 2026-09-24: 66 Treffer für einen bekannten Text, 0 für die echten Werte) | in WSL: `python geheimnis_scan.py <repo> <von> <bis> [<bis> …]` |
## IBKR-Zugang (2026-09-24): Paper-Konto über das Gateway im Container
Pascal am 2026-09-24: Zugang zum IBKR-Paper-Konto vorbereiten, Gateway als Container in WSL, erster Test nach 22:00. Nichts davon handelt.
| Datei | Was sie tut | Aufruf |
|---|---|---|
| `ibkr_zugang_eintragen.sh` | Pascal trägt Paper-Benutzername, Passwort (ohne Anzeige, zweimal) und Kontonummer ein; Ablage `/home/claude/.config/trading-plattform/ibkr/` (0700/0600); ausgegeben werden nur Längen. Mit erfundenen Werten in einem Wegwerf-Ordner geprüft (`IBKR_ZIEL`) | `wsl -d Debian -u claude -- bash …/ibkr_zugang_eintragen.sh` |
| `ibkr_gateway_test.sh` | Gateway-Container `ghcr.io/gnzsnz/ib-gateway` (auf eine Prüfsumme festgelegt), Paper, `READ_ONLY_API=yes`, Port nur auf `127.0.0.1:4004`; Schritte `laden`, `pruefen`, `starten`, `log` (Benutzername maskiert), `probe`, `stoppen`; verweigert zwischen 15 und 22 Uhr Berlin | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/ibkr_gateway_test.sh <schritt>` |
| `ibkr_probe.py` | lesende Probe über `nautilus-ibapi` im eigenen Venv `~/venvs/ibkr-probe`: Verbindung, Konto (nur Anfang und Länge), Kontoart, Währung, Positionen (Zahl), verzögerter AAPL-Kurs (nur die Kursart), Fehlercodes; nie ein Betrag. Ohne Gateway geprüft: „keine Verbindung", Code 502, Exit 3 | `~/venvs/ibkr-probe/bin/python …/ibkr_probe.py [--port 4004]` |
## Paket AF (2026-09-24): Fortsetzen zum Tagesstart ohne Klick
| Datei | Was sie tut | Aufruf |
|---|---|---|
| `af_gegenfall.py` | faehrt die echte PaperSitzung eines Arbeitsbaums mit AF und der ausgelieferten `config/paper.toml` gegen einen ProbeBroker, Kerzen aus einer Datei von `hole_kerzen.py` fuer jede Aktie, Wegwerf-Register mit der Neustartregel, ohne Netz; der Fall `restposition` (10 Stueck in der letzten Aktie) und der Fall `mitten` (Start 60 Marktminuten nach der Oeffnung) muessen `nicht_fortgesetzt` mit dem Grund in Protokoll und Meldung zeigen, der Fall `flach` ist die Gegenprobe mit `fortgesetzt`, Ausloeser `automatik` und Orders; Exit 0 nur ohne BEFUND | aus dem Arbeitsbaum in WSL: `python af_gegenfall.py <kerzen.json> <fall>`, Fall `restposition`, `mitten` oder `flach`; die Kerzen vorher einmal lesend mit `hole_kerzen.py <tag> <kerzen.json>` |
| `af_registerprobe.py` | fragt die echte Funktion `pruefe_register` (Paket AF) gegen `data/registry.db`, nur lesend (`mode=ro`; die Klasse `Registry` fuehrte beim Oeffnen das Schema aus und schriebe); druckt Zustand, die vier juengsten Killswitch-Ereignisse (Stufe, Ausloeser, Zeit) und den Befund; Exit 0 erfuellt, 1 nicht erfuellt, 3 Register fehlt. Belegt am 2026-09-24 an drei gespielten Registern (bereit / Pascals Anhalten ohne Fortsetzen / aktiv) und an einer Kopie des echten; die mtime des echten blieb gleich. Braucht den Code mit AF im genannten Ordner | in WSL im Projektordner nach dem Dashboard-Neustart: `~/venvs/trading/bin/python af_registerprobe.py .` |
Was die uebrigen Werkzeuge mit AF sehen: `handelstag_auswertung.py` braucht keine Aenderung, es zaehlt das Ereignis `auto_fortsetzen` unter `ereignisse je art` und druckt seinen Text. `abendkontrolle_wsl.py` liest Zustand, Seit und Ausloeser des Killswitch und berichtet jede Zeile `auto_fortsetzen` als Hinweis (die ersten 200 Zeichen des Feldes text, das mit dem Ergebnis beginnt). `neustart_echtlauf.py` setzt `angehalten`/`neustart` ohne Ereignis, `pause_echtlauf.py` einen Ausloeser `echtlauf`: Die Sitzung setzt dort nicht fort, schreibt aber je Lauf eine Zeile `auto_fortsetzen` und in "Paper-Runner wartet" eine Zeile mehr. `generalprobe_echtlauf.py` und `killswitch_simulation.py` laden `config/paper.toml` und klicken nach Plan; gegen einen Baum mit AF setzt die Sitzung dort mit dem Schalter selbst fort, bevor der geplante Klick kommt. Wer diese vier fuer einen Vergleich mit Paket M faehrt, setzt vorher `auto_fortsetzen=False` in ihr `replace(lade_paper_config(...), ...)`; `neustart_echtlauf.py` und `pause_echtlauf.py` rufen `lade_paper_config` ohne `replace` auf — dort in der Konfiguration ihrer Kopie `[auto_fortsetzen] aktiv = false` setzen.
@@ -0,0 +1,215 @@
"""Startet die Abendkontrolle des Paper-Betriebs und schreibt ihr Ergebnis in die Tagesnotiz.
Koordinator-Werkzeug, nicht fuers Repo. Laeuft unter Windows mit pythonw.exe als geplante Aufgabe
"Trading Plattform - Abendkontrolle" (abendkontrolle_einrichten.ps1): werktags 22:45 und fuenf Minuten
nach jeder Anmeldung. Auftrag Pascal 2026-09-24: dauerhaft, uebersteht Neustarts, blockiert keine
Updates -- deshalb eine Windows-Aufgabe (bleibt ueber Neustarts, holt verpasste Laeufe nach) und kein
Claude-Prozess (haelt keine Datei von Claude Code oder Desktop offen).
Ablauf: Jeder Werktag seit ERSTER_TAG, hoechstens NACHHOLEN_TAGE zurueck, ohne Ergebnisdatei in
ABLAGE wird geprueft; der heutige erst ab FRUEHESTENS (der Bericht der Tagesauswertung kommt 22:20).
Je Tag: abendkontrolle_wsl.py in WSL (liest nur, meldet bei Befund selbst ueber ntfy), dann zuerst das
Ergebnis als JSON nach ABLAGE und danach eine Zeile in Inbox/<tag>.md unter der ersten Ueberschrift,
die mit "## Agentenprotokoll" beginnt.
- Liefert der WSL-Teil kein Ergebnis oder endet er mit Exit 2 (abgebrochen), bleibt die Ergebnisdatei
aus, die Notiz bekommt einen Befund mit #needs-review, und der naechste Lauf versucht den Tag erneut.
- Scheitert nur die Notiz (etwa eine Datei, die kein UTF-8 ist), bleibt es bei der Ergebnisdatei und
einer Zeile in lauf.log: Die Kontrolle ist gelaufen und hat ihre Meldung schon geschickt; ein zweiter
Lauf schickte sie nur noch einmal.
- Ein Tag, der laenger als NACHHOLEN_TAGE scheitert, faellt aus dem Fenster (lauf.log behaelt ihn).
Die Notiz wird ueber eine Nachbardatei und os.replace geschrieben: Ein Abbruch mitten im Schreiben
hinterlaesst die alte Datei, nicht eine halbe. Gegen eine Claude-Sitzung, die dieselbe Notiz im selben
Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millisekunden) -- benannt.
Annahme, benannt: Die Uhr dieses Rechners geht nach Berliner Zeit (tzutil: W. Europe Standard Time,
gelesen am 2026-09-24); ohne das Paket tzdata kennt Python unter Windows keine Zeitzonen-Datenbank.
Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl <json-datei>]
"""
import argparse
import json
import os
import subprocess
import sys
import traceback
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from pathlib import Path
VAULT = Path(r"D:\Claude")
PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform"
ABLAGE_TEIL = PROJEKT_TEIL / "data" / "paper" / "abendkontrollen"
WSL = r"C:\Windows\System32\wsl.exe"
WSL_PY = "/home/claude/venvs/trading/bin/python"
WSL_SKRIPT = "/mnt/d/Claude/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py"
ERSTER_TAG = date(2026, 9, 24)
NACHHOLEN_TAGE = 7
FRUEHESTENS = time(22, 30)
WSL_TIMEOUT_S = 1200
KEIN_FENSTER = 0x08000000 # CREATE_NO_WINDOW: pythonw hat keine Konsole, wsl.exe soll keine oeffnen
UEBERSCHRIFT = "## Agentenprotokoll"
def faellige_tage(jetzt: datetime, ablage: Path) -> list[str]:
tage = []
for zurueck in range(NACHHOLEN_TAGE, -1, -1):
tag = jetzt.date() - timedelta(days=zurueck)
if tag < ERSTER_TAG or tag.weekday() >= 5:
continue
if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:
continue
if (ablage / f"{tag.isoformat()}.json").exists():
continue
tage.append(tag.isoformat())
return tage
def fahre_wsl(tag: str) -> dict:
lauf = subprocess.run(
[WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--tag", tag],
capture_output=True, timeout=WSL_TIMEOUT_S, creationflags=KEIN_FENSTER,
)
ausgabe = lauf.stdout.decode("utf-8", errors="replace")
for zeile in reversed(ausgabe.splitlines()):
zeile = zeile.strip()
if zeile.startswith("{"):
try:
return json.loads(zeile)
except json.JSONDecodeError:
break
fehler = lauf.stderr.decode("utf-8", errors="replace").strip().splitlines()[-3:]
raise RuntimeError(f"keine JSON-Zeile vom WSL-Teil (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})")
def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str:
kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)"
befunde = ergebnis.get("befunde") or []
fakten = ergebnis.get("fakten") or {}
hinweise = ergebnis.get("hinweise") or []
if fakten.get("handelstag") is False and not befunde:
return f"{kopf}: kein Handelstag laut Alpaca-Kalender."
teile = []
ende = fakten.get("ende") or {}
if ende:
teile.append(
f"Ende {ende.get('grund')}, Exit {ende.get('exit')}, Orders {ende.get('orders')}, "
f"Position {ende.get('position')}"
)
laeufe = fakten.get("laeufe") or []
if laeufe:
teile.append(f"{len(laeufe)} Lauf/Läufe, {sum(l.get('takte_aktiv', 0) for l in laeufe)} Takte aktiv")
if "broker_offen" in fakten:
teile.append(f"offene Positionen beim Broker {fakten['broker_offen']}")
if "bericht" in fakten:
teile.append("Tagesbericht da" if fakten["bericht"] else "Tagesbericht fehlt")
killswitch = fakten.get("killswitch") or {}
if killswitch:
teile.append(f"Killswitch {killswitch.get('zustand')} ({killswitch.get('ausloeser')})")
zusammen = "; ".join(teile)
if not befunde:
text = f"{kopf}: ohne Befund. {zusammen}."
else:
text = f"{kopf}: **{len(befunde)} Befund(e)** #needs-review. {zusammen}."
text += "".join(f"\n - {befund}" for befund in befunde)
if fakten.get("meldung"):
text += f"\n - Meldung ans Telefon: {fakten['meldung']}"
text += "".join(f"\n - Hinweis: {hinweis}" for hinweis in hinweise[:5])
return text
def schreibe_notiz(vault: Path, tag: str, zeile: str) -> None:
notiz = vault / "Inbox" / f"{tag}.md"
if notiz.exists():
text = notiz.read_bytes().decode("utf-8") # kein UTF-8: Ausnahme, die Notiz bleibt unberuehrt
else:
text = (
f"---\ntype: daily\ntitle: \"{tag}\"\nstatus: open\nsummary: \"Tagesnotiz vom {tag}.\"\n"
f"tags: [daily]\ncreated: {tag}\nupdated: {tag}\n---\n\n# {tag}\n"
)
# Zeilenenden der Datei bleiben, wie sie sind; gesucht wird ohne \r.
zeilen = text.split("\n")
bereinigt = [z.rstrip("\r").rstrip() for z in zeilen]
cr = "\r" if any(z.endswith("\r") for z in zeilen) else ""
# Die erste Ueberschrift, die mit "## Agentenprotokoll" beginnt ("## Agentenprotokoll — …" kommt vor).
start = next((i for i, z in enumerate(bereinigt) if z == UEBERSCHRIFT or z.startswith(UEBERSCHRIFT + " ")), None)
if start is None:
nl = cr + "\n"
text = text.rstrip("\r\n") + nl + nl + UEBERSCHRIFT + nl + nl + nl.join(zeile.split("\n")) + nl
else:
ende = next((i for i in range(start + 1, len(zeilen)) if bereinigt[i].startswith("## ")), len(zeilen))
# Hinter den letzten nicht leeren Eintrag des Abschnitts; die Leerzeilen danach ersetzt eine.
einfuegen = ende
while einfuegen > start + 1 and not bereinigt[einfuegen - 1]:
einfuegen -= 1
block = [z + cr for z in zeile.split("\n")]
if einfuegen == start + 1:
block = [cr] + block
zeilen[einfuegen:ende] = block + ([cr] if ende < len(zeilen) else [""])
text = "\n".join(zeilen)
if not text.endswith("\n"):
text += "\n"
neu = notiz.with_name(notiz.name + ".abendkontrolle.tmp")
neu.write_bytes(text.encode("utf-8"))
os.replace(neu, notiz)
def logge(ablage: Path, text: str) -> None:
ablage.mkdir(parents=True, exist_ok=True)
with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as datei:
datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n")
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(allow_abbrev=False)
zerleger.add_argument("--tag")
zerleger.add_argument("--vault", default=str(VAULT))
zerleger.add_argument("--ohne-wsl", help="Ergebnis aus einer JSON-Datei statt aus WSL (Angriffsfaelle)")
zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)")
argumente = zerleger.parse_args(argv)
vault = Path(argumente.vault)
ablage = vault / ABLAGE_TEIL
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
tage = [argumente.tag] if argumente.tag else faellige_tage(jetzt, ablage)
logge(ablage, f"start, faellig: {tage}")
code = 0
for tag in tage:
uhrzeit = jetzt.strftime("%H:%M") if tag == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}"
try:
if argumente.ohne_wsl:
ergebnis = json.loads(Path(argumente.ohne_wsl).read_text(encoding="utf-8"))
else:
ergebnis = fahre_wsl(tag)
if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list):
raise RuntimeError("Ergebnis des WSL-Teils in unerwarteter Form")
except Exception as ausnahme:
code = 1
logge(ablage, f"{tag}: gescheitert {ausnahme!r}\n{traceback.format_exc()}")
try:
schreibe_notiz(
vault, tag,
f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch): **gescheitert** "
f"({type(ausnahme).__name__}: {str(ausnahme)[:200]}) #needs-review. Der nächste Lauf versucht es erneut.",
)
except Exception as zweite:
logge(ablage, f"{tag}: auch die Notiz scheiterte {zweite!r}")
continue
# Ein Abbruch im WSL-Teil (Exit 2) wird wiederholt; alles andere gilt als gelaufen.
if ergebnis.get("exit") != 2:
(ablage / f"{tag}.json").write_text(
json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n"
)
else:
code = 1
logge(ablage, f"{tag}: {len(ergebnis['befunde'])} Befund(e), exit {ergebnis.get('exit')}")
try:
schreibe_notiz(vault, tag, zeile_fuer(ergebnis, uhrzeit))
except Exception as ausnahme:
code = 1
logge(ablage, f"{tag}: Notiz nicht geschrieben {ausnahme!r}")
return code
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,406 @@
"""Angriffsfaelle fuer die Abendkontrolle (abendkontrolle_wsl.py in WSL, abendkontrolle.py unter Windows).
Koordinator-Werkzeug, nicht fuers Repo. Jeder Fall laeuft einzeln auf einer Wegwerfkopie, zuerst die
Grundlinie; ohne Netz (--ohne-broker/--ohne-kerzen, Kalender und Journal aus Dateien, eine Ersatz-
Auswertung fuer die Brokerzeile) und ohne Meldung (--ohne-meldung). Faelle an zwei Listenpositionen,
wo die Reihenfolge zaehlen kann (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 3).
Aufruf in WSL (Venv) und unter Windows: python abendkontrolle_angriffe.py
Exit 0, wenn jeder Fall so ausgeht wie erwartet; sonst 1 mit der Liste der Abweichungen.
"""
import json
import os
import shutil
import sqlite3
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
TAG = "2026-09-23"
ECHTES_PROJEKT = Path("/mnt/d/Claude/Code Projekte/active - Trading Plattform")
ECHTES_PROTOKOLL = ECHTES_PROJEKT / "artifacts" / "paper" / "2026-09-23T131023-paper.jsonl"
KALENDER_TAG = [{"date": TAG, "open": "09:30", "close": "16:00"}]
JOURNAL_GUT = (
"Ende: boersenschluss, Position 0, Orders 2, Killswitch aktiv, Exit 0\n"
"trading-paper.service: Deactivated successfully.\n"
)
DIENSTE_GUT = {
"trading-dashboard.service": "active",
"trading-paper.timer": "active",
"trading-auswertung.timer": "active",
}
abweichungen: list[str] = []
# --- WSL-Teil ----------------------------------------------------------------------------------
def baue_projekt(wurzel: Path, killswitch: str | None = "aktiv") -> Path:
projekt = wurzel / "projekt"
(projekt / "artifacts" / "paper").mkdir(parents=True)
(projekt / "data" / "paper" / "auswertungen").mkdir(parents=True)
(projekt / "config").mkdir()
# Die Auswertung importiert trading.geheimnisse aus dem Arbeitsverzeichnis.
(projekt / "trading").symlink_to(ECHTES_PROJEKT / "trading")
shutil.copyfile(ECHTES_PROTOKOLL, projekt / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl")
(projekt / "data" / "paper" / "auswertungen" / f"{TAG}.md").write_text("# Bericht\n", encoding="utf-8")
with sqlite3.connect(projekt / "data" / "registry.db") as db:
db.execute("CREATE TABLE killswitch_zustand (id INTEGER PRIMARY KEY, zustand TEXT, seit TEXT, ausloeser TEXT)")
if killswitch is not None:
db.execute("INSERT INTO killswitch_zustand VALUES (1, ?, '2026-09-23T13:31:00+00:00', 'pascal')", (killswitch,))
(wurzel / "kalender.json").write_text(json.dumps(KALENDER_TAG), encoding="utf-8")
(wurzel / "journal.txt").write_text(JOURNAL_GUT, encoding="utf-8")
(wurzel / "dienste.json").write_text(json.dumps(DIENSTE_GUT), encoding="utf-8")
return projekt
def fahre(wurzel: Path, projekt: Path, extra_env: dict | None = None, tag: str = TAG, meldung: bool = False) -> dict:
befehl = [
sys.executable, str(HIER / "abendkontrolle_wsl.py"), "--tag", tag, "--projekt", str(projekt),
"--kalender", str(wurzel / "kalender.json"), "--journal", str(wurzel / "journal.txt"),
"--dienste", str(wurzel / "dienste.json"), "--ohne-kerzen",
]
if not meldung:
befehl.append("--ohne-meldung")
# Mit der Ersatz-Auswertung darf die Brokerzeile geprueft werden: Der Ersatz geht nicht ans Netz.
if not (extra_env and "ABENDKONTROLLE_AUSWERTUNG" in extra_env):
befehl.append("--ohne-broker")
env = {k: v for k, v in os.environ.items() if k != "ABENDKONTROLLE_AUSWERTUNG"}
env.update(extra_env or {})
lauf = subprocess.run(befehl, capture_output=True, text=True, env=env, timeout=300)
try:
ergebnis = json.loads(lauf.stdout.strip().splitlines()[-1])
except (IndexError, json.JSONDecodeError):
return {"befunde": [f"KEINE JSON-AUSGABE exit {lauf.returncode}: {lauf.stderr[-300:]}"], "exit": lauf.returncode}
ergebnis["_exit"] = lauf.returncode
return ergebnis
def erwarte(name: str, ergebnis: dict, *, befund: str | None, anzahl: int | None = None, exit_code: int = 0,
auch: tuple = (), nicht: tuple = (), fakt=None) -> None:
befunde = ergebnis.get("befunde", [])
alles = " | ".join(befunde)
ok = True
if befund is None and befunde:
ok = False
if befund is not None and not any(befund in b for b in befunde):
ok = False
if any(teil not in alles for teil in auch) or any(teil in alles for teil in nicht):
ok = False
if anzahl is not None and len(befunde) != anzahl:
ok = False
if ergebnis.get("_exit", ergebnis.get("exit")) != exit_code:
ok = False
if fakt is not None:
try:
ok = ok and bool(fakt(ergebnis.get("fakten") or {}, ergebnis))
except Exception:
ok = False
zeile = f"{'OK ' if ok else 'FEHL'} {name}: {len(befunde)} Befund(e) {befunde[:3]} meldung={(ergebnis.get('fakten') or {}).get('meldung')}"
print(zeile)
if not ok:
abweichungen.append(zeile)
def wsl_faelle() -> None:
def fall(name, vorbereiten, *, befund, anzahl=None, exit_code=0, env=None, tag=TAG, meldung=False,
auch=(), nicht=(), fakt=None):
with tempfile.TemporaryDirectory() as tmp:
wurzel = Path(tmp)
projekt = baue_projekt(wurzel)
extra = vorbereiten(wurzel, projekt)
umgebung = {**(env or {}), **(extra if isinstance(extra, dict) else {})}
erwarte(name, fahre(wurzel, projekt, umgebung, tag, meldung), befund=befund, anzahl=anzahl,
exit_code=exit_code, auch=auch, nicht=nicht, fakt=fakt)
nichts = lambda w, p: None # noqa: E731
prot = lambda p: p / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl" # noqa: E731
def schreibe(pfad: Path, text: str) -> None:
pfad.write_text(text, encoding="utf-8")
def zeilen_aendern(p: Path, aendern) -> None:
zeilen = prot(p).read_text(encoding="utf-8").splitlines()
schreibe(prot(p), "\n".join(aendern(zeilen)) + "\n")
fall("Grundlinie", nichts, befund=None, anzahl=0)
# 1 Kalender
# Ein Feiertag ohne Protokoll: kein Befund. Haelt die Kontrolle ihn faelschlich fuer einen Handelstag,
# fehlt das Protokoll und es gibt einen -- so trennt der Fall "kein Handelstag" von "immer Handelstag".
def feiertag(inhalt):
def vorbereiten(w, p):
schreibe(w / "kalender.json", inhalt)
prot(p).unlink()
return vorbereiten
fall("Kalender leer (Feiertag, Wochenende)", feiertag("[]"), befund=None, anzahl=0)
fall("Kalender nennt nur einen anderen Tag", feiertag(json.dumps([{"date": "2026-09-22"}])), befund=None, anzahl=0)
fall("Kalender als Objekt", lambda w, p: schreibe(w / "kalender.json", "{}"), befund="Kalender in unerwarteter Form")
fall("Kalender Eintrag ohne date, erste Position",
lambda w, p: schreibe(w / "kalender.json", json.dumps([{"open": "09:30"}] + KALENDER_TAG)),
befund="Kalender in unerwarteter Form")
fall("Kalender Eintrag ohne date, letzte Position",
lambda w, p: schreibe(w / "kalender.json", json.dumps(KALENDER_TAG + [{"date": 5}])),
befund="Kalender in unerwarteter Form")
fall("Kalender-Datei fehlt", lambda w, p: (w / "kalender.json").unlink(), befund="Kalender, und damit")
fall("Kalender kein JSON", lambda w, p: schreibe(w / "kalender.json", "<html>"), befund="Kalender, und damit")
# 2 Protokoll
fall("kein Protokoll", lambda w, p: prot(p).unlink(), befund="der Runner lief nicht")
fall("nur Probeprotokoll", lambda w, p: prot(p).rename(prot(p).with_name(f"{TAG}T131023-probe.jsonl")),
befund="der Runner lief nicht")
# 6 Handel
fall("nie aktiv",
lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for s in z]),
befund="Tag ohne Handel")
fall("kaputte Zeile vorne", lambda w, p: zeilen_aendern(p, lambda z: [z[0], "{kaputt", *z[1:]]), befund="nicht lesbar")
fall("kaputte Zeile hinten", lambda w, p: zeilen_aendern(p, lambda z: [*z, "[1, 2]"]), befund="nicht lesbar")
# 3 Journal
fall("Journal ohne Ende", lambda w, p: schreibe(w / "journal.txt", "gestartet\n"), befund="keine Zeile 'Ende")
fall("Journal leer", lambda w, p: schreibe(w / "journal.txt", ""), befund="keine Zeile 'Ende")
fall("Journal fehlt", lambda w, p: (w / "journal.txt").unlink(), befund="Journal nicht geprueft")
fall("Exit 4", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Exit 0", "Exit 4")), befund="Exit 4")
fall("Exit 4 im ersten von zwei Laeufen",
lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Exit 0", "Exit 4") + JOURNAL_GUT), befund="Lauf 1 von 2")
fall("Exit 4 im letzten von zwei Laeufen",
lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT + JOURNAL_GUT.replace("Exit 0", "Exit 4")), befund="Lauf 2 von 2")
fall("Grund stoerung", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Ende: boersenschluss", "Ende: stoerung")),
befund="nicht mit boersenschluss")
fall("Position 10", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Position 0", "Position 10")),
befund="Position 10")
fall("Failed with result",
lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT + "trading-paper.service: Failed with result 'exit-code'.\n"),
befund="Failed with result")
# 4 Auswertung
fall("Orderzeile nicht gefuellt",
lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"status": "filled"', '"status": "canceled"', 1) if '"art": "order"' in s else s for s in z]),
befund="nicht gefuellt")
fall("Schlussposition 10 im Protokoll",
lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"position": 0', '"position": 10') if '"art": "ende"' in s else s for s in z]),
befund="Schlussposition ungleich null")
# 5 Broker (Ersatz-Auswertung, kein Netz)
ersatz = HIER / "abendkontrolle_ersatz_auswertung.py"
fall("Broker 2 offen", nichts, befund="2 offene Position",
env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "offene positionen beim broker jetzt: 2"},
)
fall("Broker 0 offen", nichts, befund=None, anzahl=0,
env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "offene positionen beim broker jetzt: 0"})
fall("Auswertung Exit 3", nichts, befund="Exit 3",
env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "VORAUSSETZUNG FEHLT: x", "ERSATZ_EXIT": "3"})
fall("Auswertung Kontowert in BEFUND wird maskiert", nichts, befund="kontowert [x]",
env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "BEFUND: kontowert 12345.67 zu tief"})
# 7 Bericht
fall("Bericht fehlt", lambda w, p: (p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink(), befund="Tagesbericht")
# 8 Dienste
fall("Dashboard inaktiv (erste Position)",
lambda w, p: schreibe(w / "dienste.json", json.dumps({**DIENSTE_GUT, "trading-dashboard.service": "failed"})),
befund="trading-dashboard.service ist nicht aktiv")
fall("Auswertungs-Zeitgeber fehlt (letzte Position)",
lambda w, p: schreibe(w / "dienste.json", json.dumps({k: v for k, v in DIENSTE_GUT.items() if "auswertung" not in k})),
befund="trading-auswertung.timer ist nicht aktiv")
fall("Dienste als Liste", lambda w, p: schreibe(w / "dienste.json", "[]"), befund="Dienste: Zustand nicht lesbar")
# 9 Killswitch
def killswitch(zustand):
def vorbereiten(w, p):
with sqlite3.connect(p / "data" / "registry.db") as db:
if zustand is None:
db.execute("DELETE FROM killswitch_zustand")
else:
db.execute("UPDATE killswitch_zustand SET zustand = ?", (zustand,))
return vorbereiten
fall("Killswitch gesperrt", killswitch("gesperrt"), befund="gesperrt")
fall("Killswitch ohne Zeile", killswitch(None), befund="kein Killswitch-Zustand")
fall("Register fehlt", lambda w, p: (p / "data" / "registry.db").unlink(), befund="registry.db fehlt")
# 3 Ende je Lauf: mittags abgebrochen, der zweite Lauf bleibt angehalten (Befund des Pruefers W2)
def mittags_abgebrochen(w, p):
zeilen = prot(p).read_text(encoding="utf-8").splitlines()
takte = [z for z in zeilen if '"art": "takt"' in z]
ende = json.loads(next(z for z in zeilen if '"art": "ende"' in z))
lauf1 = [zeilen[0], *takte[:30], json.dumps({**ende, "grund": "abbruch"})]
lauf2 = [zeilen[0], *[z.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for z in takte[30:]], json.dumps(ende)]
schreibe(prot(p), "\n".join(lauf1) + "\n")
schreibe(p / "artifacts" / "paper" / f"{TAG}T160000-paper.jsonl", "\n".join(lauf2) + "\n")
schreibe(w / "journal.txt", JOURNAL_GUT.replace("Ende: boersenschluss", "Ende: abbruch") + JOURNAL_GUT)
fall("mittags abgebrochen, danach angehalten", mittags_abgebrochen, befund="Lauf endete mit abbruch",
auch=("Lauf 1 von 2) endete nicht mit boersenschluss",))
# Lauf nach Boersenschluss (WSL-Neustart abends): kein Befund (Befund des Pruefers W3)
def nach_schluss(w, p):
kopf = prot(p).read_text(encoding="utf-8").splitlines()[0]
ende = {"art": "ende", "zeit": f"{TAG}T21:06:40+00:00", "grund": "wartezeit", "fehler": None, "position": 0,
"orders": 0, "killswitch": "angehalten", "schritte": 0}
schreibe(p / "artifacts" / "paper" / f"{TAG}T210500-paper.jsonl", kopf + "\n" + json.dumps(ende) + "\n")
schreibe(w / "journal.txt", JOURNAL_GUT + "Ende: wartezeit, Position 0, Orders 0, Killswitch angehalten, Exit 3\n")
fall("Lauf nach Boersenschluss (wartezeit, Exit 3)", nach_schluss, befund=None, anzahl=0)
fall("nur ein Lauf mit wartezeit an einem Handelstag", lambda w, p: (
nach_schluss(w, p), prot(p).unlink(),
schreibe(w / "journal.txt", "Ende: wartezeit, Position 0, Orders 0, Killswitch angehalten, Exit 3\n")),
befund="keine Zeile 'Ende", auch=("kein Lauf des scharfen Runners in der Handelszeit",))
# 9 Killswitch: unbekannter Zustand, und eine Frage, die scheitert, haelt die anderen nicht auf
fall("Killswitch unbekannter Zustand", killswitch("kaputt"), befund="unbekanntem Zustand")
def register_ohne_tabelle(w, p):
(p / "data" / "registry.db").unlink()
sqlite3.connect(p / "data" / "registry.db").close()
(p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink()
fall("Register ohne Tabelle, Bericht fehlt", register_ohne_tabelle, befund="Killswitch nicht geprueft",
auch=("Tagesbericht",))
# Maske auch ohne Geldwort (Befund des Pruefers W4)
fall("Maske ohne Geldwort", nichts, befund="Wert",
env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz),
"ERSATZ_AUSGABE": "BEFUND: Wert 100123.45 und 172.3456 und -4.56 USD\noffene positionen beim broker jetzt: 0"},
nicht=("100123", "172.3456", "4.56"))
# Meldeweg (Befund des Pruefers B1): ein Fehlschlag darf nicht als "gesendet" stehen
def mit_melder(port):
def vorbereiten(w, p):
text = (ECHTES_PROJEKT / "config" / "paper.toml").read_text(encoding="utf-8")
anker = 'ntfy_url = "https://ntfy.sh"'
if text.count(anker) != 1:
raise RuntimeError("Anker ntfy_url fehlt in config/paper.toml")
schreibe(p / "config" / "paper.toml", text.replace(anker, f'ntfy_url = "http://127.0.0.1:{port}"\nklartext_lokal = true'))
schreibe(w / "secrets.toml", '[ntfy]\naktiv = true\nthema = "abendkontrolle-angriff"\n')
(p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink() # ein Befund, damit gemeldet wird
return {"TRADING_GEHEIMNISSE": str(w / "secrets.toml")}
return vorbereiten
fall("Meldung scheitert (niemand hoert)", mit_melder(9), befund="Tagesbericht", meldung=True,
fakt=lambda f, e: str(f.get("meldung", "")).startswith("nicht gesendet"))
with tempfile.TemporaryDirectory() as tmp:
mitschrift = Path(tmp) / "mitschrift.jsonl"
port = 18765
server = subprocess.Popen([sys.executable, str(HIER / "ntfy_probe_server.py"), str(port), str(mitschrift)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
import socket
import time
for _ in range(50):
try:
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
break
except OSError:
time.sleep(0.1)
fall("Meldung kommt an", mit_melder(port), befund="Tagesbericht", meldung=True,
fakt=lambda f, e: f.get("meldung") == "gesendet" and "Abendkontrolle" in mitschrift.read_text(encoding="utf-8"))
finally:
server.terminate()
server.wait(timeout=10)
# Bedienfehler
fall("Tag falsch", nichts, befund="abgebrochen", exit_code=2, tag="2026-13-40")
# --- Windows-Teil --------------------------------------------------------------------------------
def windows_faelle() -> None:
sys.path.insert(0, str(HIER))
import abendkontrolle as ak
from datetime import datetime
gut = {"tag": TAG, "befunde": [], "hinweise": ["auto_fortsetzen: fortgesetzt"], "exit": 0,
"fakten": {"handelstag": True, "ende": {"grund": "boersenschluss", "exit": "0", "orders": "79", "position": "0"},
"laeufe": [{"takte_aktiv": 375}], "broker_offen": 0, "bericht": True,
"killswitch": {"zustand": "aktiv", "ausloeser": "automatik"}}}
def lauf(name, notiz_vorher, ergebnis, *, jetzt="2026-09-23T22:45:00", pruefe):
with tempfile.TemporaryDirectory() as tmp:
vault = Path(tmp)
(vault / "Inbox").mkdir()
notiz = vault / "Inbox" / f"{TAG}.md"
if notiz_vorher is not None:
notiz.write_bytes(notiz_vorher if isinstance(notiz_vorher, bytes) else notiz_vorher.encode("utf-8"))
eingabe = vault / "ergebnis.json"
eingabe.write_text(ergebnis if isinstance(ergebnis, str) else json.dumps(ergebnis), encoding="utf-8")
code = ak.main(["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", jetzt])
text = notiz.read_bytes().decode("utf-8", errors="replace") if notiz.exists() else ""
marke = (vault / ak.ABLAGE_TEIL / f"{TAG}.json").exists()
try:
ok, grund = pruefe(code, text, marke)
except Exception as ausnahme: # der Treiber selbst bricht nicht ab
ok, grund = False, f"Pruefung warf {ausnahme!r}; Notiz {text[-120:]!r}"
zeile = f"{'OK ' if ok else 'FEHL'} W {name}: {grund}"
print(zeile)
if not ok:
abweichungen.append(zeile)
kopf = "---\ntype: daily\n---\n\n# 2026-09-23\n\n"
lauf("neue Notiz, ohne Befund", None, gut, pruefe=lambda c, t, m: (
c == 0 and m and "## Agentenprotokoll" in t and "ohne Befund" in t and "type: daily" in t, f"code {c} marke {m}"))
lauf("Abschnitt vor einer weiteren Ueberschrift", kopf + "## Agentenprotokoll\n\n- alt\n\n## Danach\n\ntext\n", gut,
pruefe=lambda c, t, m: (t.index("ohne Befund") < t.index("## Danach") and "- alt\n- 22:45" in t
and "\n\n## Danach" in t and "\n\n\n" not in t, repr(t[-80:])))
lauf("Abschnitt am Ende", kopf + "## Agentenprotokoll\n\n- alt\n", gut,
pruefe=lambda c, t, m: (t.endswith(".\n - Hinweis: auto_fortsetzen: fortgesetzt\n") and "- alt\n- 22:45" in t, repr(t[-60:])))
lauf("ohne Abschnitt", kopf + "## Anderes\n\nx\n", gut,
pruefe=lambda c, t, m: (t.count("## Agentenprotokoll") == 1 and t.index("## Anderes") < t.index("## Agentenprotokoll"), repr(t[-60:])))
lauf("Abschnitt direkt vor naechster Ueberschrift", kopf + "## Agentenprotokoll\n## Danach\n", gut,
pruefe=lambda c, t, m: ("## Agentenprotokoll\n\n- 22:45" in t and "\n\n## Danach" in t, repr(t)))
lauf("CRLF-Notiz", (kopf + "## Agentenprotokoll\n\n- alt\n").replace("\n", "\r\n"), gut,
pruefe=lambda c, t, m: ("\n" not in t.replace("\r\n", ""), "gemischte Zeilenenden" if "\n" in t.replace("\r\n", "") else "ok"))
mit_befund = {**gut, "befunde": ["Runner endete mit Exit 4", "Beim Broker stehen 2 offene Position(en)"]}
lauf("mit Befund", None, mit_befund, pruefe=lambda c, t, m: (
"**2 Befund(e)** #needs-review" in t and " - Runner endete mit Exit 4" in t and m, repr(t[-160:])))
lauf("Ergebnis ohne befunde", None, {"tag": TAG}, pruefe=lambda c, t, m: (
c == 1 and not m and "**gescheitert**" in t and "#needs-review" in t, f"code {c} marke {m}"))
lauf("Ergebnis kein JSON", None, "nicht json", pruefe=lambda c, t, m: (c == 1 and not m and "**gescheitert**" in t, f"code {c}"))
lauf("kein Handelstag", None, {"tag": TAG, "befunde": [], "hinweise": [], "fakten": {"handelstag": False}},
pruefe=lambda c, t, m: ("kein Handelstag" in t and m, repr(t[-60:])))
# Befunde des Pruefers: Ueberschrift mit Zusatz, Notiz ohne UTF-8, Abbruch mit JSON, Nachbardatei
lauf("Ueberschrift mit Zusatz", kopf + "## Agentenprotokoll — Sitzung 9\n\n- alt\n\n## Offene Punkte\n\n- x\n", gut,
pruefe=lambda c, t, m: (t.count("## Agentenprotokoll") == 1 and t.index("ohne Befund") < t.index("## Offene Punkte"), repr(t[-120:])))
kaputt = "---\ntype: daily\n---\n\n# Tag äöü\n".encode("cp1252")
lauf("Notiz kein UTF-8", kaputt, mit_befund, pruefe=lambda c, t, m: (m and c == 1, f"code {c} marke {m}"))
lauf("Abbruch mit JSON (exit 2) wird wiederholt", None,
{"tag": TAG, "befunde": ["Abendkontrolle abgebrochen: X"], "hinweise": [], "fakten": {}, "exit": 2},
pruefe=lambda c, t, m: (not m and "**1 Befund(e)**" in t and c == 1, f"code {c} marke {m}"))
with tempfile.TemporaryDirectory() as tmp:
vault = Path(tmp)
(vault / "Inbox").mkdir()
eingabe = vault / "e.json"
eingabe.write_text(json.dumps(gut), encoding="utf-8")
ak.main(["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", "2026-09-23T22:45:00"])
rest = [p.name for p in (vault / "Inbox").iterdir() if not p.name.endswith(".md")]
ok = not rest
print(f"{'OK ' if ok else 'FEHL'} W keine Nachbardatei uebrig: {rest}")
if not ok:
abweichungen.append("Nachbardatei uebrig")
# faellige_tage
from datetime import date
with tempfile.TemporaryDirectory() as tmp:
ablage = Path(tmp)
def tage(jetzt):
return ak.faellige_tage(datetime.fromisoformat(jetzt), ablage)
faelle = [
("Do vor 22:30: heute nicht", "2026-09-24T21:00", ["2026-09-24"], False),
("Do nach 22:30: heute", "2026-09-24T22:45", ["2026-09-24"], True),
("Mo frueh: Fr nachholen, Wochenende nicht", "2026-09-28T08:00", ["2026-09-25"], True),
("Mo frueh: nichts vor ERSTER_TAG", "2026-09-28T08:00", ["2026-09-23"], False),
]
for name, jetzt, erwartet, drin in faelle:
ergebnis = tage(jetzt)
ok = all((tag in ergebnis) == drin for tag in erwartet) and not any(
date.fromisoformat(t).weekday() >= 5 for t in ergebnis)
zeile = f"{'OK ' if ok else 'FEHL'} T {name}: {ergebnis}"
print(zeile)
if not ok:
abweichungen.append(zeile)
(ablage / "2026-09-25.json").write_text("{}", encoding="utf-8")
ergebnis = tage("2026-09-28T08:00")
ok = "2026-09-25" not in ergebnis
print(f"{'OK ' if ok else 'FEHL'} T Ergebnisdatei da: nicht erneut: {ergebnis}")
if not ok:
abweichungen.append("Ergebnisdatei ignoriert")
ergebnis = tage("2026-10-12T08:00")
ok = min(ergebnis) >= "2026-10-05"
print(f"{'OK ' if ok else 'FEHL'} T hoechstens sieben Tage zurueck: {ergebnis}")
if not ok:
abweichungen.append("Nachholfenster")
if __name__ == "__main__":
if sys.platform == "win32":
windows_faelle()
else:
wsl_faelle()
print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)")
sys.exit(1 if abweichungen else 0)
@@ -0,0 +1,43 @@
# Richtet die Windows-Aufgabe "Trading Plattform - Abendkontrolle" ein oder entfernt sie.
# Koordinator-Werkzeug, nicht fuers Repo. Auftrag Pascal 2026-09-24: taegliche Abendkontrolle,
# dauerhaft, uebersteht Neustarts, blockiert keine Updates von Claude Code oder Desktop.
#
# Zwei Ausloeser: werktags 22:45 (nach der Tagesauswertung um 22:20) und fuenf Minuten nach jeder
# Anmeldung (holt einen verpassten Abend nach; abendkontrolle.py prueft nur Tage ohne Ergebnisdatei).
# Nur bei angemeldetem Benutzer (Interactive): WSL und der ganze Paper-Betrieb brauchen die Anmeldung
# ohnehin (Aufgabe "Trading Plattform - WSL wachhalten"). pythonw.exe startet ohne Fenster.
# Voller Interpreterpfad: -Execute "python" endet in der Aufgabenplanung mit 0x80070002.
#
# Aufruf: powershell -NoProfile -File abendkontrolle_einrichten.ps1 [-Aktion einrichten|entfernen]
param(
[ValidateSet("einrichten", "entfernen")]
[string]$Aktion = "einrichten"
)
$Name = "Trading Plattform - Abendkontrolle"
if ($Aktion -eq "entfernen") {
Unregister-ScheduledTask -TaskName $Name -Confirm:$false
return
}
$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe"
$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\abendkontrolle.py"
if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" }
if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" }
$Benutzer = "$env:USERDOMAIN\$env:USERNAME"
$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '"') -WorkingDirectory "D:\Claude"
$Abends = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday, Tuesday, Wednesday, Thursday, Friday -At "22:45"
$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer
$Anmeldung.Delay = "PT5M"
$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited
$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Minutes 45) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger @($Abends, $Anmeldung) -Principal $Wer `
-Settings $Regeln -Force `
-Description "Abendkontrolle des Paper-Betriebs: liest nur, schreibt in die Tagesnotiz, meldet Befunde per ntfy. Koordinator-Werkzeug, siehe abendkontrolle.py." | Out-Null
$Aufgabe = Get-ScheduledTask -TaskName $Name
"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State
$Aufgabe.Triggers | ForEach-Object { "Ausloeser: {0} {1} {2}" -f $_.CimClass.CimClassName, $_.StartBoundary, $_.Delay }
"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime
@@ -0,0 +1,7 @@
"""Ersatz fuer handelstag_auswertung.py in den Angriffsfaellen der Abendkontrolle: druckt ERSATZ_AUSGABE,
endet mit ERSATZ_EXIT. Geht nicht ans Netz. Koordinator-Werkzeug, nicht fuers Repo."""
import os
import sys
print(os.environ.get("ERSATZ_AUSGABE", ""))
sys.exit(int(os.environ.get("ERSATZ_EXIT", "0")))
@@ -0,0 +1,98 @@
"""Mutanten der Abendkontrolle und des Rueckwegs: jeder muss seinen Angriffstreiber rot machen.
Koordinator-Werkzeug, nicht fuers Repo. Jeder Mutant laeuft auf einer Wegwerfkopie dieses Verzeichnisses.
In WSL (Venv) die Mutanten von abendkontrolle_wsl.py, unter Windows die von abendkontrolle.py und
rueckweg_aapl.py. Aufruf: python abendkontrolle_mutanten.py Exit 0, wenn keiner ueberlebt.
Ein Mutant, der das Verhalten nicht aendert, ist keine Luecke (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 8).
"""
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
WSL_MUTANTEN = [
("Exit-Vergleich verkehrt", "abendkontrolle_wsl.py", 'if satz["exit"] != "0":', 'if satz["exit"] == "9":'),
("Handelstag immer wahr", "abendkontrolle_wsl.py", 'return any(eintrag["date"] == tag for eintrag in liste)', "return True"),
("gesperrt nicht geprueft", "abendkontrolle_wsl.py", 'if zeile[0] == "gesperrt":', 'if zeile[0] == "nie":'),
("unbekannter Zustand still", "abendkontrolle_wsl.py", "elif zeile[0] not in KILLSWITCH_ZUSTAENDE:", "elif False:"),
("Broker-Schwelle", "abendkontrolle_wsl.py", "if offen[-1] != 0:", "if offen[-1] > 5:"),
("Probe zaehlt mit", "abendkontrolle_wsl.py", 'glob(f"{tag}T*-paper.jsonl")', 'glob(f"{tag}T*.jsonl")'),
("Maske aus", "abendkontrolle_wsl.py", " text = muster.sub(ersatz, text)", " pass"),
("nie-aktiv aus", "abendkontrolle_wsl.py", 'if satz.get("killswitch") == "aktiv":', "if True:"),
("Bericht nicht geprueft", "abendkontrolle_wsl.py", "if not bericht.is_file():\n k.befund", "if False:\n k.befund"),
("Failed-Zeile aus", "abendkontrolle_wsl.py", 'if "Failed with result" in text:', 'if "nie da" in text:'),
("nur letzter Lauf im Journal", "abendkontrolle_wsl.py", "for nummer, satz in enumerate(enden, start=1):",
"for nummer, satz in list(enumerate(enden, start=1))[-1:]:"),
("Melder-Rueckgabe verworfen", "abendkontrolle_wsl.py",
'return "gesendet" if fehler is None else f"nicht gesendet: {fehler}"', 'return "gesendet"'),
("Ende je Protokoll nicht bewertet", "abendkontrolle_wsl.py",
" if ende.get(\"grund\") != \"boersenschluss\":\n k.befund(f\"{lauf['datei']}",
" if False:\n k.befund(f\"{lauf['datei']}"),
("wartezeit im Journal mitgezaehlt", "abendkontrolle_wsl.py",
'enden = [satz for satz in alle if satz["grund"] != "wartezeit"]', "enden = alle"),
("Lauf nach Schluss als Handelslauf", "abendkontrolle_wsl.py",
'return bool(lauf["ende"]) and lauf["ende"].get("grund") == "wartezeit" and lauf["takte"] == 0', "return False"),
("Fragen nicht voneinander getrennt", "abendkontrolle_wsl.py",
" except Exception as ausnahme:\n self.befund(f\"{name} nicht geprueft",
" except ZeroDivisionError as ausnahme:\n self.befund(f\"{name} nicht geprueft"),
]
WIN_MUTANTEN = [
("keine Ergebnisdatei", "abendkontrolle.py", '(ablage / f"{tag}.json").write_text(', '(ablage / f"{tag}.nix").write_text('),
("Wochenende mit", "abendkontrolle.py", "if tag < ERSTER_TAG or tag.weekday() >= 5:", "if tag < ERSTER_TAG:"),
("heute vor 22:30", "abendkontrolle.py", "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:", "if False:"),
("Befund ohne Markierung", "abendkontrolle.py", "**{len(befunde)} Befund(e)** #needs-review", "{len(befunde)} Befund(e)"),
("CRLF vergessen", "abendkontrolle.py", 'cr = "\\r" if any(z.endswith("\\r") for z in zeilen) else ""', 'cr = ""'),
("Formpruefung aus", "abendkontrolle.py",
'if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list):', "if False:"),
("Ueberschrift nur exakt", "abendkontrolle.py",
"if z == UEBERSCHRIFT or z.startswith(UEBERSCHRIFT + \" \")), None)", "if z == UEBERSCHRIFT), None)"),
("Abbruch gilt als erledigt", "abendkontrolle.py", 'if ergebnis.get("exit") != 2:', "if True:"),
("nicht atomar", "abendkontrolle.py", " os.replace(neu, notiz)", " notiz.write_bytes(text.encode(\"utf-8\"))"),
("Zeit egal", "rueckweg_aapl.py", "if jetzt < FRUEHESTENS:", "if False:"),
("Runner egal", "rueckweg_aapl.py", " if runner_aktiv(runner_vorgabe):", " if False and runner_aktiv(runner_vorgabe):"),
("unlesbar gilt als steht", "rueckweg_aapl.py",
'raise RuntimeError(f"Zustand des Runners nicht lesbar: {zustand!r}")', "return False"),
("schmutziger Ordner egal", "rueckweg_aapl.py", " else:\n if geaendert:", " else:\n if False:"),
("folge egal", "rueckweg_aapl.py", 'if MEHRERE not in ziel.read_text(encoding="utf-8"):', "if False:"),
("Ergebnisdatei egal", "rueckweg_aapl.py", 'if (ablage / "rueckweg.json").exists():', "if False:"),
("kein Commit", "rueckweg_aapl.py", '("add", ZIEL.as_posix()), ("commit", "-m", NACHRICHT)', '("add", ZIEL.as_posix()),'),
("halb getauscht gilt als fertig", "rueckweg_aapl.py",
" if not geaendert:\n return \"config/paper.toml ist schon",
" if True:\n return \"config/paper.toml ist schon"),
("Schluesselvergleich aus", "rueckweg_aapl.py", " if fehlend:", " if False:"),
("Zweig egal", "rueckweg_aapl.py", " if zweig != ZWEIG:", " if False:"),
("Wertevergleich aus", "rueckweg_aapl.py", " elif neu[name] != alt[name]:", " elif False:"),
("Gegenrichtung aus", "rueckweg_aapl.py", " elif name not in alt:", " elif False:"),
]
TREIBER = {
"abendkontrolle_wsl.py": "abendkontrolle_angriffe.py",
"abendkontrolle.py": "abendkontrolle_angriffe.py",
"rueckweg_aapl.py": "rueckweg_aapl_angriffe.py",
}
mutanten = WIN_MUTANTEN if sys.platform == "win32" else WSL_MUTANTEN
ueberlebt = []
for name, datei, alt, neu in mutanten:
with tempfile.TemporaryDirectory() as tmp:
ziel = Path(tmp) / "pruefung"
shutil.copytree(HIER, ziel, ignore=shutil.ignore_patterns("__pycache__"))
pfad = ziel / datei
text = pfad.read_text(encoding="utf-8")
if text.count(alt) != 1:
print(f"ANKER {text.count(alt)}-mal: {name}")
ueberlebt.append(name)
continue
pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n")
lauf = subprocess.run([sys.executable, str(ziel / TREIBER[datei])], capture_output=True, text=True,
timeout=1800, encoding="utf-8", errors="replace", cwd=tmp)
faelle = [z for z in lauf.stdout.splitlines() if z.startswith("FEHL")]
rot = lauf.returncode != 0
print(f"{'ROT (gefangen)' if rot else 'GRUEN (UEBERLEBT)'} {name}: {[f[:110] for f in faelle[:2]]}")
if not rot:
ueberlebt.append(name)
print(f"ERGEBNIS: {len(ueberlebt)} ueberlebt von {len(mutanten)} {ueberlebt}")
sys.exit(1 if ueberlebt else 0)
@@ -0,0 +1,475 @@
"""Abendkontrolle eines Paper-Handelstags: liest nur, urteilt im Code, meldet nur bei Befund.
Koordinator-Werkzeug, nicht fuers Repo. Laeuft in WSL im Venv des Projekts; gestartet von
abendkontrolle.py (Windows-Aufgabe "Trading Plattform - Abendkontrolle", werktags 22:45 Berlin und
bei jeder Anmeldung). Auftrag Pascal 2026-09-24: taegliche Abendkontrolle, dauerhaft, uebersteht
Neustarts und blockiert keine Updates von Claude Code oder Desktop -- deshalb ohne Claude-Prozess.
Entscheidungsliste (Frage -> Herkunft der Antwort):
1. Ist <tag> ein Handelstag? Quelle: GET /v2/calendar?start=<tag>&end=<tag>, gemessen am
2026-09-24: eine Liste von Objekten mit 'date' (JJJJ-MM-TT) und
'close' (HH:MM New York); Feiertage fehlen, das Wochenende ist
leer, verkuerzte Tage tragen ein frueheres 'close'. Antwort
nicht lesbar -> Befund, und die Fragen 2 bis 7 bleiben offen.
2. Lief ein scharfer Runner? Quelle: scripts/paper.py:554, Protokoll
artifacts/paper/<UTC-Stempel>-paper.jsonl. Der Start um 09:10
New York traegt das Datum des Handelstags (gilt, solange
max_wartezeit_h unter etwa 13 h bleibt; heute 8). Keines -> Befund.
3. Wie endete jeder Lauf? Quelle: scripts/paper.py:517, Zeile "Ende: <grund>, Position <n>,
Orders <n>, Killswitch <z>, Exit <n>" im Journal, und die Zeile
'ende' jedes Protokolls. Projektvorgabe: jeder Lauf endet mit
boersenschluss und Exit 0, der letzte mit Position 0. Ausgenommen
ist ein Lauf ausserhalb der Handelszeit (Ende wartezeit, im
Protokoll ohne Takt; gemessen am 2026-09-20 nach WSL-Neustarts):
nur ein Hinweis. Ein Lauf, der mittags mit abbruch endet, ist ein
Befund, auch wenn der naechste sauber schliesst. Keine Endezeile
im Journal -> Befund; "Failed with result" -> Befund.
4. Was sagt das Protokoll? Quelle: handelstag_auswertung.py (dieses Verzeichnis); jede
Zeile, die mit "BEFUND" beginnt, ist ein Befund; Exit ungleich 0
ebenfalls.
5. Ist das Konto glatt? Quelle: dieselbe Auswertung mit --broker, Zeile "offene
positionen beim broker jetzt: <n>". n != 0 -> Befund; fehlt die
Zeile -> Befund (die Frage blieb unbeantwortet).
6. Hat der Runner gehandelt? Projektvorgabe (Pascal 2026-09-24, Paket AF): an einem Handelstag
steht der Killswitch in mindestens einem Takt auf aktiv. Kein
solcher Takt -> Befund "Tag ohne Handel". Takte, die nach dem
ersten aktiven nicht mehr aktiv sind (Watchdog, Pascals Anhalten),
sind ein Hinweis: Der Runner hat dafuer schon selbst gemeldet.
Ereignisse auto_fortsetzen werden berichtet, nicht bewertet.
7. Liegt der Tagesbericht? Quelle: scripts/tagesauswertung.py:58 und :192,
data/paper/auswertungen/<tag>.md. Fehlt -> Befund.
8. Laufen Dashboard und Zeitgeber? Projektvorgabe (Pakete F und Z): trading-dashboard.service,
trading-paper.timer, trading-auswertung.timer "active"; ein
anderer Zustand wird nach DIENSTE_NOCHMAL_S einmal neu gelesen
(eine eben geweckte WSL startet ihre Dienste noch).
9. Wie steht der Killswitch? Quelle: Tabelle killswitch_zustand in data/registry.db
(trading/registry/registry.py:281). gesperrt -> Befund: ohne
"Freigeben" handelt der naechste Tag nicht. Ein Zustand
ausserhalb aktiv/angehalten/gesperrt -> Befund.
Jede Frage laeuft fuer sich: Scheitert eine (Sperre im Register, Journal nicht lesbar), ist das ein
Befund, und die anderen werden trotzdem beantwortet.
Meldung: bei mindestens einem Befund eine Meldung mit hoher Prioritaet ueber den Melder des Projekts.
Ob sie ankam, sagt der Rueckgabewert von Melder.melde (None oder der Grund, trading/paper/melder.py);
er steht in fakten.meldung. Kein Alarm entsteht, wenn WSL selbst nicht laeuft -- dann steht nur die
Zeile in der Tagesnotiz (benannte Luecke; der Totmann in Home Assistant, Paket Z2-7, deckt sie ab).
Nicht gesehen: die Guete der Kurse (steht im Bericht), Teilausfuehrungen einzeln, alles vor dem
Handelstag. Betraege gibt das Werkzeug nie aus; fremde Zeilen laufen durch maskiere().
Ein Fangnetz, kein Nachweis ueber den Handelstag.
Aufruf (WSL, Venv): python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung]
Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "<titel>" "<text>"
Fuer Angriffsfaelle ohne Netz und ohne systemd: --projekt, --kalender, --journal, --dienste,
--ohne-broker, --ohne-kerzen.
Ausgabe: genau eine JSON-Zeile auf stdout. Exit 0 gelaufen (Befunde stehen in der Ausgabe),
2 abgebrochen (die bis dahin gesammelten Befunde stehen trotzdem in der Ausgabe).
"""
import argparse
import json
import os
import re
import sqlite3
import subprocess
import sys
import time
from datetime import date
from datetime import timedelta
from pathlib import Path
PROJEKT = "/mnt/d/Claude/Code Projekte/active - Trading Plattform"
GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml"
AUSWERTUNG = Path(__file__).resolve().parent / "handelstag_auswertung.py"
# Nur fuer die Angriffsfaelle: ein Ersatz, der vorgegebene Zeilen druckt, statt ans Netz zu gehen.
AUSWERTUNG_ERSATZ = os.environ.get("ABENDKONTROLLE_AUSWERTUNG")
HANDEL_URL = "https://paper-api.alpaca.markets"
DIENSTE = ("trading-dashboard.service", "trading-paper.timer", "trading-auswertung.timer")
DIENSTE_NOCHMAL_S = 30
KILLSWITCH_ZUSTAENDE = ("aktiv", "angehalten", "gesperrt")
AUSWERTUNG_TIMEOUT_S = 900
ENDE = re.compile(
r"Ende: (?P<grund>[^,]+), Position (?P<position>[^,]+), Orders (?P<orders>[^,]+), "
r"Killswitch (?P<killswitch>[^,]+), Exit (?P<exit>-?\d+)"
)
BROKER_OFFEN = re.compile(r"offene positionen beim broker jetzt: (\d+)")
# Maske fuer alles, was aus fremder Ausgabe in Notiz und Meldung geht, in dieser Reihenfolge:
# - jede Zahl hinter einem Geldwort,
# - jede Zahl vor einer Waehrung,
# - jede Zahl mit mehr als zwei Nachkommastellen,
# - jede Zahl mit mindestens vier Stellen vor dem Komma und Nachkommastellen.
MASKEN = (
(re.compile(r"(?i)\b(kontowert|konto|startkapital|equity|cash|preis|kurs|usd|pnl)([^0-9-]{0,3})-?[0-9][0-9.,]*"),
lambda m: f"{m.group(1)}{m.group(2)}[x]"),
(re.compile(r"-?\d[\d.,]*\s?(?:USD|EUR|\$|€)"), lambda m: "[x]"),
(re.compile(r"-?\d[\d,]*\.\d{3,}"), lambda m: "[x]"),
(re.compile(r"-?\d{4,}[.,]\d{1,2}\b"), lambda m: "[x]"),
)
def maskiere(text: str) -> str:
for muster, ersatz in MASKEN:
text = muster.sub(ersatz, text)
return text
class Kontrolle:
def __init__(self) -> None:
self.befunde: list[str] = []
self.hinweise: list[str] = []
self.fakten: dict = {}
def befund(self, text: str) -> None:
self.befunde.append(maskiere(text))
def hinweis(self, text: str) -> None:
self.hinweise.append(maskiere(text))
def schritt(self, name: str, aufruf, *argumente):
"""Eine Frage fuer sich: Scheitert sie, ist das ein Befund, und der Rest laeuft weiter."""
try:
return aufruf(*argumente)
except Exception as ausnahme:
self.befund(f"{name} nicht geprueft ({type(ausnahme).__name__}: {str(ausnahme)[:160]})")
return None
# --- 1 Handelstag ---------------------------------------------------------------------------------
def lies_kalender(tag: str, kalender_datei: str | None, projekt: Path):
"""Die Liste aus /v2/calendar oder aus einer Datei (Angriffsfaelle)."""
if kalender_datei is not None:
return json.loads(Path(kalender_datei).read_text(encoding="utf-8"))
import requests # erst hier: Die Angriffsfaelle laufen ohne Netz
sys.path.insert(0, str(projekt))
sys.path.insert(0, str(AUSWERTUNG.parent))
from handelstag_auswertung import kopfzeilen # liest die Schluessel, gibt sie nie aus
kopf = kopfzeilen()
if kopf is None:
raise RuntimeError("Schluessel fuer Alpaca nicht lesbar")
antwort = requests.get(
f"{HANDEL_URL}/v2/calendar", headers=kopf, params={"start": tag, "end": tag}, timeout=30
)
antwort.raise_for_status()
return antwort.json()
def ist_handelstag(tag: str, liste) -> bool | None:
"""True, False, oder None, wenn die Antwort nicht die gemessene Form hat."""
if not isinstance(liste, list):
return None
for eintrag in liste:
if not isinstance(eintrag, dict) or not isinstance(eintrag.get("date"), str):
return None
return any(eintrag["date"] == tag for eintrag in liste)
# --- 2 Protokolle, 3 Ende je Lauf, 6 Handel --------------------------------------------------------
def protokolle(projekt: Path, tag: str) -> list[Path]:
return sorted((projekt / "artifacts" / "paper").glob(f"{tag}T*-paper.jsonl"))
def lies_protokoll(pfad: Path, k: Kontrolle) -> dict:
takte = aktiv = pausiert_danach = unlesbar = 0
auto = []
ende = None
# Dateiiteration statt splitlines: U+2028 in einem Text zerlegte sonst eine JSON-Zeile.
with pfad.open(encoding="utf-8", errors="replace") as datei:
for zeile in datei:
if not zeile.strip():
continue
try:
satz = json.loads(zeile)
except json.JSONDecodeError:
unlesbar += 1
continue
if not isinstance(satz, dict):
unlesbar += 1
continue
art = satz.get("art")
if art == "takt":
takte += 1
if satz.get("killswitch") == "aktiv":
aktiv += 1
elif aktiv:
pausiert_danach += 1
elif art == "ereignis" and satz.get("was") == "auto_fortsetzen":
auto.append(str(satz.get("text"))[:200])
elif art == "ende":
ende = {
schluessel: satz.get(schluessel)
for schluessel in ("grund", "fehler", "position", "positionen", "orders", "killswitch")
}
if unlesbar:
k.befund(f"{pfad.name}: {unlesbar} Zeile(n) nicht lesbar")
return {"datei": pfad.name, "takte": takte, "takte_aktiv": aktiv, "takte_pausiert_danach": pausiert_danach,
"auto_fortsetzen": auto, "ende": ende}
def ausserhalb_der_handelszeit(lauf: dict) -> bool:
"""Ein Lauf, der ohne einen einzigen Takt mit wartezeit endete (Start nach Boersenschluss)."""
return bool(lauf["ende"]) and lauf["ende"].get("grund") == "wartezeit" and lauf["takte"] == 0
def pruefe_laeufe(laeufe: list[dict], k: Kontrolle) -> None:
for lauf in laeufe:
ende = lauf["ende"]
if ende is None:
continue # die Auswertung meldet "keine Zeile 'ende'"
if ausserhalb_der_handelszeit(lauf):
k.hinweis(f"{lauf['datei']}: Lauf ausserhalb der Handelszeit (Ende wartezeit), nicht bewertet")
continue
if ende.get("grund") != "boersenschluss":
k.befund(f"{lauf['datei']}: Lauf endete mit {ende.get('grund')} nach {lauf['takte']} Takten")
if ende.get("fehler"):
k.befund(f"{lauf['datei']}: Lauf endete mit Fehler {str(ende.get('fehler'))[:120]}")
if lauf["takte_pausiert_danach"]:
k.hinweis(f"{lauf['datei']}: {lauf['takte_pausiert_danach']} Takte nach dem Fortsetzen nicht aktiv")
for text in lauf["auto_fortsetzen"]:
k.hinweis(f"auto_fortsetzen: {text}")
bewertet = [lauf for lauf in laeufe if not ausserhalb_der_handelszeit(lauf)]
if laeufe and not bewertet:
k.befund("kein Lauf des scharfen Runners in der Handelszeit, nur Laeufe nach Boersenschluss")
elif bewertet and sum(lauf["takte_aktiv"] for lauf in bewertet) == 0:
k.befund("Tag ohne Handel: in keinem Takt stand der Killswitch auf aktiv")
# --- 3 Journal -------------------------------------------------------------------------------------
def lies_journal(tag: str, journal_datei: str | None) -> str:
if journal_datei is not None:
return Path(journal_datei).read_text(encoding="utf-8", errors="replace")
morgen = (date.fromisoformat(tag) + timedelta(days=1)).isoformat()
lauf = subprocess.run(
["journalctl", "-u", "trading-paper.service", "--since", f"{tag} 00:00", "--until", f"{morgen} 06:00",
"-o", "cat", "--no-pager"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60,
)
if lauf.returncode != 0:
raise RuntimeError(f"journalctl Exit {lauf.returncode}")
return lauf.stdout
def pruefe_journal(text: str, k: Kontrolle) -> dict | None:
alle = [{schluessel: wert.strip() for schluessel, wert in m.groupdict().items()} for m in ENDE.finditer(text)]
# Ein Lauf ausserhalb der Handelszeit (Neustart nach Boersenschluss) endet mit wartezeit und Exit 3.
enden = [satz for satz in alle if satz["grund"] != "wartezeit"]
k.fakten["enden_im_journal"] = len(enden)
if len(alle) > len(enden):
k.hinweis(f"{len(alle) - len(enden)} Lauf/Laeufe ausserhalb der Handelszeit im Journal (Ende wartezeit)")
if "Failed with result" in text:
k.befund("Journal: trading-paper.service endete mit 'Failed with result'")
if not enden:
k.befund("Journal: keine Zeile 'Ende: …, Exit n' eines Handelslaufs von trading-paper.service an diesem Tag")
return None
# Jeder Lauf des Tages zaehlt: Ein Runner, der mittags mit einer Stoerung endete und neu gestartet
# wurde, ist auch dann ein Befund, wenn der zweite sauber schliesst.
for nummer, satz in enumerate(enden, start=1):
if satz["exit"] != "0":
k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete mit Exit {satz['exit']} (Grund {satz['grund']})")
if satz["grund"] != "boersenschluss":
k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete nicht mit boersenschluss, sondern mit {satz['grund']}")
letzte = enden[-1]
if letzte["position"] != "0":
k.befund(f"Runner endete mit Position {letzte['position']}")
return letzte
# --- 4 und 5 Auswertung ----------------------------------------------------------------------------
def fahre_auswertung(pfad: Path, projekt: Path, broker: bool, kerzen: bool, k: Kontrolle) -> None:
befehl = [sys.executable, AUSWERTUNG_ERSATZ or str(AUSWERTUNG), str(pfad)]
if broker:
befehl.append("--broker")
if kerzen:
befehl.append("--kerzen")
lauf = subprocess.run(
befehl, cwd=projekt, capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=AUSWERTUNG_TIMEOUT_S, env={**os.environ, "PYTHONIOENCODING": "utf-8"},
)
zeilen = lauf.stdout.splitlines() + lauf.stderr.splitlines()
for zeile in zeilen:
if zeile.strip().startswith("BEFUND") or zeile.startswith("VORAUSSETZUNG FEHLT"):
k.befund(f"{pfad.name}: {zeile.strip()}")
if lauf.returncode != 0:
k.befund(f"{pfad.name}: handelstag_auswertung.py endete mit Exit {lauf.returncode}")
if broker:
offen = [int(m.group(1)) for m in map(BROKER_OFFEN.search, zeilen) if m]
if not offen:
k.befund(f"{pfad.name}: keine Zeile zu offenen Positionen beim Broker -- Konto nicht geprueft")
else:
k.fakten["broker_offen"] = offen[-1]
if offen[-1] != 0:
k.befund(f"Beim Broker stehen {offen[-1]} offene Position(en)")
# --- 8 Dienste und 9 Killswitch -------------------------------------------------------------------
def lies_dienste(dienste_datei: str | None) -> dict[str, str]:
if dienste_datei is not None:
return json.loads(Path(dienste_datei).read_text(encoding="utf-8"))
def lesen():
ergebnis = {}
for dienst in DIENSTE:
lauf = subprocess.run(["systemctl", "is-active", dienst], capture_output=True, text=True, timeout=30)
ergebnis[dienst] = lauf.stdout.strip() or f"exit {lauf.returncode}"
return ergebnis
zustaende = lesen()
if any(wert != "active" for wert in zustaende.values()):
time.sleep(DIENSTE_NOCHMAL_S)
zustaende = lesen()
return zustaende
def pruefe_dienste(zustaende, k: Kontrolle) -> None:
if not isinstance(zustaende, dict):
k.befund("Dienste: Zustand nicht lesbar")
return
k.fakten["dienste"] = zustaende
for dienst in DIENSTE:
if zustaende.get(dienst) != "active":
k.befund(f"{dienst} ist nicht aktiv ({zustaende.get(dienst)})")
def pruefe_killswitch(projekt: Path, k: Kontrolle) -> None:
register = projekt / "data" / "registry.db"
if not register.is_file():
k.befund("Register data/registry.db fehlt")
return
with sqlite3.connect(f"file:{register}?mode=ro", uri=True, timeout=10) as verbindung:
zeile = verbindung.execute(
"SELECT zustand, seit, ausloeser FROM killswitch_zustand WHERE id = 1"
).fetchone()
if zeile is None:
k.befund("Register: kein Killswitch-Zustand")
return
k.fakten["killswitch"] = {"zustand": zeile[0], "seit": zeile[1], "ausloeser": zeile[2]}
if zeile[0] == "gesperrt":
k.befund("Killswitch steht auf gesperrt: Ohne 'Freigeben' handelt der naechste Tag nicht")
elif zeile[0] not in KILLSWITCH_ZUSTAENDE:
k.befund(f"Killswitch in unbekanntem Zustand {zeile[0]!r}")
# --- Meldung ---------------------------------------------------------------------------------------
def melde(titel: str, text: str, projekt: Path) -> str:
"""Eine Meldung mit hoher Prioritaet ueber den Melder des Projekts. Scheitert nie nach aussen.
Melder.melde wirft nicht: Es gibt None bei Erfolg und sonst den Grund zurueck (Typ der Ausnahme
oder "HTTP <status>"). Genau das steht im Ergebnis.
"""
try:
sys.path.insert(0, str(projekt))
os.chdir(projekt)
from trading.paper.melder import baue_melder
from trading.paper.meldungen import HOCH
from trading.paper.meldungen import Meldung
melder = baue_melder(config_pfad="config/paper.toml")
if not melder.aktiv:
return "nicht gesendet: Melder nicht aktiv"
fehler = melder.melde(Meldung(titel=titel, text=maskiere(text), prioritaet=HOCH, tags="warning"))
return "gesendet" if fehler is None else f"nicht gesendet: {fehler}"
except Exception as ausnahme: # die Kontrolle haengt nicht an der Meldung
return f"nicht gesendet: {type(ausnahme).__name__}"
# --- Ablauf ----------------------------------------------------------------------------------------
def kontrolliere(argumente: argparse.Namespace, k: Kontrolle) -> None:
projekt = Path(argumente.projekt)
tag = argumente.tag
date.fromisoformat(tag) # ein falsches Datum ist ein Bedienfehler und bricht ab
# handelstag_auswertung.py und der Melder lesen aus dem Arbeitsverzeichnis (Projektordner).
os.chdir(projekt)
zustaende = k.schritt("Dienste", lies_dienste, argumente.dienste)
if zustaende is not None:
pruefe_dienste(zustaende, k)
k.schritt("Killswitch", pruefe_killswitch, projekt, k)
liste = k.schritt("Kalender, und damit Lauf, Ende und Bericht,", lies_kalender, tag, argumente.kalender, projekt)
handelstag = None if liste is None else ist_handelstag(tag, liste)
k.fakten["handelstag"] = handelstag
if handelstag is None:
if liste is not None:
k.befund("Kalender in unerwarteter Form; Lauf, Ende und Bericht ungeprueft")
return
dateien = protokolle(projekt, tag)
if not handelstag:
if dateien:
k.hinweis(f"kein Handelstag, aber {len(dateien)} Protokoll(e) des scharfen Runners")
return
if not dateien:
k.befund("kein Protokoll des scharfen Runners an einem Handelstag -- der Runner lief nicht")
laeufe = [lauf for lauf in (k.schritt(f"Protokoll {pfad.name}", lies_protokoll, pfad, k) for pfad in dateien) if lauf]
k.fakten["laeufe"] = laeufe
pruefe_laeufe(laeufe, k)
text = k.schritt("Journal", lies_journal, tag, argumente.journal)
if text is not None:
k.fakten["ende"] = pruefe_journal(text, k)
ausserhalb = {lauf["datei"] for lauf in laeufe if ausserhalb_der_handelszeit(lauf)}
for pfad in dateien:
if pfad.name in ausserhalb:
continue
k.schritt(f"Auswertung {pfad.name}", fahre_auswertung, pfad, projekt,
not argumente.ohne_broker, not argumente.ohne_kerzen, k)
bericht = projekt / "data" / "paper" / "auswertungen" / f"{tag}.md"
k.fakten["bericht"] = bericht.is_file()
if not bericht.is_file():
k.befund(f"Tagesbericht data/paper/auswertungen/{tag}.md fehlt")
def parse_args(argv=None) -> argparse.Namespace:
zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False)
zerleger.add_argument("--tag")
zerleger.add_argument("--meldung", nargs=2, metavar=("TITEL", "TEXT"))
zerleger.add_argument("--projekt", default=PROJEKT)
zerleger.add_argument("--kalender")
zerleger.add_argument("--journal")
zerleger.add_argument("--dienste")
zerleger.add_argument("--ohne-broker", action="store_true")
zerleger.add_argument("--ohne-kerzen", action="store_true")
zerleger.add_argument("--ohne-meldung", action="store_true")
argumente = zerleger.parse_args(argv)
if bool(argumente.tag) == bool(argumente.meldung):
zerleger.error("genau eines von --tag und --meldung")
return argumente
def main(argv=None) -> int:
os.environ.setdefault("TRADING_GEHEIMNISSE", GEHEIMNISSE)
argumente = parse_args(argv)
if argumente.meldung:
print(json.dumps({"meldung": melde(*argumente.meldung, Path(argumente.projekt))}, ensure_ascii=False))
return 0
k = Kontrolle()
code = 0
try:
kontrolliere(argumente, k)
except Exception as ausnahme:
k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}")
code = 2
if k.befunde and not argumente.ohne_meldung:
text = "\n".join(f"- {befund}" for befund in k.befunde[:12])
k.fakten["meldung"] = melde(
f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)", text, Path(argumente.projekt)
)
print(json.dumps(
{"tag": argumente.tag, "befunde": k.befunde, "hinweise": k.hinweise, "fakten": k.fakten, "exit": code},
ensure_ascii=False, default=str,
))
return code
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,204 @@
"""Paket AF ohne Netz: Gegenfall und Gegenprobe der Fortsetzung zum Tagesstart.
Koordinator-Werkzeug, nicht Teil des Repos (Paket AF, 2026-09-24). Faehrt die echte
PaperSitzung des Arbeitsbaums mit der ausgelieferten config/paper.toml und ihrem Schalter
[auto_fortsetzen] gegen einen ProbeBroker, mit echten IEX-Kerzen aus einer JSON-Datei von
hole_kerzen.py (fuer jedes Instrument dieselben), einer ProbeUhr, einem Wegwerf-Register
mit der Neustartregel und einem Melder, der nur sammelt. Kein Netz, keine Order an Alpaca,
das echte Register bleibt unberuehrt.
Drei Faelle:
restposition vor der Oeffnung gestartet, Restposition beim Start -> nicht fortgesetzt
mitten 60 Marktminuten nach der Oeffnung gestartet, flach -> nicht fortgesetzt
flach vor der Oeffnung gestartet, flach -> fortgesetzt (die Gegenprobe)
Exit 0 heisst: Der Fall lief wie erwartet. Jede Abweichung ist eine Zeile BEFUND und Exit 2,
eine fehlende Voraussetzung Exit 3.
Aufruf (WSL, aus dem Arbeitsbaum): python af_gegenfall.py <kerzen.json> <fall>
"""
import json
import sys
import tempfile
from datetime import datetime
from datetime import timedelta
from pathlib import Path
sys.path.insert(0, str(Path.cwd()))
from trading.killswitch.killswitch import Killswitch # noqa: E402
from trading.killswitch.killswitch import lade_killswitch_config # noqa: E402
from trading.paper.agenten import agenten_je_instrument # noqa: E402
from trading.paper.agenten import beschreibung_je_instrument # noqa: E402
from trading.paper.agenten import lade_agent # noqa: E402
from trading.paper.config import enge_limits # noqa: E402
from trading.paper.config import lade_paper_config # noqa: E402
from trading.paper.kerzen import FesteKerzenquelle # noqa: E402
from trading.paper.kerzen import Kerze # noqa: E402
from trading.paper.probe import ProbeBroker # noqa: E402
from trading.paper.sitzung import PaperSitzung # noqa: E402
from trading.paper.uhr import ProbeUhr # noqa: E402
from trading.registry.registry import Registry # noqa: E402
from trading.risk.limits import lade_limits # noqa: E402
FAELLE = ("restposition", "mitten", "flach")
RESTPOSITION = 10
class SammelMelder:
"""Nimmt jede Meldung an und schickt nichts."""
aktiv = True
def __init__(self) -> None:
self.gesehen = []
def melde(self, meldung):
self.gesehen.append(meldung)
return None
class Protokoll:
"""Haelt die Zeilen im Speicher, durch json.dumps wie das echte Protokoll."""
def __init__(self) -> None:
self.zeilen = []
def schreibe(self, eintrag) -> None:
self.zeilen.append(json.loads(json.dumps(eintrag, ensure_ascii=False, default=str)))
def lies_kerzen(pfad: Path) -> list:
roh = json.loads(pfad.read_text(encoding="utf-8"))
return [
Kerze(
zeit=datetime.fromisoformat(k["zeit"]),
open=float(k["open"]),
high=float(k["high"]),
low=float(k["low"]),
close=float(k["close"]),
volume=float(k["volume"]),
)
for k in roh
]
def main() -> int:
if len(sys.argv) != 3 or sys.argv[2] not in FAELLE:
print(f"Aufruf: python af_gegenfall.py <kerzen.json> <{'|'.join(FAELLE)}>")
return 3
pfad, fall = Path(sys.argv[1]), sys.argv[2]
if not pfad.is_file():
print(f"VORAUSSETZUNG FEHLT: {pfad}")
return 3
kerzen = sorted(lies_kerzen(pfad), key=lambda kerze: kerze.zeit)
if not kerzen:
print(f"VORAUSSETZUNG FEHLT: keine Kerze in {pfad}")
return 3
config = lade_paper_config("config/paper.toml")
if not getattr(config, "auto_fortsetzen", False):
print("VORAUSSETZUNG FEHLT: config/paper.toml traegt [auto_fortsetzen] aktiv = true nicht")
return 3
limits = enge_limits(lade_limits("config/risk.toml"), config)
ks_config = lade_killswitch_config("config/killswitch.toml")
quelle = FesteKerzenquelle(kerzen)
if fall == "mitten":
beginn = kerzen[0].zeit + timedelta(minutes=60)
else:
beginn = kerzen[0].zeit - timedelta(minutes=30)
uhr = ProbeUhr(beginn)
rest = config.alle_instrumente[-1]
broker = ProbeBroker(
kerzenquelle=quelle,
uhr=uhr,
instrument_id=config.instrument_id,
instrumente=config.alle_instrumente,
positionen={rest: RESTPOSITION} if fall == "restposition" else None,
)
protokoll = Protokoll()
melder = SammelMelder()
with tempfile.TemporaryDirectory(prefix="af-gegenfall-") as wurzel:
ordner = Path(wurzel)
registry = Registry(db_pfad=ordner / "registry.db", artefakt_wurzel=ordner / "artifacts")
Killswitch.laden(broker=broker, registry=registry, config=ks_config)
agent, beschreibung = lade_agent(config, registry)
sitzung = PaperSitzung(
broker=broker,
kerzenquelle=quelle,
agent=agent,
agent_beschreibung=beschreibung_je_instrument(config, beschreibung),
agenten=agenten_je_instrument(config, agent),
registry=registry,
limits=limits,
killswitch_config=ks_config,
config=config,
uhr=uhr,
protokoll=protokoll,
protokoll_pfad="af-gegenfall.jsonl",
status_pfad=ordner / "status.json",
modus="probe",
soll_abbrechen=lambda: False,
melder=melder,
)
code = sitzung.laufe()
gespeichert = registry.killswitch_zustand()
ereignisse = registry.killswitch_ereignisse()
auto = [z for z in protokoll.zeilen if z.get("was") == "auto_fortsetzen"]
orders = [z for z in protokoll.zeilen if z.get("art") == "order"]
takte = [z for z in protokoll.zeilen if z.get("art") == "takt"]
print(f"Fall {fall}, {len(kerzen)} Kerzen ab {kerzen[0].zeit.isoformat()}, Beginn {beginn.isoformat()}")
print(f"Instrumente: {', '.join(config.alle_instrumente)}")
for zeile in auto:
print(f"auto_fortsetzen {zeile.get('ergebnis')} | {zeile.get('text')} | phase {zeile.get('phase')}")
for meldung in melder.gesehen:
print(f"Meldung [{meldung.prioritaet}] {meldung.titel}")
for zeile in meldung.text.split("\n"):
if zeile.startswith(("Killswitch", "Nicht selbst fortgesetzt", "Ausloeser", "Position")):
print(f" {zeile}")
print(f"Killswitch am Ende: {gespeichert.zustand}, Ausloeser {gespeichert.ausloeser}")
print(f"Killswitch-Ereignisse: {[(e.stufe, e.ausloeser) for e in ereignisse]}")
print(f"Takte {len(takte)}, Orders {len(orders)}, Exit {code}")
befunde = []
titel = [m.titel for m in melder.gesehen]
automatik = [e for e in ereignisse if e.ausloeser == "automatik"]
if fall == "flach":
if [z.get("ergebnis") for z in auto] != ["fortgesetzt"]:
befunde.append(f"auto_fortsetzen {[z.get('ergebnis') for z in auto]} statt ['fortgesetzt']")
if "Paper-Runner laeuft" not in titel or "Paper-Runner wartet" in titel:
befunde.append(f"Meldungen {titel}")
if len(automatik) != 1 or automatik[0].stufe != "fortsetzen":
befunde.append(f"Ereignisse mit Ausloeser automatik: {[(e.stufe, e.grund[:60]) for e in automatik]}")
if not orders:
befunde.append("keine Order -- fortgesetzt, aber nicht gehandelt")
if code != 0:
befunde.append(f"Exit {code} statt 0")
else:
if fall == "restposition":
erwartet = f"Konto nicht flach: {rest} {RESTPOSITION}"
else:
erwartet = "dieser Prozess ist nicht vor der Oeffnung gestartet"
if [z.get("text") for z in auto] != [f"nicht_fortgesetzt: {erwartet}"]:
befunde.append(f"auto_fortsetzen {[z.get('text') for z in auto]}")
wartet = [m for m in melder.gesehen if m.titel == "Paper-Runner wartet"]
if len(wartet) != 1 or f"Nicht selbst fortgesetzt: {erwartet}" not in wartet[0].text.split("\n"):
befunde.append(f"Meldungen {titel}")
if automatik:
befunde.append(f"{len(automatik)} Ereignis(se) mit Ausloeser automatik")
if orders:
befunde.append(f"{len(orders)} Order(s) ohne Fortsetzung")
if any(t.get("killswitch") != "angehalten" for t in takte):
befunde.append("ein Takt nicht angehalten")
erwarteter_code = 4 if fall == "restposition" else 0
if code != erwarteter_code:
befunde.append(f"Exit {code} statt {erwarteter_code}")
for befund in befunde:
print(f"BEFUND: {befund}")
if befunde:
return 2
print("ERGEBNIS: Fall wie erwartet")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,54 @@
"""Fragt pruefe_register (Paket AF) gegen ein Killswitch-Register, nur lesend.
Koordinator-Werkzeug, nicht fuers Repo (2026-09-24, Befund der AF-Pruefung: das Drehbuch hatte fuer
die Registerpruefung nur eine Erwartung in Prosa). Oeffnet data/registry.db mit mode=ro -- die Klasse
Registry fuehrt beim Oeffnen das Schema aus und schriebe damit in die Datei --, baut Zustand und
Ereignisse als einfache Objekte und ruft die echte Funktion trading.paper.auto_fortsetzen.pruefe_register
des Projekts. Druckt den Befund und die juengsten Ereignisse (Stufe, Ausloeser, Zeit), nie einen Betrag.
Was es nicht sieht: Konto, Spielgeld, Uhr und Schalter -- das pruefen Sitzung und vorstart_pruefung.py.
Vor der Oeffnung am Handelstag steht der Killswitch nach dem Dashboard-Neustart auf angehalten/neustart;
davor (etwa nach Pascals Klick am Vortag) ist "steht auf aktiv" der erwartete Befund, kein Fehler.
Aufruf (WSL, im Projektordner): ~/venvs/trading/bin/python af_registerprobe.py <projektordner>
Exit 0: erfuellt, 1: nicht erfuellt, 3: Register fehlt.
"""
import sqlite3
import sys
from pathlib import Path
from types import SimpleNamespace
def main(argv=None) -> int:
argumente = sys.argv[1:] if argv is None else argv
projekt = Path(argumente[0] if argumente else ".").resolve()
db = projekt / "data" / "registry.db"
if not db.is_file():
print(f"ABBRUCH: {db} fehlt")
return 3
sys.path.insert(0, str(projekt))
from trading.paper.auto_fortsetzen import pruefe_register
verbindung = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
try:
zeile = verbindung.execute("select zustand, ausloeser from killswitch_zustand").fetchone()
ereignisse = [
SimpleNamespace(id=nummer, stufe=stufe, ausloeser=ausloeser, zeit=zeit)
for nummer, stufe, ausloeser, zeit in verbindung.execute(
"select id, stufe, ausloeser, zeit from killswitch_ereignisse order by id"
)
]
finally:
verbindung.close()
gespeichert = None if zeile is None else SimpleNamespace(zustand=zeile[0], ausloeser=zeile[1])
befund = pruefe_register(gespeichert, ereignisse)
print(f"Zustand: {zeile}")
for ereignis in ereignisse[-4:]:
print(f" Nr. {ereignis.id}: {ereignis.stufe} von {ereignis.ausloeser}, {ereignis.zeit}")
print(f"pruefe_register: {'erfuellt' if befund.erfuellt else 'NICHT erfuellt'} -- {befund.grund}")
return 0 if befund.erfuellt else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,23 @@
#!/bin/bash
# Dashboard-Neustart nach einem Einspielen, mit Tor 6b (liest das Dashboard die Geheimnisdatei unter PrivateUsers=yes?).
# Koordinator-Werkzeug, nicht fuers Repo. Pascals dauerhafte Freigabe gilt fuer genau diesen Neustart, nur ausserhalb der
# Handelszeit; das Skript verweigert, solange der Runner laeuft. Aufruf aus der PowerShell mit WSL_PW per WSLENV:
# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh
set -u
[ -n "${WSL_PW:-}" ] || { echo "WSL_PW fehlt"; exit 20; }
S() { printf '%s\n' "$WSL_PW" | sudo -S -p '' "$@"; }
if systemctl is-active --quiet trading-paper.service; then echo "Runner laeuft - kein Neustart"; exit 21; fi
date
S systemctl restart trading-dashboard.service || exit 22
sleep 8
systemctl is-active trading-dashboard.service
systemctl show trading-dashboard.service --property=NRestarts,ActiveEnterTimestamp
journalctl -u trading-dashboard.service --since "-1min" --no-pager | tail -10 | sed -E 's/[0-9]{6,}/######/g'
if journalctl -u trading-dashboard.service --since "-1min" --no-pager | grep -E "kann sie aber nicht lesen|PermissionError|secrets.toml|Traceback"; then
echo "TOR 6b ZU"; exit 27
fi
echo "Tor 6b offen"
code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: 127.0.0.1:8144" http://127.0.0.1:8144/api/gesund)
echo "api/gesund: $code"
code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: 127.0.0.1:8144" http://127.0.0.1:8144/)
echo "Oberflaeche /: $code"
@@ -0,0 +1,86 @@
"""Vor dem Push: kommt ein Wert aus der Geheimnis- oder Zugangsdatei in den neuen Commits vor?
Koordinator-Werkzeug, nicht fuers Repo (Pascal 2026-09-24: Push jederzeit, vorher dieser Scan).
Regressionsnachweis vor jedem Scan: SCAN_PROBE=<bekannter Text aus dem Verlauf> muss einen Treffer melden.
Liest die Werte, gibt sie nie aus, nur Schluesselname und Trefferzahl. Laeuft in WSL als claude.
Aufruf: python geheimnis_scan.py <repo> <von> <bis> [<bis> ...]
"""
import subprocess
import sys
import tomllib
from pathlib import Path
GEHEIM = Path("/home/claude/.config/trading-plattform/secrets.toml")
repo = Path(sys.argv[1])
von = sys.argv[2]
ziele = sys.argv[3:]
def blaetter(tabelle, pfad=""):
for schluessel, wert in tabelle.items():
name = f"{pfad}.{schluessel}" if pfad else schluessel
if isinstance(wert, dict):
yield from blaetter(wert, name)
elif isinstance(wert, str):
yield name, wert
werte = []
for datei in (GEHEIM, repo / "config" / "zugang.toml"):
if not datei.exists():
print(f"BEFUND: {datei.name} fehlt, Scan ohne diese Werte")
continue
with datei.open("rb") as f:
daten = tomllib.load(f)
for name, wert in blaetter(daten):
# Kurze Werte (true/false, Hostnamen wie localhost, Modellnamen) treffen Prosa.
if len(wert) >= 12:
werte.append((f"{datei.name}:{name}", wert))
schluessel_json = repo / "config" / "zugang-schluessel.json"
if schluessel_json.exists():
import json
for i, eintrag in enumerate(json.loads(schluessel_json.read_text(encoding="utf-8")) or []):
if isinstance(eintrag, dict):
for k, v in eintrag.items():
if isinstance(v, str) and len(v) >= 12:
werte.append((f"zugang-schluessel.json:{i}.{k}", v))
# Seit 2026-09-24 (Paket IB): die IBKR-Zugangsdateien. Die Kontonummer hat 9 Zeichen, deshalb
# hier ab 6 statt ab 12; diese drei Werte stehen in keiner Prosa.
IBKR = Path("/home/claude/.config/trading-plattform/ibkr")
for name in ("paper_benutzer", "paper_passwort", "paper_konto"):
datei = IBKR / name
if datei.exists():
wert = datei.read_text(encoding="utf-8").strip()
if len(wert) >= 6:
werte.append((f"ibkr:{name}", wert))
else:
print(f"BEFUND: ibkr/{name} fehlt, Scan ohne diesen Wert")
import os
if os.environ.get("SCAN_PROBE"):
# Regressionsnachweis: ein bekannter Text aus dem Verlauf muss als Treffer erscheinen.
werte.append(("probe", os.environ["SCAN_PROBE"]))
print(f"geprueft: {len(werte)} Werte ab 12 Zeichen")
befunde = 0
for ziel in ziele:
text = subprocess.run(
["git", "-c", "safe.directory=*", "-C", str(repo), "log", "-p", "--all-match", f"{von}..{ziel}"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
).stdout
print(f"{von}..{ziel}: {len(text)} Zeichen Verlauf")
for name, wert in werte:
n = text.count(wert)
if n:
befunde += 1
print(f" TREFFER {name}: {n}")
verfolgt = subprocess.run(
["git", "-c", "safe.directory=*", "-C", str(repo), "ls-tree", "-r", "--name-only", ziel],
capture_output=True, text=True,
).stdout.splitlines()
for gesperrt in ("config/secrets.toml", "config/zugang.toml", "config/zugang-schluessel.json"):
if gesperrt in verfolgt:
befunde += 1
print(f" TREFFER verfolgt: {gesperrt}")
print(f"ERGEBNIS: {befunde} Treffer")
sys.exit(1 if befunde else 0)
@@ -0,0 +1,97 @@
#!/bin/bash
# Erster Anmeldetest am IBKR-Paper-Konto: IB Gateway als Container in WSL, nur lesend.
# Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: Container in WSL, beide Downloads frei
# (ghcr.io/gnzsnz/ib-gateway, festgelegt auf die Pruefsumme unten; nautilus-ibapi im eigenen Venv
# ~/venvs/ibkr-probe), Test nach 22:00, zweiter Faktor bei ihm nur ein Passkey.
# Das Abbild ist dasselbe, das NautilusTrader 1.231.0 mit DockerizedIBGatewayConfig startet
# (config.py: container_image "ghcr.io/gnzsnz/ib-gateway:stable"; gateway.py: Paper aussen 4004).
# Die Zugangsdaten liest der Container aus /home/claude/.config/trading-plattform/ibkr/ (Pascal traegt sie
# mit ibkr_zugang_eintragen.sh ein); dieses Skript gibt sie nie aus und maskiert den Benutzernamen im Log.
# Aufruf aus der PowerShell (sudo per WSLENV, wie bei den anderen Betriebsskripten):
# $env:WSL_PW=[Environment]::GetEnvironmentVariable('DEBIAN_WSL_PASSWORD','User'); $env:WSLENV='WSL_PW'
# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh <schritt>
# Schritte: laden | pruefen | starten | log | probe | stoppen
set -u
BILD="ghcr.io/gnzsnz/ib-gateway@sha256:5fb04cb93cd705eff8a5a58b015503003864ffd9ea890be4d07273389149aa9f"
ZUGANG=/home/claude/.config/trading-plattform/ibkr
NAME=ibkr-gateway-probe
PROBE_PY=/home/claude/venvs/ibkr-probe/bin/python
PROBE=/mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_probe.py
[ -n "${WSL_PW:-}" ] || { echo "WSL_PW fehlt (sudo per WSLENV)"; exit 20; }
S() { printf '%s\n' "$WSL_PW" | sudo -S -p '' "$@"; }
stunde=$(TZ=Europe/Berlin date +%-H)
# RUHE_AUSNAHME: nur auf Pascals ausdrueckliche Anweisung fuer einen einzelnen Lauf (so am 2026-09-24,
# 17:0x: "Bitte schon einmal testen ohne andere Workflows zu gefaehrden"); der Wert nennt Datum und Anlass.
if [ "$stunde" -ge 15 ] && [ "$stunde" -lt 22 ] && [ -z "${RUHE_AUSNAHME:-}" ]; then
echo "Ruhe-Regel: zwischen 15 und 22 Uhr Berlin laeuft hier nichts (jetzt $(TZ=Europe/Berlin date +%H:%M))."
exit 21
fi
maske() {
# Den Benutzernamen und lange Ziffernfolgen aus fremder Ausgabe nehmen, ohne ihn selbst zu zeigen.
python3 -c '
import re, sys
from pathlib import Path
benutzer = ""
p = Path(sys.argv[1])
if p.is_file():
benutzer = p.read_text(encoding="utf-8").strip()
for zeile in sys.stdin:
if benutzer:
zeile = zeile.replace(benutzer, "[benutzer]")
# Kontonummern: ein bis vier Grossbuchstaben und mindestens fuenf Ziffern. Die erste Fassung kannte
# nur DU plus Ziffern; das Paper-Konto hat einen Buchstaben mehr und stand am 2026-09-24 unmaskiert im Log.
zeile = re.sub(r"\b[A-Z]{1,4}\d{5,}\b", "[konto]", zeile)
sys.stdout.write(zeile)
' "$ZUGANG/paper_benutzer"
}
case "${1:-}" in
laden)
S docker pull "$BILD" 2>&1 | tail -n 3
S docker image inspect "$BILD" --format 'Id {{.Id}} Groesse {{.Size}} Erstellt {{.Created}}'
;;
pruefen)
# Welche Variablen liest das Abbild aus Dateien, und unter welcher UID laeuft es?
S docker run --rm --entrypoint sh "$BILD" -c '
echo "== id"; id
echo "== _FILE-Variablen in den Startskripten"; grep -rhoE "[A-Z_]+_FILE" /home /usr/local/bin /opt 2>/dev/null | sort -u | head -20
echo "== Ports"; grep -rhoE "4003|4004|4001|4002|5900" /home /usr/local/bin 2>/dev/null | sort | uniq -c | head
' 2>&1 | head -40
;;
starten)
for datei in paper_benutzer paper_passwort; do
[ -s "$ZUGANG/$datei" ] || { echo "fehlt: $ZUGANG/$datei (erst ibkr_zugang_eintragen.sh)"; exit 22; }
done
S docker rm -f "$NAME" >/dev/null 2>&1
# Benutzername und Passwort aus Dateien im Container; TWS_USERID_FILE nur, wenn "pruefen" es fand,
# sonst setzt BENUTZER_ALS_VARIABLE=1 den Namen als Variable (er ist kein Geheimnis wie das Passwort).
if [ "${BENUTZER_ALS_VARIABLE:-0}" = "1" ]; then
BENUTZER_ARG=(-e "TWS_USERID=$(cat "$ZUGANG/paper_benutzer")")
else
BENUTZER_ARG=(-e "TWS_USERID_FILE=/run/secrets/ibkr/paper_benutzer")
fi
S docker run -d --name "$NAME" \
-e TRADING_MODE=paper -e READ_ONLY_API=yes -e TWOFA_TIMEOUT_ACTION=exit \
-e TWS_PASSWORD_FILE=/run/secrets/ibkr/paper_passwort "${BENUTZER_ARG[@]}" \
-e TIME_ZONE=America/New_York \
-v "$ZUGANG:/run/secrets/ibkr:ro" \
-p 127.0.0.1:4004:4004 \
"$BILD" >/dev/null && echo "gestartet: $NAME (Paper, nur lesend, Port 127.0.0.1:4004)"
;;
log)
S docker logs --tail "${2:-60}" "$NAME" 2>&1 | maske
;;
probe)
cd /tmp && "$PROBE_PY" "$PROBE" --port 4004
;;
stoppen)
S docker rm -f "$NAME" 2>&1 | tail -n 1
;;
*)
echo "Schritt fehlt: laden | pruefen | starten | log | probe | stoppen"
exit 2
;;
esac
@@ -0,0 +1,157 @@
"""Lesende Probe gegen das IBKR-Paper-Konto ueber ein laufendes IB Gateway: verbindet, liest, trennt.
Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: Zugang zum IBKR-Paper-Konto vorbereiten,
Gateway als Container in WSL (ghcr.io/gnzsnz/ib-gateway, wie DockerizedIBGatewayConfig in NautilusTrader
1.231.0), erster Test heute nach 22:00. Diese Probe handelt nie: Das Gateway laeuft mit READ_ONLY_API=yes,
und sie ruft keine Order-Methode.
Entscheidungsliste (Frage -> Herkunft der Antwort):
1. Erreicht die Probe das Gateway? Quelle: EClient.connect/isConnected (nautilus-ibapi 10.45.1),
Port 4004 = Paper aussen (gateway.py:43-45 in Nautilus 1.231.0).
2. Welches Konto ist angemeldet? Quelle: managedAccounts-Rueckruf. Ausgegeben: die ersten zwei
Zeichen und die Laenge (Paper-Konten beginnen im Doku-Beispiel
mit "DU"; das ist ein Beispiel, keine Zusage).
3. Welche Kontoart, welche Waehrung? Quelle: reqAccountSummary mit den Tags AccountType, Currency;
Betraege (NetLiquidation, TotalCashValue) nur als "vorhanden".
4. Wie viele Positionen? Quelle: reqPositions -> positionEnd; nur die Zahl.
5. Kommt ein verzoegerter Kurs? Quelle: reqMarketDataType(4, DELAYED_FROZEN; ohne Abo laut
Nautilus-Doku) und reqMktData(AAPL, SMART, USD, Snapshot).
Ausgegeben: welche Kursart kam (Tick-Typ), nie der Kurs.
6. Welche Fehler meldet das Gateway? Quelle: error-Rueckruf; Code und gekuerzter Text (ohne Zahlen
ueber vier Stellen).
Eine fehlende Antwort nach ZEITLIMIT_S ist ein Befund, kein Schweigen. Exit 0 alles beantwortet,
1 mindestens ein Befund, 3 keine Verbindung, 2 abgebrochen.
Aufruf (WSL, eigenes Venv): ~/venvs/ibkr-probe/bin/python ibkr_probe.py [--port 4004] [--client-id 991]
"""
import argparse
import re
import sys
import threading
import time
ZEITLIMIT_S = 30
NUR_LESEN_TAGS = "AccountType,Currency,NetLiquidation,TotalCashValue"
BETRAGS_TAGS = {"NetLiquidation", "TotalCashValue"}
def maskiere(text: str) -> str:
return re.sub(r"\d{5,}|\d+\.\d+", "[x]", str(text))
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(allow_abbrev=False)
zerleger.add_argument("--host", default="127.0.0.1")
zerleger.add_argument("--port", type=int, default=4004)
zerleger.add_argument("--client-id", type=int, default=991)
argumente = zerleger.parse_args(argv)
from ibapi.client import EClient
from ibapi.contract import Contract
from ibapi.wrapper import EWrapper
class Probe(EWrapper, EClient):
def __init__(self):
EWrapper.__init__(self)
EClient.__init__(self, self)
self.ereignisse = {name: threading.Event() for name in ("konto", "zusammenfassung", "positionen", "kurs", "zeit")}
self.konten = ""
self.zusammenfassung = {}
self.positionen = 0
self.kurs_arten = set()
self.fehler = []
self.zeit = None
def managedAccounts(self, accountsList):
self.konten = accountsList
self.ereignisse["konto"].set()
def currentTime(self, zeit):
self.zeit = zeit
self.ereignisse["zeit"].set()
def accountSummary(self, reqId, account, tag, value, currency):
self.zusammenfassung[tag] = "vorhanden" if tag in BETRAGS_TAGS else value
def accountSummaryEnd(self, reqId):
self.ereignisse["zusammenfassung"].set()
def position(self, account, contract, position, avgCost):
self.positionen += 1
def positionEnd(self):
self.ereignisse["positionen"].set()
def tickPrice(self, reqId, tickType, price, attrib):
if price and price > 0:
self.kurs_arten.add(tickType)
def tickSnapshotEnd(self, reqId):
self.ereignisse["kurs"].set()
def error(self, reqId, *rest):
# Die Signatur wechselt zwischen ibapi-Versionen: nautilus-ibapi 10.45.1 schickt vor dem
# Code einen Zeitstempel in Millisekunden (gemessen am 2026-09-24: 1790261211141 vor 502).
# Fehlercodes sind klein; der Zeitstempel ist es nicht.
werte = [r for r in rest if isinstance(r, (int, str))]
code = next((w for w in werte if isinstance(w, int) and 0 < w < 100000), None)
text = next((w for w in werte if isinstance(w, str) and len(w) > 3), "")
self.fehler.append((reqId, code, maskiere(text)[:160]))
probe = Probe()
befunde = []
try:
probe.connect(argumente.host, argumente.port, argumente.client_id)
faden = threading.Thread(target=probe.run, daemon=True)
faden.start()
beginn = time.time()
while not probe.isConnected() and time.time() - beginn < 10:
time.sleep(0.2)
if not probe.isConnected():
print(f"BEFUND: keine Verbindung zu {argumente.host}:{argumente.port}")
for f in probe.fehler[:5]:
print(f" fehler {f}")
return 3
print(f"verbunden mit {argumente.host}:{argumente.port}, Serverversion {probe.serverVersion()}")
probe.reqCurrentTime()
probe.reqAccountSummary(9001, "All", NUR_LESEN_TAGS)
probe.reqPositions()
probe.reqMarketDataType(4)
aapl = Contract()
aapl.symbol, aapl.secType, aapl.exchange, aapl.currency = "AAPL", "STK", "SMART", "USD"
probe.reqMktData(9002, aapl, "", True, False, [])
for name, ereignis in probe.ereignisse.items():
if not ereignis.wait(ZEITLIMIT_S):
befunde.append(f"keine Antwort auf {name} in {ZEITLIMIT_S} s")
konten = [k for k in probe.konten.split(",") if k]
print(f"konten: {len(konten)}; " + ", ".join(f"{k[:2]}… ({len(k)} Zeichen)" for k in konten))
print(f"serverzeit: {'da' if probe.zeit else 'fehlt'}")
print(f"kontoart: {probe.zusammenfassung.get('AccountType')}, waehrung: {probe.zusammenfassung.get('Currency')}, "
f"nettoliquidation: {probe.zusammenfassung.get('NetLiquidation', 'fehlt')}, bargeld: {probe.zusammenfassung.get('TotalCashValue', 'fehlt')}")
print(f"positionen: {probe.positionen}")
print(f"kursarten AAPL (Tick-Typen mit Preis): {sorted(probe.kurs_arten) or 'keine'}")
for f in probe.fehler:
print(f"gateway meldet: reqId {f[0]}, code {f[1]}: {f[2]}")
if not konten:
befunde.append("kein Konto gemeldet")
if not probe.kurs_arten:
befunde.append("kein verzoegerter Kurs fuer AAPL")
probe.cancelAccountSummary(9001)
probe.cancelPositions()
except Exception as ausnahme:
print(f"BEFUND: Probe abgebrochen: {type(ausnahme).__name__}: {maskiere(ausnahme)[:200]}")
return 2
finally:
if probe.isConnected():
probe.disconnect()
for befund in befunde:
print(f"BEFUND: {befund}")
print(f"ERGEBNIS: {len(befunde)} Befund(e)")
return 1 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,44 @@
#!/bin/bash
# Traegt die Zugangsdaten des IBKR-Paper-Kontos ein. Pascal fuehrt es selbst in seinem Terminal aus;
# kein Agent sieht die Werte. Passwort ohne Anzeige, zweimal zur Kontrolle.
# Ablage: /home/claude/.config/trading-plattform/ibkr/ (Ordner 0700, Dateien 0600, Besitzer claude),
# neben der Geheimnisdatei aus Paket E2. Ausgegeben werden nur Dateinamen und Laengen.
# Koordinator-Werkzeug, nicht fuers Repo. Aufruf aus der PowerShell:
# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh
set -eu
umask 077
# IBKR_ZIEL nur fuer die Probe des Koordinators mit erfundenen Werten in einem Wegwerf-Ordner.
ZIEL=${IBKR_ZIEL:-/home/claude/.config/trading-plattform/ibkr}
if [ "$(id -un)" != "claude" ]; then
echo "Bitte als Benutzer claude ausfuehren (wsl -d Debian -u claude -- bash ...)."
exit 2
fi
mkdir -p "$ZIEL"
chmod 700 "$ZIEL"
echo "IBKR-Paper-Konto: Zugangsdaten eintragen (nichts davon verlaesst diesen Rechner)."
read -r -p "Paper-Benutzername: " BENUTZER
read -r -s -p "Paper-Passwort (wird nicht angezeigt): " PASSWORT
echo
read -r -s -p "Passwort noch einmal: " PASSWORT2
echo
if [ -z "$BENUTZER" ] || [ -z "$PASSWORT" ]; then
echo "Benutzername oder Passwort leer, nichts geschrieben."
exit 2
fi
if [ "$PASSWORT" != "$PASSWORT2" ]; then
echo "Die beiden Passwoerter stimmen nicht ueberein, nichts geschrieben."
exit 2
fi
read -r -p "Paper-Kontonummer (beginnt meist mit DU; leer lassen, wenn du sie nicht zur Hand hast): " KONTO
printf '%s' "$BENUTZER" > "$ZIEL/paper_benutzer"
printf '%s' "$PASSWORT" > "$ZIEL/paper_passwort"
if [ -n "$KONTO" ]; then
printf '%s' "$KONTO" > "$ZIEL/paper_konto"
fi
unset PASSWORT PASSWORT2
chmod 600 "$ZIEL"/*
echo "Gespeichert unter $ZIEL, nur fuer den Benutzer claude lesbar:"
for datei in "$ZIEL"/*; do
printf ' %s: %s Zeichen\n' "$(basename "$datei")" "$(wc -c < "$datei" | tr -d ' ')"
done
echo "Fertig. Aendern: dieses Skript noch einmal ausfuehren. Loeschen: rm -r $ZIEL"
@@ -0,0 +1,236 @@
"""Rueckweg des Paper-Runners von zehn Aktien auf AAPL, einmal, nach Pascals Vorgabe.
Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: zehn Aktien "bis Mittwoch" -- gelesen als
bis einschliesslich Mittwoch 2026-09-30; ab Donnerstag 2026-10-01 handelt der Runner wieder AAPL mit der
Registerversion. Der Rueckweg ist der aus Paket M: config/paper-aapl-register.toml wird config/paper.toml.
Geplante Aufgabe "Trading Plattform - Rueckweg AAPL" (rueckweg_aapl_einrichten.ps1): 2026-09-30 22:50,
zweiter Versuch 2026-10-01 22:10, verpasste Termine beim naechsten Start (StartWhenAvailable).
Schritte, jeder mit seinem Grund:
1. Nicht vor FRUEHESTENS (nach Boersenschluss des letzten Tages mit zehn Aktien).
2. Ergebnisdatei ABLAGE/rueckweg.json da -> schon erledigt, nichts tun.
3. Laeuft der Runner (systemctl is-active trading-paper.service in WSL), wird nichts getauscht: Er hat
seine Konfiguration beim Start gelesen, und ein Tausch mitten am Tag wirkte erst beim naechsten Start.
Befund, der zweite Termin versucht es erneut.
Stand er, wird nach WARTEN_S noch einmal gefragt: Hat erst dieser Aufruf WSL geweckt, startet der
Zeitgeber den Runner 60 s danach (OnStartupSec=60) und liest dabei die Konfiguration.
4. Im Projektordner muss ZWEIG ausgecheckt sein, sonst Befund.
5. config/paper.toml gleich der Rueckweg-Datei: sauberer Ordner -> schon getauscht (Ergebnisdatei,
Ende); nur config/paper.toml geaendert -> halb erledigt (ein gescheiterter Commit), weiter mit 8;
sonst Befund.
6. Sonst muss der Ordner sauber sein (git status --porcelain leer), sonst Befund: Ein Commit ueber
fremde Aenderungen waere schlimmer als ein Tag mehr mit zehn Aktien. Und config/paper.toml muss die
Konfiguration mit mehreren Aktien sein (Zeile 'quelle = "folge"'), sonst Befund.
7. Jeder Schluessel aus config/paper.toml ausser den erwarteten Unterschieden (ERWARTET_ANDERS) muss
in der Rueckweg-Datei mit demselben Wert stehen, sonst Befund: Der Tausch verloere ihn still oder
schaltete ihn still um (etwa [auto_fortsetzen] aktiv = false, von Pascal gesetzt).
8. Bytes kopieren, git add, git commit (lokal; gepusht wird von der naechsten Sitzung -- ein Push aus
einer Aufgabe ohne Fenster koennte an einer Anmeldeabfrage haengen).
9. Eine Zeile in Inbox/<heute>.md unter "## Agentenprotokoll"; bei einem Befund zusaetzlich eine Meldung
mit hoher Prioritaet ueber abendkontrolle_wsl.py --meldung.
Aufruf von Hand: python rueckweg_aapl.py [--jetzt ISO] [--projekt PFAD] [--vault PFAD] [--ohne-wsl]
"""
import argparse
import json
import subprocess
import sys
import time
import tomllib
import traceback
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from abendkontrolle import KEIN_FENSTER # noqa: E402
from abendkontrolle import WSL # noqa: E402
from abendkontrolle import WSL_PY # noqa: E402
from abendkontrolle import WSL_SKRIPT # noqa: E402
from abendkontrolle import schreibe_notiz # noqa: E402
VAULT = Path(r"D:\Claude")
PROJEKT = VAULT / "Code Projekte" / "active - Trading Plattform"
GIT = r"C:\Program Files\Git\cmd\git.exe"
FRUEHESTENS = datetime(2026, 9, 30, 22, 5)
ZIEL = Path("config") / "paper.toml"
RUECKWEG = Path("config") / "paper-aapl-register.toml"
MEHRERE = 'quelle = "folge"'
ZWEIG = "master"
ERWARTET_ANDERS = ("agent.", "umgebung.instrumente", "umgebung.instrument", "grenzen.max_tagesverlust_anteil")
WARTEN_S = 90
NOCHMAL = "Steht noch ein Termin der Aufgabe aus, versucht er es erneut; sonst entscheidet die naechste Sitzung."
NACHRICHT = (
"Rueckweg auf AAPL: config/paper.toml aus config/paper-aapl-register.toml\n\n"
"Pascal am 2026-09-24: zehn Aktien bis Mittwoch 2026-09-30. Ab 2026-10-01 handelt der Runner wieder\n"
"AAPL mit der Registerversion (Rueckweg aus Paket M). Automatisch durch die geplante Aufgabe\n"
"'Trading Plattform - Rueckweg AAPL' (.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py).\n\n"
"Co-Authored-By: Claude Opus 5.5 <[email protected]>\n"
)
def git(projekt: Path, *argumente: str) -> subprocess.CompletedProcess:
return subprocess.run(
[GIT, "-C", str(projekt), *argumente], capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=120, creationflags=KEIN_FENSTER,
env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"},
)
def runner_aktiv(vorgabe: str | None) -> bool:
if vorgabe is not None:
zustand = vorgabe # Angriffsfaelle: die Ausgabe von systemctl is-active, ohne WSL
else:
lauf = subprocess.run(
[WSL, "-d", "Debian", "-u", "claude", "--", "systemctl", "is-active", "trading-paper.service"],
capture_output=True, timeout=60, creationflags=KEIN_FENSTER,
)
zustand = lauf.stdout.decode("utf-8", errors="replace").strip()
if zustand in ("active", "activating", "deactivating", "reloading"):
return True
if zustand in ("inactive", "failed"):
return False
raise RuntimeError(f"Zustand des Runners nicht lesbar: {zustand!r}")
def melde(titel: str, text: str, ohne_wsl: bool) -> str:
if ohne_wsl:
return "ohne WSL, nicht gesendet"
lauf = subprocess.run(
[WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--meldung", titel, text],
capture_output=True, timeout=120, creationflags=KEIN_FENSTER,
)
return lauf.stdout.decode("utf-8", errors="replace").strip()[-200:]
def blaetter(datei: Path) -> dict:
"""Alle Blattschluessel einer TOML-Datei als Punktpfad mit ihrem Wert."""
def ablaufen(tabelle: dict, pfad: str):
for name, wert in tabelle.items():
voll = f"{pfad}.{name}" if pfad else name
if isinstance(wert, dict):
yield from ablaufen(wert, voll)
else:
yield voll, wert
with datei.open("rb") as f:
return dict(ablaufen(tomllib.load(f), ""))
def fehlende_schluessel(ziel: Path, quelle: Path) -> list[str]:
"""Was der Tausch ausser den erwarteten Unterschieden aendern wuerde.
Projektvorgabe (Paket M, Rueckweg): Die beiden Dateien unterscheiden sich nur in [agent],
umgebung.instrumente (zehn Aktien) gegen umgebung.instrument (AAPL) und im Wert von
grenzen.max_tagesverlust_anteil (1 % gegen 0,2 %); verglichen am 2026-09-24 im Arbeitsbaum M.
Jeder andere Schluessel, der nur in config/paper.toml steht, ginge beim Tausch still verloren
(etwa [auto_fortsetzen] aus Paket AF oder etwas aus Z2), und jeder andere Wert, der sich
unterscheidet, sprang still um -- etwa auto_fortsetzen.aktiv, das Pascal in config/paper.toml
abgeschaltet hat (Befund der Plankritik AF, 2026-09-24).
"""
alt, neu = blaetter(ziel), blaetter(quelle)
befunde = []
for name in sorted(alt.keys() | neu.keys()):
if name.startswith(ERWARTET_ANDERS):
continue
if name not in neu:
befunde.append(f"{name} fehlt in der Rueckweg-Datei")
elif name not in alt:
# Die Gegenrichtung (Nachkritik AF, 2026-09-24): Hat Pascal [auto_fortsetzen] in
# config/paper.toml geloescht -- laut Task 421 ein gueltiges Abschalten --, brachte der
# Tausch aktiv = true aus der Rueckweg-Datei still zurueck.
befunde.append(f"{name} steht nur in der Rueckweg-Datei")
elif neu[name] != alt[name]:
befunde.append(f"{name} anderer Wert")
return befunde
def rueckweg(jetzt: datetime, projekt: Path, ablage: Path, runner_vorgabe: str | None) -> tuple[str, str | None]:
"""(Ergebnis fuer die Notiz, Befund oder None)."""
if jetzt < FRUEHESTENS:
return f"zu frueh ({jetzt:%Y-%m-%d %H:%M}), nichts getan", None
if (ablage / "rueckweg.json").exists():
return "schon erledigt", None
if runner_aktiv(runner_vorgabe):
return "", f"Der Runner laeuft; config/paper.toml nicht getauscht. {NOCHMAL}"
if runner_vorgabe is None:
# Hat erst dieser Aufruf WSL geweckt, startet der Zeitgeber den Runner 60 s danach
# (OnStartupSec=60) mit der Konfiguration, die er dann liest. Also warten und noch einmal fragen.
time.sleep(WARTEN_S)
if runner_aktiv(None):
return "", f"Der Runner ist eben gestartet; config/paper.toml nicht getauscht. {NOCHMAL}"
zweig = git(projekt, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
if zweig != ZWEIG:
return "", f"Im Projektordner ist {zweig!r} ausgecheckt, nicht {ZWEIG}; nicht getauscht"
ziel, quelle = projekt / ZIEL, projekt / RUECKWEG
if not quelle.is_file():
return "", f"{RUECKWEG.as_posix()} fehlt; nicht getauscht"
status = git(projekt, "status", "--porcelain")
if status.returncode != 0:
return "", f"git status scheiterte ({status.returncode}); nicht getauscht"
geaendert = [z for z in status.stdout.splitlines() if z.strip()]
if ziel.is_file() and ziel.read_bytes() == quelle.read_bytes():
if not geaendert:
return "config/paper.toml ist schon die AAPL-Konfiguration", None
if geaendert != [f" M {ZIEL.as_posix()}"] and geaendert != [f"M {ZIEL.as_posix()}"]:
return "", f"Projektordner nicht sauber (git status: {' | '.join(geaendert)[:200]}); nicht getauscht"
# Halb erledigt: getauscht, aber nicht committet (etwa ein gescheiterter Commit beim ersten Termin).
else:
if geaendert:
return "", f"Projektordner nicht sauber (git status: {' | '.join(geaendert)[:200]}); nicht getauscht"
if MEHRERE not in ziel.read_text(encoding="utf-8"):
return "", "config/paper.toml ist nicht die Konfiguration mit mehreren Aktien; nicht getauscht, Pascal entscheidet"
fehlend = fehlende_schluessel(ziel, quelle)
if fehlend:
return "", (
f"config/paper-aapl-register.toml weicht ausserhalb der erwarteten Stellen von config/paper.toml ab "
f"({', '.join(fehlend)[:200]}); nicht getauscht, Pascal entscheidet"
)
ziel.write_bytes(quelle.read_bytes())
for schritt in (("add", ZIEL.as_posix()), ("commit", "-m", NACHRICHT)):
lauf = git(projekt, *schritt)
if lauf.returncode != 0:
return "", (
f"git {schritt[0]} scheiterte ({lauf.returncode}: {(lauf.stderr or lauf.stdout).strip()[:200]}); "
f"Datei ist getauscht, aber nicht committet. {NOCHMAL}"
)
kopf = git(projekt, "rev-parse", "--short", "HEAD").stdout.strip()
return f"config/paper.toml auf AAPL (Register) zurueckgestellt, Commit {kopf} (nicht gepusht)", None
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(allow_abbrev=False)
zerleger.add_argument("--jetzt")
zerleger.add_argument("--projekt", default=str(PROJEKT))
zerleger.add_argument("--vault", default=str(VAULT))
zerleger.add_argument("--ohne-wsl", action="store_true", help="Angriffsfaelle: keine Meldung, Runner laut --runner-zustand")
zerleger.add_argument("--runner-zustand", help="Angriffsfaelle: Ausgabe von systemctl is-active statt WSL")
argumente = zerleger.parse_args(argv)
if argumente.ohne_wsl and argumente.runner_zustand is None:
argumente.runner_zustand = "inactive"
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
projekt, vault = Path(argumente.projekt), Path(argumente.vault)
ablage = projekt / "data" / "paper" / "abendkontrollen"
ablage.mkdir(parents=True, exist_ok=True)
try:
ergebnis, befund = rueckweg(jetzt, projekt, ablage, argumente.runner_zustand)
except Exception as ausnahme:
ergebnis, befund = "", f"abgebrochen: {type(ausnahme).__name__}: {str(ausnahme)[:200]}"
with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as log:
log.write(traceback.format_exc())
with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as log:
log.write(f"{datetime.now().isoformat(timespec='seconds')} rueckweg: {ergebnis or befund}\n")
if ergebnis in ("schon erledigt",) or ergebnis.startswith("zu frueh"):
return 0
kopf = f"- {jetzt:%H:%M} Uhr — Rückweg auf AAPL (automatisch, Pascals Vorgabe: zehn Aktien bis 30.09.)"
if befund is None:
(ablage / "rueckweg.json").write_text(
json.dumps({"zeit": jetzt.isoformat(), "ergebnis": ergebnis}, ensure_ascii=False), encoding="utf-8"
)
schreibe_notiz(vault, jetzt.date().isoformat(), f"{kopf}: {ergebnis}.")
return 0
gemeldet = melde("Rueckweg AAPL nicht ausgefuehrt", befund, argumente.ohne_wsl)
schreibe_notiz(vault, jetzt.date().isoformat(), f"{kopf}: **nicht ausgeführt** — {befund} #needs-review (Meldung: {gemeldet})")
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,113 @@
"""Angriffsfaelle fuer rueckweg_aapl.py auf einem Wegwerf-Git-Repo. Koordinator-Werkzeug, nicht fuers Repo.
Jeder Fall einzeln, zuerst die Grundlinie; ohne WSL (--ohne-wsl, Runnerzustand vorgegeben), ohne Meldung.
Aufruf unter Windows: python rueckweg_aapl_angriffe.py Exit 0, wenn jeder Fall wie erwartet ausgeht.
"""
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
sys.path.insert(0, str(HIER))
import rueckweg_aapl as rw # noqa: E402
ZEHN = '[agent]\nquelle = "folge"\ninstrumente = ["AAPL.XNAS", "MSFT.XNAS"]\n'
AAPL = '[agent]\nquelle = "register"\ninstrument = "AAPL.XNAS"\n'
abweichungen: list[str] = []
def repo(tmp: Path, paper: str | None = ZEHN, aapl: str | None = AAPL) -> Path:
projekt = tmp / "projekt"
(projekt / "config").mkdir(parents=True)
if paper is not None:
(projekt / "config" / "paper.toml").write_bytes(paper.encode("utf-8"))
if aapl is not None:
(projekt / "config" / "paper-aapl-register.toml").write_bytes(aapl.encode("utf-8"))
(projekt / ".gitignore").write_text("data/\n", encoding="utf-8")
for befehl in (["init", "-q", "-b", "master"], ["config", "user.email", "[email protected]"], ["config", "user.name", "Test"],
["config", "core.autocrlf", "false"], ["add", "-A"], ["commit", "-q", "-m", "start"]):
subprocess.run([rw.GIT, "-C", str(projekt), *befehl], check=True, capture_output=True)
(tmp / "Inbox").mkdir()
return projekt
def fall(name, *, jetzt="2026-09-30T22:50:00", vorbereiten=None, runner="inactive", paper=ZEHN, aapl=AAPL,
erwartet_code, getauscht, notiz, marke, commits, grund=None):
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
projekt = repo(tmp, paper, aapl)
if vorbereiten:
vorbereiten(projekt)
vorher = subprocess.run([rw.GIT, "-C", str(projekt), "rev-list", "--count", "HEAD"], capture_output=True, text=True).stdout.strip()
code = rw.main(["--jetzt", jetzt, "--projekt", str(projekt), "--vault", str(tmp), "--ohne-wsl", "--runner-zustand", runner])
paper_jetzt = (projekt / "config" / "paper.toml").read_text(encoding="utf-8") if (projekt / "config" / "paper.toml").exists() else None
notizen = list((tmp / "Inbox").glob("*.md"))
text = notizen[0].read_text(encoding="utf-8") if notizen else ""
hat_marke = (projekt / "data" / "paper" / "abendkontrollen" / "rueckweg.json").exists()
nachher = subprocess.run([rw.GIT, "-C", str(projekt), "rev-list", "--count", "HEAD"], capture_output=True, text=True).stdout.strip()
ist = {
"code": code,
"getauscht": paper_jetzt == AAPL,
"notiz": ("nicht ausgeführt" if "nicht ausgeführt" in text else ("ja" if "Rückweg auf AAPL" in text else "nein")),
"marke": hat_marke,
"commits": int(nachher) - int(vorher),
}
soll = {"code": erwartet_code, "getauscht": getauscht, "notiz": notiz, "marke": marke, "commits": commits}
ok = ist == soll and (grund is None or grund in text)
zeile = f"{'OK ' if ok else 'FEHL'} {name}: {ist}" + ("" if ok else f" SOLL {soll}")
print(zeile)
if not ok:
abweichungen.append(zeile)
fall("Grundlinie", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1)
fall("zu frueh", jetzt="2026-09-30T21:59:00", erwartet_code=0, getauscht=False, notiz="nein", marke=False, commits=0)
fall("Ergebnisdatei schon da",
vorbereiten=lambda p: ((p / "data" / "paper" / "abendkontrollen").mkdir(parents=True), (p / "data" / "paper" / "abendkontrollen" / "rueckweg.json").write_text("{}")),
erwartet_code=0, getauscht=False, notiz="nein", marke=True, commits=0)
fall("Runner aktiv", runner="active", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Runner deactivating", runner="deactivating", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Runnerzustand unlesbar", runner="", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Runner failed (steht)", runner="failed", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1)
fall("schon AAPL", paper=AAPL, erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=0)
fall("Ordner nicht sauber (fremde Aenderung)",
vorbereiten=lambda p: (p / "config" / "risk.toml").write_text("x\n"),
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("paper.toml geaendert, nicht committet",
vorbereiten=lambda p: (p / "config" / "paper.toml").write_text(ZEHN + "# neu\n"),
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("paper.toml ohne folge", paper='[agent]\nquelle = "halte"\n',
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Rueckweg-Datei fehlt", aapl=None, erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("zweiter Termin Donnerstag", jetzt="2026-10-01T22:10:00", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1)
# Befunde des Pruefers: halb erledigter Tausch, verlorener Abschnitt, falscher Zweig
fall("halb getauscht (Datei getauscht, nicht committet)",
vorbereiten=lambda p: (p / "config" / "paper.toml").write_bytes(AAPL.encode("utf-8")),
erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1)
AUTO = "\n[auto_fortsetzen]\naktiv = true\n"
fall("halb getauscht und fremde Aenderung",
vorbereiten=lambda p: ((p / "config" / "paper.toml").write_bytes(AAPL.encode("utf-8")), (p / "config" / "risk.toml").write_text("x\n")),
erwartet_code=1, getauscht=True, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Abschnitt nur in paper.toml", paper=ZEHN + AUTO,
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Abschnitt in beiden", paper=ZEHN + AUTO, aapl=AAPL + AUTO,
erwartet_code=0, getauscht=False, notiz="ja", marke=True, commits=1)
AUS = "\n[auto_fortsetzen]\naktiv = false\n"
fall("Automatik nur in paper.toml abgeschaltet", paper=ZEHN + AUS, aapl=AAPL + AUTO,
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Abschnitt in paper.toml geloescht, in der Rueckweg-Datei noch da", paper=ZEHN, aapl=AAPL + AUTO,
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0,
grund="auto_fortsetzen.aktiv steht nur in der Rueckweg-Datei")
fall("anderer Wert in [grenzen]", paper=ZEHN + "\n[grenzen]\nmax_orders = 5\n", aapl=AAPL + "\n[grenzen]\nmax_orders = 10\n",
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
fall("Tagesverlust darf sich unterscheiden",
paper=ZEHN + "\n[grenzen]\nmax_tagesverlust_anteil = 0.01\n", aapl=AAPL + "\n[grenzen]\nmax_tagesverlust_anteil = 0.002\n",
erwartet_code=0, getauscht=False, notiz="ja", marke=True, commits=1)
fall("anderer Zweig ausgecheckt",
vorbereiten=lambda p: subprocess.run([rw.GIT, "-C", str(p), "checkout", "-q", "-b", "z2"], check=True),
erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0)
print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)")
sys.exit(1 if abweichungen else 0)
@@ -0,0 +1,37 @@
# Richtet die einmalige Windows-Aufgabe "Trading Plattform - Rueckweg AAPL" ein oder entfernt sie.
# Koordinator-Werkzeug, nicht fuers Repo. Pascal 2026-09-24: zehn Aktien bis Mittwoch 2026-09-30.
# Zwei Termine: Mi 2026-09-30 22:50 und als zweiter Versuch Do 2026-10-01 22:10; StartWhenAvailable holt
# einen verpassten Termin beim naechsten Start nach. rueckweg_aapl.py tut nach dem ersten Erfolg nichts mehr.
#
# Aufruf: powershell -NoProfile -File rueckweg_aapl_einrichten.ps1 [-Aktion einrichten|entfernen]
param(
[ValidateSet("einrichten", "entfernen")]
[string]$Aktion = "einrichten"
)
$Name = "Trading Plattform - Rueckweg AAPL"
if ($Aktion -eq "entfernen") {
Unregister-ScheduledTask -TaskName $Name -Confirm:$false
return
}
$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe"
$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\rueckweg_aapl.py"
if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" }
if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" }
$Benutzer = "$env:USERDOMAIN\$env:USERNAME"
$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '"') -WorkingDirectory "D:\Claude"
$Erster = New-ScheduledTaskTrigger -Once -At ([datetime]"2026-09-30T22:50:00")
$Zweiter = New-ScheduledTaskTrigger -Once -At ([datetime]"2026-10-01T22:10:00")
$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited
$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Minutes 15) -MultipleInstances IgnoreNew
Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger @($Erster, $Zweiter) -Principal $Wer `
-Settings $Regeln -Force `
-Description "Rueckweg des Paper-Runners auf AAPL nach Pascals Vorgabe (zehn Aktien bis 2026-09-30). Siehe rueckweg_aapl.py." | Out-Null
$Aufgabe = Get-ScheduledTask -TaskName $Name
"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State
$Aufgabe.Triggers | ForEach-Object { "Ausloeser: {0} {1}" -f $_.CimClass.CimClassName, $_.StartBoundary }
"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime
@@ -0,0 +1,46 @@
---
type: resource
status: active
title: "Workflow-Prüfung: Attrappe, Fälle und Messreihe zum Verbrauch"
summary: "Node-Attrappe für die gespeicherten Workflows des Vaults mit Fällen je Workflow, dazu verbrauch_reihe.py, das aus den Workflow-Protokollen rechnet, wie viel vom 5-Stunden-Fenster und von der Woche zwischen zwei Verbrauchsablesungen verbraucht wurde. Entstanden 2026-09-24."
tags: [vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
---
# Workflow-Prüfung
Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]], Abschnitt „Verbrauch und Opus-Plätze". Hier steht nur, wie man die Werkzeuge benutzt und was belegt ist. Der Plan, mit dem sie entstanden sind: `docs/plans/2026-09-24-parallelitaet/00-uebersicht.md` in diesem Ordner.
## Ablauf
1. **Nach jeder Änderung an einem Workflow unter `.claude/workflows/`:** `node .vault-tools/workflow-pruefung/faelle_workflows.mjs` im Vault-Wurzelordner. Mit einem Ordner als Argument prüft das Skript die Workflows dort, etwa eine Kopie mit angewendetem Plan. Jeder Workflow hat zuerst eine Grundlinie; scheitert sie, messen seine übrigen Fälle nichts.
2. **Ein neuer Fall braucht einen Rotlauf.** Die Fälle gegen den Stand vor der Änderung fahren, oder den Mutanten nachstellen, den der Fall fangen soll. Grün allein belegt nichts.
3. **Mutanten:** `python .vault-tools/workflow-pruefung/mutanten_workflows.py` (einige Minuten) und `python .vault-tools/workflow-pruefung/mutanten_reihe.py`. Ein veraltetes Muster bricht mit Exit 2 ab und muss an den neuen Code angepasst werden; ein wirkungsloser Mutant steht mit Grund in `WIRKUNGSLOS`. Wer einen Workflow ändert, ergänzt hier den Mutanten, den sein neuer Fall fangen soll.
4. **Messreihe:** `python .vault-tools/workflow-pruefung/verbrauch_reihe.py [--seit JJJJ-MM-TT]`. Die Ausgabe ist eine Tabelle je Paar aufeinanderfolgender Ablesungen eines Laufs: Minuten, Agentenminuten je Modellfamilie, Differenz im 5-Stunden-Fenster und in der Woche. Darunter steht eine Näherung in Prozent des 5-Stunden-Fensters je Opus-Agentenstunde. Ein `Reset` zwischen zwei Ablesungen zählt nicht mit.
## Was die Attrappe nachbildet
`attrappe.mjs` führt ein Workflow-Skript mit nachgebauten Globals aus, genauso streng wie das Laufzeitsystem: `workflow()` im Kind wirft, im Kind ist `phase()` wirkungslos und jeder Agent trägt die Phase `▸ <name>` (ab dem zweiten Aufruf desselben Kindes `▸ <name> #<n>`), ein unbekannter Workflow wirft, `Date.now()`, `new Date()` ohne Argument und `Math.random()` werfen. Ein Schema ohne `properties` wirft, ein Ergebnis ohne die `required`-Felder wird zu `null`, und ein `agent()` ohne `model` ist ein Verstoß. Aufrufe über `D:/Claude/.claude/workflows/...` leitet sie in den Ordner unter Test um. Sie zählt, wie viele Agenten je Phase und Modell gleichzeitig laufen.
**Was sie nicht sieht:** die Obergrenze gleichzeitiger Agenten je Lauf, Budget, Resume und das Verhalten echter Modelle. Wie lange echte Linsen bei drei gleichzeitigen Opus-Agenten laufen und wie viel Verbrauch sie erzeugen, zeigt nur ein echter Lauf.
## Was belegt ist
Stand 2026-09-24 nach Plankritik, Prüfung und Nachbesserung. Die Zahlen gelten für diesen Stand; wer Fälle oder Mutanten ergänzt, fährt die Läufe neu, statt die Zahlen hier nachzuziehen.
- **Rotlauf:** Gegen die vier alten Workflows scheiterten 122 von 134 Fällen (Stand vor der letzten Nachbesserung). Grün blieben die elf Selbsttests der Attrappe und „kritisch hebt auf xhigh“, ein Verhalten, das sich nicht geändert hat.
- **Mutanten an den Workflows:** 52 Mutanten, alle gefangen. Ausgenommen ist „harte Obergrenze fehlt“ in `verbrauch.js`: Er ist wirkungslos, solange keine Stufe mehr als vier Plätze gibt. Wer die Stufen anhebt, braucht dafür einen Fall.
- **Selbsttests der Attrappe:** Ein Mutant, bei dem `Date.now()` nicht mehr wirft, fällt auf.
- **`verbrauch_reihe.py`:** 27 Fälle, 18 Mutanten, alle gefangen. Die Plankritik fand den wichtigsten Fehler, den kein Fall gesehen hatte: `resetsAt` schwankt bei jeder Ablesung um Millisekunden und springt über Sekunden- und Datumsgrenzen. Das Skript hielt deshalb jedes echte Paar für einen Reset, weil die Testdaten denselben festen Zeitstempel annahmen wie der Code. Seitdem gilt eine Toleranz von fünf Minuten, und die Grundlinie trägt echte Zitterwerte.
- **`verbrauch.js` im echten Laufzeitsystem** (wf_8746f9c7-f1f): Die Ablesung ergab 43 % und 12 %, genau den Wert, den der Koordinator zur selben Zeit selbst über `get_usage` las, und damit 3 Plätze. Mit `probe` bei 95 % kamen 0 Plätze und `gebremst` heraus; ein Lauf mit `probe` startet keinen Agenten und steht deshalb in keinem Journal.
- **Neue `plankritik.js` im echten Laufzeitsystem** (wf_32edc4e9-501): Nach der Ablesung von 44 % starteten die drei Linsen innerhalb einer Sekunde; die Widerlegung lief zu zweit. Die Ablesungen stehen im Journal unter den Phasen `▸ verbrauch`, `▸ verbrauch #2` und `▸ verbrauch #3`. `verbrauch_reihe.py` wertet sie aus: erstes Paar `Reset` (das Fenster wurde um 18:30 UTC zurückgesetzt), zweites Paar 3 Punkte bei 19 Opus-Minuten.
- **Neue `pruefung.js` im echten Laufzeitsystem** (wf_e0294c82-76c): Ablesungen 13 %/16 % vor und 18 %/18 % nach einem `pruefer` von 24 Minuten.
- **Neue `planung.js` im echten Laufzeitsystem** (wf_5df09faa-a4d, Probe mit drei Tasks im Scratchpad): Task 1 lief allein, Task 2 und 3 starteten erst nach seinem Ende und liefen gleichzeitig; beide übernahmen die Zieldatei-Zeile aus Task 1, haben seine Taskdatei also gelesen.
- **Erster Eichwert** am Abend des 2026-09-24, Plan laut App „Pro“: `verbrauch_reihe.py --seit 2026-09-24` ergab 8,1 % des 5-Stunden-Fensters je Opus-Agentenstunde (5 Paare, 1,7 Opus-Stunden, darunter ein Plankritik-Lauf der Trading-Sitzung). Die Woche stieg in denselben Paaren um 5 Punkte. Der Verbrauch anderer Sitzungen und der Hauptsitzung steckt mit drin; die Zahl ist also eher zu hoch. Für das Nachstellen der Stufen reicht ein Abend nicht.
**Offen:** Die Trading-Sitzung hat `plankritik.js` am 2026-09-24 nach einem echten Lauf nachgebessert (Ausgabe der Planwerkzeuge nur bei `EXIT 0`). Für diese Stelle gibt es noch keinen Fall.
## Was die Skripte nicht sehen
Den Verbrauch anderer Sitzungen und der Hauptsitzung: Er steckt in jeder Differenz mit drin. Ablesungen über `probe` starten keinen Agenten und fehlen in der Messreihe. `get_usage` gibt es nur in der Desktop-App; anderswo gilt über den Rückfall die Zwei, und die Messreihe bleibt leer. Die Stufen in `verbrauch.js` sind vorläufig. Sie sind ein Fangnetz und kein Nachweis dafür, dass ein Lauf das Fenster nicht reißt.
+150
View File
@@ -0,0 +1,150 @@
// Attrappe für die Workflow-Skripte unter .claude/workflows/: führt ein Skript mit nachgebauten
// Globals aus und protokolliert jeden agent()-Aufruf mit Modell, Phase, Start und Ende.
//
// Grenzen wie im Ziel (Workflow-Referenz der Claude-Code-Sitzung vom 2026-09-24, dazu die
// Plankritik vom selben Tag am Laufzeitsystem 2.1.280):
// - workflow() in einem Kind wirft; Verschachtelung geht nur eine Ebene tief. Das Ziel meldet
// "workflow() cannot be called from within a child workflow", dann einen Gedankenstrich und
// "nesting is limited to one level"; die Attrappe setzt einen Doppelpunkt statt des Strichs.
// - Im Kind ist phase() wirkungslos, und jeder agent() bekommt die Phase "▸ <meta.name>", ab dem
// zweiten Aufruf desselben Kindes "▸ <meta.name> #<n>" (Bundle 2.1.280 und meta.json eines
// echten Laufs mit drei Ablesungen: "▸ verbrauch", "▸ verbrauch #2", "▸ verbrauch #3").
// - workflow() mit unbekanntem Namen oder unlesbarem scriptPath wirft.
// - Date.now(), new Date() ohne Argument und Math.random() werfen.
// - agent() mit schema braucht type object und properties, required muss in properties stehen.
// Ein Ergebnis ohne die required-Felder wird wie ein gescheiterter Agent zu null.
// - parallel() ist eine Barriere; ein werfender Thunk wird zu null.
// Zusätzlich als Verstoß protokolliert (Stamm, "Agenten & Modelle"): agent() ohne model.
// Was die Attrappe nicht nachbildet: die Obergrenze gleichzeitiger Agenten je Lauf, Budget,
// Resume und das Verhalten echter Modelle. Sie ist ein Fangnetz für die Steuerlogik der
// Skripte, kein Nachweis für einen echten Lauf.
import { readFileSync, existsSync } from 'node:fs'
import path from 'node:path'
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
// Aufrufe über den echten Pfad werden in den Ordner unter Test umgeleitet.
const ECHTER_ORDNER = 'D:/Claude/.claude/workflows/'
class DatumAttrappe extends Date {
constructor(...a) {
if (a.length === 0) throw new Error('new Date() ohne Argument ist im Workflow nicht erlaubt')
super(...a)
}
static now() { throw new Error('Date.now() ist im Workflow nicht erlaubt') }
}
const MatheAttrappe = Object.create(Math, {
random: { value: () => { throw new Error('Math.random() ist im Workflow nicht erlaubt') } },
})
function lade(datei) {
if (!existsSync(datei)) throw new Error('Workflow-Datei nicht lesbar: ' + datei)
const text = readFileSync(datei, 'utf8')
if (!/^export const meta = /m.test(text)) throw new Error(datei + ': export const meta fehlt')
const name = /^export const meta = \{[\s\S]*?\bname:\s*'([^']+)'/m.exec(text)
const quelle = text.replace(/^export const meta = /m, 'const meta = ')
return { name: name ? name[1] : path.basename(datei, '.js'), fn: new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'workflow', 'args', 'budget', 'Date', 'Math', quelle) }
}
const warte = ms => new Promise(r => setTimeout(r, ms))
// antworte(aufruf) liefert { wert, dauer } oder undefined (Agent ohne Ergebnis) oder wirft.
// aufruf trägt label, phase, modell, effort, agentType, prompt, tiefe und schema.
export async function fuehreAus(datei, args, antworte, optionen = {}) {
const ordner = optionen.ordner || path.dirname(datei)
const protokoll = { agents: [], logs: [], phasen: [], verstoesse: [], spitze: {}, spitzeJePhase: {}, workflows: [] }
const laufend = {}
const laufendJePhase = {}
const kindAufrufe = {}
let uhr = 0
function aufloesen(ref) {
if (typeof ref === 'string') return path.join(ordner, ref + '.js')
if (ref && typeof ref.scriptPath === 'string') {
const s = ref.scriptPath.replace(/\\/g, '/')
return s.startsWith(ECHTER_ORDNER) ? path.join(ordner, s.slice(ECHTER_ORDNER.length)) : s
}
throw new Error('workflow(): Name oder { scriptPath } erwartet')
}
async function lauf(datei, a, tiefe) {
const { name, fn } = lade(datei)
let kindPhase = null
if (tiefe >= 1) {
kindAufrufe[name] = (kindAufrufe[name] || 0) + 1
kindPhase = '▸ ' + name + (kindAufrufe[name] > 1 ? ' #' + kindAufrufe[name] : '')
}
let aktuellePhase = null
async function agent(prompt, opts = {}) {
if (typeof prompt !== 'string' || !prompt.trim()) protokoll.verstoesse.push('agent() ohne Auftragstext: ' + (opts.label || '?'))
if (!opts.model) protokoll.verstoesse.push('agent() ohne model: ' + (opts.label || '?'))
if (opts.schema !== undefined) {
if (!opts.schema || opts.schema.type !== 'object' || !opts.schema.properties) throw new Error('Schema braucht type object und properties: ' + opts.label)
if ((opts.schema.required || []).some(k => !(k in opts.schema.properties))) throw new Error('required nicht in properties: ' + opts.label)
}
const modell = opts.model || '(erbt)'
const phase = kindPhase || opts.phase || aktuellePhase || '(ohne)'
const eintrag = { label: opts.label || null, phase, modell, effort: opts.effort, agentType: opts.agentType, prompt, tiefe, start: ++uhr, ende: null, ergebnis: undefined }
protokoll.agents.push(eintrag)
const k = phase + '|' + modell
laufend[modell] = (laufend[modell] || 0) + 1
laufendJePhase[k] = (laufendJePhase[k] || 0) + 1
protokoll.spitze[modell] = Math.max(protokoll.spitze[modell] || 0, laufend[modell])
protokoll.spitzeJePhase[k] = Math.max(protokoll.spitzeJePhase[k] || 0, laufendJePhase[k])
let wert = null
try {
const antwort = await antworte({ ...eintrag, schema: opts.schema })
await warte(antwort && typeof antwort.dauer === 'number' ? antwort.dauer : 5)
wert = antwort && typeof antwort === 'object' && 'wert' in antwort ? antwort.wert : null
if (wert !== null && opts.schema) {
const fehlt = (opts.schema.required || []).filter(f => !wert || typeof wert !== 'object' || !(f in wert))
if (fehlt.length) {
protokoll.verstoesse.push(`${opts.label}: Ergebnis ohne ${fehlt.join(', ')}`)
wert = null
}
}
} catch (e) {
protokoll.logs.push(`agent ${opts.label} geworfen: ${e && e.message}`)
wert = null
} finally {
laufend[modell]--
laufendJePhase[k]--
eintrag.ende = ++uhr
}
eintrag.ergebnis = wert
return wert
}
const parallel = thunks => Promise.all(thunks.map(t => Promise.resolve().then(t).catch(e => {
protokoll.logs.push('parallel: ' + (e && e.message))
return null
})))
const pipeline = (items, ...stufen) => Promise.all(items.map(async (item, i) => {
try {
let r = item
for (const stufe of stufen) r = await stufe(r, item, i)
return r
} catch (e) {
protokoll.logs.push('pipeline: ' + (e && e.message))
return null
}
}))
const phase = titel => { if (!kindPhase) { aktuellePhase = titel; protokoll.phasen.push(titel) } }
const log = text => { protokoll.logs.push(String(text)) }
const workflow = async (ref, kindArgs) => {
if (tiefe >= 1) throw new Error('workflow() cannot be called from within a child workflow: nesting is limited to one level')
const ziel = aufloesen(ref)
protokoll.workflows.push({ ref: typeof ref === 'string' ? ref : ref.scriptPath, args: kindArgs, tiefe: tiefe + 1 })
return lauf(ziel, kindArgs, tiefe + 1)
}
const budget = { total: null, spent: () => 0, remaining: () => Infinity }
return fn(agent, parallel, pipeline, phase, log, workflow, a, budget, DatumAttrappe, MatheAttrappe)
}
try {
const ergebnis = await lauf(datei, args, optionen.tiefe || 0)
return { ergebnis, fehler: null, protokoll }
} catch (e) {
return { ergebnis: null, fehler: (e && e.message) || String(e), protokoll }
}
}
@@ -0,0 +1,143 @@
---
type: plan
title: "Parallelität im Codeworkflow: Verbrauchsbremse, Linsen gleichzeitig, Planer nach Voraussetzungen"
status: active
summary: "Nach dem Plan liest ein neuer Workflow verbrauch den Planverbrauch ab und rechnet die erlaubten Opus-Plätze, die Plankritik fährt ihre Linsen danach gleichzeitig, ein neuer Workflow planung startet Planer nach ihren Voraussetzungen, pruefung und welle-mit-pruefung schreiben den Verbrauch mit, und Attrappe, Fälle, Mutantenläufe und ein Auswerteskript für die Messreihe liegen unter .vault-tools/workflow-pruefung/."
tags: [plan, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
---
# Parallelität im Codeworkflow
## Ziel
Pascal hat am 2026-09-24 im Chat alle drei Stufen der Vorschläge freigegeben („Setze alle Stufen so um.“); die Vorschläge stehen in [[AI/secondbrain/regelwerk-memory/2026-09-24-parallelitaet-codeworkflow]]. „So“ heißt wie vorgeschlagen: Stufe 3, drei bis vier Opus-Plätze für Reviews und Erstplanung, kommt erst nach der Messreihe und der Eichung der Stufen. Nach diesem Plan gilt:
- **`verbrauch.js`** liest über `get_usage` das 5-Stunden-Fenster und die Woche ab und rechnet, wie viele Opus-Agenten gleichzeitig laufen dürfen. Die Mechanik reicht bis vier, bei steigendem Verbrauch sinkt die Zahl, null heißt gebremst. Ohne lesbare Ablesung gilt die alte Zwei.
- **`plankritik.js`** fährt ihre Linsen mit so vielen Plätzen, wie `verbrauch.js` erlaubt; bei drei Linsen sind das bis zu drei (Stufe 2). Die Widerlegung bleibt bei höchstens zwei. Die Aufträge sagen, wo Tests laufen dürfen.
- **`planung.js`** (neu) startet je Task einen `planer`, sobald die Taskdateien seiner Voraussetzungen geschrieben sind, statt paarweise nach Wellen. Vorgegeben sind zwei Plätze (Stufe 1); mehr erst nach der Eichung.
- **`pruefung.js`** und **`welle-mit-pruefung.js`** schreiben den Verbrauch um die Prüfung mit. `welle-mit-pruefung` kann mit `bis: "vergleich"` vor der Prüfung enden, damit der Koordinator seine Gegenprüfung parallel zum `pruefer` fährt.
- **`.vault-tools/workflow-pruefung/`** enthält die Node-Attrappe mit Fällen für alle geänderten Workflows, die beiden Mutantenläufe und `verbrauch_reihe.py` für die Messreihe.
## Nicht im Umfang
- Die Regeltexte: `CLAUDE.md` („Agenten & Modelle"), `AI/Regelwerk/codeworkflow.md`, `.claude/agents/planer.md` (Delta-Nachbesserung) und der Skill `plan-executer-pruefung`. Sie kommen vom Koordinator.
- `.claude/workflows/executer-welle.js` bleibt unverändert: Opus läuft dort nur bei der Hochstufung nach zwei Fehlschlägen, und dort bleibt die Zwei.
- Die Prüfstände für die offenen Hebel (vierte Linse „Nebenläufigkeit", Widerlegung auf Sonnet, eigene Kopie je Agent, Rot-Nachweis in der Vorbereitung). Sie waren in den Vorschlägen ein späterer Schritt, keine der drei Stufen.
- `LIESMICH.md` des Werkzeugordners schreibt der Koordinator mit den Belegen dieses Laufs.
- Kein Commit. Im Vault-Repo committet nur Pascal oder die Aufgabe um 04:00.
## Globale Vorgaben
- Arbeitsverzeichnis ist `D:/Claude`, alle Pfade in den Taskdateien gelten von dort.
- Code, der im Plan steht, wird byte-genau übernommen, auch Kommentare, Umlaute und die Zeichen `·` und `▸`. Jede Datei endet mit genau einem Zeilenumbruch, Zeilenenden LF.
- Ein Widerspruch zwischen Plan und Baum wird gemeldet und nicht still aufgelöst.
- Jeder Executer fasst nur die Dateien unter „Zieldateien" seiner Taskdatei an.
- `.claude/workflows/` und `.vault-tools/` gehen um 04:00 ins öffentliche Repo. Die Dateien dieses Plans enthalten keine Kundennamen, Domains oder Zugangsdaten.
## Architektur
```text
plankritik / planung / pruefung / welle-mit-pruefung
└─ workflow({ scriptPath: "D:/Claude/.claude/workflows/verbrauch.js" }, { anlass, wunsch })
└─ agent (haiku): ToolSearch -> mcp__ccd_session_mgmt__get_usage -> fenster
└─ Skript: Fenster einordnen, Stufen anwenden -> { status, plaetze, gebremst, grund }
Rückfall, wenn verbrauch.js nicht läuft (etwa als Kind): plaetze = min(wunsch, 2)
welle-mit-pruefung ── workflow('executer-welle') ── workflow('pruefung', { verbrauch: false })
└─ liest selbst vor und nach der Prüfung ab
.vault-tools/workflow-pruefung/
attrappe.mjs Globals des Laufzeitsystems nachgebaut, streng wie das Ziel
faelle_workflows.mjs Grundlinie und Angriffsliste je Workflow
mutanten_workflows.py Mutantenliste gegen die Workflows
verbrauch_reihe.py Differenzen zwischen Ablesungen aus journal.jsonl
faelle_verbrauch_reihe.py, mutanten_reihe.py
```
## Entscheidungsliste (pruefwerkzeuge.md, Abschnitt 1)
| Frage | Herkunft | Antwort |
|---|---|---|
| Welche Fenster zählen? | Quelle | `get_usage` in der Desktop-App lieferte am 2026-09-24 `5-hour limit`, `Weekly · all models`, `Weekly · Fable`. Von 19 typisierten Limit-Abbrüchen seit 22.08. waren 16 `five_hour`, 3 `seven_day`, keiner `seven_day_opus`. Gezählt werden 5 Stunden und Woche aller Modelle, dazu `Weekly · Opus`, falls es auftaucht. |
| Woran erkennt das Skript sie? | Quelle, Annahme aufgelöst | An den Labels (Regex, Groß- und Kleinschreibung egal). Ein unbekanntes oder fehlendes Fenster ist der Status `unbekannte_fenster` mit Rückfall auf zwei, nie ein stilles Grün. |
| Was ist ein gültiger Wert? | Projektvorgabe | Eine endliche Zahl ab 0. Über 100 % heißt überschritten und bremst. |
| Welche Stufen? | Projektvorgabe, vorläufig | Stehen nur in `verbrauch.js`: 5 Stunden unter 50 % vier, unter 70 % zwei, unter 85 % einer, sonst gebremst; Woche mit 70, 85 und 95 %. Nachgestellt wird nach der Messreihe. |
| Harte Obergrenze? | Projektvorgabe | Vier (Mechanik für Stufe 3). |
| Was gilt ohne Ablesung? | Projektvorgabe | Zwei, die bisherige Stammregel. |
| Wer wünscht wie viele? | Projektvorgabe | Plankritik: so viele wie Linsen (Stufe 2). Planung: zwei (Stufe 1), drei bis vier über `plaetze` erst nach der Eichung (Stufe 3). Widerlegung: höchstens zwei. |
| Wer entscheidet? | Projektvorgabe | Das Skript. Der Haiku-Agent überträgt nur Zahlen (Harness-Gedächtnis: `lokales-modell-urteilt-nicht`). |
| Wann ist zwischen zwei Ablesungen ein Reset? | Quelle | `resetsAt` schwankt je Ablesung um Millisekunden und springt über Sekunden- und Datumsgrenzen (gemessen 2026-09-24). Reset ist eine Verschiebung um mehr als fünf Minuten oder ein fallender Wert. |
| Wie oft wird abgelesen? | Projektvorgabe | Plankritik vor den Linsen, vor der Widerlegung, am Ende. Planung am Anfang, nach je vier fertigen Planern, solange Tasks offen sind, und am Ende. Prüfung vor und nach dem `pruefer`. Eine Ablesung kostete 12 bis 13 s. |
## Dateien und Tasks
| Task | Zieldatei | Art | Modell | Welle |
|---|---|---|---|---|
| 01 | `.claude/workflows/verbrauch.js` | neu | sonnet, low | 0 (ausgeführt) |
| 02 | `.claude/workflows/planung.js` | neu | haiku | 1 |
| 03 | `.claude/workflows/plankritik.js` | ersetzt | haiku | 1 |
| 04 | `.claude/workflows/pruefung.js` | ersetzt | haiku | 1 |
| 05 | `.claude/workflows/welle-mit-pruefung.js` | ersetzt | haiku | 1 |
| 06 | `.vault-tools/workflow-pruefung/attrappe.mjs`, `faelle_workflows.mjs`, `mutanten_workflows.py` | neu | sonnet, low | 2 |
| 07 | `.vault-tools/workflow-pruefung/verbrauch_reihe.py`, `faelle_verbrauch_reihe.py`, `mutanten_reihe.py` | neu | sonnet, low | 1 |
| 08 | `.claude/workflows/verbrauch.js` | Delta zu 01 | haiku | 1 |
| 09 | acht Dateien aus 02 bis 07 | Delta nach der Prüfung | sonnet, low | 3 |
Die Zieldateien einer Welle sind disjunkt. **Welle 0** zog Task 01 vor die Plankritik, damit sie schon mit echter Ablesung lief. **Task 08** bessert Task 01 nach der Plankritik als Delta nach; Task 01 bleibt byte-gleich. **Welle 2** folgt auf Welle 1, weil Task 06 die Fälle und die Mutanten gegen die Dateien aus Welle 1 fährt.
## Plankritik
Gelaufen am 2026-09-24 mit der neuen `plankritik.js` über `scriptPath` (wf_32edc4e9-501, 26 Minuten, drei Linsen gleichzeitig nach einer Ablesung von 44 %). 32 Befunde nach dem Zusammenfassen, 12 bestätigt, keiner widerlegt, 20 kleine ungeprüft. Eingearbeitet:
- **Blockierend, alle drei Linsen:** `verbrauch_reihe.py` hielt jedes echte Paar für einen Reset, weil `resetsAt` je Ablesung um Millisekunden schwankt. Die Fälle hatten denselben festen Zeitstempel wie der Produktivcode angenommen. Jetzt gilt die Toleranz aus der Entscheidungsliste; die Grundlinie trägt echte Zitterwerte.
- **Fehlende Fälle:** gesenkte Plätze vor der Widerlegung, gesenkte und gehobene Plätze beim Nachlesen der Planung, Bremse am Anfang der Planung, Startreihenfolge, kein Nachlesen ohne offene Tasks, Testort in allen Aufträgen, Wochenschwellen knapp darunter, Opus-Woche unter der Woche aller Modelle, Kappen am Ende eines Paars, höchstes statt erstes Fenster, Ablesezeit.
- **Mutantenliste nicht im Plan:** Die Mutantenläufe liegen jetzt als Zieldateien in Task 06 und 07.
- **Stufe 3 zu früh:** `planung.js` gibt ohne `plaetze` zwei Plätze vor.
- **Über 100 %:** bremst jetzt (Task 08).
- **Klein, mit erledigt:** `zaehlung` bleibt im gebremsten Fall ein Objekt, ein Planer-Ergebnis zählt nur mit der erwarteten Taskdatei, die Attrappe führt Agenten im Kind unter `▸ <name>` und wirft die Meldung des Ziels, `ascii` heißt `nur_ascii`, die Kommentare zur Hilfsfunktion stimmen.
- **Offen gelassen:** die Laufkopie unter `_lauf/` löscht der Koordinator nach dem letzten Lauf; ob gleichzeitige Linsen sich in einer gemeinsamen Kopie stören, ist ungemessen (siehe Risiken).
## Prüfung
Gelaufen am 2026-09-24 mit der neuen `pruefung.js` über `scriptPath` (wf_e0294c82-76c, `pruefer` auf `high`, 24 Minuten, Ablesungen 13 %/16 % vorher und 18 %/18 % nachher). Kein blockierender Befund, 2 wichtige, 12 kleine. Der Prüfer riet von einer vollen weiteren Runde ab und nannte die Nacharbeit mit Substanz. Eingearbeitet als **Task 09** (Delta, 17 Blöcke):
- **Wichtig:** Die Reset-Toleranz hatte keinen Fall für ihren Wert; eine Toleranz von 1 s bis 6 h blieb grün. Jetzt vier Fälle (4:59, 5:01 vor und zurück, fünf Stunden bei steigendem Wert) und drei Mutanten am Wert.
- **Wichtig:** `planung.js` war nie im echten Laufzeitsystem gelaufen. Nachgeholt mit einer Probe aus drei Tasks, siehe Check 9.
- **Klein, erledigt:** doppelte Linsen werden abgewiesen; `welle-mit-pruefung` meldet `status: ohne_pruefung`, wenn keine Prüfung lief; der Dateiname des Planer-Ergebnisses wird mit Pfadgrenze verglichen; die Attrappe nummeriert wiederholte Kinder wie das Ziel (`▸ name #2`); Fälle für den Nachlesetakt, den Lesehinweis, ein fehlendes `verbrauch.js` und das verankerte 5-Stunden-Label.
- **Klein, bewusst nicht geändert:** Fehlt das 5-Stunden-Fenster, gilt der Rückfall auf zwei, auch wenn die Woche hoch steht (so in der Entscheidungsliste). Die Platzregel bis zur Eichung steht an drei Stellen (Stamm, `codeworkflow.md`, `planung.js`) und muss bei der Freigabe von Stufe 3 gemeinsam nachgezogen werden.
**Fremde Änderung im Baum:** Die Trading-Sitzung hat `plankritik.js` am 2026-09-24 um 21:47 Uhr nach einem echten Lauf nachgebessert. Die Ausgabe der Planwerkzeuge gilt nur noch bei `EXIT 0` als gelaufen. Dabei hat sie die Datei im Arbeitsbaum auf CRLF umgestellt; Git normalisiert das beim Commit. Diese Stelle steht nicht in diesem Plan und hat noch keinen eigenen Fall in `faelle_workflows.mjs`. Task 09 liegt sauber darüber.
## Rot-Nachweis
Erbracht an den Entwürfen; die Executer brauchen keine Produktivdatei beiseitezulegen.
- `faelle_workflows.mjs` gegen die vier alten Workflows ohne `verbrauch.js` und `planung.js` (Stand vor Task 09): 122 von 134 Fällen rot. Grün blieben die elf Selbsttests der Attrappe und „kritisch hebt auf xhigh", ein unverändertes Verhalten.
- `mutanten_workflows.py` nach Task 09: 52 Mutanten, alle gefangen bis auf den in `WIRKUNGSLOS` begründeten („harte Obergrenze fehlt": keine Stufe gibt mehr als vier Plätze).
- `mutanten_reihe.py` nach Task 09: 18 Mutanten, alle gefangen.
## Abnahme — Fertig heißt
1. `node .vault-tools/workflow-pruefung/faelle_workflows.mjs`: Die letzte Zeile enthält `0 gescheitert`, Exit 0.
2. `python .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py`: Die letzte Zeile enthält `0 gescheitert`, Exit 0.
3. Jede Zieldatei ist byte-gleich mit der Kopie, auf die der ganze Plan angewendet ist (Task 01, 08, 02 bis 07, 06), belegt mit `cmp` und `welle_vergleich.py`.
4. `python .vault-tools/workflow-pruefung/mutanten_workflows.py` und `mutanten_reihe.py` im echten Baum: jeweils `0 ueberlebt, 0 veraltet`, Exit 0.
5. `verbrauch.js` liest im echten Laufzeitsystem ab: Status `ok`, 5-Stunden-Wert gleich der eigenen `get_usage`-Ablesung des Koordinators (erfüllt am 2026-09-24, wf_8746f9c7-f1f: 43 % und 12 % auf beiden Seiten).
6. `verbrauch.js` mit `wunsch` 3 und `probe` bei 95 % im echten Laufzeitsystem: `plaetze` 0, `gebremst` true (erfüllt, wf_8746f9c7-f1f).
7. `verbrauch_reihe.py` wertet die drei Ablesungen der Plankritik wf_32edc4e9-501 aus: Das Paar „vor Linsen → vor Widerlegung" steht im 5-Stunden-Fenster als `Reset` (das Fenster wurde um 18:30 UTC zurückgesetzt), das Paar „vor Widerlegung → Ende" mit einer Zahl.
8. `pruefung.js` läuft im echten Laufzeitsystem mit zwei Ablesungen im Ergebnis (erfüllt, wf_e0294c82-76c).
9. `planung.js` läuft im echten Laufzeitsystem mit einer Probe aus drei Tasks (2 und 3 hängen an 1): Task 1 läuft allein, 2 und 3 starten erst nach seinem Ende und laufen gleichzeitig, jede Taskdatei liegt im Planordner (erfüllt, wf_5df09faa-a4d: Task 1 20:06:59 bis 20:07:10 UTC, Task 2 und 3 ab 20:07:12; beide übernahmen die Zieldatei-Zeile aus Task 1).
## Lauf mit echten Daten
Die Checks 5 bis 8 sind der Lauf mit echten Daten. Die Plankritik dieses Plans war der erste echte Lauf mit drei gleichzeitigen Opus-Linsen: Sie starteten innerhalb einer Sekunde, die Linsenphase dauerte bis zur letzten Linse, und die Plankritik brauchte insgesamt 26 Minuten.
## Risiken
- **Andere Sitzungen nutzen die gespeicherten Workflows.** Die Trading-Sitzung fährt heute ab 22:04 Wellen und Prüfungen. Die neuen Fassungen sind rückwärtsverträglich: Alle neuen Argumente sind freiwillig, alle bisherigen Rückgabefelder bleiben. Ein altes `welle-mit-pruefung` mit neuem Kind `pruefung` ergibt Ablesungen mit `nicht_gelesen`, die Prüfung läuft trotzdem.
- **Verzögertes Nachladen.** Ein Aufruf über den Namen kann noch eine Weile die alte Fassung treffen (am 2026-09-23 gut eine Stunde, am 2026-09-24 gut 20 Minuten). Aufrufe über `scriptPath` lesen die Datei direkt.
- **Gemeinsame Kopie.** Bis zu drei Linsen und zwei Widerleger fahren Tests in derselben Kopie. Schon bei zwei Widerlegern liefen in 68 % ihrer Laufzeit zwei gleichzeitig. Ob sie sich dabei stören, ist nicht gemessen; es hängt davon ab, ob die Suite des Projekts gemeinsamen Zustand in die Kopie schreibt. Mutanten entstehen nach dem Auftrag in einer eigenen Kopie der Kopie.
- **`get_usage` gibt es nur in der Desktop-App.** In anderen Sitzungen gilt über den Rückfall die Zwei.
- **Die Stufen sind ungeeicht.** Ein erster Messpunkt: Ein Workflow mit 21 Opus-Agenten auf `high` hob das 5-Stunden-Fenster mit dem übrigen Sitzungsverbrauch um rund 36 Punkte und die Woche um rund 10 Punkte (Plan laut App „Pro").
@@ -0,0 +1,170 @@
---
type: plan-task
title: "Task 01 — verbrauch.js: Planverbrauch lesen und Opus-Plätze rechnen"
status: draft
summary: "Neuer gespeicherter Workflow, der über get_usage das 5-Stunden-Fenster und die Woche abliest und daraus die erlaubten Opus-Plätze rechnet; die Stufen stehen nur hier."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: sonnet
effort: low
modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt. Sonnet low statt Haiku, weil der Code das Zeichen · (U+00B7) trägt und Haiku in der Messung vom 2026-09-23 typografische Zeichen still ersetzt hat."
---
# Task 01 — verbrauch.js: Planverbrauch lesen und Opus-Plätze rechnen
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort.
Was danach anders ist:
- **Es gibt `verbrauch.js`.** Ein Haiku-Agent lädt `mcp__ccd_session_mgmt__get_usage` und überträgt die Fenster; das Skript ordnet sie ein (`5-hour ...`, `Weekly ... all models`, falls vorhanden `Weekly ... Opus`) und rechnet `plaetze = min(wunsch, Stufe 5 Stunden, Stufe Woche, 4)`.
- **Ohne lesbare Ablesung gilt zwei**, wie bisher im Stamm. `nicht_gelesen` und `unbekannte_fenster` sind eigene Stati, nie ein stilles Grün.
- **`wunsch: 0` misst nur** und bremst nie; `probe` ersetzt den Agenten für den Regressionslauf im echten Laufzeitsystem.
## Zieldateien
- `.claude/workflows/verbrauch.js` (neu)
## Verbote
- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.claude/workflows/verbrauch.js`
Die ganze Datei ist neu:
```javascript
export const meta = {
name: 'verbrauch',
description: 'Liest den Planverbrauch (5-Stunden-Fenster, Woche) über get_usage und rechnet daraus, wie viele Opus-Agenten gleichzeitig laufen dürfen',
whenToUse: 'Vor jeder Opus-Staffel eines Workflows, als Kind aufgerufen: workflow({ scriptPath: "D:/Claude/.claude/workflows/verbrauch.js" }, { anlass, wunsch }). Args: {anlass, wunsch: ganze Zahl ab 0, probe?}. wunsch 0 heißt nur messen. Rückgabe {status: ok|nicht_gelesen|unbekannte_fenster, quelle, fuenf_stunden, woche, woche_opus, plaetze, gebremst, grund, fenster}. Ohne lesbare Ablesung gilt die alte Obergrenze zwei. Regel: AI/Regelwerk/codeworkflow.md, "Verbrauch und Opus-Plätze".',
phases: [{ title: 'Verbrauch', detail: 'ein Haiku-Agent liest get_usage ab, das Skript entscheidet' }],
}
// Geltungsbereich (AI/Regelwerk/pruefwerkzeuge.md): Das Skript liest den Planverbrauch und
// rechnet daraus, wie viele Opus-Agenten gleichzeitig laufen dürfen. Es ist eine Bremse, kein
// Nachweis. Woher die Antworten kommen:
// - Welche Fenster zählen und wie sie heißen: Quelle. get_usage in der Desktop-App lieferte am
// 2026-09-24 "5-hour limit", "Weekly · all models" und "Weekly · Fable", je mit percentUsed
// und resetsAt. Von 19 typisierten Limit-Abbrüchen im Session-Speicher (22.08. bis 20.09.)
// waren 16 five_hour und 3 seven_day, keiner seven_day_opus. Ein Fenster "Weekly · Opus" zählt
// trotzdem wie die Woche, falls es auftaucht, weil die Agenten, um die es geht, auf Opus laufen.
// - Die Stufen: Projektvorgabe, vorläufig (Pascal, 2026-09-24). Sie stehen nur hier und werden
// nach der Messreihe mit .vault-tools/workflow-pruefung/verbrauch_reihe.py nachgestellt.
// - Ohne lesbare Ablesung: Projektvorgabe, die bisherige Stammregel von zwei Opus-Agenten.
// Was das Skript nicht sieht: Agenten, die nach der Ablesung in anderen Sitzungen starten;
// laufende Agenten, die nach der Ablesung weiter verbrauchen; Sitzungen ohne get_usage
// (außerhalb der Desktop-App). Das Urteil rechnet das Skript, der Haiku-Agent liest nur ab.
const HARTE_OBERGRENZE = 4
const OHNE_ABLESUNG = 2
// Je Stufe [Schwelle in Prozent, Plätze unterhalb der Schwelle]; ab der letzten Schwelle 0.
const STUFEN_FUENF_STUNDEN = [[50, 4], [70, 2], [85, 1]]
const STUFEN_WOCHE = [[70, 4], [85, 2], [95, 1]]
const FUENF_STUNDEN = /^\s*5[- ]?hour/i
const WOCHE_ALLE = /^\s*weekly\b.*\ball models\b/i
const WOCHE_OPUS = /^\s*weekly\b.*\bopus\b/i
const ABLESUNG = {
type: 'object',
properties: {
verfuegbar: { type: 'boolean' },
status: { type: 'string' },
plan: { type: 'string' },
fenster: {
type: 'array',
items: {
type: 'object',
properties: { label: { type: 'string' }, percentUsed: { type: 'number' }, resetsAt: { type: 'string' } },
required: ['label', 'percentUsed', 'resetsAt'],
},
},
fehler: { type: 'string' },
},
required: ['verfuegbar', 'status', 'plan', 'fenster', 'fehler'],
}
const AUFTRAG = `Lade mit dem Werkzeug ToolSearch das Werkzeug mcp__ccd_session_mgmt__get_usage (Abfrage "select:mcp__ccd_session_mgmt__get_usage") und rufe es einmal ohne Parameter auf.
Übertrage aus dem Ergebnis plan.status nach status und plan.plan nach plan. Übertrage jeden Eintrag aus plan.windows mit label, percentUsed und resetsAt unverändert nach fenster, auch Einträge, die dir unwichtig erscheinen. Setze verfuegbar auf true und fehler auf "".
Ist das Werkzeug nicht verfügbar oder scheitert der Aufruf, setze verfuegbar auf false, status auf "fehler", plan auf "", fenster auf [] und schreibe die Fehlermeldung wörtlich nach fehler.
Tu sonst nichts. Übertrage das Ergebnis in die Felder des StructuredOutput-Werkzeugs.`
function pruefeArgs(a) {
if (!a || typeof a !== 'object') throw new Error('verbrauch: args fehlen')
if (typeof a.anlass !== 'string' || !a.anlass.trim()) throw new Error('verbrauch: anlass fehlt')
if (!Number.isInteger(a.wunsch) || a.wunsch < 0) throw new Error('verbrauch: wunsch muss eine ganze Zahl ab 0 sein, ist ' + JSON.stringify(a.wunsch))
if (a.probe !== undefined && (a.probe === null || typeof a.probe !== 'object' || Array.isArray(a.probe))) throw new Error('verbrauch: probe muss ein Objekt sein')
}
function stufe(prozent, stufen) {
for (const [schwelle, plaetze] of stufen) if (prozent < schwelle) return plaetze
return 0
}
// Ordnet die Fenster ein. Ein Fenster, das als 5 Stunden oder Woche erkannt wird, aber keine
// Prozentzahl von 0 bis 100 trägt, ist ein Befund und wird nicht still übergangen.
function einordnen(fenster) {
if (!Array.isArray(fenster)) return { fehler: 'fenster ist keine Liste' }
const e = { fuenf: null, woche: null, opus: null, andere: [] }
for (const f of fenster) {
if (!f || typeof f !== 'object' || typeof f.label !== 'string') return { fehler: 'Fenster ohne label: ' + JSON.stringify(f) }
const art = FUENF_STUNDEN.test(f.label) ? 'fuenf' : WOCHE_ALLE.test(f.label) ? 'woche' : WOCHE_OPUS.test(f.label) ? 'opus' : null
if (!art) { e.andere.push(f.label); continue }
const p = f.percentUsed
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 100) return { fehler: `${f.label}: percentUsed ${JSON.stringify(p)} ist keine Zahl von 0 bis 100` }
const neu = { prozent: p, zuruecksetzung: typeof f.resetsAt === 'string' ? f.resetsAt : null }
if (!e[art] || e[art].prozent < p) e[art] = neu
}
const fehlt = [!e.fuenf && '5-hour limit', !e.woche && 'Weekly all models'].filter(Boolean)
if (fehlt.length) return { fehler: 'Fenster fehlt: ' + fehlt.join(', ') + (e.andere.length ? '; gelesen: ' + e.andere.join(', ') : '') }
return e
}
pruefeArgs(args)
phase('Verbrauch')
const wunsch = args.wunsch
const deckel = wunsch > HARTE_OBERGRENZE ? `; wunsch ${wunsch} auf die harte Obergrenze ${HARTE_OBERGRENZE} gesenkt` : ''
let quelle = 'probe'
let ablesung = args.probe
if (ablesung === undefined) {
quelle = 'agent'
ablesung = await agent(AUFTRAG, { label: 'verbrauch:' + args.anlass, phase: 'Verbrauch', schema: ABLESUNG, model: 'haiku' })
}
function ohneAblesung(status, grund) {
const plaetze = Math.min(wunsch, OHNE_ABLESUNG)
log(`Verbrauch ${args.anlass}: ${status} (${grund}); ${plaetze} Plätze nach der alten Obergrenze`)
return { status, quelle, fuenf_stunden: null, woche: null, woche_opus: null, plaetze, gebremst: false, grund: grund + deckel, fenster: ablesung && Array.isArray(ablesung.fenster) ? ablesung.fenster : null }
}
if (!ablesung || typeof ablesung !== 'object') return ohneAblesung('nicht_gelesen', 'kein Ergebnis vom Ableseagenten')
if (ablesung.verfuegbar !== true || ablesung.status !== 'ok') return ohneAblesung('nicht_gelesen', `get_usage: verfuegbar ${ablesung.verfuegbar}, status ${ablesung.status}${ablesung.fehler ? ', ' + ablesung.fehler : ''}`)
const e = einordnen(ablesung.fenster)
if (e.fehler) return ohneAblesung('unbekannte_fenster', e.fehler)
const wocheProzent = Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0)
const grenzeFuenf = stufe(e.fuenf.prozent, STUFEN_FUENF_STUNDEN)
const grenzeWoche = stufe(wocheProzent, STUFEN_WOCHE)
const plaetze = Math.min(wunsch, grenzeFuenf, grenzeWoche, HARTE_OBERGRENZE)
const gebremst = wunsch > 0 && plaetze === 0
const grund = `5 Stunden ${e.fuenf.prozent} % (bis ${grenzeFuenf} Plätze), Woche ${wocheProzent} % (bis ${grenzeWoche} Plätze)${e.opus ? ', Opus-Woche ' + e.opus.prozent + ' %' : ''}${deckel}`
log(`Verbrauch ${args.anlass}: ${grund}; ${plaetze} von ${wunsch} Plätzen${gebremst ? ', gebremst' : ''}`)
return { status: 'ok', quelle, fuenf_stunden: e.fuenf, woche: e.woche, woche_opus: e.opus, plaetze, gebremst, grund, fenster: ablesung.fenster }
```
## Verifikation
Im Vault-Wurzelordner:
```bash
node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/verbrauch.js
```
Erwartet: `PARSE OK`, Exit 0.
Danach `git status --porcelain -- .claude/workflows/verbrauch.js` zeigen.
@@ -0,0 +1,263 @@
---
type: plan-task
title: "Task 02 — planung.js: Planer starten, sobald ihre Voraussetzungen stehen"
status: draft
summary: "Neuer gespeicherter Workflow für die Erstplanung: je Task ein planer, gestartet nach den Voraussetzungen statt paarweise, Plätze nach verbrauch.js bis vier."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: haiku
modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·."
---
# Task 02 — planung.js: Planer starten, sobald ihre Voraussetzungen stehen
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort.
Was danach anders ist:
- **Kein Warten auf Paare mehr.** Ein Task startet, sobald die Planer aller seiner Voraussetzungen ein Ergebnis mit der erwarteten Taskdatei geliefert haben; Tasks hinter einer gescheiterten Voraussetzung gelten als blockiert.
- **Plätze nach Verbrauch, vorgegeben zwei.** Am Anfang und nach je `nachlesen_alle` fertigen Planern liest `verbrauch.js` ab und senkt oder hebt die Plätze; gebremst heißt kein weiterer Start, die laufenden enden. Drei oder vier Plätze (Stufe 3) gibt es erst nach der Eichung über `plaetze`.
- **Fehler im Auftrag fallen vor dem ersten Agenten auf:** Verweis ins Leere, Zyklus, doppelte Nummer oder Datei, unbekanntes Modell.
## Zieldateien
- `.claude/workflows/planung.js` (neu)
## Verbote
- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.claude/workflows/planung.js`
Die ganze Datei ist neu:
```javascript
export const meta = {
name: 'planung',
description: 'Erstplanung: je Task ein planer, gestartet, sobald die Taskdateien seiner Voraussetzungen geschrieben sind; Opus-Plätze nach Verbrauch, vorgegeben zwei',
whenToUse: 'Schritt 2 der Standardschleife, wenn 00-uebersicht.md mit der Tasktabelle steht und viele Taskdateien zu schreiben sind (AI/Regelwerk/codeworkflow.md, "Verbrauch und Opus-Plätze"). Nicht für Nachbesserungen. Args: {projekt, planordner, gemeinsam, taskauftrag, tasks: [{nr, thema, datei, modell, voraussetzungen: [nr], welle?}], erledigt?: [nr], plaetze?: 1..4, nachlesen_alle?: 1..20, zusatz?}. plaetze ist der Wunsch, verbrauch.js senkt ihn; ohne plaetze gilt zwei, drei oder vier erst nach der Eichung der Stufen. taskauftrag darf {NR}, {THEMA}, {DATEI}, {MODELL}, {WELLE} und {VORAUSSETZUNGEN} enthalten. Rückgabe mit status fertig, unvollstaendig, gebremst oder abgebrochen; offene Tasks mit erledigt in einem neuen Lauf nachholen, nicht mit resumeFromRunId. Die Plankritik läuft danach als eigener Workflow.',
phases: [{ title: 'Taskdateien', detail: 'planer je Task, Start nach den Voraussetzungen, Plätze nach Verbrauch' }],
}
// Rollenparameter: model wird immer gesetzt (Stamm), der Effort kommt aus dem Frontmatter von
// .claude/agents/planer.md. Ein Task startet erst, wenn die Planer aller seiner Voraussetzungen
// ein Ergebnis geliefert haben; sonst erfindet er Namen, die ein Vorgänger anders schreibt
// (codeworkflow.md, "Verbrauch und Opus-Plätze"). Die Zahl gleichzeitiger Planer liest
// verbrauch.js am Anfang und nach je nachlesen_alle fertigen Planern ab. Gebremst heißt: Kein
// Planer startet mehr, die laufenden enden, der Rest kommt als offen zurück. Nach drei Planern
// ohne Ergebnis startet keiner mehr, meist ist dann das Kontingent erschöpft. Ein Ergebnis zählt
// nur, wenn es die erwartete Taskdatei nennt; sonst gilt der Task als gescheitert.
// VORGABE_PLAETZE ist Stufe 1 der Freigabe vom 2026-09-24 (Pool mit zwei Plätzen). Stufe 3 mit
// drei bis vier Plätzen für die Erstplanung kommt erst nach der Eichung der Stufen in verbrauch.js.
const MODELLE = ['haiku', 'sonnet', 'opus']
const HOECHSTENS = 4
const VORGABE_PLAETZE = 2
const OHNE_ABLESUNG = 2
const ABBRUCH_NACH = 3
const NACHLESEN_ALLE = 4
const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js'
const TASK_SCHEMA = {
type: 'object',
properties: {
datei: { type: 'string' },
zieldateien: { type: 'array', items: { type: 'string' } },
testdateien: { type: 'array', items: { type: 'string' } },
zeilen_code: { type: 'integer' },
abweichungen_von_uebersicht: { type: 'array', items: { type: 'string' } },
offene_punkte: { type: 'array', items: { type: 'string' } },
},
required: ['datei', 'zieldateien', 'testdateien', 'abweichungen_von_uebersicht', 'offene_punkte'],
}
function pruefeArgs(a) {
if (!a || typeof a !== 'object') throw new Error('planung: args fehlen')
const fehlt = ['projekt', 'planordner', 'gemeinsam', 'taskauftrag', 'tasks'].filter(k => !a[k])
if (fehlt.length) throw new Error('planung: args fehlen: ' + fehlt.join(', '))
if (!Array.isArray(a.tasks) || a.tasks.length === 0) throw new Error('planung: tasks muss eine nicht leere Liste sein')
const nummern = new Set()
const dateien = new Set()
a.tasks.forEach((t, i) => {
if (!t || typeof t !== 'object') throw new Error(`planung: tasks[${i}] ist kein Objekt`)
if (!Number.isInteger(t.nr)) throw new Error(`planung: tasks[${i}]: nr muss eine ganze Zahl sein`)
if (nummern.has(t.nr)) throw new Error(`planung: Task ${t.nr} steht doppelt in tasks`)
nummern.add(t.nr)
for (const k of ['thema', 'datei', 'modell']) if (typeof t[k] !== 'string' || !t[k]) throw new Error(`planung: Task ${t.nr}: ${k} fehlt`)
if (!MODELLE.includes(t.modell)) throw new Error(`planung: Task ${t.nr}: modell "${t.modell}" nicht in ${MODELLE.join('/')}`)
if (dateien.has(t.datei)) throw new Error(`planung: Datei ${t.datei} steht bei zwei Tasks`)
dateien.add(t.datei)
if (!Array.isArray(t.voraussetzungen) || !t.voraussetzungen.every(n => Number.isInteger(n))) throw new Error(`planung: Task ${t.nr}: voraussetzungen muss eine Liste ganzer Zahlen sein`)
if (new Set(t.voraussetzungen).size !== t.voraussetzungen.length) throw new Error(`planung: Task ${t.nr}: eine Voraussetzung steht doppelt`)
})
a.tasks.forEach(t => t.voraussetzungen.forEach(n => {
if (!nummern.has(n)) throw new Error(`planung: Task ${t.nr}: Voraussetzung ${n} gibt es nicht`)
}))
if (a.erledigt !== undefined && !(Array.isArray(a.erledigt) && a.erledigt.every(n => nummern.has(n)))) throw new Error('planung: erledigt muss eine Liste bekannter Tasknummern sein')
if (a.plaetze !== undefined && !(Number.isInteger(a.plaetze) && a.plaetze >= 1 && a.plaetze <= HOECHSTENS)) throw new Error('planung: plaetze muss eine ganze Zahl von 1 bis ' + HOECHSTENS + ' sein')
if (a.nachlesen_alle !== undefined && !(Number.isInteger(a.nachlesen_alle) && a.nachlesen_alle >= 1 && a.nachlesen_alle <= 20)) throw new Error('planung: nachlesen_alle muss eine ganze Zahl von 1 bis 20 sein')
// Zyklen: Kahn-Verfahren über die Voraussetzungen; was übrig bleibt, hängt im Kreis.
const offen = new Map(a.tasks.map(t => [t.nr, t.voraussetzungen.length]))
const bereit = a.tasks.filter(t => t.voraussetzungen.length === 0).map(t => t.nr)
let geschafft = 0
while (bereit.length) {
const nr = bereit.pop()
geschafft++
for (const t of a.tasks) if (t.voraussetzungen.includes(nr)) {
offen.set(t.nr, offen.get(t.nr) - 1)
if (offen.get(t.nr) === 0) bereit.push(t.nr)
}
}
if (geschafft < a.tasks.length) throw new Error('planung: Zyklus in den Voraussetzungen bei Task ' + [...offen].filter(([, n]) => n > 0).map(([nr]) => nr).join(', '))
}
// Eine Hilfsfunktion ablesen() steht auch in plankritik.js (gleiche Form) sowie in pruefung.js
// und welle-mit-pruefung.js (dort nur messend, ohne Rückgabe). Die Regel (Fenster, Stufen,
// Rückfall) steht nur in verbrauch.js.
const ablesungen = []
async function ablesen(anlass, wunsch) {
let r = null
try {
r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'planung:' + anlass, wunsch })
} catch (e) {
r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }
}
if (!r || typeof r.plaetze !== 'number') {
r = { status: 'nicht_gelesen', quelle: 'keine', fuenf_stunden: null, woche: null, plaetze: Math.min(wunsch, OHNE_ABLESUNG), gebremst: false, grund: (r && r.grund) || 'verbrauch.js ohne verwertbares Ergebnis' }
}
ablesungen.push({ anlass, ...r })
log(`Verbrauch ${anlass}: ${r.status}, ${r.plaetze} Plätze${r.gebremst ? ', gebremst' : ''} (${r.grund})`)
return r
}
function auftrag(a, t, jeNr) {
const vor = t.voraussetzungen.map(n => jeNr.get(n).datei)
const text = a.taskauftrag
.split('{NR}').join(String(t.nr).padStart(2, '0'))
.split('{THEMA}').join(t.thema)
.split('{DATEI}').join(t.datei)
.split('{WELLE}').join(t.welle === undefined ? '' : String(t.welle))
.split('{MODELL}').join(t.modell)
.split('{VORAUSSETZUNGEN}').join(vor.length ? vor.join(', ') : '(keine)')
const lies = vor.length ? ` Die Taskdateien deiner Voraussetzungen liegen fertig im Planordner: ${vor.join(', ')}. Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst.` : ''
return `${a.gemeinsam}
${text}
Schreib genau eine Datei: ${a.planordner}/${t.datei}.${lies}
${a.zusatz ? a.zusatz + '\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.`
}
pruefeArgs(args)
phase('Taskdateien')
const jeNr = new Map(args.tasks.map(t => [t.nr, t]))
const status = new Map(args.tasks.map(t => [t.nr, (args.erledigt || []).includes(t.nr) ? 'erledigt' : 'offen']))
const reihenfolge = [...args.tasks].sort((x, y) => ((x.welle || 0) - (y.welle || 0)) || (x.nr - y.nr))
const wunsch = args.plaetze || VORGABE_PLAETZE
const nachlesenAlle = args.nachlesen_alle || NACHLESEN_ALLE
const geschrieben = []
const gescheitert = []
const plaetzeVerlauf = []
const laufend = new Map()
let gebremst = false
let abgebrochen = false
let plaetze = 0
let fertigSeitAblesung = 0
const nochOffen = () => [...status.values()].some(s => s === 'offen')
function starte(t) {
status.set(t.nr, 'laeuft')
const opts = { label: `planer:task-${String(t.nr).padStart(2, '0')}`, phase: 'Taskdateien', schema: TASK_SCHEMA, agentType: 'planer', model: 'opus' }
laufend.set(t.nr, agent(auftrag(args, t, jeNr), opts).then(r => ({ nr: t.nr, r }), e => ({ nr: t.nr, r: null, fehler: e && e.message })))
}
if (nochOffen()) {
const v = await ablesen('Anfang', wunsch)
plaetzeVerlauf.push(v.plaetze)
if (v.gebremst) gebremst = true
else plaetze = v.plaetze
}
while (true) {
// Tasks hinter einer gescheiterten oder blockierten Voraussetzung starten nie.
let geaendert = true
while (geaendert) {
geaendert = false
for (const t of args.tasks) {
if (status.get(t.nr) === 'offen' && t.voraussetzungen.some(n => ['gescheitert', 'blockiert'].includes(status.get(n)))) {
status.set(t.nr, 'blockiert')
geaendert = true
}
}
}
if (!gebremst && !abgebrochen) {
for (const t of reihenfolge) {
if (laufend.size >= plaetze) break
if (status.get(t.nr) === 'offen' && t.voraussetzungen.every(n => ['fertig', 'erledigt'].includes(status.get(n)))) starte(t)
}
}
if (laufend.size === 0) break
const { nr, r: roh, fehler: grund } = await Promise.race(laufend.values())
laufend.delete(nr)
const passt = roh && typeof roh.datei === 'string' && roh.datei.replace(/\\/g, '/').endsWith(jeNr.get(nr).datei)
const r = passt ? roh : null
const fehler = grund || (roh && !passt ? `Ergebnis nennt ${JSON.stringify(roh.datei)} statt ${jeNr.get(nr).datei}` : null)
if (r) {
status.set(nr, 'fertig')
geschrieben.push({ nr, ...r })
} else {
status.set(nr, 'gescheitert')
gescheitert.push(nr)
log(`Task ${nr}: planer ohne Ergebnis${fehler ? ' (' + fehler + ')' : ''}`)
if (gescheitert.length >= ABBRUCH_NACH && !abgebrochen) {
abgebrochen = true
log(`${gescheitert.length} Planer ohne Ergebnis, vermutlich Kontingent: kein weiterer Start`)
}
}
fertigSeitAblesung++
if (!gebremst && !abgebrochen && fertigSeitAblesung >= nachlesenAlle && nochOffen()) {
fertigSeitAblesung = 0
const v = await ablesen(`nach ${geschrieben.length + gescheitert.length} Tasks`, wunsch)
plaetzeVerlauf.push(v.plaetze)
if (v.gebremst) {
gebremst = true
log('Planung gebremst: kein weiterer Start, die laufenden Planer enden')
} else plaetze = v.plaetze
}
}
if (geschrieben.length + gescheitert.length > 0) await ablesen('Ende', 0)
const liste = s => args.tasks.filter(t => status.get(t.nr) === s).map(t => t.nr)
const blockiert = liste('blockiert')
const offen = liste('offen')
const zustand = gebremst ? 'gebremst' : abgebrochen ? 'abgebrochen' : gescheitert.length || blockiert.length || offen.length ? 'unvollstaendig' : 'fertig'
return {
status: zustand,
geschrieben,
gescheitert,
blockiert,
offen,
verbrauch: ablesungen,
plaetze_verlauf: plaetzeVerlauf,
naechster_schritt: zustand === 'fertig'
? 'plankritik über den ganzen Planordner.'
: 'Gescheiterte, blockierte und offene Tasks in einem neuen Lauf nachholen, mit erledigt: [' + args.tasks.filter(t => ['fertig', 'erledigt'].includes(status.get(t.nr))).map(t => t.nr).join(', ') + ']; nicht mit resumeFromRunId fortsetzen.',
}
```
## Verifikation
Im Vault-Wurzelordner:
```bash
node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/planung.js
```
Erwartet: `PARSE OK`, Exit 0.
Danach `git status --porcelain -- .claude/workflows/planung.js` zeigen.
@@ -0,0 +1,365 @@
---
type: plan-task
title: "Task 03 — plankritik.js: Linsen gleichzeitig nach Verbrauch, Testort im Auftrag"
status: draft
summary: "Die Linsen laufen mit so vielen Opus-Plätzen, wie verbrauch.js erlaubt, die Widerlegung mit höchstens zwei; beide Phasen können bremsen; die Aufträge nennen, wo Tests laufen dürfen."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: haiku
modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·."
---
# Task 03 — plankritik.js: Linsen gleichzeitig nach Verbrauch, Testort im Auftrag
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort.
Was danach anders ist:
- **Drei Linsen gleichzeitig**, sofern `verbrauch.js` drei Plätze gibt; bisher wartete die dritte Linse im Mittel 14,5 Minuten auf einen Opus-Platz (fünf echte Läufe).
- **Die Widerlegung bleibt bei höchstens zwei.** Vor Linsen und Widerlegung wird abgelesen; gebremst kommt als `status: gebremst` mit `gebremst_vor` zurück, ungeprüfte Befunde tragen `nicht geprüft (gebremst)`.
- **Testort im Auftrag:** Tests nur in der Kopie, nie im echten Projektbaum; Mutanten nur in einer eigenen Kopie der Kopie. Ohne Kopie keine Tests.
- **Die bisherigen Rückgabefelder bleiben**, auch `zaehlung` als Objekt im gebremsten Fall; neu sind `status`, `gebremst_vor`, `verbrauch`, `plaetze` und `naechster_schritt`.
## Zieldateien
- `.claude/workflows/plankritik.js` (geändert)
## Verbote
- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.claude/workflows/plankritik.js`
Die ganze Datei ist neu (sie ersetzt die bisherige Fassung vollständig):
```javascript
export const meta = {
name: 'plankritik',
description: 'Liest einen Planordner mit getrennten pruefer-Linsen, gleichzeitig nach Verbrauch bis vier, fasst doppelte Befunde zusammen und lässt jeden wichtigen oder blockierenden Befund von einem zweiten pruefer zu widerlegen versuchen, höchstens zwei gleichzeitig',
whenToUse: 'Schritt 2 der Standardschleife vor dem ersten Executer, Pflicht ab etwa drei Tasks (AI/Regelwerk/codeworkflow.md, "Plankritik vor dem ersten Executer"). Args: {projekt, planordner, spec?, kopie?, materialisieren?: {ohne: [relpfade], tasks?: [taskdateien]}, suite?, linsen?, plaetze?: 1..4, verifizieren?: true, verifizieren_ab?: "wichtig"|"blockierend", effort?, werkzeuge?, zusatz?}. Ohne kopie baut materialisieren sie selbst (kopie_bauen.py plus plan_materialisiere.py); ohne() muss Geheimnisdateien und große Datenordner nennen. plaetze begrenzt die gleichzeitigen Linsen; verbrauch.js senkt sie nach dem 5-Stunden-Fenster und der Woche. Rückgabe mit status fertig oder gebremst; ein gebremster Lauf wird neu gestartet, nicht fortgesetzt.',
phases: [
{ title: 'Vorbereitung', detail: 'Hilfsagent: Kopie bauen, Plan anwenden, Planwerkzeuge fahren' },
{ title: 'Plankritik', detail: 'je Linse ein pruefer, gleichzeitig nach Verbrauch' },
{ title: 'Widerlegung', detail: 'je wichtiger Befund ein zweiter pruefer, höchstens zwei gleichzeitig' },
],
}
// Rollenparameter: model wird immer gesetzt (Stamm). Der Effort der pruefer kommt aus dem
// Frontmatter von .claude/agents/pruefer.md; args.effort überschreibt ihn nur bewusst.
// Der Hilfsagent hat keinen agentType und bekommt deshalb seinen Effort mit (low: gemessen
// gleich treu wie high bei mechanischer Arbeit, codeworkflow.md "Effort je Rolle").
// Opus-Plätze: Vor den Linsen und vor der Widerlegung liest verbrauch.js den Planverbrauch ab.
// Die Linsen laufen mit so vielen Plätzen, wie verbrauch.js erlaubt (höchstens so viele wie
// Linsen, höchstens LINSEN_HOECHSTENS), die Widerlegung mit höchstens zwei. Gebremst heißt: Die
// Phase startet nicht, und der Lauf kommt mit status gebremst zurück.
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
const LINSEN_HOECHSTENS = 4
const WIDERLEGUNG_HOECHSTENS = 2
const OHNE_ABLESUNG = 2
const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js'
const RANG = { blockierend: 0, wichtig: 1, klein: 2 }
// Einfache Anführungszeichen: In Bash bleiben darin $, Backtick und Backslash wörtlich.
const q = s => "'" + String(s).replace(/'/g, "'\\''") + "'"
const LINSEN = {
arithmetik: (a, ctx) => `Linse: Fixture-Arithmetik und Rot-Nachweise. Rechne jede Mindestzahl, jede Grenze und jeden Schwellenwert im Produktivcode des Plans gegen jede Fixture der Tests nach. Prüfe jede im Plan erwartete Fehlermeldung und jede vorhergesagte Testausgabe wörtlich: Würde der Test vor der Umsetzung genau so rot, und danach grün? pytest kürzt FAILED-Zeilen ohne Terminal auf 80 Zeichen; vergleiche Fehlerzeilen deshalb mit COLUMNS=300 vor dem Befehl.${ctx.kopie ? ` Der Plan ist auf die Kopie ${ctx.kopie} angewendet. Fahre dort die Suite (${a.suite || 'Testbefehl aus dem Plan'}) und vergleiche jede Vorhersage des Plans mit der echten Ausgabe. In der Kopie änderst du nichts außer dem, was die Testläufe selbst anlegen.` : ' Eine Kopie mit angewendetem Plan gibt es nicht; du rechnest am Text.'}`,
bezeichner: (a, ctx) => `Linse: Bezeichner, Signaturen und Byte-Treue. Prüfe jeden Namen, jede Signatur und jeden Import über Taskgrenzen hinweg. Jeder Aus-Block muss genau einmal im echten Baum stehen, jeder Codeblock muss sich parsen lassen, und Codeblöcke dürfen keine Zeilen- und Absatztrenner, Format- oder Steuerzeichen enthalten. Prüfbefehle im Plan dürfen nur Code treffen, nie ein Wort, das auch in einem Kommentar steht.${ctx.werkzeuge ? `\n\nDie Planwerkzeuge sind schon gelaufen, hier ihre Rohausgabe. Bewerte sie, statt sie erneut zu fahren; nur bei Zweifeln selbst nachfahren.\n<werkzeugausgabe>\n${ctx.werkzeuge}\n</werkzeugausgabe>` : ` Fahre dazu plan_bloecke.py vor und plan_zeichen.py aus ${a.werkzeuge || 'D:/Claude/.vault-tools/plan-kopie'}.`}`,
abdeckung: (a) => `Linse: Spec-Abdeckung in beide Richtungen. Jede Anforderung${a.spec ? ` aus ${a.spec}` : ' der Übersicht'} braucht einen Task und einen Test, und jeder Task braucht eine Anforderung. Die "Fertig heißt"-Liste der Übersicht muss je Check einen Beleg außerhalb der eigenen Meinung haben. Das Frontmatter jeder Taskdatei braucht modell und modell_begruendung; Abschrift-Tasks mit Anführungszeichen, Gedanken- und Streckenstrichen oder Auslassungspunkten im Code tragen modell sonnet und effort low statt haiku.`,
}
const BEFUNDE = {
type: 'object',
properties: {
befunde: {
type: 'array',
items: {
type: 'object',
properties: {
schweregrad: { type: 'string', enum: ['blockierend', 'wichtig', 'klein'] },
datei: { type: 'string' },
zeile: { type: 'integer' },
beschreibung: { type: 'string' },
reproduktion: { type: 'string' },
},
required: ['schweregrad', 'datei', 'zeile', 'beschreibung', 'reproduktion'],
},
},
selbst_ausgefuehrt: { type: 'array', items: { type: 'string' } },
nicht_pruefbar: { type: 'array', items: { type: 'string' } },
},
required: ['befunde', 'selbst_ausgefuehrt', 'nicht_pruefbar'],
}
const LAUF = {
type: 'object',
properties: {
laeufe: { type: 'array', items: { type: 'object', properties: { befehl: { type: 'string' }, exit: { type: 'integer' }, ausgabe: { type: 'string' } }, required: ['befehl', 'exit', 'ausgabe'] } },
},
required: ['laeufe'],
}
const URTEIL = {
type: 'object',
properties: {
urteil: { type: 'string', enum: ['bestaetigt', 'widerlegt', 'offen'] },
schweregrad: { type: 'string', enum: ['blockierend', 'wichtig', 'klein'], description: 'dein eigenes Urteil über die Schwere' },
bedingung: { type: 'string', description: 'unter welcher Bedingung der Befund gilt; leer, wenn immer' },
begruendung: { type: 'string' },
beleg: { type: 'string', description: 'eigener Befehl mit Ausgabe oder wörtliches Zitat mit Fundstelle' },
},
required: ['urteil', 'schweregrad', 'bedingung', 'begruendung', 'beleg'],
}
function pruefeArgs(a) {
if (!a || typeof a !== 'object') throw new Error('plankritik: args fehlen')
const fehlt = ['projekt', 'planordner'].filter(k => !a[k])
if (fehlt.length) throw new Error('plankritik: args fehlen: ' + fehlt.join(', '))
if (a.linsen !== undefined && !Array.isArray(a.linsen)) throw new Error('plankritik: linsen muss eine Liste sein, bekannt: ' + Object.keys(LINSEN).join(', '))
if (a.materialisieren && a.materialisieren.tasks !== undefined && !Array.isArray(a.materialisieren.tasks)) throw new Error('plankritik: materialisieren.tasks muss eine Liste von Taskdateien sein')
const unbekannt = (a.linsen || []).filter(l => !LINSEN[l])
if (unbekannt.length) throw new Error('plankritik: unbekannte Linse: ' + unbekannt.join(', ') + '; bekannt: ' + Object.keys(LINSEN).join(', '))
if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('plankritik: effort "' + a.effort + '" ungültig')
if (a.verifizieren_ab !== undefined && !['wichtig', 'blockierend'].includes(a.verifizieren_ab)) throw new Error('plankritik: verifizieren_ab muss wichtig oder blockierend sein')
if (a.plaetze !== undefined && !(Number.isInteger(a.plaetze) && a.plaetze >= 1 && a.plaetze <= LINSEN_HOECHSTENS)) throw new Error('plankritik: plaetze muss eine ganze Zahl von 1 bis ' + LINSEN_HOECHSTENS + ' sein')
if (a.materialisieren && !(Array.isArray(a.materialisieren.ohne) && a.materialisieren.ohne.length > 0 && a.materialisieren.ohne.every(x => typeof x === 'string' && x))) throw new Error('plankritik: materialisieren.ohne muss eine nicht leere Liste sein, Ausschlüsse als Pfade vom Projektwurzelordner, mindestens .git (falls vorhanden) und Geheimnisdateien')
}
// Höchstens n Aufgaben gleichzeitig, ohne Leerlauf zwischen Paaren.
async function hoechstens(n, items, fn) {
const ergebnisse = new Array(items.length).fill(null)
let naechster = 0
async function arbeiter() {
while (naechster < items.length) {
const i = naechster++
try { ergebnisse[i] = await fn(items[i], i) } catch (e) { log(`Aufgabe ${i + 1} abgebrochen: ${e && e.message}`) }
}
}
await Promise.all(Array.from({ length: Math.min(n, items.length) }, () => arbeiter()))
return ergebnisse
}
// Eine Hilfsfunktion ablesen() steht auch in planung.js (gleiche Form) sowie in pruefung.js und
// welle-mit-pruefung.js (dort nur messend, ohne Rückgabe). Die Regel (Fenster, Stufen, Rückfall)
// steht nur in verbrauch.js; hier steht nur der Rückfall für den Fall, dass verbrauch.js selbst
// nicht läuft, etwa weil dieser Workflow als Kind läuft.
const ablesungen = []
async function ablesen(anlass, wunsch) {
let r = null
try {
r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'plankritik:' + anlass, wunsch })
} catch (e) {
r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }
}
if (!r || typeof r.plaetze !== 'number') {
r = { status: 'nicht_gelesen', quelle: 'keine', fuenf_stunden: null, woche: null, plaetze: Math.min(wunsch, OHNE_ABLESUNG), gebremst: false, grund: (r && r.grund) || 'verbrauch.js ohne verwertbares Ergebnis' }
}
ablesungen.push({ anlass, ...r })
log(`Verbrauch ${anlass}: ${r.status}, ${r.plaetze} Plätze${r.gebremst ? ', gebremst' : ''} (${r.grund})`)
return r
}
function werkzeugAgent(label, phase, befehl, zweck) {
return agent(`Du führst einen Werkzeugbefehl aus und gibst seine Ausgabe roh zurück. Zweck: ${zweck}
Führe genau diesen Befehl einmal im Bash-Werkzeug (Git-Bash) aus, unverändert:
${befehl}
Ändere keine Datei außer denen, die der Befehl selbst anlegt. Deute nichts und fasse nichts zusammen. Gib Befehl, Exit-Code und die vollständige Ausgabe zurück; bei mehr als 200 Zeilen die ersten 60 und die letzten 120, mit einer Zeile "[... N Zeilen gekürzt ...]" dazwischen.`,
{ label, phase, schema: LAUF, model: 'sonnet', effort: 'low' })
}
function testort(ctx) {
return ctx.kopie
? `Tests fährst du nur in der Kopie ${ctx.kopie}, nie im echten Projektbaum: Dort kann Betrieb laufen, und andere Prüfer arbeiten gleichzeitig. Mutanten legst du nur in einer eigenen Kopie dieser Kopie an, in einem neuen Ordner unter dem System-Temp, nie in ${ctx.kopie} selbst.`
: 'Tests fährst du in dieser Plankritik nicht, auch nicht im echten Projektbaum: Dort kann Betrieb laufen, und andere Prüfer arbeiten gleichzeitig.'
}
function auftrag(a, linse, ctx) {
return `Lies den Plan im Ordner ${a.planordner} für das Projekt ${a.projekt}, bevor ein Executer startet. Du änderst keine Datei im Projekt und keine im Plan. Eine Umsetzung gibt es noch nicht, also auch keinen Executer-Bericht, dessen Testlauf du wiederholen könntest. ${testort(ctx)}
${LINSEN[linse](a, ctx)}
Melde jeden Befund mit Schweregrad, auch unsichere; ein zweiter Prüfer versucht danach jeden wichtigen Befund zu widerlegen. Nenn je Befund Datei, Zeile und wie er sich reproduzieren lässt.
${a.zusatz ? a.zusatz + '\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.`
}
function pfadnorm(p) { return String(p).replace(/\\/g, '/').replace(/\/+/g, '/').toLowerCase() }
function dateiname(p) { return pfadnorm(p).split('/').pop() }
// Doppelte Befunde verschiedener Linsen zusammenfassen: gleicher Pfad und gleiche Zeile.
// Zeile 0 oder kleiner heißt "ganze Datei" und wird nie zusammengefasst, weil darunter
// verschiedene Befunde derselben Datei liegen.
function zusammenfassen(befunde) {
const gruppen = new Map()
befunde.forEach((b, i) => {
const k = b.zeile > 0 ? pfadnorm(b.datei) + ':' + b.zeile : 'einzeln:' + i
if (!gruppen.has(k)) gruppen.set(k, { ...b, linsen: [b.linse], quellen: [b] })
else {
const g = gruppen.get(k)
g.quellen.push(b)
if (!g.linsen.includes(b.linse)) g.linsen.push(b.linse)
if (RANG[b.schweregrad] < RANG[g.schweregrad]) g.schweregrad = b.schweregrad
}
})
return [...gruppen.values()]
}
function widerlegungsauftrag(a, g, ctx) {
const texte = g.quellen.map((b, i) => `Fassung ${i + 1} (Linse ${b.linse}, ${b.schweregrad}, ${b.datei}:${b.zeile}): ${b.beschreibung}\nReproduktion laut Linse: ${b.reproduktion}`).join('\n\n')
return `Eine Plankritik hat zum Plan im Ordner ${a.planordner} (Projekt ${a.projekt}) diesen Befund gemeldet, Fundstelle ${g.datei}:${g.zeile}:
<befund>
${texte}
</befund>
Versuche ihn zu widerlegen. Lies die Stelle selbst und reproduziere den Befund mit einem eigenen Befehl. Du änderst keine Datei im Projekt und keine im Plan. ${testort(ctx)}
- "bestaetigt" nur mit eigener Reproduktion oder einem wörtlichen Beleg aus der Quelle.
- "widerlegt" nur mit einem Gegenbeleg: ein Befehl mit Ausgabe oder ein Zitat, das die Aussage falsch macht.
- Sonst "offen".
Gilt der Befund nur unter einer Bedingung, nenn sie. Gib deine eigene Einschätzung der Schwere an.
Übertrage dein Urteil in die Felder des StructuredOutput-Werkzeugs.`
}
pruefeArgs(args)
const W = args.werkzeuge || 'D:/Claude/.vault-tools/plan-kopie'
const PY = 'PYTHONIOENCODING=utf-8 python -X utf8'
const ctx = { kopie: args.kopie || null, werkzeuge: null, temp: null }
const vorbereitung = []
// Eine Kopie gilt nur als verwendbar, wenn der Lauf sauber endet, den Pfad bis zum
// Zeilenende nennt (Pfade mit Leerzeichen) und das Planwerkzeug mindestens einen Block
// angewendet hat. Sonst rechnet Linse 1 am Text, und das steht im Log.
function kopieAusLauf(r, wozu) {
const lauf = r && r.laeufe && r.laeufe[0]
if (!lauf) { log(`${wozu}: Hilfsagent ohne Ergebnis, keine Kopie`); return null }
const m = /^KOPIE=(.+?)\s*$/m.exec(lauf.ausgabe)
const angewendet = /'(neu|ersetzt|angehaengt)': [1-9]/.test(lauf.ausgabe)
if (lauf.exit !== 0 || !m) { log(`${wozu}: Kopie nicht verwendbar (Exit ${lauf.exit}${m ? '' : ', kein KOPIE-Pfad'})`); return null }
ctx.temp = m[1].replace(/\/mat$/, '')
if (!angewendet) { log(`${wozu}: das Planwerkzeug hat keinen Block angewendet; die Kopie ist der unveränderte Stand und wird nicht verwendet`); return null }
return m[1]
}
function tempText() {
return ctx.temp ? `${ctx.temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn nicht mehr gebraucht` : null
}
phase('Vorbereitung')
if (ctx.kopie && args.materialisieren) log('kopie ist gesetzt, materialisieren wird übergangen')
if (!ctx.kopie && args.materialisieren) {
const liste = (args.materialisieren.tasks || []).map(q).join(' ')
const auswahl = liste
? `TASKS=(${liste})`
: `shopt -s nullglob; pushd ${q(args.planordner)} >/dev/null && TASKS=(task-*.md) && popd >/dev/null; [ \${#TASKS[@]} -gt 0 ] || { echo "KEINE_TASKS: im Planordner liegt keine task-*.md; materialisieren.tasks angeben"; exit 3; }`
const befehl = `${auswahl}; T=$(mktemp -d) && echo "KOPIE=$(cygpath -m "$T/mat")" && ${PY} ${q(W + '/kopie_bauen.py')} ${q(args.projekt)} "$T/mat" --ohne ${args.materialisieren.ohne.map(q).join(' ')} && ${PY} ${q(W + '/plan_materialisiere.py')} ${q(args.planordner)} "$T/mat" "\${TASKS[@]}"`
const r = await werkzeugAgent('werkzeug:kopie', 'Vorbereitung', befehl, 'Kopie des Projekts bauen und den Plan darauf anwenden')
ctx.kopie = kopieAusLauf(r, 'Plankritik')
vorbereitung.push(r)
}
{
const befehl = `${PY} ${q(W + '/plan_zeichen.py')} ${q(args.planordner)}; echo "EXIT plan_zeichen: $?"; ls ${q(args.planordner)}/task-*.md >/dev/null 2>&1 && { ${PY} ${q(W + '/plan_bloecke.py')} vor ${q(args.planordner)}/task-*.md --projekt ${q(args.projekt)}; echo "EXIT plan_bloecke: $?"; } || echo "plan_bloecke: keine task-*.md im Planordner, nicht gefahren"`
const r = await werkzeugAgent('werkzeug:planwerkzeuge', 'Vorbereitung', befehl, 'Codeblöcke auf unsichtbare Zeichen und Aus-Blöcke gegen den Baum prüfen')
if (r && r.laeufe && r.laeufe[0]) ctx.werkzeuge = r.laeufe[0].ausgabe
else log('Planwerkzeuge: Hilfsagent ohne Ergebnis; Linse bezeichner fährt sie selbst')
vorbereitung.push(r)
}
phase('Plankritik')
const gewaehlt = args.linsen && args.linsen.length ? args.linsen : Object.keys(LINSEN)
const vorLinsen = await ablesen('vor Linsen', Math.min(args.plaetze || gewaehlt.length, gewaehlt.length, LINSEN_HOECHSTENS))
if (vorLinsen.gebremst) {
log('Plankritik gebremst vor den Linsen: ' + vorLinsen.grund)
return {
status: 'gebremst',
gebremst_vor: 'Linsen',
zaehlung: { roh: 0, zusammengefasst: 0, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: 0 },
kopie: ctx.kopie,
temp_ordner: tempText(),
befunde: [],
selbst_ausgefuehrt: [],
nicht_pruefbar: [],
vorbereitung,
ohne_ergebnis: gewaehlt,
verbrauch: ablesungen,
plaetze: { linsen: 0, widerlegung: 0 },
naechster_schritt: 'Nach der Zurücksetzung des Fensters neu starten, nicht mit resumeFromRunId fortsetzen: Die Ablesung käme sonst aus dem Zwischenspeicher.',
}
}
const kritiken = await hoechstens(vorLinsen.plaetze, gewaehlt, linse => {
const opts = { label: 'plankritik:' + linse, phase: 'Plankritik', schema: BEFUNDE, agentType: 'pruefer', model: 'opus' }
if (args.effort) opts.effort = args.effort
return agent(auftrag(args, linse, ctx), opts).then(r => ({ linse, ergebnis: r }))
})
const ergebnisse = kritiken.filter(k => k && k.ergebnis)
const ohneErgebnis = gewaehlt.filter(l => !ergebnisse.some(e => e.linse === l))
if (ohneErgebnis.length) log('Linsen ohne Ergebnis: ' + ohneErgebnis.join(', '))
const roh = ergebnisse.flatMap(e => e.ergebnis.befunde.map(b => ({ linse: e.linse, ...b })))
const gruppen = zusammenfassen(roh)
const ab = RANG[args.verifizieren_ab || 'wichtig']
const zuPruefen = args.verifizieren === false ? [] : gruppen.filter(g => RANG[g.schweregrad] <= ab)
log(`${roh.length} Befunde, zusammengefasst ${gruppen.length}; ${zuPruefen.length} gehen in die Widerlegung, ${gruppen.length - zuPruefen.length} nicht (${args.verifizieren === false ? 'abgeschaltet' : 'unter der Schwelle ' + (args.verifizieren_ab || 'wichtig')})`)
phase('Widerlegung')
let gebremstVor = null
let plaetzeWiderlegung = 0
if (zuPruefen.length) {
const vorWiderlegung = await ablesen('vor Widerlegung', Math.min(WIDERLEGUNG_HOECHSTENS, zuPruefen.length))
if (vorWiderlegung.gebremst) {
gebremstVor = 'Widerlegung'
log('Widerlegung gebremst, die Befunde bleiben ungeprüft: ' + vorWiderlegung.grund)
} else plaetzeWiderlegung = vorWiderlegung.plaetze
}
if (!gebremstVor) {
const urteile = await hoechstens(plaetzeWiderlegung, zuPruefen, (g, i) => {
const opts = { label: `widerlegung:${i + 1}:${dateiname(g.datei)}:${g.zeile}`, phase: 'Widerlegung', schema: URTEIL, agentType: 'pruefer', model: 'opus' }
if (args.effort) opts.effort = args.effort
return agent(widerlegungsauftrag(args, g, ctx), opts)
})
zuPruefen.forEach((g, i) => {
if (!urteile[i]) log(`Widerlegung ${i + 1} (${g.datei}:${g.zeile}) ohne Ergebnis; Befund bleibt offen`)
g.widerlegung = urteile[i] || { urteil: 'offen', schweregrad: g.schweregrad, bedingung: '', begruendung: 'Widerlegungsagent ohne Ergebnis', beleg: '' }
})
}
await ablesen('Ende', 0)
const URTEILRANG = { bestaetigt: 0, offen: 1, undefined: 2, widerlegt: 3 }
const befunde = gruppen
.map(g => ({ urteil: g.widerlegung ? g.widerlegung.urteil : gebremstVor && zuPruefen.includes(g) ? 'nicht geprüft (gebremst)' : 'nicht geprüft', ...g }))
.sort((x, y) => (URTEILRANG[x.widerlegung && x.widerlegung.urteil] - URTEILRANG[y.widerlegung && y.widerlegung.urteil]) || (RANG[x.schweregrad] - RANG[y.schweregrad]))
const zaehlung = { roh: roh.length, zusammengefasst: gruppen.length, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: gruppen.filter(g => !g.widerlegung).length }
befunde.forEach(b => { if (b.widerlegung) zaehlung[b.widerlegung.urteil]++ })
return {
status: gebremstVor ? 'gebremst' : 'fertig',
gebremst_vor: gebremstVor,
zaehlung,
kopie: ctx.kopie,
temp_ordner: tempText(),
befunde,
selbst_ausgefuehrt: ergebnisse.map(e => ({ linse: e.linse, befehle: e.ergebnis.selbst_ausgefuehrt })),
nicht_pruefbar: ergebnisse.flatMap(e => e.ergebnis.nicht_pruefbar.map(x => e.linse + ': ' + x)),
vorbereitung,
ohne_ergebnis: ohneErgebnis,
verbrauch: ablesungen,
plaetze: { linsen: vorLinsen.plaetze, widerlegung: plaetzeWiderlegung },
naechster_schritt: gebremstVor ? 'Die Widerlegung nach der Zurücksetzung des Fensters nachholen: plankritik neu starten, nicht mit resumeFromRunId fortsetzen.' : 'Bestätigte und offene Befunde am Plan korrigieren, danach die Kopie neu bauen.',
}
```
## Verifikation
Im Vault-Wurzelordner:
```bash
node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/plankritik.js
```
Erwartet: `PARSE OK`, Exit 0.
Danach `git status --porcelain -- .claude/workflows/plankritik.js` zeigen.
@@ -0,0 +1,139 @@
---
type: plan-task
title: "Task 04 — pruefung.js: Verbrauch vor und nach dem pruefer mitschreiben"
status: draft
summary: "Die Prüfung liest vor und nach dem pruefer den Planverbrauch ab, nur zum Messen; verbrauch false schaltet das ab, wenn der Elternworkflow selbst liest."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: haiku
modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·."
---
# Task 04 — pruefung.js: Verbrauch vor und nach dem pruefer mitschreiben
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort.
Was danach anders ist:
- **Zwei Ablesungen um den pruefer**, `wunsch: 0`, also Messung ohne Bremse. Das Ergebnis trägt `verbrauch`.
- **Als Kind** kann `pruefung` keinen weiteren Workflow aufrufen; ohne `verbrauch: false` stehen die Ablesungen dann als `nicht_gelesen` da, die Prüfung läuft trotzdem.
## Zieldateien
- `.claude/workflows/pruefung.js` (geändert)
## Verbote
- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.claude/workflows/pruefung.js`
Die ganze Datei ist neu (sie ersetzt die bisherige Fassung vollständig):
```javascript
export const meta = {
name: 'pruefung',
description: 'Lässt eine fertige Welle vom pruefer mit dem vollen Pflichtprogramm gegen Plan und Zielumgebung prüfen; kritische Änderungen mit xhigh; schreibt den Planverbrauch vor und nach der Prüfung mit',
whenToUse: 'Schritt 4 der Standardschleife nach einer Welle, zusätzlich zur eigenen Gegenprüfung des Koordinators (AI/Regelwerk/codeworkflow.md). Args: {projekt, plan, tasks?, suite?, version?, kritisch?, abschluss?, schwerpunkt?, effort?, verbrauch?}. kritisch für sicherheits- oder datenrelevante Änderungen; abschluss fragt, ob eine weitere Runde noch Substanz hat; verbrauch: false schaltet die Ablesung ab, welle-mit-pruefung liest dann selbst.',
phases: [{ title: 'Prüfung', detail: 'ein pruefer' }],
}
// Rollenparameter: model wird immer gesetzt (Stamm). Der Effort kommt aus dem Frontmatter
// von .claude/agents/pruefer.md (high). kritisch hebt auf xhigh: In der Messung vom
// 2026-09-23 fuhr nur xhigh einen großen eigenen Datenlauf (codeworkflow.md, "Effort je Rolle").
// Verbrauch: Vor und nach dem pruefer liest verbrauch.js den Planverbrauch ab, nur zum Messen
// (wunsch 0); gebremst wird hier nicht. Als Kind von welle-mit-pruefung kann dieser Workflow
// keinen weiteren aufrufen; dort liest der Elternworkflow und gibt verbrauch: false mit.
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js'
const BERICHT = {
type: 'object',
properties: {
selbst_ausgefuehrt: { type: 'array', items: { type: 'string' } },
eigener_lauf: { type: 'array', items: { type: 'string' } },
befunde: {
type: 'array',
items: {
type: 'object',
properties: {
schweregrad: { type: 'string', enum: ['blockierend', 'wichtig', 'klein'] },
datei: { type: 'string' },
zeile: { type: 'integer' },
beschreibung: { type: 'string' },
reproduktion: { type: 'string' },
},
required: ['schweregrad', 'datei', 'zeile', 'beschreibung', 'reproduktion'],
},
},
plan_vs_umsetzung: { type: 'array', items: { type: 'string' } },
geprueft_und_in_ordnung: { type: 'array', items: { type: 'string' } },
nicht_pruefbar: { type: 'array', items: { type: 'string' } },
weitere_runde: { type: 'string', description: 'nur bei Abschlussprüfung: hat eine weitere Runde noch Substanz, und warum' },
},
required: ['selbst_ausgefuehrt', 'eigener_lauf', 'befunde', 'plan_vs_umsetzung', 'geprueft_und_in_ordnung', 'nicht_pruefbar'],
}
function pruefeArgs(a) {
const fehlt = ['projekt', 'plan'].filter(k => !a || !a[k])
if (fehlt.length) throw new Error('pruefung: args fehlen: ' + fehlt.join(', '))
if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('pruefung: effort "' + a.effort + '" ungültig')
if (a.tasks !== undefined && !Array.isArray(a.tasks)) throw new Error('pruefung: tasks muss eine Liste sein')
if (a.verbrauch !== undefined && typeof a.verbrauch !== 'boolean') throw new Error('pruefung: verbrauch muss true oder false sein')
}
function auftrag(a) {
const umfang = a.tasks && a.tasks.length ? `Geprüft wird die Umsetzung dieser Tasks: ${a.tasks.join(', ')}.` : 'Geprüft wird die gesamte Umsetzung des Plans.'
return `Prüfe die Umsetzung im Projekt ${a.projekt} gegen den Plan ${a.plan}. ${umfang}
Der Executer-Bericht ist eine Behauptung. Fahre das Pflichtprogramm deiner Agentendefinition: Tests selbst ausführen${a.suite ? ` (${a.suite})` : ''}, die zentralen Artefakte selbst lesen, einen eigenen Lauf mit realistischen Daten und eigenem Prüfcode. Eigenen Prüfcode führst du als python -c oder in einem Wegwerfordner unter dem System-Temp aus; im Projekt änderst du nichts.
${a.version ? `Aussagen über fremde Software belegst du am tatsächlich eingesetzten Versionsstand: ${a.version}.\n` : ''}${a.schwerpunkt ? `Schwerpunkt: ${a.schwerpunkt}\n` : ''}Melde jeden Befund mit Schweregrad, auch unsichere; der Koordinator filtert.
${a.abschluss ? 'Das ist die Abschlussprüfung dieser Runde. Sag im Feld weitere_runde, ob eine weitere Runde noch Substanz hat oder nur noch Text umschichtet, und warum.\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.`
}
// Eine messende Hilfsfunktion ablesen() steht auch in welle-mit-pruefung.js; plankritik.js und
// planung.js haben eine mit Wunsch und Rückgabe. Die Regel (Fenster, Stufen, Rückfall) steht nur
// in verbrauch.js.
const ablesungen = []
async function ablesen(anlass) {
if (args.verbrauch === false) return
let r = null
try {
r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'pruefung:' + anlass, wunsch: 0 })
} catch (e) {
r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }
}
ablesungen.push({ anlass, ...(r || { status: 'nicht_gelesen', grund: 'verbrauch.js ohne Ergebnis' }) })
}
pruefeArgs(args)
phase('Prüfung')
const opts = { label: 'pruefung' + (args.kritisch ? ':kritisch' : ''), phase: 'Prüfung', schema: BERICHT, agentType: 'pruefer', model: 'opus' }
if (args.effort) opts.effort = args.effort
else if (args.kritisch) opts.effort = 'xhigh'
await ablesen('vor Prüfung')
const bericht = await agent(auftrag(args), opts)
await ablesen('nach Prüfung')
if (!bericht) throw new Error('pruefung: der pruefer hat keinen Bericht geliefert')
const RANG = { blockierend: 0, wichtig: 1, klein: 2 }
const zaehlung = { blockierend: 0, wichtig: 0, klein: 0 }
bericht.befunde.forEach(b => { zaehlung[b.schweregrad]++ })
return { effort: opts.effort || '(Frontmatter)', zaehlung, ...bericht, befunde: [...bericht.befunde].sort((x, y) => RANG[x.schweregrad] - RANG[y.schweregrad]), verbrauch: ablesungen }
```
## Verifikation
Im Vault-Wurzelordner:
```bash
node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/pruefung.js
```
Erwartet: `PARSE OK`, Exit 0.
Danach `git status --porcelain -- .claude/workflows/pruefung.js` zeigen.
@@ -0,0 +1,246 @@
---
type: plan-task
title: "Task 05 — welle-mit-pruefung.js: Rückkehrpunkt nach dem Vergleich und Ablesung um die Prüfung"
status: draft
summary: "Mit bis vergleich endet der Workflow vor der Prüfung und gibt pruefung_args zurück, damit der Koordinator seine Gegenprüfung parallel zur Prüfung fahren kann; sonst liest er vor und nach der Prüfung ab."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: haiku
modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·."
---
# Task 05 — welle-mit-pruefung.js: Rückkehrpunkt nach dem Vergleich und Ablesung um die Prüfung
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort.
Was danach anders ist:
- **`bis: "vergleich"`** gibt `pruefung_args` mit den Rohbelegen im `schwerpunkt` zurück. Der Koordinator startet `pruefung` damit selbst und fährt währenddessen seine Gegenprüfung, nur lesend.
- **Ohne `bis`** liest der Workflow vor und nach dem Kind `pruefung` ab und gibt ihm `verbrauch: false` mit.
## Zieldateien
- `.claude/workflows/welle-mit-pruefung.js` (geändert)
## Verbote
- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.claude/workflows/welle-mit-pruefung.js`
Die ganze Datei ist neu (sie ersetzt die bisherige Fassung vollständig):
```javascript
export const meta = {
name: 'welle-mit-pruefung',
description: 'Schritt 3 und 4 in einem Aufruf: Kopie mit angewendetem Plan vor der Welle, executer-welle, Byte-Vergleich und Suite, danach pruefung mit den Rohbelegen; mit bis "vergleich" endet er vor der Prüfung',
whenToUse: 'Nach der Plankritik, sobald eine Welle mit disjunkten Zieldateien steht (AI/Regelwerk/codeworkflow.md, Schritte 3 und 4). Args: {projekt, planordner, tasks: [{datei, modell, effort?}], kopie? oder ohne: [relpfade, nicht leer], prosa?: [relpfade], suite?, kritisch?, abschluss?, effort?, version?, schwerpunkt?, rot_nachweis_erbracht?, zusatz?, werkzeuge?, bis?: "vergleich"}. Ohne kopie wird sie vor der Welle gebaut; ohne nennt dann .git und Geheimnisdateien. Mit bis "vergleich" kommt pruefung_args zurück: pruefung damit selbst starten und währenddessen die eigene Gegenprüfung fahren. Ersetzt die eigene Gegenprüfung des Koordinators nicht.',
phases: [
{ title: 'Kopie', detail: 'Hilfsagent: Stand vor der Welle kopieren und den Plan der Welle anwenden' },
{ title: 'Welle', detail: 'executer-welle' },
{ title: 'Vergleich', detail: 'Hilfsagent: welle_vergleich.py und Suite im echten Baum' },
{ title: 'Prüfung', detail: 'pruefung mit den Rohbelegen' },
],
}
// Ruft die gespeicherten Workflows executer-welle und pruefung verschachtelt auf, damit ihre
// Aufträge und Rollenparameter an genau einer Stelle stehen. Der Hilfsagent hat keinen
// agentType und bekommt seinen Effort deshalb mit (low). Opus läuft nur in den Kindern,
// und die laufen nacheinander; die Obergrenze von zwei hält executer-welle selbst.
// Verbrauch: Vor und nach der Prüfung liest dieser Workflow den Planverbrauch über verbrauch.js
// ab (nur messen); das Kind pruefung bekommt verbrauch: false, weil ein Kind keinen weiteren
// Workflow aufrufen kann.
const MODELLE = ['haiku', 'sonnet', 'opus']
const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js'
// Einfache Anführungszeichen: In Bash bleiben darin $, Backtick und Backslash wörtlich.
const q = s => "'" + String(s).replace(/'/g, "'\\''") + "'"
const PY = 'PYTHONIOENCODING=utf-8 python -X utf8'
const LAUF = {
type: 'object',
properties: {
laeufe: { type: 'array', items: { type: 'object', properties: { befehl: { type: 'string' }, exit: { type: 'integer' }, ausgabe: { type: 'string' } }, required: ['befehl', 'exit', 'ausgabe'] } },
},
required: ['laeufe'],
}
// Dieselben Regeln wie in executer-welle, aber VOR dem ersten Agenten: Ein ungültiger Task
// soll keine Projektkopie im Temp hinterlassen.
function pruefeArgs(a) {
if (!a || typeof a !== 'object') throw new Error('welle-mit-pruefung: args fehlen')
const fehlt = ['projekt', 'planordner', 'tasks'].filter(k => !a[k])
if (fehlt.length) throw new Error('welle-mit-pruefung: args fehlen: ' + fehlt.join(', '))
if (!Array.isArray(a.tasks) || a.tasks.length === 0) throw new Error('welle-mit-pruefung: tasks muss eine nicht leere Liste von {datei, modell, effort?} sein')
a.tasks.forEach((t, i) => {
if (!t || typeof t !== 'object' || !t.datei || typeof t.datei !== 'string') throw new Error(`welle-mit-pruefung: tasks[${i}] ohne datei`)
if (!MODELLE.includes(t.modell)) throw new Error(`welle-mit-pruefung: ${t.datei}: modell "${t.modell}" nicht in ${MODELLE.join('/')}`)
if (t.effort !== undefined && !EFFORTS.includes(t.effort)) throw new Error(`welle-mit-pruefung: ${t.datei}: effort "${t.effort}" nicht in ${EFFORTS.join('/')}`)
})
if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('welle-mit-pruefung: effort "' + a.effort + '" ungültig')
if (a.prosa !== undefined && !Array.isArray(a.prosa)) throw new Error('welle-mit-pruefung: prosa muss eine Liste sein')
if (a.bis !== undefined && a.bis !== 'vergleich') throw new Error('welle-mit-pruefung: bis kennt nur "vergleich"')
if (!a.kopie) {
const ok = Array.isArray(a.ohne) && a.ohne.length > 0 && a.ohne.every(x => typeof x === 'string' && x)
if (!ok) throw new Error('welle-mit-pruefung: kopie oder ohne angeben. ohne ist eine nicht leere Liste von Ausschlüssen für kopie_bauen.py, als Pfade vom Projektwurzelordner, mindestens .git (falls vorhanden) und Geheimnisdateien')
}
}
function werkzeugAgent(label, phase, befehl, zweck) {
return agent(`Du führst einen Werkzeugbefehl aus und gibst seine Ausgabe roh zurück. Zweck: ${zweck}
Führe genau diesen Befehl einmal im Bash-Werkzeug (Git-Bash) aus, unverändert:
${befehl}
Ändere keine Datei außer denen, die der Befehl selbst anlegt. Deute nichts und fasse nichts zusammen. Gib Befehl, Exit-Code und die vollständige Ausgabe zurück; bei mehr als 200 Zeilen die ersten 60 und die letzten 120, mit einer Zeile "[... N Zeilen gekürzt ...]" dazwischen.`,
{ label, phase, schema: LAUF, model: 'sonnet', effort: 'low' })
}
// Anfang und Ende behalten: Das Ergebnis der Suite steht am Schluss.
function kopfUndEnde(text, kopf, ende) {
if (!text || text.length <= kopf + ende) return text || ''
return text.slice(0, kopf) + '\n[... gekürzt ...]\n' + text.slice(-ende)
}
// Eine messende Hilfsfunktion ablesen() steht auch in pruefung.js; plankritik.js und planung.js
// haben eine mit Wunsch und Rückgabe. Die Regel (Fenster, Stufen, Rückfall) steht nur in
// verbrauch.js.
const ablesungen = []
async function ablesen(anlass) {
let r = null
try {
r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })
} catch (e) {
r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }
}
ablesungen.push({ anlass, ...(r || { status: 'nicht_gelesen', grund: 'verbrauch.js ohne Ergebnis' }) })
}
pruefeArgs(args)
const W = args.werkzeuge || 'D:/Claude/.vault-tools/plan-kopie'
const taskdateien = args.tasks.map(t => t.datei)
let kopie = args.kopie || null
let temp = null
const belege = {}
const fehler = []
phase('Kopie')
if (!kopie) {
const befehl = `T=$(mktemp -d) && echo "KOPIE=$(cygpath -m "$T/mat")" && ${PY} ${q(W + '/kopie_bauen.py')} ${q(args.projekt)} "$T/mat" --ohne ${args.ohne.map(q).join(' ')} && ${PY} ${q(W + '/plan_materialisiere.py')} ${q(args.planordner)} "$T/mat" ${taskdateien.map(q).join(' ')}`
const r = await werkzeugAgent('werkzeug:kopie', 'Kopie', befehl, 'Stand vor der Welle kopieren und die Taskdateien der Welle darauf anwenden')
belege.kopie = r
const lauf = r && r.laeufe && r.laeufe[0]
const m = lauf && /^KOPIE=(.+?)\s*$/m.exec(lauf.ausgabe)
if (m) temp = m[1].replace(/\/mat$/, '')
const angewendet = lauf && /'(neu|ersetzt|angehaengt)': [1-9]/.test(lauf.ausgabe)
if (lauf && lauf.exit === 0 && m && angewendet) kopie = m[1]
else log('Kopie nicht verwendbar (' + (!lauf ? 'Hilfsagent ohne Ergebnis' : lauf.exit !== 0 ? 'Exit ' + lauf.exit : !m ? 'kein KOPIE-Pfad' : 'kein Block angewendet') + '); der Byte-Vergleich entfällt, die Welle läuft trotzdem')
}
phase('Welle')
let welle = null
try {
welle = await workflow('executer-welle', {
projekt: args.projekt, planordner: args.planordner, tasks: args.tasks,
rot_nachweis_erbracht: args.rot_nachweis_erbracht, zusatz: args.zusatz,
})
} catch (e) {
fehler.push('executer-welle: ' + (e && e.message))
log('executer-welle gescheitert: ' + (e && e.message))
}
phase('Vergleich')
if (welle) {
const teile = []
if (kopie) teile.push(`${PY} ${q(W + '/welle_vergleich.py')} --projekt ${q(args.projekt)} --mat ${q(kopie)}${args.prosa && args.prosa.length ? ' --prosa ' + args.prosa.map(q).join(' ') : ''} -- ${taskdateien.map(d => q(args.planordner + '/' + d)).join(' ')}; echo "EXIT welle_vergleich: $?"`)
if (args.suite) teile.push(`if cd ${q(args.projekt)}; then export COLUMNS=300; ${args.suite}; echo "EXIT suite: $?"; else echo "EXIT suite: cd ins Projekt gescheitert"; fi`)
if (teile.length) {
belege.vergleich = await werkzeugAgent('werkzeug:vergleich', 'Vergleich', teile.join('; '), 'Nach der Welle: geänderte Dateien und Bytes gegen die Kopie, dann die Suite im echten Baum')
if (!belege.vergleich) log('Vergleich: Hilfsagent ohne Ergebnis')
} else log('Vergleich entfällt: weder Kopie noch suite')
} else log('Vergleich entfällt: keine Welle')
phase('Prüfung')
let pruefung = null
let pruefungGrund = null
if (!welle) pruefungGrund = 'keine Welle'
else if (welle.ohne_bericht && welle.ohne_bericht.length) pruefungGrund = 'Welle unvollständig, ohne Bericht: ' + welle.ohne_bericht.join(', ')
let pruefungArgs = null
if (!pruefungGrund) {
const vl = belege.vergleich && belege.vergleich.laeufe && belege.vergleich.laeufe[0]
const vergleichstext = vl ? kopfUndEnde(vl.ausgabe, 1500, 2500) : ''
const schwerpunkt = [
args.schwerpunkt || '',
welle.mit_abweichung.length ? `Diese Tasks melden Abweichungen vom Plan: ${welle.mit_abweichung.join(', ')}.` : '',
vergleichstext ? `Rohausgabe von Byte-Vergleich und Suite nach der Welle (vom Hilfsagenten, nicht von dir gefahren):\n${vergleichstext}` : '',
].filter(Boolean).join('\n')
pruefungArgs = {
projekt: args.projekt, plan: args.planordner, tasks: taskdateien, suite: args.suite,
kritisch: args.kritisch, abschluss: args.abschluss, version: args.version, effort: args.effort, schwerpunkt,
}
}
const tempText = temp ? `${temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn der Diff nicht mehr gebraucht wird` : null
if (args.bis === 'vergleich') {
if (pruefungGrund) log('Prüfung wäre übersprungen worden: ' + pruefungGrund)
return {
status: 'bis_vergleich',
kopie,
temp_ordner: tempText,
welle,
vergleich: belege.vergleich || null,
kopie_lauf: belege.kopie || null,
pruefung_args: pruefungArgs,
pruefung_grund: pruefungGrund,
fehler,
verbrauch: ablesungen,
naechster_schritt: pruefungArgs
? 'pruefung mit pruefung_args als eigenen Workflow im Hintergrund starten. Währenddessen die eigene Gegenprüfung nach Schritt 4, nur lesend: nichts in Projektbaum oder Planordner schreiben, Rot- und Mutantentests nur auf einer Kopie, den pruefer nicht abbrechen. Plankorrektur und Commit erst nach seinem Bericht.'
: 'Keine Prüfung möglich (' + pruefungGrund + '). Welle nachziehen, dann erneut.',
}
}
if (pruefungGrund) log('Prüfung übersprungen: ' + pruefungGrund)
else {
await ablesen('vor Prüfung')
try {
pruefung = await workflow('pruefung', { ...pruefungArgs, verbrauch: false })
} catch (e) {
fehler.push('pruefung: ' + (e && e.message))
pruefungGrund = 'pruefung gescheitert: ' + (e && e.message)
log(pruefungGrund)
}
await ablesen('nach Prüfung')
}
return {
status: 'fertig',
kopie,
temp_ordner: tempText,
welle,
vergleich: belege.vergleich || null,
kopie_lauf: belege.kopie || null,
pruefung: pruefung || { uebersprungen: pruefungGrund },
fehler,
verbrauch: ablesungen,
naechster_schritt: 'Eigene Gegenprüfung nach Schritt 4: Testlauf selbst wiederholen, zentrale Artefakte selbst lesen, eigener Lauf mit echten Daten; bei Abweichung im Byte-Vergleich diff auf die ganze Datei. Befunde am Plan korrigieren, nicht am Code.',
}
```
## Verifikation
Im Vault-Wurzelordner:
```bash
node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/welle-mit-pruefung.js
```
Erwartet: `PARSE OK`, Exit 0.
Danach `git status --porcelain -- .claude/workflows/welle-mit-pruefung.js` zeigen.
@@ -0,0 +1,661 @@
---
type: plan-task
title: "Task 07 — verbrauch_reihe.py: Messreihe aus den Workflow-Protokollen"
status: draft
summary: "Auswertung der Ablesungen: Differenz im 5-Stunden-Fenster und in der Woche zwischen zwei Ablesungen und die Agentenminuten dazwischen, dazu die Fälle."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: sonnet
effort: low
modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt. Sonnet low statt Haiku, weil der Code das Zeichen · (U+00B7) trägt und Haiku in der Messung vom 2026-09-23 typografische Zeichen still ersetzt hat."
---
# Task 07 — verbrauch_reihe.py: Messreihe aus den Workflow-Protokollen
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort.
Was danach anders ist:
- **`verbrauch_reihe.py`** liest aus `~/.claude/projects/D--Claude/*/subagents/workflows/wf_*/` die Agenten mit Label `verbrauch:` und rechnet je Paar die Differenz. Ein Reset ist eine Verschiebung von `resetsAt` um mehr als fünf Minuten oder ein fallender Wert; das Zittern um Millisekunden zählt nicht.
- **Die Ausgabe ist reines ASCII**, kaputte Zeilen und Abstürze einzelner Läufe sind Hinweise und kein Abbruch.
- **`mutanten_reihe.py`** fährt die Mutantenliste gegen das Skript.
## Zieldateien
- `.vault-tools/workflow-pruefung/verbrauch_reihe.py` (neu)
- `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py` (neu)
- `.vault-tools/workflow-pruefung/mutanten_reihe.py` (neu)
## Verbote
- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.vault-tools/workflow-pruefung/verbrauch_reihe.py`
Die ganze Datei ist neu:
```python
"""Wertet die Verbrauchsablesungen der Workflows aus: wie viel Prozent des 5-Stunden-Fensters und der Woche zwischen zwei Ablesungen verbraucht wurden und welche Agenten dazwischen liefen.
Koordinator-Werkzeug (entstanden 2026-09-24). Es misst und beurteilt nichts; die Stufen in
.claude/workflows/verbrauch.js stellt der Mensch nach dieser Messreihe ein.
Quelle der Daten ist der Session-Speicher von Claude Code. Je Workflow-Lauf liegen unter
<speicher>/<sitzung>/subagents/workflows/<lauf>/ die Datei journal.jsonl (Ereignisse "started"
mit agentId und label, "result" mit agentId und result), je Agent agent-<id>.jsonl (Zeilen mit
timestamp) und agent-<id>.meta.json (model). Ablesungen sind Agenten, deren label mit
"verbrauch:" beginnt; ihr result traegt fenster mit label, percentUsed und resetsAt.
Die Fenster werden wie in verbrauch.js erkannt: "5-hour ..." und "Weekly ... all models".
Was das Skript nicht sieht: den Verbrauch anderer Sitzungen und der Hauptsitzung im selben
Zeitraum (er steckt in der Differenz mit drin), Ablesungen ueber probe (sie starten keinen
Agenten) und Laeufe, deren Protokolle geloescht sind. Die Kennzahl je Opus-Agentenstunde ist eine
Naeherung, kein Nachweis.
Aufruf: python verbrauch_reihe.py [--speicher <pfad>] [--seit JJJJ-MM-TT]
Exit 0 mit Auswertung (auch bei null Ablesungen), 2 wenn der Speicher fehlt oder --seit kein
Datum ist. Die Ausgabe ist reines ASCII, damit sie auch auf einer cp1252-Konsole durchlaeuft.
"""
import argparse
import json
import math
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
STANDARD_SPEICHER = Path.home() / ".claude" / "projects" / "D--Claude"
RESET_TOLERANZ = timedelta(minutes=5)
FUENF = re.compile(r"^\s*5[- ]?hour", re.I)
WOCHE = re.compile(r"^\s*weekly\b.*\ball models\b", re.I)
FAMILIEN = ("opus", "sonnet", "haiku", "fable")
def zeit(text):
return datetime.fromisoformat(text.replace("Z", "+00:00"))
def lies_jsonl(pfad, befunde):
"""Liest eine JSONL-Datei zeilenweise; kaputte Zeilen sind ein Befund, kein Absturz."""
zeilen = []
try:
with pfad.open(encoding="utf-8") as datei:
for nr, zeile in enumerate(datei, 1):
if not zeile.strip():
continue
try:
zeilen.append(json.loads(zeile))
except ValueError:
befunde.append(f"{pfad.parent.name}/{pfad.name}: Zeile {nr} ist kein JSON, uebergangen")
except OSError as e:
befunde.append(f"{pfad.parent.name}/{pfad.name}: nicht lesbar ({type(e).__name__})")
return zeilen
def einordnen(fenster):
"""(prozent, resetsAt) fuer 5 Stunden und Woche; None, wo das Fenster fehlt oder kaputt ist."""
fuenf = woche = None
if not isinstance(fenster, list):
return None, None
for f in fenster:
if not isinstance(f, dict) or not isinstance(f.get("label"), str):
return None, None
art = "fuenf" if FUENF.match(f["label"]) else "woche" if WOCHE.match(f["label"]) else None
if art is None:
continue
p = f.get("percentUsed")
if isinstance(p, bool) or not isinstance(p, (int, float)) or not math.isfinite(p) or p < 0:
return None, None
eintrag = (float(p), f.get("resetsAt"))
if art == "fuenf" and (fuenf is None or p > fuenf[0]):
fuenf = eintrag
elif art == "woche" and (woche is None or p > woche[0]):
woche = eintrag
return fuenf, woche
def zeitspanne(ordner, agent_id, befunde):
stempel = []
for o in lies_jsonl(ordner / f"agent-{agent_id}.jsonl", befunde):
t = o.get("timestamp") if isinstance(o, dict) else None
if isinstance(t, str):
try:
stempel.append(zeit(t))
except ValueError:
pass
return (min(stempel), max(stempel)) if stempel else None
def familie(ordner, agent_id):
try:
modell = json.loads((ordner / f"agent-{agent_id}.meta.json").read_text(encoding="utf-8")).get("model")
except (OSError, ValueError, AttributeError):
return "?"
if not isinstance(modell, str):
return "?"
for f in FAMILIEN:
if f in modell.lower():
return f
return modell
def lauf_auswerten(ordner, befunde):
labels, ergebnisse = {}, {}
for o in lies_jsonl(ordner / "journal.jsonl", befunde):
if not isinstance(o, dict) or not isinstance(o.get("agentId"), str):
continue
if o.get("type") == "started":
labels[o["agentId"]] = o.get("label") if isinstance(o.get("label"), str) else ""
elif o.get("type") == "result":
ergebnisse[o["agentId"]] = o.get("result")
ablesungen, agenten = [], []
for agent_id, label in labels.items():
spanne = zeitspanne(ordner, agent_id, befunde)
if label.startswith("verbrauch:"):
if spanne is None:
befunde.append(f"{ordner.name}: Ablesung {label} ohne Zeitstempel, uebergangen")
continue
ergebnis = ergebnisse.get(agent_id)
fuenf, woche = einordnen(ergebnis.get("fenster") if isinstance(ergebnis, dict) else None)
# Ablesezeit ist das Ende des Ableseagenten: get_usage laeuft kurz vor seinem Schluss.
ablesungen.append({"anlass": label[len("verbrauch:"):], "zeit": spanne[1], "fuenf": fuenf, "woche": woche})
elif spanne is not None:
agenten.append({"familie": familie(ordner, agent_id), "start": spanne[0], "ende": spanne[1]})
ablesungen.sort(key=lambda a: a["zeit"])
return ablesungen, agenten
def zeitpunkt(text):
if not isinstance(text, str):
return None
try:
return zeit(text)
except ValueError:
return None
def differenz(a, b):
"""Prozentpunkte von a nach b im selben Fenster.
get_usage rechnet resetsAt bei jeder Ablesung neu aus; der Wert schwankt um Millisekunden und
springt dabei auch ueber eine Sekunden- oder Datumsgrenze (gemessen 2026-09-24: 18:29:59.664Z,
18:30:00.267Z, 18:30:00.459Z fuer dasselbe Fenster). Als Reset gilt deshalb erst eine
Verschiebung um mehr als RESET_TOLERANZ oder ein fallender Wert.
"""
if a is None or b is None:
return "unbekannt"
ra, rb = zeitpunkt(a[1]), zeitpunkt(b[1])
if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None):
return "unbekannt"
if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:
return "Reset"
return round(b[0] - a[0], 1)
def paare(ordner, ablesungen, agenten):
for a, b in zip(ablesungen, ablesungen[1:]):
minuten = {}
for g in agenten:
anteil = (min(g["ende"], b["zeit"]) - max(g["start"], a["zeit"])).total_seconds()
if anteil > 0:
minuten[g["familie"]] = minuten.get(g["familie"], 0.0) + anteil / 60
yield {
"lauf": ordner.name,
"von": a["anlass"],
"bis": b["anlass"],
"minuten": round((b["zeit"] - a["zeit"]).total_seconds() / 60, 1),
"agenten": {k: round(v, 1) for k, v in sorted(minuten.items())},
"d5": differenz(a["fuenf"], b["fuenf"]),
"dw": differenz(a["woche"], b["woche"]),
}
UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"})
def nur_ascii(text):
return str(text).translate(UMLAUTE).encode("ascii", "replace").decode("ascii")
def main(argv=None):
p = argparse.ArgumentParser(description="Messreihe der Verbrauchsablesungen aus den Workflow-Protokollen")
p.add_argument("--speicher", type=Path, default=STANDARD_SPEICHER)
p.add_argument("--seit", default=None, help="nur Ablesungen ab diesem Tag (JJJJ-MM-TT, UTC)")
a = p.parse_args(argv)
if not a.speicher.is_dir():
print(nur_ascii(f"Speicher fehlt: {a.speicher}"))
return 2
seit = None
if a.seit:
try:
seit = datetime.fromisoformat(a.seit).replace(tzinfo=timezone.utc)
except ValueError:
print(nur_ascii(f"--seit {a.seit!r} ist kein Datum JJJJ-MM-TT"))
return 2
befunde, zeilen, anzahl = [], [], 0
for ordner in sorted(a.speicher.glob("*/subagents/workflows/wf_*")):
if not ordner.is_dir():
continue
try:
ablesungen, agenten = lauf_auswerten(ordner, befunde)
if seit is not None:
ablesungen = [x for x in ablesungen if x["zeit"] >= seit]
anzahl += len(ablesungen)
zeilen.extend(paare(ordner, ablesungen, agenten))
except Exception as e: # Ein Absturz ist ein Befund (pruefwerkzeuge.md, Abschnitt 4).
befunde.append(f"{ordner.name}: Auswertung abgebrochen ({type(e).__name__}: {e})")
print(f"{anzahl} Ablesungen, {len(zeilen)} Paare")
if zeilen:
print("| Lauf | von -> bis | Minuten | Agentenminuten | d 5h % | d Woche % |")
print("|---|---|---|---|---|---|")
for z in zeilen:
agenten = ", ".join(f"{k} {v}" for k, v in z["agenten"].items()) or "-"
print(nur_ascii(f"| {z['lauf']} | {z['von']} -> {z['bis']} | {z['minuten']} | {agenten} | {z['d5']} | {z['dw']} |"))
werte = [z for z in zeilen if isinstance(z["d5"], float) and z["agenten"].get("opus", 0) > 0]
if werte:
prozent = sum(z["d5"] for z in werte)
stunden = sum(z["agenten"]["opus"] for z in werte) / 60
print(f"Naeherung: {prozent / stunden:.1f} % des 5-Stunden-Fensters je Opus-Agentenstunde "
f"({len(werte)} Paare, {stunden:.1f} Opus-Stunden; andere Sitzungen und Nicht-Opus-Agenten stecken in der Differenz)")
else:
print("Keine auswertbaren Paare mit Opus-Agenten.")
if befunde:
print("Hinweise:")
for b in befunde:
print(nur_ascii("- " + b))
return 0
if __name__ == "__main__":
sys.exit(main())
```
### `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py`
Die ganze Datei ist neu:
```python
"""Faelle fuer verbrauch_reihe.py: baut je Fall einen kuenstlichen Session-Speicher und faehrt das Skript als eigenen Prozess.
Die Grundlinie laeuft zuerst; scheitert sie, messen die uebrigen Faelle nichts. Der Prozess laeuft
ohne PYTHONIOENCODING, damit eine Ausgabe ausserhalb von ASCII auf cp1252 auffaellt.
Aufruf: python faelle_verbrauch_reihe.py
Exit 0, wenn alle Faelle bestehen, sonst 1.
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
SKRIPT = Path(__file__).with_name("verbrauch_reihe.py")
def fenster(fuenf, woche, reset5="2026-09-24T18:30:00Z", resetw="2026-09-28T00:00:00Z"):
return [
{"label": "5-hour limit", "percentUsed": fuenf, "resetsAt": reset5},
{"label": "Weekly · all models", "percentUsed": woche, "resetsAt": resetw},
]
def baue(speicher, laeufe):
"""laeufe: {lauf: [agent]}; agent: {id, label, start, ende, modell, ergebnis, ohne_zeit, kaputt}."""
for lauf, agenten in laeufe.items():
ordner = speicher / "sitzung-1" / "subagents" / "workflows" / lauf
ordner.mkdir(parents=True)
with (ordner / "journal.jsonl").open("w", encoding="utf-8") as journal:
journal.write(json.dumps({"type": "launched"}) + "\n")
for a in agenten:
journal.write(json.dumps({"type": "started", "agentId": a["id"], "label": a["label"]}) + "\n")
if a.get("kaputt"):
journal.write("{kein json\n")
if "ergebnis" in a:
journal.write(json.dumps({"type": "result", "agentId": a["id"], "result": a["ergebnis"]}) + "\n")
for a in agenten:
(ordner / f"agent-{a['id']}.meta.json").write_text(json.dumps({"model": a.get("modell", "haiku")}), encoding="utf-8")
if a.get("ohne_zeit"):
continue
with (ordner / f"agent-{a['id']}.jsonl").open("w", encoding="utf-8") as d:
d.write(json.dumps({"timestamp": a["start"], "type": "user"}) + "\n")
d.write(json.dumps({"timestamp": a["ende"], "type": "assistant"}) + "\n")
def ablesung(nr, anlass, zeitpunkt, fuenf, woche, **extra):
ende = extra.pop("bis", None) or zeitpunkt
return {"id": f"v{nr}", "label": "verbrauch:" + anlass, "start": zeitpunkt, "ende": ende, "modell": "haiku",
"ergebnis": {"verfuegbar": True, "status": "ok", "plan": "Pro", "fenster": extra.pop("liste", None) or fenster(fuenf, woche, **extra), "fehler": ""}}
# resetsAt schwankt bei echten Ablesungen um Millisekunden und springt ueber Sekunden- und
# Datumsgrenzen (gemessen 2026-09-24); die Grundlinie traegt deshalb solche Werte.
ZITTER = {"reset5": "2026-09-24T18:29:59.664Z", "resetw": "2026-09-27T23:59:59.664Z"}
def opus(nr, start, ende, modell="opus"):
return {"id": f"o{nr}", "label": f"plankritik:linse{nr}", "start": start, "ende": ende, "modell": modell, "ergebnis": {}}
def fahre(speicher, *zusatz):
umgebung = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")}
lauf = subprocess.run([sys.executable, str(SKRIPT), "--speicher", str(speicher), *zusatz],
capture_output=True, env=umgebung)
return lauf.returncode, lauf.stdout.decode("ascii", "replace"), lauf.stderr.decode("utf-8", "replace")
GRUND = {
"wf_a": [
ablesung(1, "plankritik:vor Linsen", "2026-09-24T10:00:00Z", 10, 5),
opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z"),
ablesung(2, "plankritik:Ende", "2026-09-24T10:30:10Z", 14, 6, **ZITTER),
]
}
def f_grundlinie(tmp):
baue(tmp, GRUND)
code, aus, err = fahre(tmp)
fehler = []
if code != 0:
fehler.append(f"Exit {code}: {err[-300:]}")
for erwartet in ["2 Ablesungen, 1 Paare", "| wf_a | plankritik:vor Linsen -> plankritik:Ende | 30.2 | opus 30.0 | 4.0 | 1.0 |",
"Naeherung: 8.0 % des 5-Stunden-Fensters je Opus-Agentenstunde (1 Paare, 0.5 Opus-Stunden"]:
if erwartet not in aus:
fehler.append("fehlt: " + erwartet)
if "Hinweise" in aus:
fehler.append("unerwartete Hinweise")
return fehler
def f_reset(tmp):
baue(tmp, {"wf_r": [ablesung(1, "a", "2026-09-24T10:00:00Z", 80, 5), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"),
ablesung(2, "b", "2026-09-24T10:30:00Z", 3, 6, reset5="2026-09-24T15:30:00Z")]})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "| Reset | 1.0 |" not in aus:
fehler.append("Reset nicht erkannt: " + aus[-300:])
if "Keine auswertbaren Paare mit Opus-Agenten." not in aus:
fehler.append("Reset-Paar wurde mitgerechnet")
return fehler
def f_speicher_fehlt(tmp):
code, aus, _ = fahre(tmp / "gibt-es-nicht")
return [] if code == 2 and "Speicher fehlt" in aus else [f"Exit {code}, Ausgabe {aus!r}"]
def f_null_ablesungen(tmp):
baue(tmp, {"wf_n": [opus(1, "2026-09-24T10:00:00Z", "2026-09-24T10:10:00Z")]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "0 Ablesungen, 0 Paare" in aus else [f"Exit {code}, Ausgabe {aus!r}"]
def f_kaputte_zeile(tmp):
agenten = [dict(GRUND["wf_a"][0], kaputt=True), GRUND["wf_a"][1], GRUND["wf_a"][2]]
baue(tmp, {"wf_k": agenten})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "1 Paare" not in aus:
fehler.append("Auswertung trotz kaputter Zeile gescheitert: " + aus[-300:])
if "ist kein JSON" not in aus:
fehler.append("kaputte Zeile nicht als Hinweis gemeldet")
return fehler
def f_ohne_zeitstempel(tmp, position):
agenten = [dict(a) for a in GRUND["wf_a"]]
agenten[position]["ohne_zeit"] = True
baue(tmp, {"wf_z": agenten})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "1 Ablesungen, 0 Paare" not in aus:
fehler.append(f"Exit {code}, Ausgabe {aus[-300:]!r}")
if "ohne Zeitstempel" not in aus:
fehler.append("fehlender Zeitstempel nicht gemeldet")
return fehler
def f_fenster_kaputt(tmp, liste, erwartet="| unbekannt | unbekannt |"):
baue(tmp, {"wf_f": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, liste=liste), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"),
ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 6)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_umlaut_im_anlass(tmp):
baue(tmp, {"wf_u": [ablesung(1, "pruefung:vor Prüfung", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "pruefung:nach Prüfung", "2026-09-24T10:30:00Z", 12, 5)]})
code, aus, err = fahre(tmp)
return [] if code == 0 and "pruefung:vor Pruefung -> pruefung:nach Pruefung" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}, stderr {err[-200:]!r}"]
def f_seit(tmp):
baue(tmp, {"wf_s": [ablesung(1, "alt", "2026-09-20T10:00:00Z", 10, 5), ablesung(2, "a", "2026-09-24T10:00:00Z", 10, 5),
ablesung(3, "b", "2026-09-24T11:00:00Z", 12, 5)]})
code, aus, _ = fahre(tmp, "--seit", "2026-09-24")
return [] if code == 0 and "2 Ablesungen, 1 Paare" in aus and "alt ->" not in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_seit_ungueltig(tmp):
baue(tmp, GRUND)
code, aus, _ = fahre(tmp, "--seit", "gestern")
return [] if code == 2 and "kein Datum" in aus else [f"Exit {code}, Ausgabe {aus!r}"]
def f_voller_modellname(tmp):
baue(tmp, {"wf_m": [GRUND["wf_a"][0], opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z", modell="claude-opus-5-5"), GRUND["wf_a"][2]]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "opus 30.0" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_agent_ausserhalb(tmp):
# Ein Agent, der vor der ersten Ablesung endet, zaehlt nicht mit; einer, der halb hineinreicht, anteilig.
baue(tmp, {"wf_x": [opus(1, "2026-09-24T09:00:00Z", "2026-09-24T09:50:00Z"), GRUND["wf_a"][0],
opus(2, "2026-09-24T09:55:00Z", "2026-09-24T10:10:00Z"), GRUND["wf_a"][2]]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| opus 10.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-400:]!r}"]
def f_absturz_ist_befund(tmp):
# Ein Zeitstempel ohne Zeitzone neben einem mit ihr laesst den Vergleich mit TypeError scheitern.
# Der Lauf muss das als Hinweis melden und die anderen Laeufe trotzdem auswerten.
kaputt = [dict(GRUND["wf_a"][0]), dict(GRUND["wf_a"][1], start="2026-09-24T10:00:10"), dict(GRUND["wf_a"][2])]
baue(tmp, {"wf_a": GRUND["wf_a"], "wf_b": kaputt})
code, aus, err = fahre(tmp)
fehler = []
if code != 0:
fehler.append(f"Exit {code}: {err[-300:]}")
if "wf_b: Auswertung abgebrochen (TypeError" not in aus:
fehler.append("Absturz nicht als Hinweis gemeldet")
if "| wf_a |" not in aus:
fehler.append("der gesunde Lauf fehlt")
return fehler
def f_fallender_wert(tmp):
baue(tmp, {"wf_d": [ablesung(1, "a", "2026-09-24T10:00:00Z", 30, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 5, 5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| Reset | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_reset_unlesbar(tmp):
baue(tmp, {"wf_u": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, reset5="bald"), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 6)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| unbekannt | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_hoechstes_fenster(tmp):
liste = [{"label": "5-hour limit", "percentUsed": 10, "resetsAt": "2026-09-24T18:30:00Z"},
{"label": "5-hour limit", "percentUsed": 30, "resetsAt": "2026-09-24T18:30:00Z"},
{"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}]
baue(tmp, {"wf_h": [ablesung(1, "a", "2026-09-24T10:00:00Z", 0, 0, liste=liste), ablesung(2, "b", "2026-09-24T10:20:00Z", 35, 6)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| 5.0 | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_ablesezeit_ist_ende(tmp):
# Der Opus-Agent laeuft ganz innerhalb des ersten Ableseagenten, also vor der Ablesung.
baue(tmp, {"wf_t": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, bis="2026-09-24T10:00:10Z"),
opus(1, "2026-09-24T10:00:02Z", "2026-09-24T10:00:08Z"),
ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| 29.8 | - | 2.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_agent_ueber_das_paar_hinaus(tmp):
baue(tmp, {"wf_b": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5),
opus(1, "2026-09-24T10:20:00Z", "2026-09-24T10:50:00Z"), ablesung(3, "c", "2026-09-24T11:00:00Z", 15, 5)]})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "| a -> b | 30.0 | opus 10.0 |" not in aus:
fehler.append("erstes Paar nicht am Ende gekappt: " + aus[-400:])
if "| b -> c | 30.0 | opus 20.0 |" not in aus:
fehler.append("zweites Paar falsch: " + aus[-400:])
return fehler
def f_ueber_hundert(tmp):
baue(tmp, {"wf_g": [ablesung(1, "a", "2026-09-24T10:00:00Z", 98, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 103, 5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| 5.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
FAELLE = [
("Grundlinie: zwei Ablesungen, ein Opus-Agent dazwischen", f_grundlinie),
("Reset zwischen zwei Ablesungen", f_reset),
("Speicher fehlt: Exit 2", f_speicher_fehlt),
("null Ablesungen: Exit 0 mit Zaehlung", f_null_ablesungen),
("kaputte Journalzeile: Hinweis, kein Absturz", f_kaputte_zeile),
("erste Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 0)),
("letzte Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 2)),
("5-Stunden-Fenster fehlt", lambda t: f_fenster_kaputt(t, [{"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}], "| unbekannt | 1.0 |")),
("Umlaut im Anlass auf cp1252", f_umlaut_im_anlass),
("Prozent als Text", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": "10", "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])),
("Prozent als Wahrheitswert", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": True, "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])),
("fenster kein Array", lambda t: f_fenster_kaputt(t, {"a": 1})),
("--seit filtert aeltere Ablesungen", f_seit),
("--seit ungueltig: Exit 2", f_seit_ungueltig),
("voller Modellname zaehlt als opus", f_voller_modellname),
("Agent ausserhalb und halb innerhalb des Paars", f_agent_ausserhalb),
("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund),
("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert),
("resetsAt ohne Datum: unbekannt", f_reset_unlesbar),
("doppeltes 5-Stunden-Fenster: das hoechste zaehlt", f_hoechstes_fenster),
("Ablesezeit ist das Ende des Ableseagenten", f_ablesezeit_ist_ende),
("Agent ueber das Paar hinaus wird am Ende gekappt", f_agent_ueber_das_paar_hinaus),
("ueber 100 Prozent ist ein Wert, kein Fehler", f_ueber_hundert),
]
def main():
# Fehlertexte koennen Zeichen ausserhalb der Konsolenkodierung tragen; der Treiber darf daran nicht sterben.
sys.stdout.reconfigure(errors="replace")
gescheitert = 0
for name, fall in FAELLE:
with tempfile.TemporaryDirectory() as tmp:
try:
fehler = fall(Path(tmp))
except Exception as e: # ein Absturz im Fall ist selbst ein Befund
fehler = [f"Absturz im Fall: {type(e).__name__}: {e}"]
if fehler:
gescheitert += 1
print(f"FEHLER {name}\n " + "\n ".join(fehler))
else:
print(f"OK {name}")
print(f"{len(FAELLE)} Faelle, {gescheitert} gescheitert")
return 1 if gescheitert else 0
if __name__ == "__main__":
sys.exit(main())
```
### `.vault-tools/workflow-pruefung/mutanten_reihe.py`
Die ganze Datei ist neu:
```python
"""Mutantenlauf gegen verbrauch_reihe.py: je Mutant genau eine Ersetzung in einer Kopie des Ordners, dann faelle_verbrauch_reihe.py.
Jede Ersetzung muss genau einmal vorkommen, sonst bricht der Lauf mit Exit 2 ab (veralteter Mutant).
Aufruf: python mutanten_reihe.py
Exit 0, wenn jeder Mutant gefangen wird, 1 bei einem Ueberlebenden, 2 bei einem veralteten Muster.
"""
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
MUTANTEN = [
("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"),
("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"),
("unlesbares resetsAt als gleich", " if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None):\n return \"unbekannt\"\n", ""),
("bool als Zahl", "isinstance(p, bool) or ", ""),
("ueber 100 als Fehler", "not math.isfinite(p) or p < 0", "not math.isfinite(p) or not 0 <= p <= 100"),
("erstes statt hoechstes 5-Stunden-Fenster", "if art == \"fuenf\" and (fuenf is None or p > fuenf[0]):", "if art == \"fuenf\" and fuenf is None:"),
("Ablesezeit Start statt Ende", "\"zeit\": spanne[1]", "\"zeit\": spanne[0]"),
("nur_ascii fehlt", "print(nur_ascii(f\"| {z", "print((f\"| {z"),
("Ende des Paars nicht gekappt", "min(g[\"ende\"], b[\"zeit\"])", "g[\"ende\"]"),
("Anfang des Paars nicht gekappt", "max(g[\"start\"], a[\"zeit\"])", "g[\"start\"]"),
("Absturz nicht gefangen", "except Exception as e: # Ein Absturz", "except ZeroDivisionError as e: # Ein Absturz"),
("kaputte Zeile bricht ab", " except ValueError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile", " except KeyError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile"),
("seit ignoriert", "ablesungen = [x for x in ablesungen if x[\"zeit\"] >= seit]", "pass"),
("Speicher fehlt still", " return 2\n seit = None", " return 0\n seit = None"),
("Familie aus vollem Namen fehlt", " if f in modell.lower():", " if f == modell.lower():"),
]
def main() -> int:
ueberlebt, veraltet = [], []
for name, alt, neu in MUTANTEN:
with tempfile.TemporaryDirectory() as tmp:
ziel = Path(tmp) / "w"
ziel.mkdir()
for datei in ("verbrauch_reihe.py", "faelle_verbrauch_reihe.py"):
shutil.copy2(HIER / datei, ziel / datei)
pfad = ziel / "verbrauch_reihe.py"
text = pfad.read_text(encoding="utf-8")
if text.count(alt) != 1:
veraltet.append(name)
print(f"VERALTET {name} (Muster {text.count(alt)}-mal)")
continue
pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n")
lauf = subprocess.run([sys.executable, str(ziel / "faelle_verbrauch_reihe.py")], capture_output=True, text=True, encoding="utf-8", errors="replace")
rot = [z for z in lauf.stdout.splitlines() if z.startswith("FEHLER")]
if lauf.returncode == 0:
ueberlebt.append(name)
print(f"UEBERLEBT {name}")
else:
print(f"GEFANGEN {name} ({rot[0][7:80] if rot else 'Absturz'})")
print(f"{len(MUTANTEN)} Mutanten, {len(ueberlebt)} ueberlebt, {len(veraltet)} veraltet")
return 2 if veraltet else 1 if ueberlebt else 0
if __name__ == "__main__":
sys.stdout.reconfigure(errors="replace")
sys.exit(main())
```
## Verifikation
Im Vault-Wurzelordner:
```bash
python .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py
```
Erwartet: Alle übrigen Zeilen beginnen mit `OK`, die letzte enthält `0 gescheitert`, Exit 0.
```bash
python .vault-tools/workflow-pruefung/mutanten_reihe.py
```
Erwartet: Die letzte Zeile enthält `0 ueberlebt, 0 veraltet`, Exit 0.
Danach `git status --porcelain -- .vault-tools/workflow-pruefung/verbrauch_reihe.py .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py .vault-tools/workflow-pruefung/mutanten_reihe.py` zeigen.
@@ -0,0 +1,74 @@
---
type: plan-task
title: "Task 08 — Nachbesserung verbrauch.js: über 100 Prozent bremst"
status: draft
summary: "Delta nach Welle 0: Ein Fenster über 100 Prozent ist ein gültiger Wert und bremst, statt als unbekanntes Fenster auf zwei Plätze zurückzufallen. Befund der Plankritik vom 2026-09-24."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: haiku
modell_begruendung: "Abschrift: zwei Aus-wird-Blöcke stehen wörtlich im Plan, Zieldatei und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·."
---
# Task 08 — Nachbesserung verbrauch.js: über 100 Prozent bremst
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. Task 01 ist in Welle 0 ausgeführt und bleibt byte-gleich; diese Nachbesserung ändert die Datei nur an zwei Stellen.
Was danach anders ist:
- **Über 100 % bremst.** Bisher war ein Fenster über 100 % ein unbekanntes Fenster mit Rückfall auf zwei Plätze, die Bremse lief also nicht monoton: bei 84 % ein Platz, bei 85 bis 100 % keiner, bei 101 % wieder zwei. Jetzt ist nur ein negativer oder nicht endlicher Wert ein Befund.
## Zieldateien
- `.claude/workflows/verbrauch.js` (geändert)
## Verbote
- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/`.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.claude/workflows/verbrauch.js`, Stelle 1
Aus:
```javascript
// Ordnet die Fenster ein. Ein Fenster, das als 5 Stunden oder Woche erkannt wird, aber keine
// Prozentzahl von 0 bis 100 trägt, ist ein Befund und wird nicht still übergangen.
```
wird:
```javascript
// Ordnet die Fenster ein. Ein Fenster, das als 5 Stunden oder Woche erkannt wird, aber keine
// Prozentzahl ab 0 trägt, ist ein Befund und wird nicht still übergangen. Über 100 % heißt
// überschritten und bremst wie jeder Wert über der letzten Stufe.
```
### `.claude/workflows/verbrauch.js`, Stelle 2
Aus:
```javascript
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 100) return { fehler: `${f.label}: percentUsed ${JSON.stringify(p)} ist keine Zahl von 0 bis 100` }
```
wird:
```javascript
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0) return { fehler: `${f.label}: percentUsed ${JSON.stringify(p)} ist keine Zahl ab 0` }
```
## Verifikation
Im Vault-Wurzelordner:
```bash
node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/verbrauch.js
```
Erwartet: `PARSE OK`, Exit 0.
Danach `git status --porcelain -- .claude/workflows/verbrauch.js` zeigen.
@@ -0,0 +1,438 @@
---
type: plan-task
title: "Task 09 — Nachbesserung nach der Prüfung: Fälle für die Reset-Toleranz und kleine Lücken"
status: draft
summary: "Delta nach Welle 1 und 2 aus der Prüfung wf_e0294c82-76c: Regressionsfälle und Mutanten für die Reset-Toleranz, doppelte Linsen abweisen, Status ohne_pruefung, Dateiname mit Pfadgrenze, Kindphase mit Nummer, Fälle für den Nachlesetakt, den Lesehinweis, fehlendes verbrauch.js und das verankerte 5-Stunden-Label."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-09-24
updated: 2026-09-24
modell: sonnet
effort: low
modell_begruendung: "Abschrift: alle Aus-wird-Blöcke stehen wörtlich im Plan, Zieldateien und Verbote sind genannt. Sonnet low statt Haiku, weil Blöcke die Zeichen · und ▸ tragen und Haiku in der Messung vom 2026-09-23 typografische Zeichen still ersetzt hat."
---
# Task 09 — Nachbesserung nach der Prüfung
Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. Die Tasks 02 bis 07 sind ausgeführt und bleiben byte-gleich; diese Nachbesserung ändert die Dateien nur an den Stellen unten. Jeder Aus-Block steht genau einmal in seiner Datei.
Was danach anders ist:
- **Die Reset-Toleranz hat Fälle für ihren Wert:** Verschiebung um 4:59 (dasselbe Fenster), um 5:01 nach vorn und nach hinten (Reset), um fünf Stunden bei steigendem Wert (Reset). Die Mutanten 6 Stunden, 1 Sekunde und „nur nach vorn“ werden gefangen.
- **`plankritik.js`** weist doppelte Linsen ab. **`welle-mit-pruefung.js`** meldet `status: ohne_pruefung`, wenn die Prüfung übersprungen wurde oder scheiterte. **`planung.js`** vergleicht den Dateinamen des Planer-Ergebnisses mit Pfadgrenze.
- **Die Attrappe** nummeriert wiederholte Kinder wie das Ziel (`▸ name #2`).
- **Neue Fälle** für den Nachlesetakt, den Lesehinweis im Planerauftrag, ein fehlendes `verbrauch.js` in `welle-mit-pruefung` und das verankerte 5-Stunden-Label, dazu die Mutanten dafür.
## Zieldateien
- `.claude/workflows/plankritik.js` (geändert)
- `.claude/workflows/planung.js` (geändert)
- `.claude/workflows/welle-mit-pruefung.js` (geändert)
- `.vault-tools/workflow-pruefung/attrappe.mjs` (geändert)
- `.vault-tools/workflow-pruefung/faelle_workflows.mjs` (geändert)
- `.vault-tools/workflow-pruefung/mutanten_workflows.py` (geändert)
- `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py` (geändert)
- `.vault-tools/workflow-pruefung/mutanten_reihe.py` (geändert)
## Verbote
- Keine andere Datei.
- Kein Commit, kein Push, kein Lauf eines echten Workflows.
- Kein Umformulieren; ein Widerspruch zwischen Plan und Baum wird gemeldet.
## Umsetzung
### `.claude/workflows/plankritik.js`, Stelle 1
Aus:
```javascript
if (unbekannt.length) throw new Error('plankritik: unbekannte Linse: ' + unbekannt.join(', ') + '; bekannt: ' + Object.keys(LINSEN).join(', '))
if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('plankritik: effort "' + a.effort + '" ungültig')
```
wird:
```javascript
if (unbekannt.length) throw new Error('plankritik: unbekannte Linse: ' + unbekannt.join(', ') + '; bekannt: ' + Object.keys(LINSEN).join(', '))
if (a.linsen && new Set(a.linsen).size !== a.linsen.length) throw new Error('plankritik: eine Linse steht doppelt in linsen: ' + a.linsen.join(', '))
if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('plankritik: effort "' + a.effort + '" ungültig')
```
### `.claude/workflows/planung.js`, Stelle 1
Aus:
```javascript
laufend.delete(nr)
const passt = roh && typeof roh.datei === 'string' && roh.datei.replace(/\\/g, '/').endsWith(jeNr.get(nr).datei)
const r = passt ? roh : null
```
wird:
```javascript
laufend.delete(nr)
const passt = roh && typeof roh.datei === 'string' && ('/' + roh.datei.replace(/\\/g, '/')).endsWith('/' + jeNr.get(nr).datei)
const r = passt ? roh : null
```
### `.claude/workflows/welle-mit-pruefung.js`, Stelle 1
Aus:
```javascript
return {
status: 'fertig',
kopie,
```
wird:
```javascript
return {
status: pruefung ? 'fertig' : 'ohne_pruefung',
kopie,
```
### `.vault-tools/workflow-pruefung/attrappe.mjs`, Stelle 1
Aus:
```javascript
// "nesting is limited to one level"; die Attrappe setzt einen Doppelpunkt statt des Strichs.
// - Im Kind ist phase() wirkungslos, und jeder agent() bekommt die Phase "▸ <meta.name>".
// - workflow() mit unbekanntem Namen oder unlesbarem scriptPath wirft.
```
wird:
```javascript
// "nesting is limited to one level"; die Attrappe setzt einen Doppelpunkt statt des Strichs.
// - Im Kind ist phase() wirkungslos, und jeder agent() bekommt die Phase "▸ <meta.name>", ab dem
// zweiten Aufruf desselben Kindes "▸ <meta.name> #<n>" (Bundle 2.1.280 und meta.json eines
// echten Laufs mit drei Ablesungen: "▸ verbrauch", "▸ verbrauch #2", "▸ verbrauch #3").
// - workflow() mit unbekanntem Namen oder unlesbarem scriptPath wirft.
```
### `.vault-tools/workflow-pruefung/attrappe.mjs`, Stelle 2
Aus:
```javascript
const laufendJePhase = {}
let uhr = 0
```
wird:
```javascript
const laufendJePhase = {}
const kindAufrufe = {}
let uhr = 0
```
### `.vault-tools/workflow-pruefung/attrappe.mjs`, Stelle 3
Aus:
```javascript
const { name, fn } = lade(datei)
const kindPhase = tiefe >= 1 ? '▸ ' + name : null
let aktuellePhase = null
```
wird:
```javascript
const { name, fn } = lade(datei)
let kindPhase = null
if (tiefe >= 1) {
kindAufrufe[name] = (kindAufrufe[name] || 0) + 1
kindPhase = '▸ ' + name + (kindAufrufe[name] > 1 ? ' #' + kindAufrufe[name] : '')
}
let aktuellePhase = null
```
### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 1
Aus:
```javascript
// Exit 0, wenn alle Fälle bestehen, sonst 1.
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import os from 'node:os'
```
wird:
```javascript
// Exit 0, wenn alle Fälle bestehen, sonst 1.
import { mkdtempSync, writeFileSync, rmSync, cpSync } from 'node:fs'
import os from 'node:os'
```
### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 2
Aus:
```javascript
soll(JSON.stringify(p.phasen) === '["Oben"]', 'phase() im Kind hat gewirkt: ' + JSON.stringify(p.phasen))
return fehler
```
wird:
```javascript
soll(JSON.stringify(p.phasen) === '["Oben"]', 'phase() im Kind hat gewirkt: ' + JSON.stringify(p.phasen))
return fehler
})
fall('attrappe', 'dasselbe Kind zweimal: ▸ name und ▸ name #2', async () => {
const { fehler, soll } = pruefe()
const kind = skript('kindzwei', "return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet' })")
const { protokoll: p } = await A(skript('elternzwei', `await workflow({ scriptPath: ${JSON.stringify(kind)} }, {}); return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`))
soll(JSON.stringify(p.agents.map(a => a.phase)) === '["▸ kindzwei","▸ kindzwei #2"]', 'Phasen: ' + JSON.stringify(p.agents.map(a => a.phase)))
return fehler
```
### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 3
Aus:
```javascript
fall('verbrauch', 'Woche aller Modelle 90 % zählt auch neben einer niedrigen Opus-Woche', async () => {
```
wird:
```javascript
fall('verbrauch', 'ein Label mit "hour" in der Mitte ist kein 5-Stunden-Fenster', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Opus hours', percentUsed: 90, resetsAt: 'z' }]) })
soll(e && e.plaetze === 1 && e.fuenf_stunden.prozent === 10, JSON.stringify(e))
return fehler
})
fall('verbrauch', 'Woche aller Modelle 90 % zählt auch neben einer niedrigen Opus-Woche', async () => {
```
### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 4
Aus:
```javascript
fall('plankritik', 'Linse ohne Ergebnis steht in ohne_ergebnis', async () => {
```
wird:
```javascript
fall('plankritik', 'doppelte Linse wirft vor dem ersten Agenten', async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await PK({ ...PK_ARGS, linsen: ['arithmetik', 'bezeichner', 'arithmetik'] }, {})
soll(f && /doppelt in linsen/.test(f), 'Fehler: ' + f)
soll(p.agents.length === 0, 'Agent gestartet')
return fehler
})
fall('plankritik', 'Linse ohne Ergebnis steht in ohne_ergebnis', async () => {
```
### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 5
Aus:
```javascript
const { ergebnis: e, protokoll: p } = await WM(WM_ARGS, { executer: OHNE })
soll(e.status === 'fertig' && /unvollständig/.test(e.pruefung.uebersprungen), JSON.stringify(e.pruefung))
soll(mitLabel(p, 'verbrauch:').length === 0 && mitLabel(p, 'pruefung').length === 0, 'Ablesung oder Prüfung trotz Lücke')
return fehler
```
wird:
```javascript
const { ergebnis: e, protokoll: p } = await WM(WM_ARGS, { executer: OHNE })
soll(e.status === 'ohne_pruefung' && /unvollständig/.test(e.pruefung.uebersprungen), JSON.stringify([e.status, e.pruefung]))
soll(mitLabel(p, 'verbrauch:').length === 0 && mitLabel(p, 'pruefung').length === 0, 'Ablesung oder Prüfung trotz Lücke')
return fehler
})
fall('welle-mit-pruefung', 'verbrauch.js fehlt: Ablesungen nicht_gelesen, Welle und Prüfung bleiben', async () => {
const { fehler, soll } = pruefe()
const ohneVerbrauch = mkdtempSync(path.join(os.tmpdir(), 'ohne-verbrauch-'))
cpSync(ORDNER, ohneVerbrauch, { recursive: true })
rmSync(path.join(ohneVerbrauch, 'verbrauch.js'))
const { ergebnis: e, fehler: f } = await fuehreAus(path.join(ohneVerbrauch, 'welle-mit-pruefung.js'), WM_ARGS, antworten({}), { ordner: ohneVerbrauch })
rmSync(ohneVerbrauch, { recursive: true, force: true })
soll(f === null && e.status === 'fertig' && e.welle && e.pruefung.zaehlung, 'Fehler/Ergebnis: ' + f + ' ' + JSON.stringify(e && e.status))
soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'nicht_gelesen'), JSON.stringify(e && e.verbrauch))
return fehler
```
### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 6
Aus:
```javascript
fall('planung', '5 Stunden bei 60 %: höchstens zwei gleichzeitig', async () => {
```
wird:
```javascript
fall('planung', 'Ergebnis mit ähnlichem Dateinamen gilt als gescheitert', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await PL(PL_ARGS, { planerDatei: (nr, richtig) => (nr === 7 ? 'D:/x/plan/xtask-07-t.md' : richtig) })
soll(JSON.stringify(e.gescheitert) === '[7]', JSON.stringify(e.gescheitert))
return fehler
})
fall('planung', 'Nachlesen im Takt von nachlesen_alle, solange Tasks offen sind', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 2, nachlesen_alle: 2 }, { planerDauer: () => 100 })
const ab = mitLabel(p, 'verbrauch:').map(x => x.label.slice('verbrauch:planung:'.length))
soll(JSON.stringify(ab) === '["Anfang","nach 2 Tasks","nach 4 Tasks","nach 6 Tasks","Ende"]', 'Ablesungen: ' + JSON.stringify(ab))
return fehler
})
fall('planung', '5 Stunden bei 60 %: höchstens zwei gleichzeitig', async () => {
```
### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 7
Aus:
```javascript
soll(!/\{(NR|THEMA|DATEI|MODELL|WELLE|VORAUSSETZUNGEN)\}/.test(t5), 'Platzhalter übrig')
soll(/vor: \(keine\)/.test(planerVon(p, 1).prompt), 'Task 1 ohne "(keine)"')
```
wird:
```javascript
soll(!/\{(NR|THEMA|DATEI|MODELL|WELLE|VORAUSSETZUNGEN)\}/.test(t5), 'Platzhalter übrig')
soll(/Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst/.test(t5), 'Lesehinweis fehlt')
soll(!/Lies sie/.test(planerVon(p, 1).prompt), 'Lesehinweis ohne Voraussetzungen')
soll(/vor: \(keine\)/.test(planerVon(p, 1).prompt), 'Task 1 ohne "(keine)"')
```
### `.vault-tools/workflow-pruefung/mutanten_workflows.py`, Stelle 1
Aus:
```python
("planung", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"),
]
```
wird:
```python
("planung", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"),
("planung", "Datei ohne Pfadgrenze", "('/' + roh.datei.replace(/\\\\/g, '/')).endsWith('/' + jeNr.get(nr).datei)", "roh.datei.replace(/\\\\/g, '/').endsWith(jeNr.get(nr).datei)"),
("planung", "Nachlesezaehler nicht zurueckgesetzt", " fertigSeitAblesung = 0\n const v = await ablesen(", " const v = await ablesen("),
("planung", "nachlesen_alle ignoriert", "const nachlesenAlle = args.nachlesen_alle || NACHLESEN_ALLE", "const nachlesenAlle = NACHLESEN_ALLE"),
("planung", "Lesehinweis fehlt", " Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst.", ""),
("plankritik", "doppelte Linse erlaubt", " if (a.linsen && new Set(a.linsen).size !== a.linsen.length) throw", " if (false) throw"),
("welle-mit-pruefung", "status fertig ohne Pruefung", "status: pruefung ? 'fertig' : 'ohne_pruefung',", "status: 'fertig',"),
("welle-mit-pruefung", "Ablesung ohne try", " try {\n r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })\n } catch (e) {\n r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }\n }", " r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })"),
("verbrauch", "5-Stunden-Regex unverankert", "const FUENF_STUNDEN = /^\\s*5[- ]?hour/i", "const FUENF_STUNDEN = /hour/i"),
]
```
### `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py`, Stelle 1
Aus:
```python
def f_fallender_wert(tmp):
```
wird:
```python
def f_verschiebung(tmp, reset5, erwartet):
# Nur resetsAt verschiebt sich, der Wert steigt; entscheidend ist allein die Toleranz.
baue(tmp, {"wf_v": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 5, reset5=reset5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_fallender_wert(tmp):
```
### `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py`, Stelle 2
Aus:
```python
("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund),
("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert),
```
wird:
```python
("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund),
("resetsAt 5 Stunden weiter, Wert steigt: Reset", lambda t: f_verschiebung(t, "2026-09-24T23:30:00Z", "| Reset | 0.0 |")),
("resetsAt 4:59 verschoben: dasselbe Fenster", lambda t: f_verschiebung(t, "2026-09-24T18:34:59Z", "| 2.0 | 0.0 |")),
("resetsAt 5:01 verschoben: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:35:01Z", "| Reset | 0.0 |")),
("resetsAt 5:01 frueher: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:24:59Z", "| Reset | 0.0 |")),
("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert),
```
### `.vault-tools/workflow-pruefung/mutanten_reihe.py`, Stelle 1
Aus:
```python
("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"),
("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"),
```
wird:
```python
("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"),
("Toleranz 6 Stunden", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(hours=6)"),
("Toleranz 1 Sekunde", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(seconds=1)"),
("Toleranz nur nach vorn", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if rb - ra > RESET_TOLERANZ or b[0] < a[0]:"),
("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"),
```
## Verifikation
Im Vault-Wurzelordner:
```bash
node .vault-tools/workflow-pruefung/faelle_workflows.mjs
```
Erwartet: Alle übrigen Zeilen beginnen mit `OK`, die letzte enthält `0 gescheitert`, Exit 0.
```bash
python .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py
```
Erwartet: Die letzte Zeile enthält `0 gescheitert`, Exit 0.
```bash
python .vault-tools/workflow-pruefung/mutanten_reihe.py
```
Erwartet: Die letzte Zeile enthält `0 ueberlebt, 0 veraltet`, Exit 0.
```bash
python .vault-tools/workflow-pruefung/mutanten_workflows.py
```
Erwartet: Die letzte Zeile enthält `0 ueberlebt, 0 veraltet`, Exit 0. Der Lauf dauert einige Minuten.
Danach `git status --porcelain -- .claude/workflows/plankritik.js .claude/workflows/planung.js .claude/workflows/welle-mit-pruefung.js .vault-tools/workflow-pruefung/attrappe.mjs .vault-tools/workflow-pruefung/faelle_workflows.mjs .vault-tools/workflow-pruefung/mutanten_workflows.py .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py .vault-tools/workflow-pruefung/mutanten_reihe.py` zeigen.
@@ -0,0 +1,304 @@
"""Faelle fuer verbrauch_reihe.py: baut je Fall einen kuenstlichen Session-Speicher und faehrt das Skript als eigenen Prozess.
Die Grundlinie laeuft zuerst; scheitert sie, messen die uebrigen Faelle nichts. Der Prozess laeuft
ohne PYTHONIOENCODING, damit eine Ausgabe ausserhalb von ASCII auf cp1252 auffaellt.
Aufruf: python faelle_verbrauch_reihe.py
Exit 0, wenn alle Faelle bestehen, sonst 1.
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
SKRIPT = Path(__file__).with_name("verbrauch_reihe.py")
def fenster(fuenf, woche, reset5="2026-09-24T18:30:00Z", resetw="2026-09-28T00:00:00Z"):
return [
{"label": "5-hour limit", "percentUsed": fuenf, "resetsAt": reset5},
{"label": "Weekly · all models", "percentUsed": woche, "resetsAt": resetw},
]
def baue(speicher, laeufe):
"""laeufe: {lauf: [agent]}; agent: {id, label, start, ende, modell, ergebnis, ohne_zeit, kaputt}."""
for lauf, agenten in laeufe.items():
ordner = speicher / "sitzung-1" / "subagents" / "workflows" / lauf
ordner.mkdir(parents=True)
with (ordner / "journal.jsonl").open("w", encoding="utf-8") as journal:
journal.write(json.dumps({"type": "launched"}) + "\n")
for a in agenten:
journal.write(json.dumps({"type": "started", "agentId": a["id"], "label": a["label"]}) + "\n")
if a.get("kaputt"):
journal.write("{kein json\n")
if "ergebnis" in a:
journal.write(json.dumps({"type": "result", "agentId": a["id"], "result": a["ergebnis"]}) + "\n")
for a in agenten:
(ordner / f"agent-{a['id']}.meta.json").write_text(json.dumps({"model": a.get("modell", "haiku")}), encoding="utf-8")
if a.get("ohne_zeit"):
continue
with (ordner / f"agent-{a['id']}.jsonl").open("w", encoding="utf-8") as d:
d.write(json.dumps({"timestamp": a["start"], "type": "user"}) + "\n")
d.write(json.dumps({"timestamp": a["ende"], "type": "assistant"}) + "\n")
def ablesung(nr, anlass, zeitpunkt, fuenf, woche, **extra):
ende = extra.pop("bis", None) or zeitpunkt
return {"id": f"v{nr}", "label": "verbrauch:" + anlass, "start": zeitpunkt, "ende": ende, "modell": "haiku",
"ergebnis": {"verfuegbar": True, "status": "ok", "plan": "Pro", "fenster": extra.pop("liste", None) or fenster(fuenf, woche, **extra), "fehler": ""}}
# resetsAt schwankt bei echten Ablesungen um Millisekunden und springt ueber Sekunden- und
# Datumsgrenzen (gemessen 2026-09-24); die Grundlinie traegt deshalb solche Werte.
ZITTER = {"reset5": "2026-09-24T18:29:59.664Z", "resetw": "2026-09-27T23:59:59.664Z"}
def opus(nr, start, ende, modell="opus"):
return {"id": f"o{nr}", "label": f"plankritik:linse{nr}", "start": start, "ende": ende, "modell": modell, "ergebnis": {}}
def fahre(speicher, *zusatz):
umgebung = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")}
lauf = subprocess.run([sys.executable, str(SKRIPT), "--speicher", str(speicher), *zusatz],
capture_output=True, env=umgebung)
return lauf.returncode, lauf.stdout.decode("ascii", "replace"), lauf.stderr.decode("utf-8", "replace")
GRUND = {
"wf_a": [
ablesung(1, "plankritik:vor Linsen", "2026-09-24T10:00:00Z", 10, 5),
opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z"),
ablesung(2, "plankritik:Ende", "2026-09-24T10:30:10Z", 14, 6, **ZITTER),
]
}
def f_grundlinie(tmp):
baue(tmp, GRUND)
code, aus, err = fahre(tmp)
fehler = []
if code != 0:
fehler.append(f"Exit {code}: {err[-300:]}")
for erwartet in ["2 Ablesungen, 1 Paare", "| wf_a | plankritik:vor Linsen -> plankritik:Ende | 30.2 | opus 30.0 | 4.0 | 1.0 |",
"Naeherung: 8.0 % des 5-Stunden-Fensters je Opus-Agentenstunde (1 Paare, 0.5 Opus-Stunden"]:
if erwartet not in aus:
fehler.append("fehlt: " + erwartet)
if "Hinweise" in aus:
fehler.append("unerwartete Hinweise")
return fehler
def f_reset(tmp):
baue(tmp, {"wf_r": [ablesung(1, "a", "2026-09-24T10:00:00Z", 80, 5), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"),
ablesung(2, "b", "2026-09-24T10:30:00Z", 3, 6, reset5="2026-09-24T15:30:00Z")]})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "| Reset | 1.0 |" not in aus:
fehler.append("Reset nicht erkannt: " + aus[-300:])
if "Keine auswertbaren Paare mit Opus-Agenten." not in aus:
fehler.append("Reset-Paar wurde mitgerechnet")
return fehler
def f_speicher_fehlt(tmp):
code, aus, _ = fahre(tmp / "gibt-es-nicht")
return [] if code == 2 and "Speicher fehlt" in aus else [f"Exit {code}, Ausgabe {aus!r}"]
def f_null_ablesungen(tmp):
baue(tmp, {"wf_n": [opus(1, "2026-09-24T10:00:00Z", "2026-09-24T10:10:00Z")]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "0 Ablesungen, 0 Paare" in aus else [f"Exit {code}, Ausgabe {aus!r}"]
def f_kaputte_zeile(tmp):
agenten = [dict(GRUND["wf_a"][0], kaputt=True), GRUND["wf_a"][1], GRUND["wf_a"][2]]
baue(tmp, {"wf_k": agenten})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "1 Paare" not in aus:
fehler.append("Auswertung trotz kaputter Zeile gescheitert: " + aus[-300:])
if "ist kein JSON" not in aus:
fehler.append("kaputte Zeile nicht als Hinweis gemeldet")
return fehler
def f_ohne_zeitstempel(tmp, position):
agenten = [dict(a) for a in GRUND["wf_a"]]
agenten[position]["ohne_zeit"] = True
baue(tmp, {"wf_z": agenten})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "1 Ablesungen, 0 Paare" not in aus:
fehler.append(f"Exit {code}, Ausgabe {aus[-300:]!r}")
if "ohne Zeitstempel" not in aus:
fehler.append("fehlender Zeitstempel nicht gemeldet")
return fehler
def f_fenster_kaputt(tmp, liste, erwartet="| unbekannt | unbekannt |"):
baue(tmp, {"wf_f": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, liste=liste), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"),
ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 6)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_umlaut_im_anlass(tmp):
baue(tmp, {"wf_u": [ablesung(1, "pruefung:vor Prüfung", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "pruefung:nach Prüfung", "2026-09-24T10:30:00Z", 12, 5)]})
code, aus, err = fahre(tmp)
return [] if code == 0 and "pruefung:vor Pruefung -> pruefung:nach Pruefung" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}, stderr {err[-200:]!r}"]
def f_seit(tmp):
baue(tmp, {"wf_s": [ablesung(1, "alt", "2026-09-20T10:00:00Z", 10, 5), ablesung(2, "a", "2026-09-24T10:00:00Z", 10, 5),
ablesung(3, "b", "2026-09-24T11:00:00Z", 12, 5)]})
code, aus, _ = fahre(tmp, "--seit", "2026-09-24")
return [] if code == 0 and "2 Ablesungen, 1 Paare" in aus and "alt ->" not in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_seit_ungueltig(tmp):
baue(tmp, GRUND)
code, aus, _ = fahre(tmp, "--seit", "gestern")
return [] if code == 2 and "kein Datum" in aus else [f"Exit {code}, Ausgabe {aus!r}"]
def f_voller_modellname(tmp):
baue(tmp, {"wf_m": [GRUND["wf_a"][0], opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z", modell="claude-opus-5-5"), GRUND["wf_a"][2]]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "opus 30.0" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_agent_ausserhalb(tmp):
# Ein Agent, der vor der ersten Ablesung endet, zaehlt nicht mit; einer, der halb hineinreicht, anteilig.
baue(tmp, {"wf_x": [opus(1, "2026-09-24T09:00:00Z", "2026-09-24T09:50:00Z"), GRUND["wf_a"][0],
opus(2, "2026-09-24T09:55:00Z", "2026-09-24T10:10:00Z"), GRUND["wf_a"][2]]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| opus 10.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-400:]!r}"]
def f_absturz_ist_befund(tmp):
# Ein Zeitstempel ohne Zeitzone neben einem mit ihr laesst den Vergleich mit TypeError scheitern.
# Der Lauf muss das als Hinweis melden und die anderen Laeufe trotzdem auswerten.
kaputt = [dict(GRUND["wf_a"][0]), dict(GRUND["wf_a"][1], start="2026-09-24T10:00:10"), dict(GRUND["wf_a"][2])]
baue(tmp, {"wf_a": GRUND["wf_a"], "wf_b": kaputt})
code, aus, err = fahre(tmp)
fehler = []
if code != 0:
fehler.append(f"Exit {code}: {err[-300:]}")
if "wf_b: Auswertung abgebrochen (TypeError" not in aus:
fehler.append("Absturz nicht als Hinweis gemeldet")
if "| wf_a |" not in aus:
fehler.append("der gesunde Lauf fehlt")
return fehler
def f_verschiebung(tmp, reset5, erwartet):
# Nur resetsAt verschiebt sich, der Wert steigt; entscheidend ist allein die Toleranz.
baue(tmp, {"wf_v": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 5, reset5=reset5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_fallender_wert(tmp):
baue(tmp, {"wf_d": [ablesung(1, "a", "2026-09-24T10:00:00Z", 30, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 5, 5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| Reset | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_reset_unlesbar(tmp):
baue(tmp, {"wf_u": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, reset5="bald"), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 6)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| unbekannt | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_hoechstes_fenster(tmp):
liste = [{"label": "5-hour limit", "percentUsed": 10, "resetsAt": "2026-09-24T18:30:00Z"},
{"label": "5-hour limit", "percentUsed": 30, "resetsAt": "2026-09-24T18:30:00Z"},
{"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}]
baue(tmp, {"wf_h": [ablesung(1, "a", "2026-09-24T10:00:00Z", 0, 0, liste=liste), ablesung(2, "b", "2026-09-24T10:20:00Z", 35, 6)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| 5.0 | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_ablesezeit_ist_ende(tmp):
# Der Opus-Agent laeuft ganz innerhalb des ersten Ableseagenten, also vor der Ablesung.
baue(tmp, {"wf_t": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, bis="2026-09-24T10:00:10Z"),
opus(1, "2026-09-24T10:00:02Z", "2026-09-24T10:00:08Z"),
ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| 29.8 | - | 2.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
def f_agent_ueber_das_paar_hinaus(tmp):
baue(tmp, {"wf_b": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5),
opus(1, "2026-09-24T10:20:00Z", "2026-09-24T10:50:00Z"), ablesung(3, "c", "2026-09-24T11:00:00Z", 15, 5)]})
code, aus, _ = fahre(tmp)
fehler = []
if code != 0 or "| a -> b | 30.0 | opus 10.0 |" not in aus:
fehler.append("erstes Paar nicht am Ende gekappt: " + aus[-400:])
if "| b -> c | 30.0 | opus 20.0 |" not in aus:
fehler.append("zweites Paar falsch: " + aus[-400:])
return fehler
def f_ueber_hundert(tmp):
baue(tmp, {"wf_g": [ablesung(1, "a", "2026-09-24T10:00:00Z", 98, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 103, 5)]})
code, aus, _ = fahre(tmp)
return [] if code == 0 and "| 5.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"]
FAELLE = [
("Grundlinie: zwei Ablesungen, ein Opus-Agent dazwischen", f_grundlinie),
("Reset zwischen zwei Ablesungen", f_reset),
("Speicher fehlt: Exit 2", f_speicher_fehlt),
("null Ablesungen: Exit 0 mit Zaehlung", f_null_ablesungen),
("kaputte Journalzeile: Hinweis, kein Absturz", f_kaputte_zeile),
("erste Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 0)),
("letzte Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 2)),
("5-Stunden-Fenster fehlt", lambda t: f_fenster_kaputt(t, [{"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}], "| unbekannt | 1.0 |")),
("Umlaut im Anlass auf cp1252", f_umlaut_im_anlass),
("Prozent als Text", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": "10", "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])),
("Prozent als Wahrheitswert", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": True, "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])),
("fenster kein Array", lambda t: f_fenster_kaputt(t, {"a": 1})),
("--seit filtert aeltere Ablesungen", f_seit),
("--seit ungueltig: Exit 2", f_seit_ungueltig),
("voller Modellname zaehlt als opus", f_voller_modellname),
("Agent ausserhalb und halb innerhalb des Paars", f_agent_ausserhalb),
("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund),
("resetsAt 5 Stunden weiter, Wert steigt: Reset", lambda t: f_verschiebung(t, "2026-09-24T23:30:00Z", "| Reset | 0.0 |")),
("resetsAt 4:59 verschoben: dasselbe Fenster", lambda t: f_verschiebung(t, "2026-09-24T18:34:59Z", "| 2.0 | 0.0 |")),
("resetsAt 5:01 verschoben: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:35:01Z", "| Reset | 0.0 |")),
("resetsAt 5:01 frueher: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:24:59Z", "| Reset | 0.0 |")),
("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert),
("resetsAt ohne Datum: unbekannt", f_reset_unlesbar),
("doppeltes 5-Stunden-Fenster: das hoechste zaehlt", f_hoechstes_fenster),
("Ablesezeit ist das Ende des Ableseagenten", f_ablesezeit_ist_ende),
("Agent ueber das Paar hinaus wird am Ende gekappt", f_agent_ueber_das_paar_hinaus),
("ueber 100 Prozent ist ein Wert, kein Fehler", f_ueber_hundert),
]
def main():
# Fehlertexte koennen Zeichen ausserhalb der Konsolenkodierung tragen; der Treiber darf daran nicht sterben.
sys.stdout.reconfigure(errors="replace")
gescheitert = 0
for name, fall in FAELLE:
with tempfile.TemporaryDirectory() as tmp:
try:
fehler = fall(Path(tmp))
except Exception as e: # ein Absturz im Fall ist selbst ein Befund
fehler = [f"Absturz im Fall: {type(e).__name__}: {e}"]
if fehler:
gescheitert += 1
print(f"FEHLER {name}\n " + "\n ".join(fehler))
else:
print(f"OK {name}")
print(f"{len(FAELLE)} Faelle, {gescheitert} gescheitert")
return 1 if gescheitert else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,848 @@
// Fälle für die Workflow-Skripte unter .claude/workflows/, gefahren mit attrappe.mjs.
// Aufruf: node faelle_workflows.mjs [<ordner mit den Workflows>]
// Ohne Argument gilt ../../.claude/workflows relativ zu dieser Datei. Je Workflow läuft zuerst
// die Grundlinie; scheitert sie, messen die übrigen Fälle dieses Workflows nichts.
// Exit 0, wenn alle Fälle bestehen, sonst 1.
import { mkdtempSync, writeFileSync, rmSync, cpSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { fuehreAus } from './attrappe.mjs'
const hier = path.dirname(fileURLToPath(import.meta.url))
const ORDNER = path.resolve(process.argv[2] || path.join(hier, '..', '..', '.claude', 'workflows'))
const WF = name => path.join(ORDNER, name + '.js')
const ok = (wert, dauer = 5) => ({ wert, dauer })
const fenster = (fuenf, woche, extra = []) => [
{ label: '5-hour limit', percentUsed: fuenf, resetsAt: '2026-09-24T18:30:00.000Z' },
{ label: 'Weekly · all models', percentUsed: woche, resetsAt: '2026-09-28T00:00:00.000Z' },
...extra,
]
const ablesung = (fuenf, woche, extra) => ({ verfuegbar: true, status: 'ok', plan: 'Pro', fenster: fenster(fuenf, woche, extra), fehler: '' })
const OHNE = 'ohne'
// Antworten für alle Agenten, die die Workflows starten. Unbekannte Labels werfen, damit ein
// falsches Label im Protokoll auffällt.
function antworten(k = {}) {
let n = 0
return async a => {
const label = a.label || ''
if (label.startsWith('verbrauch:')) {
const x = (k.ablesungen || [])[n++]
if (x === OHNE) return undefined
return ok(x === undefined ? ablesung(10, 10) : x)
}
if (label === 'werkzeug:planwerkzeuge') return ok({ laeufe: [{ befehl: 'planwerkzeuge', exit: 0, ausgabe: 'keine Befunde' }] })
if (label === 'werkzeug:kopie') return ok({ laeufe: [{ befehl: 'kopie', exit: 0, ausgabe: "KOPIE=C:/tmp/k/mat\n{'neu': 2}" }] })
if (label === 'werkzeug:vergleich') return ok({ laeufe: [{ befehl: 'vergleich', exit: 0, ausgabe: 'VERGLEICH-OK\nEXIT suite: 0' }] })
if (label.startsWith('plankritik:')) {
const linse = label.slice('plankritik:'.length)
if (k.linse) { const x = k.linse(linse); if (x === OHNE) return undefined; if (x !== undefined) return ok(x, k.dauer || 30) }
const basis = { arithmetik: 10, bezeichner: 20, abdeckung: 30 }[linse] || 40
return ok({ befunde: [0, 1].map(j => ({ schweregrad: 'wichtig', datei: 'task-01.md', zeile: basis + j, beschreibung: linse + ' ' + j, reproduktion: 'x' })), selbst_ausgefuehrt: [], nicht_pruefbar: [] }, k.dauer || 30)
}
if (label.startsWith('widerlegung:')) return ok({ urteil: 'bestaetigt', schweregrad: 'wichtig', bedingung: '', begruendung: 'nachgestellt', beleg: 'befehl' }, k.dauer || 30)
if (label.startsWith('pruefung')) {
if (k.pruefer === OHNE) return undefined
return ok({ selbst_ausgefuehrt: [], eigener_lauf: [], befunde: [{ schweregrad: 'klein', datei: 'a.py', zeile: 1, beschreibung: 'x', reproduktion: 'y' }], plan_vs_umsetzung: [], geprueft_und_in_ordnung: [], nicht_pruefbar: [] }, 20)
}
if (label.startsWith('executer:')) {
if (k.executer === OHNE) return undefined
return ok({ erledigt: ['alles'], geaenderte_dateien: [], verifikation: [], abweichungen: ['keine'], annahmen: [], beobachtet: [] }, 10)
}
if (label.startsWith('planer:task-')) {
const nr = Number(label.slice('planer:task-'.length))
const x = k.planer ? k.planer(nr) : undefined
if (x === OHNE) return undefined
const ziel = /Schreib genau eine Datei: (\S+\.md)/.exec(a.prompt)
const datei = k.planerDatei ? k.planerDatei(nr, ziel && ziel[1]) : ziel && ziel[1]
return ok({ datei, zieldateien: [], testdateien: [], abweichungen_von_uebersicht: [], offene_punkte: [] }, k.planerDauer ? k.planerDauer(nr) : 20 + nr * 3)
}
throw new Error('unerwarteter Agent ' + label)
}
}
function pruefe() {
const fehler = []
return { fehler, soll: (bedingung, text) => { if (!bedingung) fehler.push(text) } }
}
const mitLabel = (p, anfang) => p.agents.filter(x => (x.label || '').startsWith(anfang))
const spitze = (p, phase, modell) => p.spitzeJePhase[phase + '|' + modell] || 0
// Wie viele andere Agenten mit diesem Labelanfang liefen, als a startete.
const laufendBeiStart = (p, a, anfang) => mitLabel(p, anfang).filter(b => b !== a && b.start < a.start && (b.ende === null || b.ende > a.start)).length
function ohneVerstoss(p, soll) { soll(p.verstoesse.length === 0, 'Verstöße: ' + p.verstoesse.join('; ')) }
const faelle = []
const fall = (workflow, name, lauf) => faelle.push({ workflow, name, lauf })
// ---------------------------------------------------------------- attrappe
// Die Attrappe muss so streng sein wie das Ziel, sonst misst jeder grüne Fall nur sie selbst.
const TMP = mkdtempSync(path.join(os.tmpdir(), 'attrappe-'))
function skript(name, rumpf) {
const datei = path.join(TMP, name + '.js')
writeFileSync(datei, `export const meta = { name: '${name}', description: 'Selbsttest' }\n${rumpf}\n`, 'utf8')
return datei
}
const A = (datei, k) => fuehreAus(datei, {}, antworten(k), { ordner: TMP })
for (const [i, [name, rumpf, muster]] of [
['Date.now() wirft', 'return Date.now()', /Date\.now\(\)/],
['new Date() ohne Argument wirft', 'return new Date()', /new Date\(\)/],
['Math.random() wirft', 'return Math.random()', /Math\.random\(\)/],
['unbekannter Workflow wirft', "return await workflow('gibt-es-nicht', {})", /nicht lesbar/],
['Schema ohne properties wirft', "return await agent('x', { label: 'verbrauch:t', model: 'haiku', schema: { type: 'object' } })", /Schema braucht/],
].entries()) {
fall('attrappe', name, async () => {
const { fehler, soll } = pruefe()
const { fehler: f } = await A(skript('wirft-' + i, rumpf))
soll(f && muster.test(f), 'Fehler: ' + f)
return fehler
})
}
fall('attrappe', 'Grundlinie: Math.min und new Date(0) gehen, Globals sind da', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f } = await A(skript('grund', "phase('P'); log('x'); return [Math.min(1, 2), new Date(0).getTime(), typeof parallel, typeof pipeline, args !== undefined]"))
soll(f === null && JSON.stringify(e) === '[1,0,"function","function",true]', 'Fehler/Ergebnis: ' + f + ' ' + JSON.stringify(e))
return fehler
})
fall('attrappe', 'workflow() im Kind wirft', async () => {
const { fehler, soll } = pruefe()
const enkel = skript('enkel', 'return 1')
const kind = skript('kind', `return await workflow({ scriptPath: ${JSON.stringify(enkel)} }, {})`)
const { fehler: f } = await A(skript('eltern', `return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`))
soll(f && /nesting is limited to one level/.test(f), 'Fehler: ' + f)
return fehler
})
fall('attrappe', 'im Kind tragen Agenten die Phase ▸ <name>, phase() wirkt dort nicht', async () => {
const { fehler, soll } = pruefe()
const kind = skript('kindphase', "phase('Eigen'); return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet', phase: 'Eigen' })")
const { protokoll: p } = await A(skript('elternphase', `phase('Oben'); return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`))
soll(p.agents.length === 1 && p.agents[0].phase === '▸ kindphase', 'Phase: ' + (p.agents[0] && p.agents[0].phase))
soll(JSON.stringify(p.phasen) === '["Oben"]', 'phase() im Kind hat gewirkt: ' + JSON.stringify(p.phasen))
return fehler
})
fall('attrappe', 'dasselbe Kind zweimal: ▸ name und ▸ name #2', async () => {
const { fehler, soll } = pruefe()
const kind = skript('kindzwei', "return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet' })")
const { protokoll: p } = await A(skript('elternzwei', `await workflow({ scriptPath: ${JSON.stringify(kind)} }, {}); return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`))
soll(JSON.stringify(p.agents.map(a => a.phase)) === '["▸ kindzwei","▸ kindzwei #2"]', 'Phasen: ' + JSON.stringify(p.agents.map(a => a.phase)))
return fehler
})
fall('attrappe', 'agent() ohne model ist ein Verstoß', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await A(skript('ohne-modell', "return await agent('x', { label: 'verbrauch:t' })"))
soll(p.verstoesse.some(v => /ohne model/.test(v)), 'Verstöße: ' + JSON.stringify(p.verstoesse))
return fehler
})
fall('attrappe', 'Ergebnis ohne required-Feld wird null und ist ein Verstoß', async () => {
const { fehler, soll } = pruefe()
const schema = "{ type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a', 'b'] }"
const { ergebnis: e, protokoll: p } = await A(skript('required', `return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet', schema: ${schema} })`))
soll(e === null && p.verstoesse.some(v => /Ergebnis ohne/.test(v)), 'Ergebnis/Verstöße: ' + JSON.stringify([e, p.verstoesse]))
return fehler
})
fall('attrappe', 'Spitze zählt gleichzeitige Agenten je Phase und Modell', async () => {
const { fehler, soll } = pruefe()
const rumpf = "phase('P'); await parallel([1, 2, 3].map(() => () => agent('x', { label: 'widerlegung:t', model: 'opus', phase: 'P' }))); await agent('x', { label: 'widerlegung:t', model: 'opus', phase: 'P' }); return 1"
const { protokoll: p } = await A(skript('spitze', rumpf))
soll(spitze(p, 'P', 'opus') === 3, 'Spitze ' + spitze(p, 'P', 'opus'))
return fehler
})
// ---------------------------------------------------------------- verbrauch
const V =(a, k) => fuehreAus(WF('verbrauch'), a, antworten(k), { ordner: ORDNER })
fall('verbrauch', 'Grundlinie: Agent liest 10/10, wunsch 3 ergibt 3 Plätze', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await V({ anlass: 'test', wunsch: 3 }, {})
soll(f === null, 'Fehler: ' + f)
soll(e && e.status === 'ok' && e.plaetze === 3 && e.gebremst === false && e.quelle === 'agent', 'Ergebnis: ' + JSON.stringify(e))
soll(p.agents.length === 1 && p.agents[0].label === 'verbrauch:test' && p.agents[0].modell === 'haiku', 'Agent: ' + JSON.stringify(p.agents.map(x => [x.label, x.modell])))
ohneVerstoss(p, soll)
return fehler
})
fall('verbrauch', 'probe ersetzt den Agenten', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await V({ anlass: 't', wunsch: 2, probe: ablesung(10, 10) })
soll(p.agents.length === 0, 'Agent gestartet trotz probe')
soll(e && e.quelle === 'probe' && e.plaetze === 2, JSON.stringify(e))
return fehler
})
for (const [fuenf, woche, wunsch, plaetze] of [
[49.9, 10, 4, 4], [50, 10, 4, 2], [69.9, 10, 4, 2], [70, 10, 4, 1], [84.9, 10, 4, 1], [85, 10, 4, 0], [100, 10, 4, 0], [0, 0, 4, 4],
[10, 69.9, 4, 4], [10, 70, 4, 2], [10, 84.9, 4, 2], [10, 85, 4, 1], [10, 94.9, 4, 1], [10, 95, 4, 0], [10, 100, 4, 0],
[101, 10, 4, 0], [10, 150, 4, 0],
]) {
fall('verbrauch', `Stufe 5 h ${fuenf} %, Woche ${woche} % ergibt ${plaetze}`, async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch, probe: ablesung(fuenf, woche) })
soll(e && e.plaetze === plaetze, `plaetze ${e && e.plaetze}, erwartet ${plaetze}`)
soll(e && e.gebremst === (plaetze === 0), 'gebremst ' + (e && e.gebremst))
return fehler
})
}
fall('verbrauch', 'wunsch 0 bei 95 % misst nur und bremst nicht', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 0, probe: ablesung(95, 10) })
soll(e && e.plaetze === 0 && e.gebremst === false && e.status === 'ok', JSON.stringify(e))
return fehler
})
fall('verbrauch', 'wunsch 7 wird auf die harte Obergrenze 4 gesenkt', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 7, probe: ablesung(10, 10) })
soll(e && e.plaetze === 4 && /harte Obergrenze/.test(e.grund), JSON.stringify(e))
return fehler
})
for (const wunsch of [-1, 1.5, '3', null, undefined]) {
fall('verbrauch', `wunsch ${JSON.stringify(wunsch)} wirft vor dem Agenten`, async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await V({ anlass: 't', wunsch })
soll(f && /wunsch muss/.test(f), 'Fehler: ' + f)
soll(p.agents.length === 0, 'Agent gestartet')
return fehler
})
}
for (const anlass of [undefined, '', ' ', 3]) {
fall('verbrauch', `anlass ${JSON.stringify(anlass)} wirft`, async () => {
const { fehler, soll } = pruefe()
const { fehler: f } = await V({ anlass, wunsch: 1 })
soll(f && /anlass fehlt/.test(f), 'Fehler: ' + f)
return fehler
})
}
for (const probe of [null, [], 'x']) {
fall('verbrauch', `probe ${JSON.stringify(probe)} wirft`, async () => {
const { fehler, soll } = pruefe()
const { fehler: f } = await V({ anlass: 't', wunsch: 1, probe })
soll(f && /probe muss/.test(f), 'Fehler: ' + f)
return fehler
})
}
fall('verbrauch', 'Agent ohne Ergebnis: nicht_gelesen, zwei Plätze, bei wunsch 1 einer', async () => {
const { fehler, soll } = pruefe()
const a = await V({ anlass: 't', wunsch: 3 }, { ablesungen: [OHNE] })
soll(a.ergebnis && a.ergebnis.status === 'nicht_gelesen' && a.ergebnis.plaetze === 2 && a.ergebnis.gebremst === false, JSON.stringify(a.ergebnis))
const b = await V({ anlass: 't', wunsch: 1 }, { ablesungen: [OHNE] })
soll(b.ergebnis && b.ergebnis.plaetze === 1, JSON.stringify(b.ergebnis))
return fehler
})
for (const [name, probe] of [
['status unavailable', { verfuegbar: true, status: 'unavailable', plan: '', fenster: [], fehler: '' }],
['verfuegbar false', { verfuegbar: false, status: 'fehler', plan: '', fenster: [], fehler: 'Werkzeug fehlt' }],
['verfuegbar als Text', { verfuegbar: 'true', status: 'ok', plan: 'Pro', fenster: fenster(10, 10), fehler: '' }],
]) {
fall('verbrauch', `${name}: nicht_gelesen`, async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 3, probe })
soll(e && e.status === 'nicht_gelesen' && e.plaetze === 2, JSON.stringify(e))
return fehler
})
}
const ok5 = { label: '5-hour limit', percentUsed: 10, resetsAt: 'x' }
const okW = { label: 'Weekly · all models', percentUsed: 10, resetsAt: 'y' }
for (const [name, liste] of [
['fenster leer', []],
['fenster kein Array', { a: 1 }],
['nur Woche', [okW]],
['nur 5 Stunden', [ok5]],
['Eintrag null an erster Stelle', [null, ok5, okW]],
['Eintrag null an letzter Stelle', [ok5, okW, null]],
['label fehlt', [ok5, { percentUsed: 1, resetsAt: 'z' }, okW]],
['percentUsed null vorn', [{ ...ok5, percentUsed: null }, okW]],
['percentUsed null hinten', [ok5, { ...okW, percentUsed: null }]],
['percentUsed als Text', [{ ...ok5, percentUsed: '12' }, okW]],
['percentUsed NaN', [{ ...ok5, percentUsed: NaN }, okW]],
['percentUsed -1', [ok5, { ...okW, percentUsed: -1 }]],
['percentUsed Infinity', [{ ...ok5, percentUsed: Infinity }, okW]],
['Opus-Woche ohne Zahl', [ok5, okW, { label: 'Weekly · Opus', percentUsed: null, resetsAt: 'z' }]],
]) {
fall('verbrauch', `${name}: unbekannte_fenster, alte Obergrenze`, async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: liste, fehler: '' } })
soll(e && e.status === 'unbekannte_fenster' && e.plaetze === 2 && e.gebremst === false, JSON.stringify(e))
return fehler
})
}
for (const [name, liste] of [
['doppeltes 5-Stunden-Fenster, hohes zuerst', [{ ...ok5, percentUsed: 90 }, ok5, okW]],
['doppeltes 5-Stunden-Fenster, hohes zuletzt', [ok5, okW, { ...ok5, percentUsed: 90 }]],
]) {
fall('verbrauch', `${name}: das höhere zählt`, async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: liste, fehler: '' } })
soll(e && e.plaetze === 0 && e.gebremst === true && e.fuenf_stunden.prozent === 90, JSON.stringify(e))
return fehler
})
}
fall('verbrauch', 'Opus-Woche 90 % zählt wie die Woche', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Opus', percentUsed: 90, resetsAt: 'z' }]) })
soll(e && e.plaetze === 1 && e.woche_opus && e.woche_opus.prozent === 90, JSON.stringify(e))
return fehler
})
fall('verbrauch', 'ein Label mit "hour" in der Mitte ist kein 5-Stunden-Fenster', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Opus hours', percentUsed: 90, resetsAt: 'z' }]) })
soll(e && e.plaetze === 1 && e.fuenf_stunden.prozent === 10, JSON.stringify(e))
return fehler
})
fall('verbrauch', 'Woche aller Modelle 90 % zählt auch neben einer niedrigen Opus-Woche', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 90, [{ label: 'Weekly · Opus', percentUsed: 10, resetsAt: 'z' }]) })
soll(e && e.plaetze === 1, JSON.stringify(e))
return fehler
})
for (const [name, fremd] of [['Fable 99 %', 99], ['Fable ohne Zahl', null], ['Fable als Text', 'viel']]) {
fall('verbrauch', `fremdes Fenster (${name}) wird übergangen`, async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Fable', percentUsed: fremd, resetsAt: 'z' }]) })
soll(e && e.status === 'ok' && e.plaetze === 4, JSON.stringify(e))
return fehler
})
}
for (const [a, b] of [['5-Hour Limit', 'Weekly · All Models'], ['5 hour limit', 'weekly all models'], ['5hour', 'WEEKLY - ALL MODELS']]) {
fall('verbrauch', `Schreibweise "${a}" / "${b}" wird erkannt`, async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: [{ label: a, percentUsed: 60, resetsAt: 'x' }, { label: b, percentUsed: 10, resetsAt: 'y' }], fehler: '' } })
soll(e && e.status === 'ok' && e.plaetze === 2, JSON.stringify(e))
return fehler
})
}
// ---------------------------------------------------------------- plankritik
const PK_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', kopie: 'C:/tmp/k/mat' }
const PK = (a, k, o = {}) => fuehreAus(WF('plankritik'), a, antworten(k), { ordner: ORDNER, ...o })
fall('plankritik', 'Grundlinie: drei Linsen gleichzeitig, Widerlegung zu zweit, drei Ablesungen', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, {})
soll(f === null, 'Fehler: ' + f)
soll(e && e.status === 'fertig', 'status ' + (e && e.status))
soll(spitze(p, 'Plankritik', 'opus') === 3, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus'))
soll(spitze(p, 'Widerlegung', 'opus') === 2, 'Widerlegung gleichzeitig: ' + spitze(p, 'Widerlegung', 'opus'))
const ab = mitLabel(p, 'verbrauch:').map(x => x.label)
soll(JSON.stringify(ab) === JSON.stringify(['verbrauch:plankritik:vor Linsen', 'verbrauch:plankritik:vor Widerlegung', 'verbrauch:plankritik:Ende']), 'Ablesungen: ' + JSON.stringify(ab))
soll(mitLabel(p, 'verbrauch:').every(x => x.modell === 'haiku'), 'Ablesung nicht auf haiku')
soll(e && e.verbrauch.length === 3 && e.plaetze.linsen === 3 && e.plaetze.widerlegung === 2, 'verbrauch/plaetze: ' + JSON.stringify(e && e.plaetze))
soll(e && e.zaehlung.bestaetigt === 6 && e.zaehlung.nicht_geprueft === 0, 'zaehlung ' + JSON.stringify(e && e.zaehlung))
for (const k of ['zaehlung', 'kopie', 'temp_ordner', 'befunde', 'selbst_ausgefuehrt', 'nicht_pruefbar', 'vorbereitung', 'ohne_ergebnis']) soll(e && k in e, 'Feld fehlt: ' + k)
ohneVerstoss(p, soll)
return fehler
})
fall('plankritik', '5 Stunden bei 60 %: Linsen zu zweit', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(60, 10)] })
soll(spitze(p, 'Plankritik', 'opus') === 2 && e.plaetze.linsen === 2, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus'))
soll(e.status === 'fertig', 'status ' + e.status)
return fehler
})
fall('plankritik', 'gebremst vor den Linsen: keine Linse, keine Widerlegung', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(90, 10)] })
soll(f === null, 'Fehler: ' + f)
soll(e && e.status === 'gebremst' && e.gebremst_vor === 'Linsen', JSON.stringify(e && [e.status, e.gebremst_vor]))
soll(mitLabel(p, 'plankritik:').length === 0 && mitLabel(p, 'widerlegung:').length === 0, 'Opus gestartet trotz Bremse')
soll(e && e.verbrauch.length === 1 && e.ohne_ergebnis.length === 3, 'verbrauch/ohne_ergebnis ' + JSON.stringify(e && [e.verbrauch.length, e.ohne_ergebnis]))
soll(e && e.zaehlung && e.zaehlung.bestaetigt === 0 && e.zaehlung.nicht_geprueft === 0, 'zaehlung ' + JSON.stringify(e && e.zaehlung))
soll(e && /neu starten/.test(e.naechster_schritt), 'naechster_schritt ' + (e && e.naechster_schritt))
return fehler
})
fall('plankritik', 'gebremst vor der Widerlegung: nur die Befunde über der Schwelle heißen gebremst', async () => {
const { fehler, soll } = pruefe()
const zeile = { arithmetik: 11, bezeichner: 12, abdeckung: 13 }
const mitKlein = linse => ({ befunde: [
{ schweregrad: 'wichtig', datei: 'task-01.md', zeile: zeile[linse], beschreibung: 'w', reproduktion: 'x' },
{ schweregrad: 'klein', datei: 'task-02.md', zeile: zeile[linse], beschreibung: 'k', reproduktion: 'x' },
], selbst_ausgefuehrt: [], nicht_pruefbar: [] })
const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(10, 10), ablesung(90, 10)], linse: mitKlein })
soll(mitLabel(p, 'plankritik:').length === 3, 'Linsen: ' + mitLabel(p, 'plankritik:').length)
soll(mitLabel(p, 'widerlegung:').length === 0, 'Widerlegung trotz Bremse')
soll(e.status === 'gebremst' && e.gebremst_vor === 'Widerlegung', JSON.stringify([e.status, e.gebremst_vor]))
const urteile = e.befunde.map(b => b.schweregrad + ':' + b.urteil).sort()
soll(urteile.filter(u => u === 'wichtig:nicht geprüft (gebremst)').length === 3 && urteile.filter(u => u === 'klein:nicht geprüft').length === 3, 'Urteile: ' + JSON.stringify(urteile))
soll(e.zaehlung.nicht_geprueft === 6 && e.zaehlung.bestaetigt === 0, JSON.stringify(e.zaehlung))
soll(mitLabel(p, 'verbrauch:').length === 3, 'Ablesung am Ende fehlt')
soll(/nachholen/.test(e.naechster_schritt), 'naechster_schritt ' + e.naechster_schritt)
return fehler
})
fall('plankritik', 'vor der Widerlegung 75 %: Widerlegung einzeln', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(10, 10), ablesung(75, 10)] })
soll(spitze(p, 'Widerlegung', 'opus') === 1 && e.plaetze.widerlegung === 1, 'Widerlegung gleichzeitig: ' + spitze(p, 'Widerlegung', 'opus'))
soll(mitLabel(p, 'widerlegung:').length === 6 && e.status === 'fertig', JSON.stringify([mitLabel(p, 'widerlegung:').length, e.status]))
return fehler
})
fall('plankritik', 'Ablesung ohne Ergebnis: Linsen zu zweit (alte Obergrenze)', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [OHNE] })
soll(spitze(p, 'Plankritik', 'opus') === 2, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus'))
soll(e.status === 'fertig' && e.verbrauch[0].status === 'nicht_gelesen', JSON.stringify(e.verbrauch[0]))
return fehler
})
fall('plankritik', 'als Kind: verbrauch.js wirft, Rückfall auf zwei', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, {}, { tiefe: 1 })
soll(f === null, 'Fehler: ' + f)
soll(spitze(p, '▸ plankritik', 'opus') === 2, 'Linsen gleichzeitig: ' + spitze(p, '▸ plankritik', 'opus'))
soll(mitLabel(p, 'verbrauch:').length === 0, 'Ableseagent trotz Kind')
soll(e && e.verbrauch.every(v => v.status === 'nicht_gelesen' && /nicht ausführbar/.test(v.grund)), JSON.stringify(e && e.verbrauch))
soll(e && e.status === 'fertig', 'status ' + (e && e.status))
return fehler
})
fall('plankritik', 'Aufträge nennen den Testort', async () => {
const { fehler, soll } = pruefe()
const mit = await PK(PK_ARGS, {})
const satz = 'Tests fährst du nur in der Kopie C:/tmp/k/mat, nie im echten Projektbaum'
for (const a of [...mitLabel(mit.protokoll, 'plankritik:'), ...mitLabel(mit.protokoll, 'widerlegung:')]) {
soll(a.prompt.includes(satz) && /eigenen Kopie dieser Kopie/.test(a.prompt), a.label + ' ohne Testort')
}
const ohne = await PK({ projekt: 'D:/x', planordner: 'D:/x/plan' }, {})
for (const a of mitLabel(ohne.protokoll, 'plankritik:')) {
soll(/Tests fährst du in dieser Plankritik nicht/.test(a.prompt), a.label + ': ohne Kopie kein Testverbot')
}
return fehler
})
fall('plankritik', 'plaetze 1 begrenzt die Linsen auf eine', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PK({ ...PK_ARGS, plaetze: 1 }, {})
soll(spitze(p, 'Plankritik', 'opus') === 1, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus'))
return fehler
})
for (const plaetze of [0, 5, 2.5, '3']) {
fall('plankritik', `plaetze ${JSON.stringify(plaetze)} wirft vor dem ersten Agenten`, async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await PK({ ...PK_ARGS, plaetze }, {})
soll(f && /plaetze muss/.test(f), 'Fehler: ' + f)
soll(p.agents.length === 0, 'Agent gestartet')
return fehler
})
}
fall('plankritik', 'doppelte Linse wirft vor dem ersten Agenten', async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await PK({ ...PK_ARGS, linsen: ['arithmetik', 'bezeichner', 'arithmetik'] }, {})
soll(f && /doppelt in linsen/.test(f), 'Fehler: ' + f)
soll(p.agents.length === 0, 'Agent gestartet')
return fehler
})
fall('plankritik', 'Linse ohne Ergebnis steht in ohne_ergebnis', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await PK(PK_ARGS, { linse: l => (l === 'arithmetik' ? OHNE : undefined) })
soll(e.status === 'fertig' && JSON.stringify(e.ohne_ergebnis) === '["arithmetik"]', JSON.stringify(e.ohne_ergebnis))
return fehler
})
fall('plankritik', 'verifizieren false: keine Ablesung vor der Widerlegung', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PK({ ...PK_ARGS, verifizieren: false }, {})
soll(mitLabel(p, 'widerlegung:').length === 0, 'Widerlegung trotz verifizieren false')
soll(mitLabel(p, 'verbrauch:').length === 2, 'Ablesungen: ' + mitLabel(p, 'verbrauch:').length)
soll(e.status === 'fertig' && e.zaehlung.nicht_geprueft === 6, JSON.stringify(e.zaehlung))
return fehler
})
// ---------------------------------------------------------------- pruefung
const PR_ARGS = { projekt: 'D:/x', plan: 'D:/x/plan' }
const PR = (a, k, o = {}) => fuehreAus(WF('pruefung'), a, antworten(k), { ordner: ORDNER, ...o })
fall('pruefung', 'Grundlinie: Ablesung vor und nach dem pruefer', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PR(PR_ARGS, {})
soll(f === null, 'Fehler: ' + f)
const folge = p.agents.slice().sort((x, y) => x.start - y.start).map(x => x.label)
soll(JSON.stringify(folge) === JSON.stringify(['verbrauch:pruefung:vor Prüfung', 'pruefung', 'verbrauch:pruefung:nach Prüfung']), 'Folge: ' + JSON.stringify(folge))
soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'ok' && v.plaetze === 0 && v.gebremst === false), JSON.stringify(e && e.verbrauch))
soll(e && e.effort === '(Frontmatter)', 'effort ' + (e && e.effort))
soll(mitLabel(p, 'pruefung')[0].modell === 'opus', 'pruefer nicht auf opus')
ohneVerstoss(p, soll)
return fehler
})
fall('pruefung', 'kritisch hebt auf xhigh', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PR({ ...PR_ARGS, kritisch: true }, {})
soll(e.effort === 'xhigh' && mitLabel(p, 'pruefung:kritisch')[0].effort === 'xhigh', 'effort ' + e.effort)
return fehler
})
fall('pruefung', 'verbrauch false: keine Ablesung', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PR({ ...PR_ARGS, verbrauch: false }, {})
soll(mitLabel(p, 'verbrauch:').length === 0 && e.verbrauch.length === 0, 'Ablesung trotz verbrauch false')
return fehler
})
fall('pruefung', 'als Kind ohne verbrauch false: Ablesung scheitert, Prüfung läuft', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PR(PR_ARGS, {}, { tiefe: 1 })
soll(f === null, 'Fehler: ' + f)
soll(mitLabel(p, 'pruefung').length === 1, 'pruefer nicht gelaufen')
soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'nicht_gelesen'), JSON.stringify(e && e.verbrauch))
return fehler
})
fall('pruefung', 'pruefer ohne Bericht: Fehler, beide Ablesungen gelaufen', async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await PR(PR_ARGS, { pruefer: OHNE })
soll(f === 'pruefung: der pruefer hat keinen Bericht geliefert', 'Fehler: ' + f)
soll(mitLabel(p, 'verbrauch:').length === 2, 'Ablesungen: ' + mitLabel(p, 'verbrauch:').length)
return fehler
})
for (const verbrauch of ['nein', 0, null]) {
fall('pruefung', `verbrauch ${JSON.stringify(verbrauch)} wirft`, async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await PR({ ...PR_ARGS, verbrauch }, {})
soll(f && /verbrauch muss/.test(f), 'Fehler: ' + f)
soll(p.agents.length === 0, 'Agent gestartet')
return fehler
})
}
// ---------------------------------------------------------------- welle-mit-pruefung
const WM_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', tasks: [{ datei: 'task-01.md', modell: 'haiku' }], kopie: 'C:/tmp/k/mat', suite: 'pytest -q' }
const WM = (a, k) => fuehreAus(WF('welle-mit-pruefung'), a, antworten(k), { ordner: ORDNER })
fall('welle-mit-pruefung', 'Grundlinie: Welle, Vergleich, Ablesung um die Prüfung, Kind liest nicht selbst', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await WM(WM_ARGS, {})
soll(f === null, 'Fehler: ' + f)
soll(e && e.status === 'fertig', 'status ' + (e && e.status))
soll(mitLabel(p, 'executer:').length === 1 && mitLabel(p, 'werkzeug:vergleich').length === 1 && mitLabel(p, 'pruefung').length === 1, 'Agenten: ' + JSON.stringify(p.agents.map(x => x.label)))
const ab = mitLabel(p, 'verbrauch:').map(x => x.label)
soll(JSON.stringify(ab) === JSON.stringify(['verbrauch:welle-mit-pruefung:vor Prüfung', 'verbrauch:welle-mit-pruefung:nach Prüfung']), 'Ablesungen: ' + JSON.stringify(ab))
const kind = p.workflows.find(w => w.ref === 'pruefung')
soll(kind && kind.args.verbrauch === false, 'Kind pruefung ohne verbrauch false')
soll(e.pruefung.verbrauch.length === 0 && mitLabel(p, 'verbrauch:pruefung:').length === 0, 'Kind hat selbst abgelesen')
soll(e.verbrauch.length === 2, 'verbrauch ' + e.verbrauch.length)
ohneVerstoss(p, soll)
return fehler
})
fall('welle-mit-pruefung', 'bis vergleich: keine Prüfung, pruefung_args mit Rohbelegen', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await WM({ ...WM_ARGS, bis: 'vergleich' }, {})
soll(f === null, 'Fehler: ' + f)
soll(e && e.status === 'bis_vergleich', 'status ' + (e && e.status))
soll(mitLabel(p, 'pruefung').length === 0 && mitLabel(p, 'verbrauch:').length === 0, 'Prüfung oder Ablesung trotz bis')
const pa = e && e.pruefung_args
soll(pa && /VERGLEICH-OK/.test(pa.schwerpunkt) && pa.plan === 'D:/x/plan' && JSON.stringify(pa.tasks) === '["task-01.md"]', 'pruefung_args ' + JSON.stringify(pa))
soll(pa && !('verbrauch' in pa), 'pruefung_args trägt verbrauch')
soll(e && /nur lesend/.test(e.naechster_schritt), 'naechster_schritt ohne Lesevorgabe')
return fehler
})
fall('welle-mit-pruefung', 'bis vergleich bei unvollständiger Welle: keine pruefung_args', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await WM({ ...WM_ARGS, bis: 'vergleich' }, { executer: OHNE })
soll(e && e.status === 'bis_vergleich' && e.pruefung_args === null && /unvollständig/.test(e.pruefung_grund), JSON.stringify(e && [e.status, e.pruefung_args, e.pruefung_grund]))
return fehler
})
fall('welle-mit-pruefung', 'unvollständige Welle: Prüfung übersprungen, keine Ablesung', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await WM(WM_ARGS, { executer: OHNE })
soll(e.status === 'ohne_pruefung' && /unvollständig/.test(e.pruefung.uebersprungen), JSON.stringify([e.status, e.pruefung]))
soll(mitLabel(p, 'verbrauch:').length === 0 && mitLabel(p, 'pruefung').length === 0, 'Ablesung oder Prüfung trotz Lücke')
return fehler
})
fall('welle-mit-pruefung', 'verbrauch.js fehlt: Ablesungen nicht_gelesen, Welle und Prüfung bleiben', async () => {
const { fehler, soll } = pruefe()
const ohneVerbrauch = mkdtempSync(path.join(os.tmpdir(), 'ohne-verbrauch-'))
cpSync(ORDNER, ohneVerbrauch, { recursive: true })
rmSync(path.join(ohneVerbrauch, 'verbrauch.js'))
const { ergebnis: e, fehler: f } = await fuehreAus(path.join(ohneVerbrauch, 'welle-mit-pruefung.js'), WM_ARGS, antworten({}), { ordner: ohneVerbrauch })
rmSync(ohneVerbrauch, { recursive: true, force: true })
soll(f === null && e.status === 'fertig' && e.welle && e.pruefung.zaehlung, 'Fehler/Ergebnis: ' + f + ' ' + JSON.stringify(e && e.status))
soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'nicht_gelesen'), JSON.stringify(e && e.verbrauch))
return fehler
})
for (const bis of ['alles', '', 1]) {
fall('welle-mit-pruefung', `bis ${JSON.stringify(bis)} wirft`, async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await WM({ ...WM_ARGS, bis }, {})
soll(f && /bis kennt nur/.test(f), 'Fehler: ' + f)
soll(p.agents.length === 0, 'Agent gestartet')
return fehler
})
}
// ---------------------------------------------------------------- planung
const T = (nr, voraussetzungen, welle) => ({ nr, thema: 'Thema ' + nr, datei: `task-${String(nr).padStart(2, '0')}-t.md`, modell: 'haiku', voraussetzungen, welle })
const DAG = [T(1, [], 1), T(2, [1], 2), T(3, [1], 2), T(4, [1], 2), T(5, [2, 3], 3), T(6, [4], 3), T(7, [], 1)]
const PL_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', gemeinsam: 'GEMEINSAM', taskauftrag: 'Task {NR} {THEMA} in {DATEI}, Modell {MODELL}, Welle {WELLE}, vor: {VORAUSSETZUNGEN}', tasks: DAG }
const PL = (a, k, o = {}) => fuehreAus(WF('planung'), a, antworten(k), { ordner: ORDNER, ...o })
const planerVon = (p, nr) => p.agents.find(x => x.label === `planer:task-${String(nr).padStart(2, '0')}`)
function voraussetzungenEingehalten(p, tasks, soll) {
for (const t of tasks) {
const a = planerVon(p, t.nr)
if (!a) continue
for (const n of t.voraussetzungen) {
const v = planerVon(p, n)
soll(v && v.ende !== null && v.ende < a.start, `Task ${t.nr} startete vor dem Ende von Task ${n}`)
}
}
}
fall('planung', 'Grundlinie: Start nach den Voraussetzungen, ohne Wunsch zwei gleichzeitig', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PL(PL_ARGS, {})
soll(f === null, 'Fehler: ' + f)
soll(e && e.status === 'fertig' && e.geschrieben.length === 7, 'status ' + (e && e.status))
voraussetzungenEingehalten(p, DAG, soll)
soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus'))
const ab = mitLabel(p, 'verbrauch:').map(x => x.label)
soll(ab[0] === 'verbrauch:planung:Anfang' && ab[ab.length - 1] === 'verbrauch:planung:Ende' && ab.includes('verbrauch:planung:nach 4 Tasks'), 'Ablesungen: ' + JSON.stringify(ab))
soll(e && e.plaetze_verlauf[0] === 2, 'plaetze_verlauf ' + JSON.stringify(e && e.plaetze_verlauf))
soll(p.agents.filter(x => x.label.startsWith('planer:')).every(x => x.modell === 'opus' && x.agentType === 'planer'), 'planer nicht auf opus/planer')
ohneVerstoss(p, soll)
return fehler
})
fall('planung', 'Wunsch vier: drei bis vier gleichzeitig, Voraussetzungen halten', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, {})
const s = spitze(p, 'Taskdateien', 'opus')
soll(s >= 3 && s <= 4, 'gleichzeitig: ' + s)
soll(e.status === 'fertig' && e.plaetze_verlauf[0] === 4, JSON.stringify([e.status, e.plaetze_verlauf]))
voraussetzungenEingehalten(p, DAG, soll)
return fehler
})
const FREI = [1, 2, 3, 4, 5, 6, 7, 8].map(n => T(n, [], 1))
fall('planung', 'Nachlesen senkt die Plätze: danach startet keiner neben zwei anderen', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 4, nachlesen_alle: 2 }, { ablesungen: [ablesung(10, 10), ablesung(60, 10), ablesung(60, 10), ablesung(60, 10), ablesung(60, 10)] })
const zweite = mitLabel(p, 'verbrauch:')[1]
const danach = mitLabel(p, 'planer:').filter(a => a.start > zweite.start)
soll(danach.length > 0, 'nach der zweiten Ablesung startete kein Planer')
soll(danach.every(a => laufendBeiStart(p, a, 'planer:') <= 1), 'Start neben zwei oder mehr: ' + JSON.stringify(danach.map(a => [a.label, laufendBeiStart(p, a, 'planer:')])))
soll(e.status === 'fertig' && JSON.stringify(e.plaetze_verlauf.slice(0, 2)) === '[4,2]', JSON.stringify([e.status, e.plaetze_verlauf]))
return fehler
})
fall('planung', 'Nachlesen hebt die Plätze: danach laufen mehr als zwei', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 4, nachlesen_alle: 2 }, { ablesungen: [ablesung(60, 10), ablesung(10, 10), ablesung(10, 10), ablesung(10, 10)] })
const zweite = mitLabel(p, 'verbrauch:')[1]
const danach = mitLabel(p, 'planer:').filter(a => a.start > zweite.start)
soll(danach.some(a => laufendBeiStart(p, a, 'planer:') >= 2), 'nach dem Heben nie mehr als zwei gleichzeitig')
soll(e.status === 'fertig' && JSON.stringify(e.plaetze_verlauf.slice(0, 2)) === '[2,4]', JSON.stringify([e.status, e.plaetze_verlauf]))
return fehler
})
fall('planung', 'gebremst schon am Anfang: kein Planer, Status gebremst', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { ablesungen: [ablesung(90, 10)] })
soll(mitLabel(p, 'planer:').length === 0, 'Planer trotz Bremse')
soll(e.status === 'gebremst' && e.offen.length === 7, JSON.stringify([e.status, e.offen]))
soll(/nicht mit resumeFromRunId/.test(e.naechster_schritt), 'naechster_schritt ' + e.naechster_schritt)
return fehler
})
fall('planung', 'Startreihenfolge folgt Welle und Nummer, nicht der Liste', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PL({ ...PL_ARGS, tasks: [T(3, [], 2), T(2, [], 1), T(1, [], 1)], plaetze: 1 }, {})
const folge = mitLabel(p, 'planer:').sort((x, y) => x.start - y.start).map(a => a.label)
soll(JSON.stringify(folge) === '["planer:task-01","planer:task-02","planer:task-03"]', 'Folge ' + JSON.stringify(folge))
return fehler
})
fall('planung', 'kein Nachlesen, wenn kein Task mehr offen ist', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI.slice(0, 3), plaetze: 4, nachlesen_alle: 1 }, {})
const ab = mitLabel(p, 'verbrauch:').map(x => x.label)
soll(JSON.stringify(ab) === '["verbrauch:planung:Anfang","verbrauch:planung:Ende"]', 'Ablesungen: ' + JSON.stringify(ab))
return fehler
})
fall('planung', 'Ergebnis mit fremder Datei gilt als gescheitert', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { planerDatei: (nr, richtig) => (nr === 2 ? 'D:/x/plan/task-99-t.md' : richtig) })
soll(e.gescheitert.includes(2) && e.blockiert.includes(5) && !planerVon(p, 5), JSON.stringify([e.gescheitert, e.blockiert]))
soll(p.logs.some(l => /statt task-02-t\.md/.test(l)), 'kein Hinweis auf die fremde Datei')
return fehler
})
fall('planung', 'Ergebnis mit ähnlichem Dateinamen gilt als gescheitert', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await PL(PL_ARGS, { planerDatei: (nr, richtig) => (nr === 7 ? 'D:/x/plan/xtask-07-t.md' : richtig) })
soll(JSON.stringify(e.gescheitert) === '[7]', JSON.stringify(e.gescheitert))
return fehler
})
fall('planung', 'Nachlesen im Takt von nachlesen_alle, solange Tasks offen sind', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 2, nachlesen_alle: 2 }, { planerDauer: () => 100 })
const ab = mitLabel(p, 'verbrauch:').map(x => x.label.slice('verbrauch:planung:'.length))
soll(JSON.stringify(ab) === '["Anfang","nach 2 Tasks","nach 4 Tasks","nach 6 Tasks","Ende"]', 'Ablesungen: ' + JSON.stringify(ab))
return fehler
})
fall('planung', '5 Stunden bei 60 %: höchstens zwei gleichzeitig', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, { ablesungen: [ablesung(60, 10), ablesung(60, 10)] })
soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus'))
soll(e.status === 'fertig', 'status ' + e.status)
voraussetzungenEingehalten(p, DAG, soll)
return fehler
})
fall('planung', 'Bremse beim Nachlesen: kein Start mehr, laufende enden', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, nachlesen_alle: 2 }, { ablesungen: [ablesung(10, 10), ablesung(90, 10)] })
soll(e.status === 'gebremst' && e.offen.length > 0, JSON.stringify([e.status, e.offen]))
const zweite = mitLabel(p, 'verbrauch:')[1]
soll(zweite && p.agents.filter(x => x.label.startsWith('planer:') && x.start > zweite.start).length === 0, 'Planer nach der Bremse gestartet')
soll(p.agents.filter(x => x.label.startsWith('planer:')).every(x => x.ende !== null), 'laufender Planer nicht beendet')
return fehler
})
fall('planung', 'erste Voraussetzung scheitert: abhängige blockiert, unabhängige laufen', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { planer: nr => (nr === 1 ? OHNE : undefined) })
soll(e.status === 'unvollstaendig', 'status ' + e.status)
soll(JSON.stringify(e.gescheitert) === '[1]' && JSON.stringify(e.blockiert) === '[2,3,4,5,6]', JSON.stringify([e.gescheitert, e.blockiert]))
soll(e.geschrieben.map(g => g.nr).join() === '7', 'geschrieben ' + e.geschrieben.map(g => g.nr))
soll([2, 3, 4, 5, 6].every(n => !planerVon(p, n)), 'blockierter Task gestartet')
return fehler
})
fall('planung', 'letzte Tasks scheitern: nichts blockiert', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await PL(PL_ARGS, { planer: nr => (nr === 6 || nr === 7 ? OHNE : undefined) })
soll(e.status === 'unvollstaendig' && JSON.stringify(e.gescheitert.slice().sort()) === '[6,7]' && e.blockiert.length === 0, JSON.stringify([e.status, e.gescheitert, e.blockiert]))
soll(e.geschrieben.length === 5, 'geschrieben ' + e.geschrieben.length)
return fehler
})
fall('planung', 'erledigt: Task 1 startet nicht, die abhängigen schon', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, erledigt: [1] }, {})
soll(!planerVon(p, 1), 'Task 1 gestartet')
soll(e.status === 'fertig' && e.geschrieben.length === 6, JSON.stringify([e.status, e.geschrieben.length]))
return fehler
})
fall('planung', 'drei Planer ohne Ergebnis: Abbruch, kein weiterer Start', async () => {
const { fehler, soll } = pruefe()
const frei = [1, 2, 3, 4, 5, 6].map(n => T(n, [], 1))
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: frei, plaetze: 2 }, { planer: nr => (nr <= 3 ? OHNE : undefined), planerDauer: nr => (nr <= 3 ? 5 : 60) })
soll(e.status === 'abgebrochen', 'status ' + e.status)
soll(!planerVon(p, 5) && !planerVon(p, 6) && JSON.stringify(e.offen) === '[5,6]', 'offen ' + JSON.stringify(e.offen))
return fehler
})
for (const [name, aendern, text] of [
['Voraussetzung ins Leere', a => ({ ...a, tasks: [T(1, [], 1), T(2, [99], 2)] }), /Voraussetzung 99 gibt es nicht/],
['Zyklus', a => ({ ...a, tasks: [T(1, [2], 1), T(2, [1], 2)] }), /Zyklus/],
['Selbstbezug', a => ({ ...a, tasks: [T(1, [1], 1)] }), /Zyklus/],
['doppelte Nummer', a => ({ ...a, tasks: [T(1, [], 1), T(1, [], 1)] }), /doppelt in tasks/],
['doppelte Datei', a => ({ ...a, tasks: [T(1, [], 1), { ...T(2, [], 1), datei: T(1, [], 1).datei }] }), /bei zwei Tasks/],
['doppelte Voraussetzung', a => ({ ...a, tasks: [T(1, [], 1), T(2, [1, 1], 2)] }), /Voraussetzung steht doppelt/],
['modell unbekannt', a => ({ ...a, tasks: [{ ...T(1, [], 1), modell: 'gpt' }] }), /modell "gpt"/],
['erledigt ins Leere', a => ({ ...a, erledigt: [99] }), /erledigt muss/],
['plaetze 5', a => ({ ...a, plaetze: 5 }), /plaetze muss/],
['nachlesen_alle 0', a => ({ ...a, nachlesen_alle: 0 }), /nachlesen_alle muss/],
['tasks leer', a => ({ ...a, tasks: [] }), /nicht leere Liste/],
['nr als Text', a => ({ ...a, tasks: [{ ...T(1, [], 1), nr: '1' }] }), /nr muss/],
['voraussetzungen fehlen', a => ({ ...a, tasks: [{ ...T(1, [], 1), voraussetzungen: undefined }] }), /voraussetzungen muss/],
['gemeinsam fehlt', a => ({ ...a, gemeinsam: '' }), /args fehlen: gemeinsam/],
]) {
fall('planung', `${name} wirft vor dem ersten Agenten`, async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await PL(aendern(PL_ARGS), {})
soll(f && text.test(f), 'Fehler: ' + f)
soll(p.agents.length === 0, 'Agent gestartet')
return fehler
})
}
fall('planung', 'Auftrag nennt die Voraussetzungen und ersetzt alle Platzhalter', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PL(PL_ARGS, {})
const t5 = planerVon(p, 5).prompt
soll(t5.includes('GEMEINSAM') && t5.includes('task-02-t.md') && t5.includes('task-03-t.md') && t5.includes('D:/x/plan/task-05-t.md'), 'Auftrag Task 5 unvollständig')
soll(!/\{(NR|THEMA|DATEI|MODELL|WELLE|VORAUSSETZUNGEN)\}/.test(t5), 'Platzhalter übrig')
soll(/Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst/.test(t5), 'Lesehinweis fehlt')
soll(!/Lies sie/.test(planerVon(p, 1).prompt), 'Lesehinweis ohne Voraussetzungen')
soll(/vor: \(keine\)/.test(planerVon(p, 1).prompt), 'Task 1 ohne "(keine)"')
return fehler
})
fall('planung', 'alles erledigt: kein Agent, keine Ablesung', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, erledigt: DAG.map(t => t.nr) }, {})
soll(p.agents.length === 0 && e.status === 'fertig' && e.verbrauch.length === 0, JSON.stringify([p.agents.length, e.status]))
return fehler
})
fall('planung', 'Ablesung ohne Ergebnis: höchstens zwei gleichzeitig', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, { ablesungen: [OHNE, OHNE] })
soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus'))
return fehler
})
fall('planung', 'als Kind: Rückfall auf zwei, Planung läuft durch', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, {}, { tiefe: 1 })
soll(f === null && e.status === 'fertig', 'Fehler/status: ' + f + ' ' + (e && e.status))
soll(spitze(p, '▸ planung', 'opus') === 2, 'gleichzeitig: ' + spitze(p, '▸ planung', 'opus'))
return fehler
})
// ---------------------------------------------------------------- Lauf
let gescheitert = 0
const grundlinieGescheitert = new Set()
for (const f of faelle) {
let fehler
try { fehler = await f.lauf() } catch (e) { fehler = ['Absturz im Fall: ' + (e && e.stack || e)] }
if (f.name.startsWith('Grundlinie') && fehler.length) grundlinieGescheitert.add(f.workflow)
if (fehler.length) {
gescheitert++
console.log(`FEHLER ${f.workflow}: ${f.name}\n ${fehler.join('\n ')}`)
} else console.log(`OK ${f.workflow}: ${f.name}`)
}
for (const w of grundlinieGescheitert) console.log(`HINWEIS Grundlinie von ${w} gescheitert: Die übrigen Fälle dieses Workflows messen nichts.`)
rmSync(TMP, { recursive: true, force: true })
console.log(`${faelle.length} Fälle, ${gescheitert} gescheitert (Ordner ${ORDNER})`)
process.exit(gescheitert ? 1 : 0)
@@ -0,0 +1,65 @@
"""Mutantenlauf gegen verbrauch_reihe.py: je Mutant genau eine Ersetzung in einer Kopie des Ordners, dann faelle_verbrauch_reihe.py.
Jede Ersetzung muss genau einmal vorkommen, sonst bricht der Lauf mit Exit 2 ab (veralteter Mutant).
Aufruf: python mutanten_reihe.py
Exit 0, wenn jeder Mutant gefangen wird, 1 bei einem Ueberlebenden, 2 bei einem veralteten Muster.
"""
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
MUTANTEN = [
("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"),
("Toleranz 6 Stunden", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(hours=6)"),
("Toleranz 1 Sekunde", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(seconds=1)"),
("Toleranz nur nach vorn", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if rb - ra > RESET_TOLERANZ or b[0] < a[0]:"),
("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"),
("unlesbares resetsAt als gleich", " if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None):\n return \"unbekannt\"\n", ""),
("bool als Zahl", "isinstance(p, bool) or ", ""),
("ueber 100 als Fehler", "not math.isfinite(p) or p < 0", "not math.isfinite(p) or not 0 <= p <= 100"),
("erstes statt hoechstes 5-Stunden-Fenster", "if art == \"fuenf\" and (fuenf is None or p > fuenf[0]):", "if art == \"fuenf\" and fuenf is None:"),
("Ablesezeit Start statt Ende", "\"zeit\": spanne[1]", "\"zeit\": spanne[0]"),
("nur_ascii fehlt", "print(nur_ascii(f\"| {z", "print((f\"| {z"),
("Ende des Paars nicht gekappt", "min(g[\"ende\"], b[\"zeit\"])", "g[\"ende\"]"),
("Anfang des Paars nicht gekappt", "max(g[\"start\"], a[\"zeit\"])", "g[\"start\"]"),
("Absturz nicht gefangen", "except Exception as e: # Ein Absturz", "except ZeroDivisionError as e: # Ein Absturz"),
("kaputte Zeile bricht ab", " except ValueError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile", " except KeyError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile"),
("seit ignoriert", "ablesungen = [x for x in ablesungen if x[\"zeit\"] >= seit]", "pass"),
("Speicher fehlt still", " return 2\n seit = None", " return 0\n seit = None"),
("Familie aus vollem Namen fehlt", " if f in modell.lower():", " if f == modell.lower():"),
]
def main() -> int:
ueberlebt, veraltet = [], []
for name, alt, neu in MUTANTEN:
with tempfile.TemporaryDirectory() as tmp:
ziel = Path(tmp) / "w"
ziel.mkdir()
for datei in ("verbrauch_reihe.py", "faelle_verbrauch_reihe.py"):
shutil.copy2(HIER / datei, ziel / datei)
pfad = ziel / "verbrauch_reihe.py"
text = pfad.read_text(encoding="utf-8")
if text.count(alt) != 1:
veraltet.append(name)
print(f"VERALTET {name} (Muster {text.count(alt)}-mal)")
continue
pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n")
lauf = subprocess.run([sys.executable, str(ziel / "faelle_verbrauch_reihe.py")], capture_output=True, text=True, encoding="utf-8", errors="replace")
rot = [z for z in lauf.stdout.splitlines() if z.startswith("FEHLER")]
if lauf.returncode == 0:
ueberlebt.append(name)
print(f"UEBERLEBT {name}")
else:
print(f"GEFANGEN {name} ({rot[0][7:80] if rot else 'Absturz'})")
print(f"{len(MUTANTEN)} Mutanten, {len(ueberlebt)} ueberlebt, {len(veraltet)} veraltet")
return 2 if veraltet else 1 if ueberlebt else 0
if __name__ == "__main__":
sys.stdout.reconfigure(errors="replace")
sys.exit(main())
@@ -0,0 +1,110 @@
"""Mutantenlauf gegen die Workflows unter .claude/workflows/: je Mutant genau eine Ersetzung, dann faelle_workflows.mjs.
Jede Ersetzung muss genau einmal vorkommen, sonst bricht der Lauf mit Exit 2 ab: Ein Muster, das
nicht mehr passt, ist ein veralteter Mutant und kein ueberlebter. Ein ueberlebter Mutant ist ein
Befund, ausser er steht in WIRKUNGSLOS mit Begruendung.
Aufruf: python mutanten_workflows.py [<ordner mit den workflows>]
Exit 0, wenn jeder wirksame Mutant gefangen wird, 1 bei einem Ueberlebenden, 2 bei einem veralteten Muster.
"""
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
FAELLE = HIER / "faelle_workflows.mjs"
# Mutanten, die bei den heutigen Stufen kein Verhalten aendern, mit Grund.
WIRKUNGSLOS = {
"harte Obergrenze fehlt": "keine Stufe gibt mehr als vier Plaetze; wer die Stufen anhebt, braucht einen Fall",
}
MUTANTEN = [
("verbrauch", "Stufe 50 -> 60", "[[50, 4], [70, 2], [85, 1]]", "[[60, 4], [70, 2], [85, 1]]"),
("verbrauch", "Wochenstufe 85 -> 80", "[[70, 4], [85, 2], [95, 1]]", "[[70, 4], [80, 2], [95, 1]]"),
("verbrauch", "ueber 100 wieder Fehler", "|| p < 0) return", "|| p < 0 || p > 100) return"),
("verbrauch", "doppeltes Fenster: erstes gewinnt", "if (!e[art] || e[art].prozent < p) e[art] = neu", "if (!e[art]) e[art] = neu"),
("verbrauch", "Opus-Woche ignoriert", "Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0)", "e.woche.prozent"),
("verbrauch", "Opus-Woche ersetzt die Woche", "Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0)", "(e.opus ? e.opus.prozent : e.woche.prozent)"),
("verbrauch", "harte Obergrenze fehlt", "Math.min(wunsch, grenzeFuenf, grenzeWoche, HARTE_OBERGRENZE)", "Math.min(wunsch, grenzeFuenf, grenzeWoche)"),
("verbrauch", "gebremst auch bei wunsch 0", "const gebremst = wunsch > 0 && plaetze === 0", "const gebremst = plaetze === 0"),
("verbrauch", "Status ok nicht verlangt", " || ablesung.status !== 'ok'", ""),
("verbrauch", "Rueckfall 3 statt 2", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"),
("verbrauch", "wunsch-Pruefung fehlt", "if (!Number.isInteger(a.wunsch) || a.wunsch < 0) throw", "if (false) throw"),
("verbrauch", "fremdes Fenster mit Zahl geprueft", "if (!art) { e.andere.push(f.label); continue }\n const p = f.percentUsed", "const p = f.percentUsed\n if (!art) { if (typeof p !== 'number') return { fehler: 'x' }; e.andere.push(f.label); continue }"),
("plankritik", "Bremse vor Linsen fehlt", "if (vorLinsen.gebremst) {", "if (false) {"),
("plankritik", "Bremse vor Widerlegung fehlt", "if (vorWiderlegung.gebremst) {", "if (false) {"),
("plankritik", "Widerlegung bis vier", "const WIDERLEGUNG_HOECHSTENS = 2", "const WIDERLEGUNG_HOECHSTENS = 4"),
("plankritik", "Widerlegung ignoriert die Ablesung", "} else plaetzeWiderlegung = vorWiderlegung.plaetze", "} else plaetzeWiderlegung = WIDERLEGUNG_HOECHSTENS"),
("plankritik", "Linsen fest zwei", "await hoechstens(vorLinsen.plaetze, gewaehlt,", "await hoechstens(2, gewaehlt,"),
("plankritik", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"),
("plankritik", "Testort fehlt in der Widerlegung", "Du änderst keine Datei im Projekt und keine im Plan. ${testort(ctx)}\n- ", "Du änderst keine Datei im Projekt und keine im Plan.\n- "),
("plankritik", "Testort fehlt bei den Linsen", "Executer-Bericht, dessen Testlauf du wiederholen könntest. ${testort(ctx)}", "Executer-Bericht, dessen Testlauf du wiederholen könntest. ${linse === 'arithmetik' ? testort(ctx) : ''}"),
("plankritik", "keine Ablesung am Ende", "await ablesen('Ende', 0)\n", "\n"),
("plankritik", "gebremste Befunde ohne Hinweis", "'nicht geprüft (gebremst)'", "'nicht geprüft'"),
("plankritik", "gebremst auch unter der Schwelle", "gebremstVor && zuPruefen.includes(g) ?", "gebremstVor ?"),
("plankritik", "naechster_schritt uebergeht die Bremse", "naechster_schritt: gebremstVor ? ", "naechster_schritt: false ? "),
("plankritik", "zaehlung null bei Bremse vor Linsen", "zaehlung: { roh: 0, zusammengefasst: 0, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: 0 },", "zaehlung: null,"),
("pruefung", "verbrauch false wird uebergangen", "if (args.verbrauch === false) return\n", "\n"),
("pruefung", "Ablesung nach der Pruefung fehlt", "await ablesen('nach Prüfung')\n", "\n"),
("welle-mit-pruefung", "Kind liest selbst", "{ ...pruefungArgs, verbrauch: false }", "{ ...pruefungArgs }"),
("welle-mit-pruefung", "bis vergleich wirkt nicht", "if (args.bis === 'vergleich') {", "if (false) {"),
("welle-mit-pruefung", "Ablesung vor der Pruefung fehlt", " await ablesen('vor Prüfung')\n", "\n"),
("planung", "Voraussetzung laufend genuegt", "['fertig', 'erledigt'].includes(status.get(n)))) starte(t)", "['fertig', 'erledigt', 'laeuft'].includes(status.get(n)))) starte(t)"),
("planung", "keine Blockade", "status.set(t.nr, 'blockiert')\n geaendert = true", "geaendert = false"),
("planung", "kein Abbruch nach drei", "gescheitert.length >= ABBRUCH_NACH && !abgebrochen", "false"),
("planung", "Bremse beim Nachlesen fehlt", "if (v.gebremst) {\n gebremst = true\n log('Planung gebremst", "if (false) {\n gebremst = true\n log('Planung gebremst"),
("planung", "Nachlesen uebernimmt die Plaetze nicht", "log('Planung gebremst: kein weiterer Start, die laufenden Planer enden')\n } else plaetze = v.plaetze", "log('Planung gebremst: kein weiterer Start, die laufenden Planer enden')\n }"),
("planung", "Bremse am Anfang nicht gemeldet", " if (v.gebremst) gebremst = true\n else plaetze = v.plaetze", " plaetze = v.plaetze"),
("planung", "Plaetze nicht begrenzt", "if (laufend.size >= plaetze) break", "if (laufend.size >= 99) break"),
("planung", "Reihenfolge unsortiert", "const reihenfolge = [...args.tasks].sort((x, y) => ((x.welle || 0) - (y.welle || 0)) || (x.nr - y.nr))", "const reihenfolge = [...args.tasks]"),
("planung", "Nachlesen ohne offene Tasks", "fertigSeitAblesung >= nachlesenAlle && nochOffen()", "fertigSeitAblesung >= nachlesenAlle"),
("planung", "fremde Datei zaehlt als fertig", "const r = passt ? roh : null", "const r = roh"),
("planung", "Vorgabe vier statt zwei", "const VORGABE_PLAETZE = 2", "const VORGABE_PLAETZE = 4"),
("planung", "Zyklenpruefung fehlt", "if (geschafft < a.tasks.length) throw", "if (false) throw"),
("planung", "erledigt ignoriert", "(args.erledigt || []).includes(t.nr) ? 'erledigt' : 'offen'", "'offen'"),
("planung", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"),
("planung", "Datei ohne Pfadgrenze", "('/' + roh.datei.replace(/\\\\/g, '/')).endsWith('/' + jeNr.get(nr).datei)", "roh.datei.replace(/\\\\/g, '/').endsWith(jeNr.get(nr).datei)"),
("planung", "Nachlesezaehler nicht zurueckgesetzt", " fertigSeitAblesung = 0\n const v = await ablesen(", " const v = await ablesen("),
("planung", "nachlesen_alle ignoriert", "const nachlesenAlle = args.nachlesen_alle || NACHLESEN_ALLE", "const nachlesenAlle = NACHLESEN_ALLE"),
("planung", "Lesehinweis fehlt", " Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst.", ""),
("plankritik", "doppelte Linse erlaubt", " if (a.linsen && new Set(a.linsen).size !== a.linsen.length) throw", " if (false) throw"),
("welle-mit-pruefung", "status fertig ohne Pruefung", "status: pruefung ? 'fertig' : 'ohne_pruefung',", "status: 'fertig',"),
("welle-mit-pruefung", "Ablesung ohne try", " try {\n r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })\n } catch (e) {\n r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }\n }", " r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })"),
("verbrauch", "5-Stunden-Regex unverankert", "const FUENF_STUNDEN = /^\\s*5[- ]?hour/i", "const FUENF_STUNDEN = /hour/i"),
]
def main() -> int:
ordner = Path(sys.argv[1]) if len(sys.argv) > 1 else HIER.parent.parent / ".claude" / "workflows"
ueberlebt, veraltet = [], []
for datei, name, alt, neu in MUTANTEN:
with tempfile.TemporaryDirectory() as tmp:
ziel = Path(tmp) / "wf"
shutil.copytree(ordner, ziel)
pfad = ziel / (datei + ".js")
text = pfad.read_text(encoding="utf-8")
if text.count(alt) != 1:
veraltet.append(name)
print(f"VERALTET {datei}: {name} (Muster {text.count(alt)}-mal)")
continue
pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n")
lauf = subprocess.run(["node", str(FAELLE), str(ziel)], capture_output=True, text=True, encoding="utf-8", errors="replace")
rot = [z for z in lauf.stdout.splitlines() if z.startswith("FEHLER")]
if lauf.returncode == 0:
grund = WIRKUNGSLOS.get(name)
if grund:
print(f"WIRKUNGSLOS {datei}: {name} ({grund})")
else:
ueberlebt.append(name)
print(f"UEBERLEBT {datei}: {name}")
else:
print(f"GEFANGEN {datei}: {name} ({len(rot)} rot, z. B. {rot[0][7:90] if rot else 'Absturz'})")
print(f"{len(MUTANTEN)} Mutanten, {len(ueberlebt)} ueberlebt, {len(veraltet)} veraltet")
return 2 if veraltet else 1 if ueberlebt else 0
if __name__ == "__main__":
sys.stdout.reconfigure(errors="replace")
sys.exit(main())
@@ -0,0 +1,231 @@
"""Wertet die Verbrauchsablesungen der Workflows aus: wie viel Prozent des 5-Stunden-Fensters und der Woche zwischen zwei Ablesungen verbraucht wurden und welche Agenten dazwischen liefen.
Koordinator-Werkzeug (entstanden 2026-09-24). Es misst und beurteilt nichts; die Stufen in
.claude/workflows/verbrauch.js stellt der Mensch nach dieser Messreihe ein.
Quelle der Daten ist der Session-Speicher von Claude Code. Je Workflow-Lauf liegen unter
<speicher>/<sitzung>/subagents/workflows/<lauf>/ die Datei journal.jsonl (Ereignisse "started"
mit agentId und label, "result" mit agentId und result), je Agent agent-<id>.jsonl (Zeilen mit
timestamp) und agent-<id>.meta.json (model). Ablesungen sind Agenten, deren label mit
"verbrauch:" beginnt; ihr result traegt fenster mit label, percentUsed und resetsAt.
Die Fenster werden wie in verbrauch.js erkannt: "5-hour ..." und "Weekly ... all models".
Was das Skript nicht sieht: den Verbrauch anderer Sitzungen und der Hauptsitzung im selben
Zeitraum (er steckt in der Differenz mit drin), Ablesungen ueber probe (sie starten keinen
Agenten) und Laeufe, deren Protokolle geloescht sind. Die Kennzahl je Opus-Agentenstunde ist eine
Naeherung, kein Nachweis.
Aufruf: python verbrauch_reihe.py [--speicher <pfad>] [--seit JJJJ-MM-TT]
Exit 0 mit Auswertung (auch bei null Ablesungen), 2 wenn der Speicher fehlt oder --seit kein
Datum ist. Die Ausgabe ist reines ASCII, damit sie auch auf einer cp1252-Konsole durchlaeuft.
"""
import argparse
import json
import math
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
STANDARD_SPEICHER = Path.home() / ".claude" / "projects" / "D--Claude"
RESET_TOLERANZ = timedelta(minutes=5)
FUENF = re.compile(r"^\s*5[- ]?hour", re.I)
WOCHE = re.compile(r"^\s*weekly\b.*\ball models\b", re.I)
FAMILIEN = ("opus", "sonnet", "haiku", "fable")
def zeit(text):
return datetime.fromisoformat(text.replace("Z", "+00:00"))
def lies_jsonl(pfad, befunde):
"""Liest eine JSONL-Datei zeilenweise; kaputte Zeilen sind ein Befund, kein Absturz."""
zeilen = []
try:
with pfad.open(encoding="utf-8") as datei:
for nr, zeile in enumerate(datei, 1):
if not zeile.strip():
continue
try:
zeilen.append(json.loads(zeile))
except ValueError:
befunde.append(f"{pfad.parent.name}/{pfad.name}: Zeile {nr} ist kein JSON, uebergangen")
except OSError as e:
befunde.append(f"{pfad.parent.name}/{pfad.name}: nicht lesbar ({type(e).__name__})")
return zeilen
def einordnen(fenster):
"""(prozent, resetsAt) fuer 5 Stunden und Woche; None, wo das Fenster fehlt oder kaputt ist."""
fuenf = woche = None
if not isinstance(fenster, list):
return None, None
for f in fenster:
if not isinstance(f, dict) or not isinstance(f.get("label"), str):
return None, None
art = "fuenf" if FUENF.match(f["label"]) else "woche" if WOCHE.match(f["label"]) else None
if art is None:
continue
p = f.get("percentUsed")
if isinstance(p, bool) or not isinstance(p, (int, float)) or not math.isfinite(p) or p < 0:
return None, None
eintrag = (float(p), f.get("resetsAt"))
if art == "fuenf" and (fuenf is None or p > fuenf[0]):
fuenf = eintrag
elif art == "woche" and (woche is None or p > woche[0]):
woche = eintrag
return fuenf, woche
def zeitspanne(ordner, agent_id, befunde):
stempel = []
for o in lies_jsonl(ordner / f"agent-{agent_id}.jsonl", befunde):
t = o.get("timestamp") if isinstance(o, dict) else None
if isinstance(t, str):
try:
stempel.append(zeit(t))
except ValueError:
pass
return (min(stempel), max(stempel)) if stempel else None
def familie(ordner, agent_id):
try:
modell = json.loads((ordner / f"agent-{agent_id}.meta.json").read_text(encoding="utf-8")).get("model")
except (OSError, ValueError, AttributeError):
return "?"
if not isinstance(modell, str):
return "?"
for f in FAMILIEN:
if f in modell.lower():
return f
return modell
def lauf_auswerten(ordner, befunde):
labels, ergebnisse = {}, {}
for o in lies_jsonl(ordner / "journal.jsonl", befunde):
if not isinstance(o, dict) or not isinstance(o.get("agentId"), str):
continue
if o.get("type") == "started":
labels[o["agentId"]] = o.get("label") if isinstance(o.get("label"), str) else ""
elif o.get("type") == "result":
ergebnisse[o["agentId"]] = o.get("result")
ablesungen, agenten = [], []
for agent_id, label in labels.items():
spanne = zeitspanne(ordner, agent_id, befunde)
if label.startswith("verbrauch:"):
if spanne is None:
befunde.append(f"{ordner.name}: Ablesung {label} ohne Zeitstempel, uebergangen")
continue
ergebnis = ergebnisse.get(agent_id)
fuenf, woche = einordnen(ergebnis.get("fenster") if isinstance(ergebnis, dict) else None)
# Ablesezeit ist das Ende des Ableseagenten: get_usage laeuft kurz vor seinem Schluss.
ablesungen.append({"anlass": label[len("verbrauch:"):], "zeit": spanne[1], "fuenf": fuenf, "woche": woche})
elif spanne is not None:
agenten.append({"familie": familie(ordner, agent_id), "start": spanne[0], "ende": spanne[1]})
ablesungen.sort(key=lambda a: a["zeit"])
return ablesungen, agenten
def zeitpunkt(text):
if not isinstance(text, str):
return None
try:
return zeit(text)
except ValueError:
return None
def differenz(a, b):
"""Prozentpunkte von a nach b im selben Fenster.
get_usage rechnet resetsAt bei jeder Ablesung neu aus; der Wert schwankt um Millisekunden und
springt dabei auch ueber eine Sekunden- oder Datumsgrenze (gemessen 2026-09-24: 18:29:59.664Z,
18:30:00.267Z, 18:30:00.459Z fuer dasselbe Fenster). Als Reset gilt deshalb erst eine
Verschiebung um mehr als RESET_TOLERANZ oder ein fallender Wert.
"""
if a is None or b is None:
return "unbekannt"
ra, rb = zeitpunkt(a[1]), zeitpunkt(b[1])
if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None):
return "unbekannt"
if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:
return "Reset"
return round(b[0] - a[0], 1)
def paare(ordner, ablesungen, agenten):
for a, b in zip(ablesungen, ablesungen[1:]):
minuten = {}
for g in agenten:
anteil = (min(g["ende"], b["zeit"]) - max(g["start"], a["zeit"])).total_seconds()
if anteil > 0:
minuten[g["familie"]] = minuten.get(g["familie"], 0.0) + anteil / 60
yield {
"lauf": ordner.name,
"von": a["anlass"],
"bis": b["anlass"],
"minuten": round((b["zeit"] - a["zeit"]).total_seconds() / 60, 1),
"agenten": {k: round(v, 1) for k, v in sorted(minuten.items())},
"d5": differenz(a["fuenf"], b["fuenf"]),
"dw": differenz(a["woche"], b["woche"]),
}
UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"})
def nur_ascii(text):
return str(text).translate(UMLAUTE).encode("ascii", "replace").decode("ascii")
def main(argv=None):
p = argparse.ArgumentParser(description="Messreihe der Verbrauchsablesungen aus den Workflow-Protokollen")
p.add_argument("--speicher", type=Path, default=STANDARD_SPEICHER)
p.add_argument("--seit", default=None, help="nur Ablesungen ab diesem Tag (JJJJ-MM-TT, UTC)")
a = p.parse_args(argv)
if not a.speicher.is_dir():
print(nur_ascii(f"Speicher fehlt: {a.speicher}"))
return 2
seit = None
if a.seit:
try:
seit = datetime.fromisoformat(a.seit).replace(tzinfo=timezone.utc)
except ValueError:
print(nur_ascii(f"--seit {a.seit!r} ist kein Datum JJJJ-MM-TT"))
return 2
befunde, zeilen, anzahl = [], [], 0
for ordner in sorted(a.speicher.glob("*/subagents/workflows/wf_*")):
if not ordner.is_dir():
continue
try:
ablesungen, agenten = lauf_auswerten(ordner, befunde)
if seit is not None:
ablesungen = [x for x in ablesungen if x["zeit"] >= seit]
anzahl += len(ablesungen)
zeilen.extend(paare(ordner, ablesungen, agenten))
except Exception as e: # Ein Absturz ist ein Befund (pruefwerkzeuge.md, Abschnitt 4).
befunde.append(f"{ordner.name}: Auswertung abgebrochen ({type(e).__name__}: {e})")
print(f"{anzahl} Ablesungen, {len(zeilen)} Paare")
if zeilen:
print("| Lauf | von -> bis | Minuten | Agentenminuten | d 5h % | d Woche % |")
print("|---|---|---|---|---|---|")
for z in zeilen:
agenten = ", ".join(f"{k} {v}" for k, v in z["agenten"].items()) or "-"
print(nur_ascii(f"| {z['lauf']} | {z['von']} -> {z['bis']} | {z['minuten']} | {agenten} | {z['d5']} | {z['dw']} |"))
werte = [z for z in zeilen if isinstance(z["d5"], float) and z["agenten"].get("opus", 0) > 0]
if werte:
prozent = sum(z["d5"] for z in werte)
stunden = sum(z["agenten"]["opus"] for z in werte) / 60
print(f"Naeherung: {prozent / stunden:.1f} % des 5-Stunden-Fensters je Opus-Agentenstunde "
f"({len(werte)} Paare, {stunden:.1f} Opus-Stunden; andere Sitzungen und Nicht-Opus-Agenten stecken in der Differenz)")
else:
print("Keine auswertbaren Paare mit Opus-Agenten.")
if befunde:
print("Hinweise:")
for b in befunde:
print(nur_ascii("- " + b))
return 0
if __name__ == "__main__":
sys.exit(main())