Files
claude-regelwerk/.vault-tools/workflow-pruefung/verbrauch_reihe.py
T
Pascal MichelandClaude Opus 5 ea278dc24f 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]>
2026-09-25 04:00:02 +02:00

232 lines
9.7 KiB
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())