Abgleich mit dem Vault-Stand 7f484f9

38 Datei(en) geändert.
Erzeugt von .vault-tools/auto-push/werkzeuge_spiegel.py.
This commit is contained in:
Pascal Michel committed 2026-11-02 03:00:03 +01:00
1 parent f5747605c7
commit 9e506ff588
38 files changed
+2325 -24

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
---
type: resource
title: "Entwurf: Shell-Wache"
status: active
summary: "Entscheidungsliste, Herkunft je Antwort, Angriffsliste und Abbruchbedingung für shell_wache.py, einen PreToolUse-Hook auf das Bash-Werkzeug, der bei vier bekannten Windows-Shell-Fallen einen Hinweis in den Kontext gibt und nie ablehnt."
tags: [vault-tools, pruefwerkzeuge]
created: 2026-10-30
updated: 2026-10-30
---
# Entwurf: Shell-Wache
Angelegt am 2026-10-30 als Entwurfsschritt nach [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]], Abschnitt 1. Anlass ist Befund S-14 des Workflow-Reviews: Bekannte Shell-Fallen kehren trotz Erinnerung wieder. Pascal hat einen Hook beschlossen; das Prüferurteil hat ihn auf maschinell eindeutige Muster und auf Warnen begrenzt. Die Regeln selbst stehen in [[AI/Regelwerk/windows-shell|windows-shell.md]].
Plan: [00-uebersicht.md](docs/plans/2026-10-30-shell-wache/00-uebersicht.md) · Codedoku: [shell_wache.md](docs/code/shell_wache.md)
## Entscheidungsliste
| # | Frage | Antwort | Herkunft |
|---|---|---|---|
| 1 | Welches Ereignis, welcher Matcher? | `PreToolUse`, Matcher `Bash` | **Projektvorgabe** — Pascal, Workflow-Review 2026-10-30, S-14 |
| 2 | Lehnt der Hook ab? | Nie. Kein `permissionDecision` in der Ausgabe: `allow` würde die Berechtigungsabfrage überspringen, `deny` und `ask` halten den Befehl an | **Projektvorgabe** — Prüferurteil S-14 („nur warnen“) |
| 3 | Welche Ausgabeform? | Exit 0. Mit Befund genau ein JSON-Objekt `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": "…"}}`, ohne Befund keine Ausgabe | **Quelle** — Schema im Programmabbild von Claude Code 2.1.309: `hookEventName:C("PreToolUse"), permissionDecision…optional(), …, additionalContext:o().optional()`, gelesen 2026-10-30 mit `grep -a` |
| 4 | Welche Exit-Codes? | Immer 0, auch bei kaputter Eingabe und internem Fehler | **Quelle** — Hilfetext im selben Abbild: „Exit code 2 - show stderr to model and block tool call“; „Other exit codes - show stderr to user only“. Exit 2 wäre eine Ablehnung, andere Codes erreichten das Modell nicht |
| 5 | Woher kommt der Befehl? | `tool_input.command` der Hook-Eingabe auf stdin | **Quelle** — Hilfetext: „Input to command is JSON of tool call arguments“; das Feld `command` ist das Argument des Bash-Werkzeugs. Erst die Probe-Sitzung belegt die Form am echten Aufruf |
| 6 | Welche Muster? | Genau vier: `python -I` ohne `-X utf8`, `pkill` mit `-f`, `python3`, `bash` ohne Pfad im Windows-Kontext | **Projektvorgabe** — Auftrag nach S-14; Fallgeschichten in den Erinnerungen `python-isoliert-ignoriert-pythonioencoding`, `pkill-f-trifft-die-eigene-shell`, `bash-startet-unter-windows-den-wsl-stub` |
| 7 | Werden Heredoc-Backslashes geprüft? | Nein | **Projektvorgabe** — Prüferurteil S-14: Die Verfälschung passiert in der Werkzeugschicht und ist im Befehlstext nicht als Fehler erkennbar |
| 8 | Was ist „Windows-Kontext“ für `bash`? | (a) der Rest der Zeile hinter `cmd /c`, `cmd //c`, `cmd.exe /c`, `powershell`, `pwsh` (mit oder ohne `.exe`, mit oder ohne Pfad); (b) im ganzen Befehl ein Python-Listenelement `["bash"` oder `['bash.exe'` | **Projektvorgabe** — gemessen 2026-10-30 im Bash-Werkzeug: `type -a bash` liefert zuerst `/usr/bin/bash`; ein `bash` auf oberster Ebene der Git-Bash ist also die Git-Bash und bleibt still. Windows selbst sucht zuerst in `System32` (windows-shell.md) |
| 9 | Wann gilt `bash` als „mit Pfad“? | Steht direkt davor `/` oder `\`, bleibt es still | **Projektvorgabe** |
| 10 | Was ist eine Linux-Weiche? | Ein Wort `wsl`, `ssh`, `docker`, `podman` oder `kubectl` (mit oder ohne `.exe`). Im Windows-Kontext (a) bleibt ein `bash` hinter einer Weiche auf derselben Zeile still. `python3` und Muster (b) bleiben im ganzen Befehl still, sobald irgendwo eine Weiche steht | **Projektvorgabe** — dort läuft der Befehl unter Linux, wo `python3` und `bash` richtig sind |
| 11 | Wann ist `python -I` ohne `-X utf8`? | Je Aufruf von `python` oder `python.exe` (mit oder ohne Pfad): Optionen bis zum ersten Nicht-Optionswort lesen; `-c` und `-m` beenden die Optionen, auch in einem Bündel wie `-Ic`; `-W` und `-X` nehmen ein Argument, angehängt oder als nächstes Wort. Treffer, wenn `I` in einem Bündel steht und kein `-X utf8` oder `-X utf8=1` davor | **Quelle** — Python-Kommandozeile (`python --help`): `-I` schließt `-E` ein, `-c` und `-m` beenden die Optionsliste. **Projektvorgabe:** `-X utf8=0` zählt nicht als utf8 |
| 12 | Gilt das `-I`-Muster auch für `python3`? | Nein, dafür gibt es Muster `python3` | **Projektvorgabe** — ein Hinweis je Fehler |
| 13 | Wann ist `pkill` mit `-f`? | Hinter dem Wort `pkill` (mit oder ohne Pfad) die Optionswörter bis zum ersten Nicht-Optionswort: Treffer bei `--full` oder einem Bündel aus Buchstaben mit kleinem `f` (`-f`, `-fx`). `-u`, `-U`, `-g`, `-G`, `-P`, `-s`, `-t`, `-F`, `--signal` nehmen das nächste Wort als Argument. Auch in WSL und über ssh | **Quelle** — `pkill(1)`, procps-ng: `-f, --full`. **Projektvorgabe:** keine Weiche, weil die Falle gerade unter Linux zuschlägt; `pgrep -f` bleibt still, es beendet nichts |
| 14 | Was meldet der Hook bei kaputter Eingabe? | Einen Hinweis `[eingabe]` mit dem Grund (leer, kein JSON, kein Objekt, `tool_input` oder `command` fehlt oder hat den falschen Typ) | **Projektvorgabe** — pruefwerkzeuge.md, Abschnitt 6: eine fehlende Voraussetzung ist ein Befund |
| 15 | Was bei leerem `command`? | Still | **Projektvorgabe** — nichts zu prüfen, Abschnitt 6 |
| 16 | Was bei `tool_name` ungleich `Bash`? | Hinweis `[eingabe]`, nichts geprüft. Fehlt `tool_name`, wird trotzdem geprüft | **Projektvorgabe** — ein erweiterter Matcher soll laut auffallen, nicht mit falscher Kontextregel prüfen |
| 17 | Was bei einem internen Fehler? | Gefangen, Hinweis `[intern]` mit Ausnahmetyp, die bis dahin gesammelten Befunde stehen trotzdem im Text | **Projektvorgabe** — pruefwerkzeuge.md, Abschnitt 4 |
| 18 | Wie sieht der Hinweis aus? | Erste Zeile: „shell-wache warnt nur, der Befehl läuft trotzdem.“ Danach je getroffenem Muster genau eine Zeile mit Schlüssel in eckigen Klammern, Abhilfe und Verweis auf `AI/Regelwerk/windows-shell.md`, bei `pkill` zusätzlich auf die Erinnerung | **Projektvorgabe** |
| 19 | Groß- und Kleinschreibung? | `cmd`, `powershell`, `pwsh`, `bash`, `python` ohne Rücksicht darauf; `pkill` und seine Optionen genau | **Projektvorgabe** — Windows-Namen sind beliebig geschrieben, Linux-Optionen nicht |
| 20 | Wie liest das Skript stdin? | Als Bytes, Dekodierung UTF-8 mit Ersatzzeichen; Aufruf `python -I -X utf8` | **Projektvorgabe** — windows-shell.md, „Welche Shell, welches Python“ |
## Angriffsliste
Je Fall ein Treffer und ein gesunder Gegenfall; jeder Treffer einmal am Anfang und einmal am Ende des Befehls.
| Klasse | Fälle |
|---|---|
| fehlt | stdin leer; `tool_input` fehlt; `command` fehlt |
| null | `null` als ganze Eingabe, als `tool_input`, als `command` |
| falscher Typ | Liste statt Objekt; `tool_input` als Zeichenkette; `command` als Zahl und als Liste |
| leer | `command` gleich `""` (still) |
| zusätzlich | unbekannte Felder in Eingabe und `tool_input` (still); `tool_name` gleich `PowerShell` (Hinweis `[eingabe]`) |
| doppelt | dasselbe Muster zweimal in einem Befehl: eine Zeile |
| mehrere | alle vier Muster in einem Befehl: vier Zeilen |
| Wertebereich | `-IB`, `-BI`, `-Ic`, `-IX utf8`, `-Xutf8`, `-X utf8=0`, `-I` hinter `-c` oder hinter dem Skriptnamen; `pkill -TERM -f`, `pkill -fx`, `pkill --full`, `pkill -u root -f`; `python3.12`, `python3-config`; `bash.exe`, `.bashrc`, `bash_profile`, `bash-completion` |
| Reihenfolge | jedes Muster am Anfang und am Ende, dazu in Zeile 3 eines mehrzeiligen Befehls |
| Umgebung | ungültiges UTF-8 auf stdin; kein JSON; Aufruf ohne `PYTHONIOENCODING` |
## Abbruchbedingung
Schluss ist, wenn eine Prüfrunde nur noch Umformulierungen oder neue Fehlalarm-Grenzfälle in Zeichenketten findet. Fehlalarme kosten hier eine Zeile Kontext, keinen abgelehnten Befehl; sie werden als Lücke benannt, nicht wegprogrammiert.
## Benannte Lücken
- Erwähnungen in Zeichenketten schlagen an, etwa `grep "python -I" datei` oder eine Commit-Nachricht. Der Hook kennt keine Shell-Grammatik.
- Das PowerShell-Werkzeug ist nicht abgedeckt; dort ist jeder Befehl Windows-Kontext. Eine Erweiterung bräuchte eine eigene Kontextregel.
- `py` (Python-Launcher), `subprocess` mit `shell=True` und Skriptdateien, die erst zur Laufzeit gelesen werden, sieht der Hook nicht.
- Ein Befehl, der ein lokales `python3` und zugleich `ssh` enthält, bleibt still (Weiche gilt für den ganzen Befehl).
- `cmd` zählt nur mit `/c` oder `//c` direkt hinter dem Namen; `cmd /d /c` oder `cmd /s /c` sind kein Windows-Kontext.
- Im Windows-Kontext zählt nur die erste Weiche einer Zeile: `powershell -c "wsl -- bash a; bash b"` bleibt still.
- Workflow-Subagenten laufen mit derselben `settings.json`; der Hook wirkt dort mit. Ob ihr Kontext den Hinweis zeigt, belegt erst ein Lauf.
- Dieser Hook ist ein Fangnetz, kein Nachweis. Er erkennt Muster im Befehlstext, nicht, was der Befehl tut.
## Referenzen
- [[AI/Regelwerk/pruefwerkzeuge|↑ Prüfwerkzeuge]]
- [[AI/Regelwerk/windows-shell|windows-shell.md]]
@@ -0,0 +1,50 @@
---
type: resource
title: "shell_wache.py"
status: active
summary: "Codedoku zum PreToolUse-Hook, der im Befehl des Bash-Werkzeugs vier Windows-Shell-Fallen erkennt und einen Hinweis in den Kontext gibt, ohne je abzulehnen."
tags: [vault-tools, codedoku]
created: 2026-10-30
updated: 2026-10-30
---
Werkzeug: [ENTWURF.md](../../ENTWURF.md)
# shell_wache.py
## Wofür
- Warnt vor `python -I` ohne `-X utf8`, `python3`, `bash` ohne Pfad aus cmd, PowerShell oder Python und `pkill -f`, bevor das Bash-Werkzeug den Befehl ausführt.
## Ablauf
1. Claude Code startet das Skript vor jedem Aufruf des Bash-Werkzeugs, eingetragen in `.claude/settings.json` unter `PreToolUse`, Matcher `Bash`, als `python -I -X utf8`, Zeitlimit 10 s.
2. `lies_eingabe()` liest stdin als Bytes und holt `tool_input.command`. Kaputte Eingabe ergibt den Grund für `[eingabe]`, ein leerer Befehl bleibt still.
3. Die vier Detektoren in `DETEKTOREN` laufen je in eigenem `try`. Ein Absturz darin wird `[intern]`, die übrigen laufen weiter.
4. `baue_text()` setzt Kopfzeile und je getroffenem Schlüssel eine Zeile aus `HINWEISE` zusammen.
5. `melde()` schreibt genau ein JSON-Objekt mit `hookSpecificOutput.additionalContext`. Ohne Befund schreibt das Skript nichts.
6. Das Skript endet immer mit Exit 0.
```bash
# Regressionstreiber: Grundlinie, dann alle Fälle, je Fall ein eigener Prozess
python -I -X utf8 .vault-tools/shell-wache/faelle_shell_wache.py
# Gegen eine mutierte Kopie: Pfad zum Hook als erstes Argument
python -I -X utf8 .vault-tools/shell-wache/faelle_shell_wache.py /pfad/zur/kopie/shell_wache.py
```
## Annahmen
- Claude Code liefert die Hook-Eingabe als JSON mit `tool_name` und `tool_input.command`. Belegt am 2026-10-30 in einer frischen Sitzung mit Claude Code 2.1.309: Im Transkript stehen `hook_success` und `hook_additional_context` für `PreToolUse:Bash`.
- `additionalContext` bei PreToolUse erreicht das Modell. Belegt in derselben Probe: Das Modell gab den Hinweis wörtlich wieder.
- Das Bash-Werkzeug ist die Git-Bash. Ein `bash` auf oberster Ebene ist dort `/usr/bin/bash` (`type -a bash`, 2026-10-30) und bleibt still.
- Eine geänderte `settings.json` wirkt in laufenden Sitzungen sofort. Am 2026-10-30 schlug der Hook in der Sitzung, die ihn eingetragen hat, beim nächsten Bash-Aufruf an.
## Was beim Anfassen weh tut
- **Nie Exit 2 und nie `permissionDecision`.** Exit 2 lehnt den Befehl ab, `allow` überspringt die Berechtigungsabfrage. Beides fängt der Treiber, die letzte Schranke in `__main__` hält Exit 0 auch bei Absturz.
- **Keine Importe aus dem eigenen Ordner.** `python -I` nimmt den Skriptordner aus `sys.path`.
- **Erwähnungen schlagen an.** Ein Befehl, der `pkill -f` nur als Text enthält, löst den Hinweis aus. Das ist eine benannte Lücke, kein Fehler. Wer sie schließen will, braucht eine Shell-Grammatik, keinen weiteren regulären Ausdruck.
- **Die Linux-Weiche gilt für den ganzen Befehl.** Wer sie auf Zeilen einschränkt, macht `ssh h python - <<'EOF'` mit `["bash"` im Heredoc laut.
- **Zwei Mutanten sind wirkungslos:** `-c` nicht als Ende der Python-Optionen zu werten und einen leeren Befehl an die Detektoren zu geben. In beiden Fällen ändert sich keine Ausgabe, deshalb fängt sie kein Fall.
- **Ein neues Muster braucht einen Treffer- und einen Gegenfall im Treiber, je am Anfang und am Ende des Befehls,** und einen Rotlauf mit gelöschtem Detektor.
- **Probe-Eingaben mit Backslash legst du mit dem Write-Werkzeug an.** Ein `printf` mit `\\` in einem Bash-Aufruf kam am 2026-10-30 als einzelnes `\` an, und der Hook meldete richtig „kein JSON“.
@@ -0,0 +1,24 @@
---
type: plan
title: "Shell-Wache und Haiku für Werkzeug-Hilfsagenten"
status: active
summary: "Zwei beschlossene Änderungen aus dem Workflow-Review 2026-10-30: ein warnender PreToolUse-Hook gegen vier Windows-Shell-Fallen (S-14) und Haiku 5.5 für Hilfsagenten der Workflows, deren Ausgabe nur das Skript liest (S-17)."
tags: [plan, vault-tools]
created: 2026-10-30
updated: 2026-10-30
---
Entwurf: [ENTWURF.md](../../../ENTWURF.md)
# Shell-Wache und Haiku für Werkzeug-Hilfsagenten
Anlass: Workflow-Review vom 2026-10-30, Befunde S-14 und S-17 mit Prüferurteil. Beide Teile berühren getrennte Dateien und laufen als eine Welle.
| Task | Thema | Zieldateien | Modell |
|---|---|---|---|
| [01](task-01-shell-wache.md) | Hook und Regressionstreiber | `.vault-tools/shell-wache/shell_wache.py`, `.vault-tools/shell-wache/faelle_shell_wache.py` | sonnet |
| [02](task-02-hilfsagenten-haiku.md) | Haiku für Hilfsagenten, Kommentare berichtigen, Fälle | `.claude/workflows/quorum.js`, `plankritik.js`, `welle-mit-pruefung.js`, `executer-welle.js`, `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, `.vault-tools/workflow-pruefung/mutanten_workflows.py` | sonnet |
Nach der Welle macht der Koordinator selbst: Gegenprüfung, Mutanten am Hook, Eintrag in `.claude/settings.json`, Probe-Sitzung, echte Workflow-Läufe, Codedoku.
Nicht in dieser Welle: die Zeile „Werkzeug-Hilfsagent“ in der Rollentabelle von `CLAUDE.md` und der Hinweis in `AI/Regelwerk/navigation.md`. Beide Dateien bearbeitet zur selben Zeit eine andere Sitzung.
@@ -0,0 +1,115 @@
---
type: plan
title: "Task 01: shell_wache.py und Regressionstreiber"
status: active
modell: sonnet
summary: "PreToolUse-Hook, der im Befehl des Bash-Werkzeugs vier Windows-Shell-Fallen erkennt und nur warnt, dazu ein Treiber mit Treffer- und Gegenfall je Muster."
tags: [plan, vault-tools]
created: 2026-10-30
updated: 2026-10-30
---
Übersicht: [00-uebersicht.md](00-uebersicht.md)
# Task 01: shell_wache.py und Regressionstreiber
## Ziel
Zwei neue Dateien, sonst nichts:
- `D:/Claude/.vault-tools/shell-wache/shell_wache.py`
- `D:/Claude/.vault-tools/shell-wache/faelle_shell_wache.py`
**Verboten:** `.claude/settings.json` anfassen, irgendeine andere Datei ändern, committen. Nur Standardbibliothek, keine Importe aus dem eigenen Ordner (der Hook läuft mit `python -I`, das den Skriptordner aus `sys.path` nimmt). Python immer als `python -I -X utf8`, nie `python3`. Kein Heredoc für Python-Code: Dateien mit dem Write-Werkzeug anlegen. Vor jedem Lauf `python -m py_compile <datei>`.
Die verbindliche Spezifikation ist die Entscheidungsliste in `D:/Claude/.vault-tools/shell-wache/ENTWURF.md` (Fragen 1 bis 20) samt Angriffsliste. Lies sie ganz. Was hier steht, ergänzt sie; bei Widerspruch gilt ENTWURF.md, und du meldest den Widerspruch.
## shell_wache.py
**Kopfkommentar** (Prüfskript, also volle Länge nach `AI/Regelwerk/codedoku.md`, „Ausnahme: Prüfskripte“), im Stil von `D:/Claude/.vault-tools/session-hooks/regelwerk_nach_edit.py`:
```
"""PreToolUse-Hook auf das Bash-Werkzeug: warnt vor vier bekannten Windows-Shell-Fallen, lehnt nie ab.
Ersteller: Pascal Michel
Erstellt: 2026-10-30
Entwurf: ENTWURF.md im selben Ordner · Doku: docs/code/shell_wache.md
GELTUNGSBEREICH
(vier Muster, Windows-Kontext, Linux-Weiche, Ausgabeform, immer Exit 0 – knapp aus ENTWURF.md)
BENANNTE LÜCKEN
(die Liste aus ENTWURF.md, Abschnitt „Benannte Lücken“)
DIESER HOOK IST EIN FANGNETZ, KEIN NACHWEIS. ...
"""
```
**Ablauf:**
1. stdin als Bytes lesen, `decode("utf-8", "replace")`.
2. Eingabe prüfen (ENTWURF 14 bis 16). Gründe für `[eingabe]`, wörtlich: `stdin leer`, `kein JSON`, `kein JSON-Objekt`, `tool_input fehlt oder ist kein Objekt`, `command fehlt oder ist keine Zeichenkette`, `tool_name ist <name>, nicht Bash`. Leerer `command` (`""` oder nur Leerraum): still.
3. Die vier Detektoren laufen je in eigenem `try`; ein Fehler darin wird `[intern]` mit `type(e).__name__`, die übrigen laufen weiter.
4. Ausgabe: ohne Befund nichts auf stdout. Mit Befund `json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}}, ensure_ascii=True)` auf stdout. Nie `permissionDecision`, nie `decision`, nie `continue`.
5. `main()` in `try/except BaseException` (außer `KeyboardInterrupt` ist egal); auch ein Fehler dort wird als `[intern]` ausgegeben. **Das Skript endet in jedem Fall mit Exit 0** – nach der Ausgabe `sys.exit(0)` am Ende von `if __name__ == "__main__":`.
**Text des Hinweises:** erste Zeile wörtlich `shell-wache warnt nur, der Befehl läuft trotzdem.` Danach je getroffenem Schlüssel genau eine Zeile in dieser Reihenfolge, auch wenn das Muster mehrfach trifft:
| Schlüssel | Zeile (wörtlich) |
|---|---|
| `[python-I]` | `[python-I] python -I ohne -X utf8: -I ignoriert PYTHONIOENCODING, Umlaute brechen unter cp1252. Nimm python -I -X utf8 (AI/Regelwerk/windows-shell.md, „Welches Python“).` |
| `[python3]` | `[python3] python3 ist unter Windows der Microsoft-Store-Stub. Nimm python (AI/Regelwerk/windows-shell.md, „Welches Python“).` |
| `[bash-windows]` | `[bash-windows] bash ohne Pfad aus cmd, PowerShell oder Python startet unter Windows den WSL-Starter. Git-Bash mit vollem Pfad C:\Program Files\Git\bin\bash.exe, WSL mit wsl -d <Distro> (AI/Regelwerk/windows-shell.md, „Welches Python“).` |
| `[pkill-f]` | `[pkill-f] pkill -f trifft jede Befehlszeile mit dem Muster, auch die eigene Shell und fremde Läufe in WSL. Erst pgrep -af lesen, dann gezielt kill <pid> oder ein Muster wie "[s]leep 7199" (Erinnerung pkill-f-trifft-die-eigene-shell; AI/Regelwerk/windows-shell.md).` |
| `[eingabe]` | `[eingabe] Hook-Eingabe unbrauchbar (<grund>), nichts geprüft.` |
| `[intern]` | `[intern] shell-wache ist abgestürzt (<Ausnahmetyp>), Prüfung unvollständig.` |
## Detektoren
Regeln genau nach ENTWURF.md, Fragen 8 bis 13 und 19. Ergänzend, damit es eindeutig ist:
- **Wortgrenze:** Ein Programmwort beginnt am Befehlsanfang oder nach Leerraum, `;`, `&`, `|`, `(`, `` ` ``, `'`, `"`, `=`; es darf einen Pfad davor tragen (`/c/x/python.exe`, `"C:/P/python.exe"`), außer bei `bash` (Frage 9). Es endet vor Leerraum, `'`, `"`, `;`, `)`, `|`, `&` oder dem Textende. `python3-config`, `.bashrc`, `bash_profile`, `bash-completion`, `ipython`, `mypython` sind keine Programmwörter.
- **`python3`:** `python3` und `python3.<n>`, mit oder ohne `.exe`.
- **Optionen lesen** (`python`, `pkill`): den Rest der Zeile nach dem Programmwort an Leerraum zerlegen, je Wort umschließende `'` und `"` abstreifen, Wörter lesen, solange sie mit `-` beginnen und nicht genau `-` oder `--` sind.
- **Windows-Kontext (a):** gilt vom Kontextwort bis zum Zeilenende. Darin trifft jedes `bash` oder `bash.exe` ohne Pfad, vor dem auf derselben Zeile hinter dem Kontextwort keine Weiche steht.
- **Muster (b):** regulärer Ausdruck auf `[` plus optionalem Leerraum plus `"bash"`, `'bash'`, `"bash.exe"` oder `'bash.exe'`, ohne Rücksicht auf Groß- und Kleinschreibung.
## faelle_shell_wache.py
Treiber im Stil von `D:/Claude/.vault-tools/session-hooks/faelle_nach_edit.py`: startet den Hook je Fall als eigenen Prozess (`[sys.executable, "-I", "-X", "utf8", HOOK]`, stdin als Bytes, Zeitlimit 10 s), ohne `PYTHONIOENCODING` in der Umgebung. Je Fall prüft er:
- Exit-Code ist 0 (nie 2),
- stdout ist leer, wenn der Fall still sein soll; sonst genau ein JSON-Objekt, `hookSpecificOutput.hookEventName == "PreToolUse"`, kein Schlüssel `permissionDecision` irgendwo, und die Menge der Schlüssel in eckigen Klammern am Zeilenanfang von `additionalContext` ist genau die erwartete,
- die Laufzeit liegt unter 2 s.
**Grundlinie zuerst:** Ein gesunder Befehl (`ls -la && python -I -X utf8 x.py`) muss still sein. Scheitert sie, bricht der Treiber ab, weil der Rest nichts misst.
**Fälle**, jeder als Eintrag `(name, stdin_bytes, erwartete_schluessel)`. Ein Hilfsaufbau `eingabe(cmd)` baut `{"tool_name": "Bash", "tool_input": {"command": cmd}}`. Pflicht, mindestens:
| Gruppe | Treffer | Gegenfall (still) |
|---|---|---|
| python-I | `python -I x.py`; `python -IB x.py`; `python -BI x.py`; `python -Ic "print(1)"`; `python -I -c "x" -X utf8`; `python -I -X utf8=0 x.py`; `/c/Py/python.exe -I x.py`; `"C:/Py/python.exe" -I x.py`; `cd a && python -I x.py`; `PYTHONIOENCODING=utf-8 python -I x.py` | `python -I -X utf8 x.py`; `python -IXutf8 x.py`; `python -IX utf8 x.py`; `python -X utf8 -I -B x.py`; `python -I -X utf8=1 x.py`; `python x.py -I`; `python -c "import x" -I`; `ipython -I`; `python -m pytest -I` |
| python3 | `python3 x.py`; `python3.12 -m venv v`; `ls; python3 -V`; `python3.exe x.py` | `python x.py`; `python3-config --libs`; `wsl -d Debian -- python3 x.py`; `ssh h python3 x.py`; `docker exec c python3 x.py` |
| bash-windows | `cmd //c "bash x.sh"`; `cmd.exe /c bash -c "x"`; `CMD /C BASH x`; `powershell -c "bash -lc 'x'"`; `powershell.exe -NoProfile -Command "& bash x"`; `pwsh -c "bash x"`; `python -X utf8 -c "import subprocess; subprocess.run(['bash', '-c', 'x'])"`; `python x.py <<'EOF'` + Zeilenumbruch + `subprocess.run(["bash.exe", "x"])` + Zeilenumbruch + `EOF` | `bash -c "x"`; `bash x.sh`; `cmd //c "C:/Program Files/Git/bin/bash.exe" x`; `cmd //c "C:\Program Files\Git\bin\bash.exe" x`; `powershell -c "wsl -d Debian -- bash -lc 'x'"`; `cmd /c "ssh.exe -o A=B root@h bash -s < f.sh"`; `powershell -c "Get-Content ~/.bashrc"`; `powershell -c "x"` + Zeilenumbruch + `bash y.sh`; `ssh h python - <<'EOF'` + Zeilenumbruch + `subprocess.run(['bash','x'])` + Zeilenumbruch + `EOF`; `subprocess.run([GITBASH, '-c'])` in `python -c` |
| pkill-f | `pkill -f x`; `pkill -TERM -f "[s]leep 1"`; `pkill -fx x`; `pkill --full x`; `pkill -u root -f x`; `sudo pkill -f x`; `wsl -d Debian -- bash -c 'a; pkill -f "y"'`; `/usr/bin/pkill -f x` | `pkill x`; `pkill -TERM x`; `pgrep -f x`; `pkill -u fx x` (fx ist Argument von -u) |
| mehrere | alle vier Muster in einem Befehl: vier Schlüssel; `python -I a.py; python -I b.py`: ein Schlüssel | |
| Reihenfolge | je Muster: Treffer am Anfang (`<treffer>; ls`), am Ende (`ls; <treffer>`), in Zeile 3 eines dreizeiligen Befehls | |
| Eingabe | leer; `nicht json`; `[]`; `null`; `{}`; `{"tool_name": "Bash"}`; `tool_input` `null`, als Zeichenkette `"x"`; `command` fehlt, `null`, `5`, `["ls"]`; ungültiges UTF-8 (`b'{"tool_name":"Bash","tool_input":{"command":"ls \xff"}}'`, still); `tool_name` `PowerShell` mit `python3 x` → nur `[eingabe]` | `command` gleich `""` und `" "`: still; zusätzliche Felder (`"tool_use_id"`, `"description"`, `"timeout"`): still |
Ausgabe des Treibers: je Fall eine Zeile `OK` oder `FEHLER` mit Name und Abweichung, am Ende die Zahl der Fälle und der Fehler, gerechnet, nicht geschrieben. Exit 0 nur, wenn alle Fälle passen; Exit 1 sonst; ein Absturz des Treibers selbst wird gefangen und als Fehler gezählt.
## Prüfen
```bash
# Syntax beider Dateien
python -I -X utf8 -m py_compile D:/Claude/.vault-tools/shell-wache/shell_wache.py D:/Claude/.vault-tools/shell-wache/faelle_shell_wache.py
# Alle Fälle
python -I -X utf8 D:/Claude/.vault-tools/shell-wache/faelle_shell_wache.py
# Kein CR in den neuen Dateien (je 0)
tr -cd '\r' < D:/Claude/.vault-tools/shell-wache/shell_wache.py | wc -c
tr -cd '\r' < D:/Claude/.vault-tools/shell-wache/faelle_shell_wache.py | wc -c
```
**Rotnachweis, Pflicht im Bericht:** Kopiere `shell_wache.py` in einen neuen Ordner unter dem Scratchpad oder `mktemp -d`, lösche dort je Detektor dessen Aufruf (vier Mutanten, einzeln, mit Rücksetzen) und fahre den Treiber gegen die Kopie (der Treiber nimmt den Hook-Pfad als optionales erstes Argument). Jeder Mutant muss mindestens einen `FEHLER` erzeugen. Nenne je Mutant die Zahl der Fehler.
Berichte: Zahl der Fälle, Ergebnis, die vier Mutanten mit Fehlerzahl, jede Stelle, an der du von diesem Task oder ENTWURF.md abgewichen bist, und jeden Fall, den du nicht eindeutig umsetzen konntest.
@@ -0,0 +1,115 @@
---
type: plan
title: "Task 02: Haiku für Werkzeug-Hilfsagenten, deren Ausgabe nur das Skript liest"
status: active
modell: sonnet
summary: "In quorum.js, plankritik.js und welle-mit-pruefung.js laufen Hilfsagenten, deren Ausgabe nur das Skript liest, auf haiku; Hilfsagenten, deren Rohausgabe an Opus geht, bleiben auf sonnet. Veraltete Haiku-Kommentare in quorum.js und executer-welle.js berichtigen, Fälle und Mutanten nachziehen."
tags: [plan, vault-tools]
created: 2026-10-30
updated: 2026-10-30
---
Übersicht: [00-uebersicht.md](00-uebersicht.md)
# Task 02: Haiku für Werkzeug-Hilfsagenten
## Ziel und Grenzen
Zieldateien, sonst keine:
- `D:/Claude/.claude/workflows/quorum.js`
- `D:/Claude/.claude/workflows/plankritik.js`
- `D:/Claude/.claude/workflows/welle-mit-pruefung.js`
- `D:/Claude/.claude/workflows/executer-welle.js`
- `D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs`
- `D:/Claude/.vault-tools/workflow-pruefung/mutanten_workflows.py`
**Verboten:** jede andere Datei, besonders `CLAUDE.md` und `AI/Regelwerk/*` (eine andere Sitzung bearbeitet sie gerade); committen; einen Workflow über das Workflow-Werkzeug starten; `sed` oder globales Ersetzen – nur das Edit-Werkzeug mit eindeutigen Stellen. Die `.js`-Dateien tragen LF und müssen es behalten. Kein Verhalten außer dem Modell ändern.
Regel, nach der entschieden ist (Prüferurteil S-17, Workflow-Review 2026-10-30): `model: 'haiku'` nur für Hilfsagenten, deren Ausgabe ausschließlich das Skript liest. Geht die Rohausgabe wörtlich an einen Opus-Agenten (Linse, pruefer), bleibt `sonnet`. `effort: 'low'` bleibt überall.
| Datei | Label | Modell neu | Grund |
|---|---|---|---|
| quorum.js | `werkzeug:paket` | haiku | `lesePaket()` liest nur `QUORUM_PAKET` und `EXIT` |
| quorum.js | `codex:start`, `codex:warten:<n>`, `codex:abbruch` | haiku | das Skript liest nur `EXIT`-Zeilen, `jobId`, `waitTimedOut` |
| quorum.js | `codex:ende` | haiku | Felder gehen an `codexUngueltig()` und als Befunde in den Bericht |
| quorum.js | `zusammenfuehren` | bleibt sonnet, medium | urteilt, überträgt nicht |
| plankritik.js | `werkzeug:kopie` | haiku | `kopieAusLauf()` liest nur `KOPIE=` und die Blockzählung |
| plankritik.js | `werkzeug:planwerkzeuge`, `werkzeug:mutanten` | bleibt sonnet | Rohausgabe geht wörtlich an die Linsen |
| welle-mit-pruefung.js | `werkzeug:kopie` | haiku | das Skript liest `KOPIE=` und die Blockzählung; Rückgabe an den Koordinator, nicht an den pruefer |
| welle-mit-pruefung.js | `werkzeug:vergleich` | bleibt sonnet | Rohausgabe geht als `schwerpunkt` an den pruefer |
## Änderungen im Code
1. **quorum.js:** In `werkzeugAgent` `model: 'sonnet'` → `model: 'haiku'` (alle Aufrufer stehen oben in der Tabelle auf haiku). Im `agent()`-Aufruf mit `label: 'codex:ende'` ebenso. `zusammenfuehren` und `nachpruefung` nicht anfassen.
2. **plankritik.js:** `werkzeugAgent(label, phase, befehl, zweck, zusatz)` bekommt einen sechsten Parameter `modell = 'sonnet'` und setzt `model: modell`. Nur der Aufruf `'werkzeug:kopie'` übergibt `'haiku'` (als `zusatz` dann `''`).
3. **welle-mit-pruefung.js:** `werkzeugAgent(label, phase, befehl, zweck)` bekommt einen fünften Parameter `modell = 'sonnet'` und setzt `model: modell`. Nur der Aufruf `'werkzeug:kopie'` übergibt `'haiku'`.
4. **executer-welle.js:** Verhalten bleibt. Nur Text:
- Zeile 12 `// Haiku kennt keinen Effort.` ersetzen durch `// Bei haiku verwirft effortFuer() einen Task-Effort; es gilt das medium aus dem Frontmatter, das Haiku 5.5 auch erhält (AI/Regelwerk/haiku-5-5.md, A1 und A7).`
- Logzeile in `effortFuer`: `` `${t.datei}: effort ${t.effort} bei haiku wirkungslos, nicht gesetzt` `` ersetzen durch `` `${t.datei}: effort ${t.effort} bei haiku nicht gesetzt, es gilt das medium aus dem Frontmatter des executer` ``.
## Änderungen an Kommentaren
- **quorum.js, Kopfkommentar „Paket:“** – `Hilfsagent (sonnet, low)` → `Hilfsagent (haiku, low)`.
- **quorum.js, Zeilen 35 und 36** – die zwei Zeilen ab `// - Die Codex-Hilfsagenten laufen auf sonnet mit low` ersetzen durch genau diese drei:
```
// - Die Hilfsagenten für Paket und Codex laufen auf haiku mit low: feste Befehle, das Skript liest nur
// Marken und Felder. Haiku 4.5 ersetzte typografische Zeichen still; Haiku 5.5 hielt sie in 6 von 6
// Läufen (AI/Regelwerk/haiku-5-5.md, A3). codex:ende überträgt Befundtexte, der Byte-Vergleich fehlt dort.
```
- **plankritik.js, Kopfkommentar** – hinter `// gleich treu wie high bei mechanischer Arbeit, codeworkflow.md "Effort je Rolle").` diese zwei Zeilen einfügen:
```
// Modell: werkzeug:kopie läuft auf haiku, weil nur kopieAusLauf() die Ausgabe liest. Planwerkzeuge und
// Mutantenlauf bleiben auf sonnet: ihre Rohausgabe geht wörtlich an die Linsen.
```
- **welle-mit-pruefung.js, Kopfkommentar** – hinter der Zeile `// und die laufen nacheinander; die Obergrenze von zwei hält executer-welle selbst.` und vor `// Verbrauch:` diese zwei Zeilen einfügen:
```
// Modell: werkzeug:kopie läuft auf haiku, weil nur das Skript die Ausgabe liest. werkzeug:vergleich
// bleibt auf sonnet: seine Rohausgabe geht an den pruefer.
```
Die Beschreibungen in `meta` (`description`, `phases`) nennen bei quorum.js „Codex über Hilfsagenten“; das bleibt.
## Fälle in faelle_workflows.mjs
Bestehende Erwartungen, die jetzt falsch sind, an die Tabelle anpassen (Grep nach `'sonnet'` bei den Labels `werkzeug:paket` und `codex:`, etwa Zeilen 1190 und 1201). Dazu neue Fälle, die das Modell **je Label** festnageln:
- quorum: Grundlinienlauf mit Codex – `werkzeug:paket`, jedes `codex:`-Label einschließlich `codex:ende` hat `modell === 'haiku'`, `effort === 'low'`, kein `agentType`; `zusammenfuehren` `sonnet`/`medium`.
- quorum: Lauf, in dem Codex abgebrochen wird (vorhandene Antwortvorgaben für Warten ohne Ende nutzen) – `codex:abbruch` ist `haiku`/`low`.
- plankritik: Lauf mit `materialisieren` und Mutanten an – `werkzeug:kopie` `haiku`/`low`, `werkzeug:planwerkzeuge` und `werkzeug:mutanten` `sonnet`/`low`.
- welle-mit-pruefung: Lauf ohne `kopie`, mit `ohne` und `suite` – `werkzeug:kopie` `haiku`/`low`, `werkzeug:vergleich` `sonnet`/`low`.
Ein neuer Fall braucht einen Rotlauf (`.vault-tools/workflow-pruefung/LIESMICH.md`, Ablauf 2): Lege mit `mktemp -d` einen Ordner an, schreibe die vier geänderten Workflows im Stand von `git -C D:/Claude show HEAD:.claude/workflows/<datei>` hinein und kopiere die übrigen `.js` aus `D:/Claude/.claude/workflows/` dazu. Fahre `node D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs <ordner>`: Die neuen und geänderten Fälle müssen dort scheitern, alle übrigen grün bleiben.
## Mutanten in mutanten_workflows.py
Je Datei einen Mutanten in `MUTANTEN` ergänzen, der genau ein Modell zurückdreht, im Format der vorhandenen Einträge (Ersetzung muss genau einmal vorkommen):
- quorum: `codex:ende` zurück auf `sonnet`
- plankritik: Aufruf `werkzeug:kopie` übergibt `'sonnet'` statt `'haiku'`
- plankritik: Vorgabe `modell = 'sonnet'` → `modell = 'haiku'` (Planwerkzeuge und Mutantenlauf kippen)
- welle-mit-pruefung: Aufruf `werkzeug:kopie` übergibt `'sonnet'`
- welle-mit-pruefung: Vorgabe `modell = 'sonnet'` → `modell = 'haiku'`
- quorum: `model: 'haiku'` in `werkzeugAgent` → `'sonnet'`
Den vollen Mutantenlauf fährst du **nicht** (knapp 50 Minuten); das macht der Koordinator. Prüfe nur, dass jede neue Ersetzung genau einmal vorkommt: `grep -c` je Muster in der Zieldatei, Ausgabe in den Bericht.
## Prüfen
```bash
# Alle Fälle gegen die geänderten Workflows, Exit 0 erwartet
node D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs
# Kein CR in den .js-Dateien (je 0)
for f in quorum plankritik welle-mit-pruefung executer-welle; do printf '%s ' $f; tr -cd '\r' < D:/Claude/.claude/workflows/$f.js | wc -c; done
# Modellstellen nachlesen
grep -n "model: \|modell = \|'haiku')" D:/Claude/.claude/workflows/quorum.js D:/Claude/.claude/workflows/plankritik.js D:/Claude/.claude/workflows/welle-mit-pruefung.js
# Mutanten-Datei kompiliert
python -I -X utf8 -m py_compile D:/Claude/.vault-tools/workflow-pruefung/mutanten_workflows.py
```
Berichte: Zahl der Fälle vor und nach der Änderung, Ergebnis, Rotlauf mit Zahl und Namen der dort gescheiterten Fälle, die `grep -c`-Werte der neuen Mutanten, `git -C D:/Claude diff --stat -- .claude/workflows .vault-tools/workflow-pruefung`, und jede Abweichung von diesem Task.
@@ -0,0 +1,303 @@
"""Fährt die Angriffsliste gegen shell_wache.py, je Fall als eigener Prozess.
Ersteller: Pascal Michel
Erstellt: 2026-10-30
Entwurf: ENTWURF.md im selben Ordner
Je Fall: Exit 0, stdout leer oder genau ein JSON-Objekt mit
hookSpecificOutput.hookEventName == "PreToolUse", ohne permissionDecision, die
Schlüssel in eckigen Klammern am Zeilenanfang von additionalContext sind genau
die erwarteten, Laufzeit unter 2 s. Der Hook läuft mit python -I -X utf8 und ohne
PYTHONIOENCODING in der Umgebung, wie Claude Code ihn startet.
Grundlinie zuerst: Ein gesunder Befehl muss still sein. Scheitert sie, bricht der
Treiber ab, weil der Rest nichts misst.
Aufruf:
python -I -X utf8 .vault-tools/shell-wache/faelle_shell_wache.py [Hook-Pfad]
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
HIER = Path(__file__).resolve().parent
MAX_DAUER = 2.0
ZEITLIMIT = 10
KOPFZEILE = "shell-wache warnt nur, der Befehl läuft trotzdem."
# (Name, stdin als Bytes, erwartete Schlüssel[, Text, der im Hinweis stehen muss])
Fall = tuple
def eingabe(cmd: str) -> bytes:
return json.dumps({"tool_name": "Bash", "tool_input": {"command": cmd}}).encode("utf-8")
def roh(daten: object) -> bytes:
return json.dumps(daten).encode("utf-8")
def treffer(gruppe: str, key: str, befehle: list[str]) -> list[Fall]:
return [(f"{gruppe} | Treffer | {b!r}", eingabe(b), {key}) for b in befehle]
def still(gruppe: str, befehle: list[str]) -> list[Fall]:
return [(f"{gruppe} | still | {b!r}", eingabe(b), set()) for b in befehle]
GRUNDLINIE: list[Fall] = [
("GRUNDLINIE | gesunder Befehl bleibt still", eingabe("ls -la && python -I -X utf8 x.py"), set()),
("GRUNDLINIE | python3 wird gemeldet", eingabe("python3 x.py"), {"python3"}),
]
PY_I_TREFFER = [
"python -I x.py",
"python -IB x.py",
"python -BI x.py",
'python -Ic "print(1)"',
'python -I -c "x" -X utf8',
"python -I -X utf8=0 x.py",
"/c/Py/python.exe -I x.py",
'"C:/Py/python.exe" -I x.py',
"cd a && python -I x.py",
"PYTHONIOENCODING=utf-8 python -I x.py",
]
PY_I_STILL = [
"python -I -X utf8 x.py",
"python -IXutf8 x.py",
"python -IX utf8 x.py",
"python -X utf8 -I -B x.py",
"python -I -X utf8=1 x.py",
"python x.py -I",
'python -c "import x" -I',
"ipython -I",
"python -m pytest -I",
]
PY3_TREFFER = ["python3 x.py", "python3.12 -m venv v", "ls; python3 -V", "python3.exe x.py"]
PY3_STILL = [
"python x.py",
"python3-config --libs",
"wsl -d Debian -- python3 x.py",
"ssh h python3 x.py",
"docker exec c python3 x.py",
]
BASH_TREFFER = [
'cmd //c "bash x.sh"',
'cmd.exe /c bash -c "x"',
"CMD /C BASH x",
"powershell -c \"bash -lc 'x'\"",
'powershell.exe -NoProfile -Command "& bash x"',
'pwsh -c "bash x"',
"python -X utf8 -c \"import subprocess; subprocess.run(['bash', '-c', 'x'])\"",
"python x.py <<'EOF'\nsubprocess.run([\"bash.exe\", \"x\"])\nEOF",
]
BASH_STILL = [
'bash -c "x"',
"bash x.sh",
'cmd //c "C:/Program Files/Git/bin/bash.exe" x',
'cmd //c "C:\\Program Files\\Git\\bin\\bash.exe" x',
"powershell -c \"wsl -d Debian -- bash -lc 'x'\"",
'cmd /c "ssh.exe -o A=B root@h bash -s < f.sh"',
'powershell -c "Get-Content ~/.bashrc"',
'powershell -c "x"\nbash y.sh',
"ssh h python - <<'EOF'\nsubprocess.run(['bash','x'])\nEOF",
"python -c \"subprocess.run([GITBASH, '-c'])\"",
]
PKILL_TREFFER = [
"pkill -f x",
'pkill -TERM -f "[s]leep 1"',
"pkill -fx x",
"pkill --full x",
"pkill -u root -f x",
"sudo pkill -f x",
"wsl -d Debian -- bash -c 'a; pkill -f \"y\"'",
"/usr/bin/pkill -f x",
]
PKILL_STILL = ["pkill x", "pkill -TERM x", "pgrep -f x", "pkill -u fx x"]
FAELLE: list[Fall] = []
FAELLE += treffer("python-I", "python-I", PY_I_TREFFER) + still("python-I", PY_I_STILL)
FAELLE += treffer("python3", "python3", PY3_TREFFER) + still("python3", PY3_STILL)
FAELLE += treffer("bash-windows", "bash-windows", BASH_TREFFER) + still("bash-windows", BASH_STILL)
FAELLE += treffer("pkill-f", "pkill-f", PKILL_TREFFER) + still("pkill-f", PKILL_STILL)
# Mehrere Muster in einem Befehl
FAELLE += [
(
"mehrere | alle vier Muster",
eingabe("python -I x.py; python3 y.py; cmd /c bash z; pkill -f w"),
{"python-I", "python3", "bash-windows", "pkill-f"},
),
("mehrere | dasselbe Muster zweimal", eingabe("python -I a.py; python -I b.py"), {"python-I"}),
]
# Reihenfolge: je Muster am Anfang, am Ende, in Zeile 3
for _key, _treffer in (
("python-I", "python -I x.py"),
("python3", "python3 x.py"),
("bash-windows", "cmd /c bash x"),
("pkill-f", "pkill -f x"),
):
FAELLE += [
(f"reihenfolge | {_key} am Anfang", eingabe(f"{_treffer}; ls"), {_key}),
(f"reihenfolge | {_key} am Ende", eingabe(f"ls; {_treffer}"), {_key}),
(f"reihenfolge | {_key} in Zeile 3", eingabe(f"ls\npwd\n{_treffer}"), {_key}),
]
# Eingabe
FAELLE += [
("eingabe | stdin leer", b"", {"eingabe"}, "stdin leer"),
("eingabe | kein JSON", b"nicht json", {"eingabe"}, "kein JSON)"),
("eingabe | Liste", b"[]", {"eingabe"}, "kein JSON-Objekt"),
("eingabe | null", b"null", {"eingabe"}, "kein JSON-Objekt"),
("eingabe | leeres Objekt", b"{}", {"eingabe"}, "tool_input fehlt oder ist kein Objekt"),
("eingabe | tool_input fehlt", roh({"tool_name": "Bash"}), {"eingabe"},
"tool_input fehlt oder ist kein Objekt"),
("eingabe | tool_input null", roh({"tool_name": "Bash", "tool_input": None}), {"eingabe"},
"tool_input fehlt oder ist kein Objekt"),
("eingabe | tool_input Zeichenkette", roh({"tool_name": "Bash", "tool_input": "x"}), {"eingabe"},
"tool_input fehlt oder ist kein Objekt"),
("eingabe | command fehlt", roh({"tool_name": "Bash", "tool_input": {}}), {"eingabe"},
"command fehlt oder ist keine Zeichenkette"),
("eingabe | command null", roh({"tool_name": "Bash", "tool_input": {"command": None}}), {"eingabe"},
"command fehlt oder ist keine Zeichenkette"),
("eingabe | command Zahl", roh({"tool_name": "Bash", "tool_input": {"command": 5}}), {"eingabe"},
"command fehlt oder ist keine Zeichenkette"),
("eingabe | command Liste", roh({"tool_name": "Bash", "tool_input": {"command": ["ls"]}}), {"eingabe"},
"command fehlt oder ist keine Zeichenkette"),
("eingabe | ungueltiges UTF-8 im Befehl",
b'{"tool_name":"Bash","tool_input":{"command":"ls \xff"}}', set()),
("eingabe | tool_name PowerShell mit python3",
roh({"tool_name": "PowerShell", "tool_input": {"command": "python3 x"}}), {"eingabe"},
"tool_name ist PowerShell, nicht Bash"),
("eingabe | command leer", eingabe(""), set()),
("eingabe | command nur Leerraum", eingabe(" "), set()),
("eingabe | zusaetzliche Felder",
roh({"tool_name": "Bash", "tool_use_id": "t1", "session_id": "s",
"tool_input": {"command": "ls", "description": "d", "timeout": 5}}), set()),
]
def schluessel_von(text: str) -> set[str]:
return set(re.findall(r"^\[([^\]]+)\]", text, flags=re.M))
def hat_schluessel(objekt: object, name: str) -> bool:
if isinstance(objekt, dict):
return name in objekt or any(hat_schluessel(v, name) for v in objekt.values())
if isinstance(objekt, list):
return any(hat_schluessel(v, name) for v in objekt)
return False
def fahre(hook: Path, stdin: bytes) -> tuple[int, str, str, float]:
umgebung = {k: v for k, v in os.environ.items() if k != "PYTHONIOENCODING"}
start = time.perf_counter()
lauf = subprocess.run(
[sys.executable, "-I", "-X", "utf8", str(hook)],
input=stdin,
capture_output=True,
timeout=ZEITLIMIT,
env=umgebung,
)
dauer = time.perf_counter() - start
try:
out = lauf.stdout.decode("utf-8")
except UnicodeDecodeError as exc:
out = "UNGUELTIGES-UTF-8: %s" % exc
return lauf.returncode, out, lauf.stderr.decode("utf-8", errors="replace"), dauer
def pruefe(hook: Path, fall: Fall) -> list[str]:
_, stdin, erwartet = fall[:3]
muss_enthalten = fall[3] if len(fall) > 3 else None
try:
code, out, err, dauer = fahre(hook, stdin)
except subprocess.TimeoutExpired:
return [f"Zeitlimit {ZEITLIMIT} s ueberschritten"]
probleme: list[str] = []
if code != 0:
probleme.append(f"Exit {code} statt 0")
if dauer >= MAX_DAUER:
probleme.append(f"Laufzeit {dauer:.2f} s, Grenze {MAX_DAUER:.0f} s")
if err.strip():
probleme.append(f"stderr nicht leer: {err.strip()[:200]!r}")
if not erwartet:
if out != "":
probleme.append(f"stdout nicht leer: {out[:300]!r}")
return probleme
try:
daten = json.loads(out)
except ValueError:
return probleme + [f"stdout ist kein JSON: {out[:300]!r}"]
if not isinstance(daten, dict) or set(daten) != {"hookSpecificOutput"}:
return probleme + [f"Objekt hat nicht genau den Schluessel hookSpecificOutput: {out[:300]!r}"]
if hat_schluessel(daten, "permissionDecision"):
probleme.append("permissionDecision kommt vor")
innen = daten["hookSpecificOutput"]
if not isinstance(innen, dict) or innen.get("hookEventName") != "PreToolUse":
return probleme + ["hookEventName ist nicht PreToolUse"]
text = innen.get("additionalContext")
if not isinstance(text, str) or not text:
return probleme + ["additionalContext fehlt oder ist leer"]
if not text.startswith(KOPFZEILE + "\n"):
probleme.append(f"Kopfzeile fehlt: {text[:80]!r}")
gefunden = schluessel_von(text)
if gefunden != erwartet:
probleme.append(f"Schluessel {sorted(gefunden)} statt {sorted(erwartet)}")
if muss_enthalten and muss_enthalten not in text:
probleme.append(f"Hinweis ohne {muss_enthalten!r}: {text!r}")
return probleme
def main() -> int:
argumente = [a for a in sys.argv[1:] if not a.startswith("--")]
hook = Path(argumente[0]).resolve() if argumente else HIER / "shell_wache.py"
if not hook.is_file():
print(f"ABBRUCH: {hook} fehlt")
return 1
print(f"Hook: {hook}")
for fall in GRUNDLINIE:
try:
probleme = pruefe(hook, fall)
except Exception as exc:
probleme = [f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"]
print(f"{'OK' if not probleme else 'FEHLER':7} {fall[0]}" + "".join(f"\n {p}" for p in probleme))
if probleme:
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 1
ergebnisse: list[bool] = []
for fall in FAELLE:
try:
probleme = pruefe(hook, fall)
except Exception as exc:
probleme = [f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"]
ergebnisse.append(not probleme)
print(f"{'OK' if not probleme else 'FEHLER':7} {fall[0]}" + "".join(f"\n {p}" for p in probleme))
fehler = ergebnisse.count(False)
print()
print(f"{len(ergebnisse)} Faelle, {fehler} Fehler (zusaetzlich {len(GRUNDLINIE)} Grundlinie)")
return 0 if fehler == 0 else 1
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
try:
code = main()
except Exception as exc:
print(f"FEHLER Treiber abgestuerzt: {type(exc).__name__}: {exc}")
code = 1
raise SystemExit(code)
+293
View File
@@ -0,0 +1,293 @@
"""PreToolUse-Hook auf das Bash-Werkzeug: warnt vor vier bekannten Windows-Shell-Fallen, lehnt nie ab.
Ersteller: Pascal Michel
Erstellt: 2026-10-30
Entwurf: ENTWURF.md im selben Ordner · Doku: docs/code/shell_wache.md
GELTUNGSBEREICH
Gelesen wird tool_input.command der Hook-Eingabe auf stdin (Bytes, UTF-8 mit
Ersatzzeichen). Geprüft werden genau vier Muster, ein Hinweis je getroffenem
Muster, auch wenn es mehrfach trifft:
[python-I] python/python.exe mit -I, aber ohne -X utf8 oder -X utf8=1.
Optionen werden bis zum ersten Nicht-Optionswort gelesen;
-c und -m beenden sie, auch im Bündel (-Ic); -W und -X nehmen
ein Argument, angehängt oder als nächstes Wort. -X utf8=0
zählt nicht als utf8.
[python3] python3, python3.<n>, mit oder ohne .exe.
[bash-windows] bash/bash.exe ohne Pfad im Windows-Kontext: (a) hinter cmd /c,
cmd //c, cmd.exe /c, powershell, pwsh bis zum Zeilenende,
(b) als Python-Listenelement ["bash" oder ['bash.exe' im
ganzen Befehl. Steht direkt davor / oder \\, bleibt es still.
[pkill-f] pkill mit --full oder einem Bündel mit kleinem f, gelesen wie
bei python; -u -U -g -G -P -s -t -F und --signal nehmen das
nächste Wort als Argument. Auch in WSL und über ssh.
Linux-Weiche: ein Wort wsl, ssh, docker, podman oder kubectl (mit oder ohne
.exe). python3 und Muster (b) bleiben im ganzen Befehl still, sobald irgendwo
eine Weiche steht; Muster (a) nur für ein bash hinter einer Weiche auf
derselben Zeile. python -I und pkill kennen keine Weiche.
Groß- und Kleinschreibung: cmd, powershell, pwsh, bash, python, Weichenwörter
ohne Rücksicht darauf; pkill und alle Optionen genau.
Ein Programmwort beginnt am Befehlsanfang oder nach Leerraum oder einem der
Zeichen ; & | ( ` ' " = und darf einen Pfad vor sich tragen (außer bash).
Ausgabe: ohne Befund nichts. Mit Befund genau ein JSON-Objekt
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": ...}}.
Nie permissionDecision, nie decision, nie continue. Exit ist immer 0.
Kaputte Eingabe (leer, kein JSON-Objekt, tool_input oder command fehlt oder
falscher Typ, tool_name ungleich Bash) ist ein Befund [eingabe], ein interner
Fehler ein Befund [intern]; leerer command bleibt still.
BENANNTE LÜCKEN
* Erwähnungen in Zeichenketten schlagen an, etwa grep "python -I" datei oder eine
Commit-Nachricht. Der Hook kennt keine Shell-Grammatik.
* Das PowerShell-Werkzeug ist nicht abgedeckt; dort ist jeder Befehl
Windows-Kontext. Eine Erweiterung bräuchte eine eigene Kontextregel.
* py (Python-Launcher), subprocess mit shell=True und Skriptdateien, die erst zur
Laufzeit gelesen werden, sieht der Hook nicht.
* Ein Befehl, der ein lokales python3 und zugleich ssh enthält, bleibt still
(die Weiche gilt für den ganzen Befehl).
* Workflow-Subagenten laufen mit derselben settings.json; der Hook wirkt dort
mit. Ob ihr Kontext den Hinweis zeigt, belegt erst ein Lauf.
* Backslashes in Heredocs werden nicht geprüft (ENTWURF.md, Frage 7).
DIESER HOOK IST EIN FANGNETZ, KEIN NACHWEIS. Er erkennt Muster im Befehlstext,
nicht, was der Befehl tut. Ein Fehlalarm kostet eine Zeile Kontext, keinen
abgelehnten Befehl.
Exit ist immer 0. Exit 2 wäre bei PreToolUse eine Ablehnung (Hilfetext von Claude
Code 2.1.309), andere Codes erreichten das Modell nicht (ENTWURF.md, Frage 4).
"""
from __future__ import annotations
import json
import re
import sys
KOPFZEILE = "shell-wache warnt nur, der Befehl läuft trotzdem."
HINWEISE = {
"python-I": (
"[python-I] python -I ohne -X utf8: -I ignoriert PYTHONIOENCODING, Umlaute brechen "
"unter cp1252. Nimm python -I -X utf8 (AI/Regelwerk/windows-shell.md, „Welche Shell, welches Python“)."
),
"python3": (
"[python3] python3 ist unter Windows der Microsoft-Store-Stub. Nimm python "
"(AI/Regelwerk/windows-shell.md, „Welche Shell, welches Python“)."
),
"bash-windows": (
"[bash-windows] bash ohne Pfad aus cmd, PowerShell oder Python startet unter Windows "
"den WSL-Starter. Git-Bash mit vollem Pfad C:\\Program Files\\Git\\bin\\bash.exe, "
"WSL mit wsl -d <Distro> (AI/Regelwerk/windows-shell.md, „Welche Shell, welches Python“)."
),
"pkill-f": (
"[pkill-f] pkill -f trifft jede Befehlszeile mit dem Muster, auch die eigene Shell "
"und fremde Läufe in WSL. Erst pgrep -af lesen, dann gezielt kill <pid> oder ein "
"Muster wie \"[s]leep 7199\" (Erinnerung pkill-f-trifft-die-eigene-shell; "
"AI/Regelwerk/windows-shell.md)."
),
}
# Ein Programmwort beginnt am Zeilen- oder Befehlsanfang oder nach einem dieser Zeichen.
_START = r"(?:^|(?<=[\s;&|(`'\"=]))"
_PFAD = r"(?:[^\s;&|()`'\"=]*[/\\])?"
_ENDE = r"(?=$|[\s'\";)|&])"
def _programm(name: str, pfad: bool = True) -> str:
return _START + (_PFAD if pfad else "") + "(?:" + name + ")" + _ENDE
_RE_PYTHON = re.compile(_programm(r"python(?:\.exe)?"), re.I | re.M)
_RE_PYTHON3 = re.compile(_programm(r"python3(?:\.\d+)?(?:\.exe)?"), re.I | re.M)
_RE_PKILL = re.compile(_programm("pkill"), re.M)
_RE_WEICHE = re.compile(_programm(r"(?:wsl|ssh|docker|podman|kubectl)(?:\.exe)?"), re.I | re.M)
_RE_BASH = re.compile(_programm(r"bash(?:\.exe)?", pfad=False), re.I | re.M)
_RE_KONTEXT = re.compile(
_programm(r"cmd(?:\.exe)?[ \t]+//?c|(?:powershell|pwsh)(?:\.exe)?"), re.I | re.M
)
_RE_LISTE_BASH = re.compile(r"\[\s*[\"']bash(?:\.exe)?[\"']", re.I)
# Buchstaben, deren Option das nächste Wort als Argument nimmt.
_PKILL_MIT_ARGUMENT = "uUgGPstF"
def lies_stdin() -> bytes:
if sys.stdin is None or sys.stdin.isatty():
return b""
return sys.stdin.buffer.read()
def lies_eingabe(roh: bytes) -> tuple[str | None, str]:
"""(Grund, Befehl): Grund ist None, wenn der Befehl geprüft werden kann."""
text = roh.decode("utf-8", "replace").lstrip("\ufeff")
if not text.strip():
return "stdin leer", ""
try:
daten = json.loads(text)
except ValueError:
return "kein JSON", ""
if not isinstance(daten, dict):
return "kein JSON-Objekt", ""
name = daten.get("tool_name")
if name is not None and name != "Bash":
return f"tool_name ist {name}, nicht Bash", ""
eingabe = daten.get("tool_input")
if not isinstance(eingabe, dict):
return "tool_input fehlt oder ist kein Objekt", ""
befehl = eingabe.get("command")
if not isinstance(befehl, str):
return "command fehlt oder ist keine Zeichenkette", ""
return None, befehl
def _rest_der_zeile(befehl: str, ab: int) -> list[str]:
# Rest der Zeile hinter dem Programmwort, bis zum ersten ; & |, als Wörter ohne Anführungszeichen.
rest = befehl[ab:].split("\n", 1)[0]
rest = re.split(r"[;&|]", rest, maxsplit=1)[0].lstrip(" \t'\"")
return [w.strip("'\")") for w in rest.split()]
def python_i_ohne_utf8(woerter: list[str]) -> bool:
ist_i = False
utf8 = False
i = 0
while i < len(woerter):
w = woerter[i]
i += 1
if not w.startswith("-") or w in ("-", "--"):
break
if w.startswith("--"):
continue
buendel = w[1:]
ende = False
for pos, zeichen in enumerate(buendel):
if zeichen == "I":
ist_i = True
elif zeichen in "cm":
ende = True
break
elif zeichen in "WX":
arg = buendel[pos + 1:]
if not arg and i < len(woerter):
arg = woerter[i]
i += 1
if zeichen == "X":
utf8 = arg in ("utf8", "utf8=1")
break
if ende:
break
return ist_i and not utf8
def pkill_mit_f(woerter: list[str]) -> bool:
i = 0
while i < len(woerter):
w = woerter[i]
i += 1
if not w.startswith("-") or w in ("-", "--"):
break
if w.startswith("--"):
if w == "--full":
return True
if w == "--signal":
i += 1
continue
buendel = w[1:]
for pos, zeichen in enumerate(buendel):
if zeichen == "f":
return True
if zeichen in _PKILL_MIT_ARGUMENT:
if pos == len(buendel) - 1:
i += 1
break
return False
def erkenne_python_i(befehl: str) -> bool:
return any(python_i_ohne_utf8(_rest_der_zeile(befehl, m.end())) for m in _RE_PYTHON.finditer(befehl))
def erkenne_python3(befehl: str) -> bool:
if _RE_WEICHE.search(befehl):
return False
return _RE_PYTHON3.search(befehl) is not None
def _bash_im_kontext(befehl: str) -> bool:
for zeile in befehl.split("\n"):
kontext = _RE_KONTEXT.search(zeile)
if not kontext:
continue
rest = zeile[kontext.end():]
weiche = _RE_WEICHE.search(rest)
for bash in _RE_BASH.finditer(rest):
if weiche is None or bash.start() < weiche.start():
return True
return False
def erkenne_bash_windows(befehl: str) -> bool:
if _bash_im_kontext(befehl):
return True
if _RE_WEICHE.search(befehl):
return False
return _RE_LISTE_BASH.search(befehl) is not None
def erkenne_pkill_f(befehl: str) -> bool:
return any(pkill_mit_f(_rest_der_zeile(befehl, m.end())) for m in _RE_PKILL.finditer(befehl))
# Reihenfolge ist die Reihenfolge der Zeilen im Hinweis.
DETEKTOREN = (
("python-I", erkenne_python_i),
("python3", erkenne_python3),
("bash-windows", erkenne_bash_windows),
("pkill-f", erkenne_pkill_f),
)
def baue_text(schluessel: list[str], grund: str | None, intern: list[str]) -> str:
zeilen = [KOPFZEILE]
zeilen += [HINWEISE[k] for k in schluessel]
if grund is not None:
zeilen.append(f"[eingabe] Hook-Eingabe unbrauchbar ({grund}), nichts geprüft.")
if intern:
zeilen.append(
f"[intern] shell-wache ist abgestürzt ({', '.join(intern)}), Prüfung unvollständig."
)
return "\n".join(zeilen)
def melde(text: str) -> None:
ausgabe = {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": text}}
sys.stdout.write(json.dumps(ausgabe, ensure_ascii=True))
sys.stdout.flush()
def main() -> None:
schluessel: list[str] = []
intern: list[str] = []
grund: str | None = None
try:
grund, befehl = lies_eingabe(lies_stdin())
if grund is None and befehl.strip():
for name, detektor in DETEKTOREN:
try:
if detektor(befehl):
schluessel.append(name)
except Exception as fehler:
intern.append(type(fehler).__name__)
except BaseException as fehler:
intern.append(type(fehler).__name__)
if schluessel or grund is not None or intern:
melde(baue_text(schluessel, grund, intern))
if __name__ == "__main__":
try:
main()
except BaseException:
# Letzte Schranke: Exit 0 ist Pflicht, sonst liest Claude Code stdout nicht.
pass
sys.exit(0)
@@ -0,0 +1,48 @@
---
type: resource
status: active
title: "Verbrauchsauswertung — Hintergrund"
summary: "Warum analyse_transkripte.py je Nachricht den größten usage-Wert nimmt, über Dateien dedupliziert, Haiku 5.5 gestuft rechnet, nie in den Vault schreibt, und was die Zahlen nicht aussagen."
tags: [vault-tools, codeworkflow]
created: 2026-10-30
updated: 2026-10-30
---
# Verbrauchsauswertung — Hintergrund
Zur Anleitung: [LIESMICH](LIESMICH.md)
## Warum die drei Zählregeln
- **Größter Wert je Nachricht:** Claude Code schreibt je Antwort eine Zeile pro Inhaltsblock, alle mit derselben `message.id` und je einem `usage`-Block. In Subagenten-Transkripten bis Version 2.1.304 trägt die erste Zeile nur eine Teilzahl der `output_tokens`. Wer sie behält, unterzählt die Ausgabe; im Workflow-Review vom 2026-10-30 waren es 56.484 Nachrichten und rund 2.050 USD Listenpreis.
- **Größter statt letzter Wert:** In den gesehenen Transkripten ist der größte zugleich der letzte. Der größte Wert hängt aber nicht von der Zeilenfolge ab; der Fall `teilausgabe-letzte-zeile` hält das fest.
- **Deduplizierung über Dateien:** Eine fortgesetzte oder geklonte Sitzung kann alle Nachrichten ihrer Vorgängerin enthalten. Gezählt wird die Nachricht in der ersten Datei in Sortierreihenfolge; die Sitzung der Kopie verliert sie. Je Sitzung stimmen die Zahlen also nur für die Erstfassung.
- **Haiku 5.5 über 100k Prompt:** Diese Stufe kostet das Fünffache. Genau 100.000 Prompt-Tokens liegen noch in der Grundstufe (Fall `haiku-grenze-100k`).
## Warum nie in den Vault
- **Sitzungstitel tragen Kunden- und Projektnamen.** Die Auswertung schreibt sie in `auswertung.md` und `auswertung.json`.
- **`.vault-tools/` wird täglich veröffentlicht.** Eine Ausgabe neben dem Skript wäre beim nächsten Push um 04:00 draußen; andere Vault-Ordner liegen in eigenen Git-Repos. Das Skript lehnt deshalb jeden Ordner unter der Vault-Wurzel ab, die es aus dem eigenen Pfad bestimmt.
## Was die Zahlen nicht aussagen
- **Keine Planpunkte.** Die USD-Werte sind Listenpreis-Äquivalente nach der Tabelle `PREISE`. Wie der Max-Plan das 5-Stunden-Fenster und die Woche zählt, ist nicht veröffentlicht. Für Prozentpunkte des Plans gibt es nur die Ablesungen über `get_usage`, ausgewertet mit `.vault-tools/workflow-pruefung/verbrauch_reihe.py`.
- **`output_tokens` als abgerechneter Wert ist angenommen.** Ebenso, dass Thinking darin steckt. Beides ist nicht belegt.
- **`PREISE` ist abgeschrieben, nicht geprüft.** Quelle ist die Preistabelle des Workflow-Reviews vom 2026-10-30. Ein unbekanntes Modell rechnet das Skript mit Opus-5.5-Preisen und führt es als `unbekannt:<name>`.
- **Cache-Schreiben ohne Aufteilung:** Ältere Transkripte ohne `ephemeral_5m`/`ephemeral_1h` zählen ganz als 5-min-Schreiben, also billiger als womöglich abgerechnet.
- **Gelöschte Transkripte fehlen.** Am 2026-10-30 wurden Sitzungen mit letzter Aktivität vor dem 2026-09-30 gelöscht (Aufräumstufe C1 des Reviews). Zahlen vor diesem Tag sind seitdem unvollständig und mit dem Review-Bericht nicht vergleichbar.
- **Dauer in `auswahl.md`** ist erste bis letzte Runde der Hauptsitzung, Pausen eingeschlossen. Für die Arbeitszeit taugt sie nur bei Sitzungen ohne lange Unterbrechung.
- **Andere Konten, Rechner und Codex** sieht das Skript nicht.
## Was die Fälle belegen und was nicht
- **Belegt:** die drei Zählregeln je an zwei Positionen, die Haiku-Grenze, die Vault-Sperre, der fehlende projects-Ordner, Zeilen, die kein JSON-Objekt sind, und die Auswahl nach Titel.
- **Rotnachweis:** Je Fall eine Mutante, die genau die geprüfte Stelle bricht; alle zehn werden gefangen. Die Kopie der Mutante bekommt den echten Vault-Pfad, sonst lehnte sie jede Ausgabe im Temp ab und jede Mutante wäre scheinbar gefangen.
- **Ein echter Fund beim Ablegen:** Fassung 2 aus dem Review brach den ganzen Lauf mit `AttributeError` ab, sobald eine Zeile gültiges JSON, aber kein Objekt ist (etwa `[1]`; gegen die Fixture `kaputte-zeilen` nachgestellt am 2026-10-30). Jetzt wird die Zeile übersprungen, und eine Datei, die trotzdem scheitert, steht als Hinweis im Bericht, statt den Lauf zu beenden.
- **Nicht belegt:** die Berichtstabellen über die abgefragten Zahlen hinaus, die Preise selbst, `claudedata_wurzel.py` und `scratchpads.py`. Ein Fangnetz, kein Nachweis.
## Quellen
- Harness-Erinnerung `transkript-usage-groesster-wert-je-nachricht` (Messfallen, Belege aus dem Review).
- Dump des Workflow-Reviews: `AI/secondbrain/regelwerk-memory/2026-10-30-workflow-review/00-uebersicht.md`.
- Regeln für Kopf und Fälle: [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]], [[AI/Regelwerk/codedoku|codedoku.md]].
@@ -0,0 +1,88 @@
---
type: resource
status: active
title: "Verbrauchsauswertung: Tokens und Listenpreis aus den Claude-Code-Transkripten"
summary: "analyse_transkripte.py zählt je Nachricht Tokens, Modell, Effort und Listenpreis über alle Transkripte eines projects-Ordners, mit Auswahl einzelner Sitzungen für Effort-Messungen; dazu zwei Hilfsskripte für die Ablageorte unter D:\\ClaudeData. Entstanden 2026-10-30 im Workflow-Review."
tags: [vault-tools, codeworkflow]
created: 2026-10-30
updated: 2026-10-30
---
# Verbrauchsauswertung — Anleitung
Zählt, wo Tokens und Listenpreis hingehen: je Modell, Effort, Woche, Sitzung, Subagent und Werkzeug. Warum so: [Hintergrund](LIESMICH-hintergrund.md)
**Voraussetzungen**
- Python ab 3.12 unter dem Befehl `python`.
- Lesezugriff auf den projects-Ordner von Claude Code, auf dieser Maschine `D:\ClaudeData\config\projects`.
- Ein Ausgabeordner **außerhalb des Vaults**, etwa das Scratchpad der Sitzung unter `D:\ClaudeData\claude\D--Claude\<sitzung>\scratchpad`.
**Schritte**
1. Fälle fahren, bevor du Zahlen verwendest:
```bash
# Fixture und Mutanten, rund 5 Sekunden
python -I -X utf8 .vault-tools/verbrauch-auswertung/faelle.py
```
2. Auswertung starten:
```bash
# Gesamtauswertung ab dem Stichtag, Ausgabe ins Scratchpad
python -I -X utf8 .vault-tools/verbrauch-auswertung/analyse_transkripte.py D:/ClaudeData/config/projects --ausgabe "D:/ClaudeData/claude/D--Claude/<sitzung>/scratchpad/verbrauch" --seit 2026-10-01
```
⚠ Ein Ausgabeordner im Vault bricht mit Exit 2 ab. Das ist Absicht.
3. Für eine Messung einzelne Sitzungen auswählen:
```bash
# Sitzungen, deren Titel, Slug oder Kennung den Ausdruck trifft; schreibt zusätzlich auswahl.md
python -I -X utf8 .vault-tools/verbrauch-auswertung/analyse_transkripte.py D:/ClaudeData/config/projects --ausgabe "<scratchpad>/verbrauch" --seit 2026-10-30 --auswahl "paket 1|paket 2"
```
⚠ Die Kennung ist die der Claude-Code-Sitzung (Dateiname des Transkripts), nicht die `local_…`-Kennung der App. Sicher zuordnen kannst du nur über den Titel.
4. Ablageorte ansehen, wenn es ums Aufräumen geht:
```bash
# Zufallsordner unter D:\ClaudeData nach Alter und Größe
python -I -X utf8 .vault-tools/verbrauch-auswertung/claudedata_wurzel.py D:/ClaudeData
# Scratchpads ab 50 MB, laufende Sitzungen als Kennungsanfänge angeben
python -I -X utf8 .vault-tools/verbrauch-auswertung/scratchpads.py --laufend 370f5095,13a29464
```
⚠ Beide Skripte löschen nichts und wissen nicht, welche Sitzung läuft. Das liest du in der App nach.
**Prüfen**
- `faelle.py` endet mit „11 Fälle grün; 10 von 10 Mutanten gefangen“ und Exit 0.
- Die erste Zeile der Auswertung nennt Dateien, Zeilen, Sekunden, Kopien und Nachrichten mit Teilausgabe.
- `auswertung.md` nennt unter dem Kopf die Teilausgaben nach Claude-Code-Version. Tauchen dort Versionen ab 2.1.308 auf, stimmt die Annahme zur Teilausgabe nicht mehr; dann erst nachsehen, dann Zahlen weitergeben.
- In `auswertung.md` stehen Sitzungstitel. Kopierst du Tabellen in den Vault, prüfst du sie vorher auf Kundennamen.
## Ausgabe
| Datei | Inhalt |
|---|---|
| `auswertung.md` | Tabellen je Modell, Effort, Woche, teuerste Sitzungen, Kontextgröße, Subagenten, Werkzeuge, Lesezugriffe |
| `auswertung.json` | dieselben Summen plus je Sitzung und Subagent ein Datensatz, zum Weiterrechnen |
| `auswahl.md` | nur mit `--auswahl`: je getroffene Hauptsitzung Effort, Runden, Dauer, Ausgabe je Runde, USD; darunter ihre Subagenten nach Typ und Effort |
## Fassungen
| Fassung | Datum | Änderung |
|---|---|---|
| 2 | 2026-10-30 | Größter usage-Wert je Nachricht, Deduplizierung über Dateien, Haiku-5.5-Stufe über 100k. Als Werkzeug abgelegt mit `--ausgabe`, Vault-Sperre, `--seit`, `--auswahl`, Teilausgabe nach Version, Typprüfung je Zeile. |
## Dateien
| Datei | Zweck |
|---|---|
| `analyse_transkripte.py` | die Auswertung |
| `faelle.py` | Regressionstreiber: Fälle unter `fixture/` und Mutanten |
| `fixture/` | je Fall ein synthetischer projects-Ordner mit zwei bis drei JSONL-Zeilen |
| `claudedata_wurzel.py` | Einträge unter `D:\ClaudeData` nach bekannt und zufällig benannt |
| `scratchpads.py` | Sitzungs-Scratchpads mit Größe, Titel und Alter |
@@ -0,0 +1,601 @@
"""Zählt Tokenverbrauch und Listenpreis aus den Claude-Code-Transkripten eines projects-Ordners.
Ersteller: Pascal Michel
Erstellt: 2026-10-30
Doku: LIESMICH.md in diesem Ordner · Fassung und Änderungen: dort unter „Fassungen"
Was es misst: je Nachricht (message.id) die usage-Felder input_tokens, cache_creation_input_tokens
(getrennt nach 5 min und 1 h, wo das Transkript das trägt), cache_read_input_tokens und
output_tokens, dazu Modell, Effort, Werkzeugaufrufe, Größe der Werkzeugergebnisse und Sitzungstitel.
Es gibt keine Inhalte aus, nur Zahlen, Namen von Modellen, Werkzeugen und Sitzungstitel.
Drei Zählregeln, ohne die die Zahlen falsch werden (Harness-Erinnerung
transkript-usage-groesster-wert-je-nachricht):
- Je message.id der größte Wert jedes usage-Felds. Bis Claude Code 2.1.304 trägt in
Subagenten-Transkripten die erste Zeile einer Nachricht nur eine Teilzahl der output_tokens.
- message.id über alle Dateien dedupliziert. Fortgesetzte Sitzungen tragen die Nachrichten ihrer
Vorgängerin; gezählt wird die Nachricht in der ersten Datei in Sortierreihenfolge.
- Haiku 5.5 über 100k Prompt mit der höheren Preisstufe.
Was es nicht sieht:
- Wie der Max-Plan zählt. Die Gewichte der Planprozente sind nicht veröffentlicht; die USD-Zahlen
sind Listenpreis-Äquivalente nach PREISE und taugen nur zum Vergleich, nicht als Planpunkte.
- Ob output_tokens der abgerechnete Wert ist. Angenommen, nicht belegt; Thinking steckt nach
derselben Annahme darin.
- Gelöschte Transkripte, Verbrauch anderer Konten und Rechner, Codex.
- PREISE ist abgeschrieben aus der Preistabelle des Workflow-Reviews vom 2026-10-30, nicht aus einer
Quelle, die das Skript prüft. Ein unbekanntes Modell rechnet es mit Opus-5.5-Preisen und weist es
als „unbekannt:<name>" aus.
Ein Messwerkzeug, kein Nachweis: Die Fixture in faelle.py belegt nur die drei Zählregeln.
Aufruf: python -I -X utf8 analyse_transkripte.py <projects-Ordner> --ausgabe <Ordner außerhalb des Vaults>
[--seit JJJJ-MM-TT] [--auswahl REGEX]
Exit 0 mit Auswertung, 2 bei fehlendem projects-Ordner, Ausgabe im Vault oder ungültigem Argument.
"""
import argparse
import collections
import datetime
import json
import os
import re
import statistics
import sys
import time
from pathlib import Path
# $/MTok: Eingabe, Ausgabe, Cache-Lesen, Cache-Schreiben 5 min, Cache-Schreiben 1 h
PREISE = {
"claude-fable-5-1": (10, 50, 0.25, 12.5, 20),
"claude-fable-5": (10, 50, 1.0, 12.5, 20),
"claude-opus-5-5": (4, 20, 0.20, 5, 8),
"claude-opus-5": (5, 25, 0.5, 6.25, 10),
"claude-opus-4-8": (5, 25, 0.5, 6.25, 10),
"claude-opus-4-7": (5, 25, 0.5, 6.25, 10),
"claude-opus-4-6": (5, 25, 0.5, 6.25, 10),
"claude-sonnet-5-5": (2, 10, 0.10, 2.5, 4),
"claude-sonnet-5": (2, 10, 0.20, 2.5, 4),
"claude-sonnet-4-6": (3, 15, 0.3, 3.75, 6),
"claude-haiku-5-5": (0.10, 0.50, 0.01, 0.125, 0.2),
"claude-haiku-4-5": (1, 5, 0.1, 1.25, 2),
}
HAIKU55_UEBER_100K = (0.50, 2.50, 0.05, 0.625, 1.0)
# Längster Präfix zuerst, sonst fängt claude-opus-5 auch claude-opus-5-5 ab.
ORDNUNG = sorted(PREISE, key=len, reverse=True)
USAGE_FELDER = ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens", "output_tokens")
STUFEN = [(0, 100000, "0-100k"), (100000, 200000, "100-200k"), (200000, 400000, "200-400k"), (400000, 700000, "400-700k"), (700000, 10**9, "700k+")]
VAULT = Path(__file__).resolve().parents[2]
ROOT = OUT = SEIT = None
AUSWAHL = None
def norm(m):
m = m or "?"
for k in ORDNUNG:
if m.startswith(k):
return k
return "unbekannt:" + m
def kosten(mk, u, cc, ctx):
p = PREISE.get(mk, PREISE["claude-opus-5-5"])
if mk == "claude-haiku-5-5" and ctx > 100000:
p = HAIKU55_UEBER_100K
c5 = cc.get("ephemeral_5m_input_tokens")
c1 = cc.get("ephemeral_1h_input_tokens")
# Ältere Transkripte ohne Aufteilung: alles als 5-min-Schreiben, das ist die billigere Annahme.
if c5 is None and c1 is None:
c5 = u.get("cache_creation_input_tokens") or 0
c1 = 0
return ((u.get("input_tokens") or 0) * p[0] + (u.get("output_tokens") or 0) * p[1]
+ (u.get("cache_read_input_tokens") or 0) * p[2] + (c5 or 0) * p[3] + (c1 or 0) * p[4]) / 1e6
def Z():
return collections.Counter()
agg = {
"modell": collections.defaultdict(Z), # (scope, art, modell)
"effort": collections.defaultdict(Z), # (scope, modell, effort) nur Hauptsitzungen
"tag": collections.defaultdict(Z), # (datum, art)
"tool": collections.defaultdict(Z), # name
"stop": collections.Counter(),
"versionen": collections.Counter(),
"teilausgabe_version": collections.Counter(),
"agent_tool": collections.Counter(), # (subagent_type, model)
"workflow_calls": 0,
"dateien": 0,
"zeilen": 0,
"doppelt_dateiuebergreifend": 0,
"nachrichten_mit_teilausgabe": 0,
"ctx_stufe": collections.defaultdict(Z), # (art, stufe) seit SEIT: Kosten je Runde nach Kontextgroesse
"read_pfad": collections.defaultdict(Z), # Pfad: Aufrufe, Zeichen (seit SEIT)
}
GESEHEN = set() # Nachrichten-IDs ueber alle Dateien
BEFUNDE = []
sessions = {}
subagents = []
meta_keys = collections.Counter()
def stufe(ctx):
for a, b, n in STUFEN:
if a <= ctx < b:
return n
return "?"
def kurzpfad(p):
p = str(p).replace("\\", "/")
teile = p.split("/")
return "/".join(teile[-3:]) if len(teile) > 3 else p
def dateien():
for proj in sorted(os.listdir(ROOT)):
pd = os.path.join(ROOT, proj)
if not os.path.isdir(pd):
continue
for root, dirs, files in os.walk(pd):
dirs.sort()
for f in sorted(files):
if not f.endswith(".jsonl"):
continue
p = os.path.join(root, f)
rel = os.path.relpath(p, pd).replace("\\", "/")
parts = rel.split("/")
if len(parts) == 1:
yield proj, parts[0][:-6], None, p
elif "subagents" in parts:
yield proj, parts[0], f[:-6], p
else:
yield proj, parts[0], "sonst:" + f[:-6], p
def lese_meta(path):
mp = path[:-6] + ".meta.json"
out = {}
if os.path.exists(mp):
try:
d = json.load(open(mp, encoding="utf-8"))
except Exception:
return out
if isinstance(d, dict):
for k, v in d.items():
meta_keys[k] += 1
if k.lower() in ("prompt", "message", "content", "messages", "systemprompt", "system_prompt"):
continue
if isinstance(v, str) and len(v) <= 80:
out[k] = v
elif isinstance(v, (int, float, bool)):
out[k] = v
return out
def verarbeite(proj, sid, agent_id, path):
art_datei = "haupt" if agent_id is None else "sub"
s = {"proj": proj, "sid": sid, "agent": agent_id, "turns": 0, "in": 0, "cc": 0, "cr": 0, "out": 0,
"usd": 0.0, "first": None, "last": None, "max_ctx": 0, "last_ctx": 0, "modelle": collections.Counter(),
"efforts": collections.Counter(), "tools": collections.Counter(), "title": None, "slug": None,
"cold": 0, "sidechain_usd": 0.0, "sidechain_turns": 0, "turns_seit": 0, "usd_seit": 0.0,
"out_seit": 0, "cr_seit": 0, "in_seit": 0, "cc_seit": 0, "doppelt": 0}
id2name = {}
msgs = {}
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
agg["zeilen"] += 1
try:
o = json.loads(line)
except Exception:
continue
if not isinstance(o, dict):
continue
t = o.get("type")
if t == "custom-title":
s["title"] = o.get("customTitle")
continue
if t not in ("assistant", "user"):
continue
ts = o.get("timestamp") or ""
if t == "assistant":
m = o.get("message") or {}
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, list):
for b in c:
if isinstance(b, dict) and b.get("type") == "tool_use":
n = b.get("name") or "?"
id2name[b.get("id")] = n
s["tools"][n] += 1
z = agg["tool"][n]
z["aufrufe"] += 1
if ts >= SEIT:
z["aufrufe_seit"] += 1
inp = b.get("input") or {}
if n == "Read" and isinstance(inp, dict) and inp.get("file_path"):
id2name[b.get("id")] = ("Read", kurzpfad(inp.get("file_path")))
if n == "Agent" and isinstance(inp, dict) and ts >= SEIT:
agg["agent_tool"][(str(inp.get("subagent_type") or "-"), str(inp.get("model") or "-"))] += 1
if n == "Workflow" and ts >= SEIT:
agg["workflow_calls"] += 1
u = m.get("usage") or {}
if not isinstance(u, dict) or not u:
continue
mid = m.get("id") or o.get("requestId")
rec = msgs.get(mid)
if rec is None:
msgs[mid] = {"ts": ts, "mk": norm(m.get("model") or o.get("requestedModel")), "eff": str(o.get("effort") or "?"),
"side": bool(o.get("isSidechain")), "stop": str(m.get("stop_reason")), "version": str(o.get("version")),
"slug": o.get("slug"), "u": {k: (u.get(k) or 0) for k in USAGE_FELDER},
"cc": dict(u.get("cache_creation") or {}), "erste_out": u.get("output_tokens") or 0}
else:
for k in USAGE_FELDER:
rec["u"][k] = max(rec["u"][k], u.get(k) or 0)
cc = u.get("cache_creation") or {}
for k in ("ephemeral_5m_input_tokens", "ephemeral_1h_input_tokens"):
if cc.get(k) is not None:
rec["cc"][k] = max(rec["cc"].get(k) or 0, cc.get(k) or 0)
else:
m = o.get("message") or {}
c = m.get("content") if isinstance(m, dict) else None
if isinstance(c, list):
for b in c:
if isinstance(b, dict) and b.get("type") == "tool_result":
n = id2name.get(b.get("tool_use_id"), "?")
pfad = None
if isinstance(n, tuple):
n, pfad = n
cont = b.get("content")
if isinstance(cont, str):
ch = len(cont); txt = cont[:300]
elif isinstance(cont, list):
ch = sum(len(x.get("text", "")) for x in cont if isinstance(x, dict))
txt = "".join(x.get("text", "") for x in cont if isinstance(x, dict))[:300]
else:
ch = 0; txt = ""
z = agg["tool"][n]
z["ergebnisse"] += 1; z["zeichen"] += ch
z["max"] = max(z["max"], ch)
if ch > 50000:
z["ueber50k"] += 1
if "persisted-output" in txt:
z["persistiert"] += 1
if ts >= SEIT:
z["zeichen_seit"] += ch; z["ergebnisse_seit"] += 1
if ch > 50000:
z["ueber50k_seit"] += 1
if pfad:
zp = agg["read_pfad"][pfad]
zp["aufrufe"] += 1; zp["zeichen"] += ch
# Nachrichten einmal zaehlen, mit dem groessten Wert je Feld; Kopien anderer Dateien ueberspringen
for mid, rec in msgs.items():
if mid in GESEHEN:
s["doppelt"] += 1; agg["doppelt_dateiuebergreifend"] += 1
continue
GESEHEN.add(mid)
u = rec["u"]; ts = rec["ts"]; mk = rec["mk"]; eff = rec["eff"]
if u["output_tokens"] > rec["erste_out"]:
agg["nachrichten_mit_teilausgabe"] += 1
agg["teilausgabe_version"][rec["version"]] += 1
ti, tc, tr, to = u["input_tokens"], u["cache_creation_input_tokens"], u["cache_read_input_tokens"], u["output_tokens"]
ctx = ti + tc + tr
usd = kosten(mk, u, rec["cc"], ctx)
art = "sub" if (art_datei == "sub" or rec["side"]) else "haupt"
if art_datei == "haupt" and rec["side"]:
s["sidechain_usd"] += usd; s["sidechain_turns"] += 1
scopes = ["gesamt"] + (["seit"] if ts >= SEIT else [])
for scope in scopes:
z = agg["modell"][(scope, art, mk)]
z["turns"] += 1; z["in"] += ti; z["cc"] += tc; z["cr"] += tr; z["out"] += to; z["usd_x1000"] += int(usd * 1000)
if tr == 0:
z["kalt"] += 1
if art == "haupt":
z2 = agg["effort"][(scope, mk, eff)]
z2["turns"] += 1; z2["out"] += to; z2["usd_x1000"] += int(usd * 1000); z2["ctx_sum"] += ctx
tag = ts[:10]
zt = agg["tag"][(tag, art)]
zt["turns"] += 1; zt["out"] += to; zt["usd_x1000"] += int(usd * 1000); zt["cr"] += tr; zt["cc"] += tc; zt["in"] += ti
if ts >= SEIT:
zs = agg["ctx_stufe"][(art, stufe(ctx))]
zs["turns"] += 1; zs["usd_x1000"] += int(usd * 1000); zs["cr"] += tr; zs["cc"] += tc; zs["out"] += to
if s["turns"] == 0:
s["first_cc"] = tc + ti
s["turns"] += 1; s["in"] += ti; s["cc"] += tc; s["cr"] += tr; s["out"] += to; s["usd"] += usd
if ts >= SEIT:
s["turns_seit"] += 1; s["usd_seit"] += usd; s["out_seit"] += to; s["cr_seit"] += tr; s["in_seit"] += ti; s["cc_seit"] += tc
s["first"] = ts if (s["first"] is None or ts < s["first"]) else s["first"]
s["last"] = ts if (s["last"] is None or ts > s["last"]) else s["last"]
s["max_ctx"] = max(s["max_ctx"], ctx)
s["last_ctx"] = ctx
s["modelle"][mk] += 1
s["efforts"][eff] += 1
if tr == 0:
s["cold"] += 1
if not s["slug"] and rec["slug"]:
s["slug"] = rec["slug"]
agg["stop"][rec["stop"]] += 1
agg["versionen"][rec["version"]] += 1
return s
def mio(n):
return f"{n/1e6:.2f}"
def usd(c):
return f"{c:.2f}"
def dauer_h(s):
try:
a = datetime.datetime.fromisoformat(s["first"].replace("Z", "+00:00"))
b = datetime.datetime.fromisoformat(s["last"].replace("Z", "+00:00"))
except (AttributeError, ValueError):
return "?"
return f"{(b - a).total_seconds() / 3600:.1f}"
def auswahl_bericht():
"""Je Hauptsitzung, deren Titel, Slug oder Kennung AUSWAHL trifft: Runden, Ausgabe, Effort, Subagenten."""
rx = re.compile(AUSWAHL, re.I)
L = [f"# Auswahl `{AUSWAHL}` (seit {SEIT}, Fassung 2)\n",
"Runden und Ausgabe ohne Subagenten; Dauer ist erste bis letzte Runde, Pausen eingeschlossen.\n",
"| Beginn | Titel | Kennung | Effort (Runden) | Runden | Dauer h | Ausgabe | Ausgabe je Runde | USD | max. Kontext |",
"|---|---|---|---|---|---|---|---|---|---|"]
treffer = [s for s in sessions.values()
if s["turns_seit"] > 0 and any(rx.search(str(x or "")) for x in (s["title"], s["slug"], s["sid"]))]
for s in sorted(treffer, key=lambda s: s["first"] or ""):
ef = ", ".join(f"{k} {v}" for k, v in s["efforts"].most_common())
L.append(f"| {(s['first'] or '')[:16]} | {(s['title'] or s['slug'] or '-')[:50]} | {s['sid'][:8]} | {ef} | {s['turns_seit']} "
f"| {dauer_h(s)} | {s['out_seit']} | {s['out_seit'] // max(s['turns_seit'], 1)} | {usd(s['usd_seit'])} | {s['max_ctx']} |")
L.append("\n## Subagenten der ausgewählten Sitzungen\n")
L.append("| Kennung | Typ (meta) | Modell | Effort | Anzahl | Runden | Ausgabe | USD |")
L.append("|---|---|---|---|---|---|---|---|")
schluessel = {(s["proj"], s["sid"]) for s in treffer}
grp = collections.defaultdict(lambda: [0, 0, 0, 0.0])
for sa in subagents:
if (sa["proj"], sa["sid"]) not in schluessel or sa["turns"] == 0:
continue
meta = sa.get("meta") or {}
mk = sa["modelle"].most_common(1)[0][0] if sa["modelle"] else "?"
ef = sa["efforts"].most_common(1)[0][0] if sa["efforts"] else "?"
g = grp[(sa["sid"][:8], str(meta.get("agentType") or "-"), mk, ef)]
g[0] += 1; g[1] += sa["turns"]; g[2] += sa["out"]; g[3] += sa["usd"]
for k, g in sorted(grp.items()):
L.append(f"| {k[0]} | {k[1]} | {k[2]} | {k[3]} | {g[0]} | {g[1]} | {g[2]} | {usd(g[3])} |")
L.append(f"\nTreffer: {len(treffer)} Hauptsitzungen.")
return "\n".join(L)
def bericht(t0):
L = []
L.append(f"# Transkript-Auswertung (Zeitraum seit {SEIT}, Stand {datetime.date.today().isoformat()}, Fassung 2)\n")
L.append(f"Quelle: {ROOT} — {agg['dateien']} JSONL-Dateien, {agg['zeilen']} Zeilen, Lauf {round(time.time()-t0)} s. Kosten sind Listenpreis-Äquivalente (USD) nach der Preistabelle im Skript; der Max-Plan rechnet nicht in USD ab, die Zahl dient dem Vergleich der Hebel. Fassung 2 zählt je Nachrichten-ID den größten Wert jedes usage-Felds (vor Claude Code 2.1.308 trug die erste Zeile einer Subagenten-Nachricht nur eine Teilzahl der Ausgabe; betroffen: {agg['nachrichten_mit_teilausgabe']} Nachrichten), überspringt Nachrichten, die schon in einer anderen Datei standen ({agg['doppelt_dateiuebergreifend']} Kopien), und rechnet Haiku 5.5 über 100k Prompt mit dem Fünffachen.\n")
L.append("Nachrichten mit Teilausgabe in der ersten Zeile nach Claude-Code-Version: "
+ (", ".join(f"{k}: {v}" for k, v in sorted(agg["teilausgabe_version"].items())) or "keine") + "\n")
if BEFUNDE:
L.append("Hinweise: " + "; ".join(BEFUNDE[:20]) + (f" (und {len(BEFUNDE) - 20} weitere)" if len(BEFUNDE) > 20 else "") + "\n")
for scope in ("seit", "gesamt"):
L.append(f"\n## Verbrauch nach Modell ({'seit ' + SEIT if scope == 'seit' else 'gesamt'})\n")
L.append("| Art | Modell | Runden | Eingabe Mio | Cache-Schreiben Mio | Cache-Lesen Mio | Ausgabe Mio | USD | Anteil USD | Runden ohne Cache |")
L.append("|---|---|---|---|---|---|---|---|---|---|")
rows = [(k, v) for k, v in agg["modell"].items() if k[0] == scope]
total = sum(v["usd_x1000"] for _, v in rows) / 1000 or 1
for (sc, art, mk), v in sorted(rows, key=lambda kv: -kv[1]["usd_x1000"]):
c = v["usd_x1000"] / 1000
L.append(f"| {art} | {mk} | {v['turns']} | {mio(v['in'])} | {mio(v['cc'])} | {mio(v['cr'])} | {mio(v['out'])} | {usd(c)} | {100*c/total:.0f} % | {v['kalt']} |")
L.append(f"| **Summe** | | | | | | | **{usd(total)}** | | |")
L.append(f"\n## Kostenzerlegung je Art und Modell (seit {SEIT}, Listenpreis)\n")
L.append("| Art | Modell | Eingabe | Cache-Schreiben | Cache-Lesen | Ausgabe |")
L.append("|---|---|---|---|---|---|")
for (sc, art, mk), v in sorted(((k, v) for k, v in agg["modell"].items() if k[0] == "seit"), key=lambda kv: -kv[1]["usd_x1000"])[:6]:
p = PREISE.get(mk, PREISE["claude-opus-5-5"])
ein = v["in"] * p[0] / 1e6; les = v["cr"] * p[2] / 1e6; aus = v["out"] * p[1] / 1e6
ges = v["usd_x1000"] / 1000 or 1
schr = max(ges - ein - les - aus, 0)
L.append(f"| {art} | {mk} | {100*ein/ges:.0f} % | {100*schr/ges:.0f} % | {100*les/ges:.0f} % | {100*aus/ges:.0f} % |")
L.append(f"\n## Hauptsitzungen nach Modell und Effort (seit {SEIT})\n")
L.append("| Modell | Effort | Runden | Ausgabe Mio | Ausgabe je Runde | USD | mittlerer Kontext je Runde |")
L.append("|---|---|---|---|---|---|---|")
for (sc, mk, eff), v in sorted(((k, v) for k, v in agg["effort"].items() if k[0] == "seit"), key=lambda kv: -kv[1]["usd_x1000"]):
L.append(f"| {mk} | {eff} | {v['turns']} | {mio(v['out'])} | {v['out']//max(v['turns'],1)} | {usd(v['usd_x1000']/1000)} | {v['ctx_sum']//max(v['turns'],1)} |")
L.append("\n## Je Kalenderwoche (UTC-Datum)\n")
L.append("| Woche | Runden Haupt | USD Haupt | Runden Sub | USD Sub | Ausgabe Mio Haupt | Ausgabe Mio Sub | Cache-Lesen Mio | Cache-Schreiben Mio |")
L.append("|---|---|---|---|---|---|---|---|---|")
wochen = collections.defaultdict(Z)
for (tag, art), v in agg["tag"].items():
try:
d = datetime.date.fromisoformat(tag)
except Exception:
continue
w = f"{d.isocalendar()[0]}-W{d.isocalendar()[1]:02d}"
z = wochen[w]
z[f"turns_{art}"] += v["turns"]; z[f"usd_{art}"] += v["usd_x1000"]; z[f"out_{art}"] += v["out"]; z["cr"] += v["cr"]; z["cc"] += v["cc"]
for w in sorted(wochen):
z = wochen[w]
L.append(f"| {w} | {z['turns_haupt']} | {usd(z['usd_haupt']/1000)} | {z['turns_sub']} | {usd(z['usd_sub']/1000)} | {mio(z['out_haupt'])} | {mio(z['out_sub'])} | {mio(z['cr'])} | {mio(z['cc'])} |")
L.append(f"\n## Teuerste Hauptsitzungen (seit {SEIT}, 25)\n")
L.append("| Beginn | Titel | Modell / Effort | Runden | USD | max. Kontext | Cache-Lesen Anteil | Subagenten (n / USD) |")
L.append("|---|---|---|---|---|---|---|---|")
sub_by_parent = collections.defaultdict(lambda: [0, 0.0])
for sa in subagents:
if (sa["last"] or "") >= SEIT:
sub_by_parent[(sa["proj"], sa["sid"])][0] += 1
sub_by_parent[(sa["proj"], sa["sid"])][1] += sa["usd"]
haupt = [s for s in sessions.values() if s["turns_seit"] > 0]
for s in sorted(haupt, key=lambda s: -s["usd_seit"])[:25]:
mk = ", ".join(f"{k}" for k, _ in s["modelle"].most_common(2))
ef = ", ".join(f"{k}" for k, _ in s["efforts"].most_common(2))
ges = s["in_seit"] + s["cc_seit"] + s["cr_seit"] or 1
n, c = sub_by_parent.get((s["proj"], s["sid"]), [0, 0.0])
L.append(f"| {(s['first'] or '')[:10]} | {(s['title'] or s['slug'] or s['sid'][:8])[:50]} | {mk} / {ef} | {s['turns_seit']} | {usd(s['usd_seit'])} | {s['max_ctx']} | {100*s['cr_seit']/ges:.0f} % | {n} / {usd(c)} |")
L.append(f"\nHauptsitzungen mit Runden seit {SEIT}: {len(haupt)}; davon mit maximalem Kontext über 400k: {sum(1 for s in haupt if s['max_ctx'] > 400000)}, über 700k: {sum(1 for s in haupt if s['max_ctx'] > 700000)}.")
L.append(f"Summe USD Hauptsitzungen seit {SEIT}: {usd(sum(s['usd_seit'] for s in haupt))}; Summe USD Subagenten seit {SEIT}: {usd(sum(c for n, c in sub_by_parent.values()))}.")
L.append(f"\n## Kontextgröße der Hauptsitzungen (max je Sitzung, seit {SEIT})\n")
L.append("| Stufe | Sitzungen | USD | Anteil USD |")
L.append("|---|---|---|---|")
tot = sum(s["usd_seit"] for s in haupt) or 1
for a, b, n in STUFEN:
grp = [s for s in haupt if a <= s["max_ctx"] < b]
L.append(f"| {n} | {len(grp)} | {usd(sum(s['usd_seit'] for s in grp))} | {100*sum(s['usd_seit'] for s in grp)/tot:.0f} % |")
L.append(f"\n## Subagenten (seit {SEIT}, aus Subagenten-Transkripten)\n")
sa_seit = [sa for sa in subagents if (sa["last"] or "") >= SEIT and sa["turns"] > 0]
L.append(f"Anzahl: {len(sa_seit)}; Meta-Schlüssel gesehen: {dict(meta_keys.most_common(12))}\n")
L.append("| Typ (meta) | Modell | Effort | Anzahl | Median Ausgabe | Median USD | Summe USD | Median Runden | Median max. Kontext |")
L.append("|---|---|---|---|---|---|---|---|---|")
grp = collections.defaultdict(list)
for sa in sa_seit:
meta = sa.get("meta") or {}
typ = str(meta.get("agentType") or meta.get("subagent_type") or meta.get("type") or meta.get("agent_type") or "-")
mk = sa["modelle"].most_common(1)[0][0] if sa["modelle"] else "?"
ef = sa["efforts"].most_common(1)[0][0] if sa["efforts"] else "?"
grp[(typ, mk, ef)].append(sa)
for k, lst in sorted(grp.items(), key=lambda kv: -sum(x["usd"] for x in kv[1])):
L.append(f"| {k[0]} | {k[1]} | {k[2]} | {len(lst)} | {int(statistics.median(x['out'] for x in lst))} | {usd(statistics.median(x['usd'] for x in lst))} | {usd(sum(x['usd'] for x in lst))} | {int(statistics.median(x['turns'] for x in lst))} | {int(statistics.median(x['max_ctx'] for x in lst))} |")
L.append(f"\n## Subagenten nach Workflow-Phase (meta.workflowPhase, seit {SEIT})\n")
L.append("| Phase | Anzahl | Summe USD | Mittel USD | Median Runden |")
L.append("|---|---|---|---|---|")
ph = collections.defaultdict(list)
for sa in sa_seit:
ph[str((sa.get("meta") or {}).get("workflowPhase") or "-")].append(sa)
for k, lst in sorted(ph.items(), key=lambda kv: -sum(x["usd"] for x in kv[1]))[:20]:
L.append(f"| {k} | {len(lst)} | {usd(sum(x['usd'] for x in lst))} | {usd(sum(x['usd'] for x in lst)/len(lst))} | {int(statistics.median(x['turns'] for x in lst))} |")
L.append(f"\n## Agent-Werkzeug: subagent_type und model (seit {SEIT})\n")
L.append("| subagent_type | model | Aufrufe |")
L.append("|---|---|---|")
for (st, mo), n in agg["agent_tool"].most_common(25):
L.append(f"| {st} | {mo} | {n} |")
L.append(f"\nWorkflow-Aufrufe seit {SEIT}: {agg['workflow_calls']}")
L.append(f"\n## Werkzeuge nach Ergebnisvolumen (seit {SEIT}, Zeichen der Werkzeugergebnisse)\n")
L.append("| Werkzeug | Aufrufe | Ergebnis-MB | Ø Zeichen | Max Zeichen | Ergebnisse > 50k | persistiert |")
L.append("|---|---|---|---|---|---|---|")
for n, z in sorted(agg["tool"].items(), key=lambda kv: -kv[1]["zeichen_seit"])[:30]:
L.append(f"| {n} | {z['aufrufe_seit']} | {z['zeichen_seit']/1e6:.1f} | {z['zeichen_seit']//max(z['ergebnisse_seit'],1)} | {z['max']} | {z['ueber50k_seit']} | {z['persistiert']} |")
L.append(f"\n## Kosten je Runde nach Kontextgröße der Runde (seit {SEIT})\n")
L.append("| Art | Kontext der Runde | Runden | USD gesamt | USD je Runde | Cache-Lesen je Runde | Cache-Schreiben je Runde | Ausgabe je Runde |")
L.append("|---|---|---|---|---|---|---|---|")
for art in ("haupt", "sub"):
for a, b, n in STUFEN:
z = agg["ctx_stufe"].get((art, n))
if not z or not z["turns"]:
continue
t = z["turns"]
L.append(f"| {art} | {n} | {t} | {usd(z['usd_x1000']/1000)} | {z['usd_x1000']/1000/t:.3f} | {z['cr']//t} | {z['cc']//t} | {z['out']//t} |")
L.append(f"\n## Fester Startaufwand je Subagent: Tokens der ersten Runde (Eingabe plus Cache-Schreiben), seit {SEIT}\n")
L.append("| Typ (meta) | Modell | Anzahl | Median erste Runde | Median Runden | Anteil erste Runde am Gesamtkontext-Maximum |")
L.append("|---|---|---|---|---|---|")
grp2 = collections.defaultdict(list)
for sa in sa_seit:
meta = sa.get("meta") or {}
typ = str(meta.get("agentType") or "-")
mk = sa["modelle"].most_common(1)[0][0] if sa["modelle"] else "?"
if sa.get("first_cc") is not None:
grp2[(typ, mk)].append(sa)
for k, lst in sorted(grp2.items(), key=lambda kv: -len(kv[1]))[:20]:
f = statistics.median(x["first_cc"] for x in lst)
m = statistics.median(x["max_ctx"] for x in lst) or 1
L.append(f"| {k[0]} | {k[1]} | {len(lst)} | {int(f)} | {int(statistics.median(x['turns'] for x in lst))} | {100*f/m:.0f} % |")
for titel, schl, n in (("nach Zeichen", "zeichen", 40), ("nach Aufrufen", "aufrufe", 25)):
L.append(f"\n## Meistgelesene Dateien (Read, seit {SEIT}, {titel})\n")
L.append("| Pfad (letzte drei Glieder) | Aufrufe | Zeichen gesamt Mio | Ø Zeichen |")
L.append("|---|---|---|---|")
for p, z in sorted(agg["read_pfad"].items(), key=lambda kv: -kv[1][schl])[:n]:
L.append(f"| {p} | {z['aufrufe']} | {z['zeichen']/1e6:.2f} | {z['zeichen']//max(z['aufrufe'],1)} |")
L.append("\n## Stop-Gründe (gesamt)\n")
L.append(", ".join(f"{k}: {v}" for k, v in agg["stop"].most_common()))
L.append("\n## Claude-Code-Versionen (Nachrichten, gesamt)\n")
L.append(", ".join(f"{k}: {v}" for k, v in agg["versionen"].most_common(12)))
return "\n".join(L)
def im_vault(pfad):
p = Path(pfad).resolve()
return p == VAULT or VAULT in p.parents
def main(argv=None):
global ROOT, OUT, SEIT, AUSWAHL
t0 = time.time()
ap = argparse.ArgumentParser(description="Tokenverbrauch aus Claude-Code-Transkripten")
ap.add_argument("projects", help="projects-Ordner von Claude Code, etwa D:\\ClaudeData\\config\\projects")
ap.add_argument("--ausgabe", required=True, help="Ordner für auswertung.md und auswertung.json, nie im Vault")
ap.add_argument("--seit", default="2026-09-15", help="Beginn des Zeitraums JJJJ-MM-TT (UTC), Vorgabe 2026-09-15")
ap.add_argument("--auswahl", default=None, help="regulärer Ausdruck auf Titel, Slug oder Kennung der Hauptsitzung; schreibt auswahl.md")
a = ap.parse_args(argv)
ROOT, OUT, SEIT, AUSWAHL = a.projects, a.ausgabe, a.seit, a.auswahl
try:
datetime.date.fromisoformat(SEIT)
except ValueError:
print(f"--seit {SEIT!r} ist kein Datum JJJJ-MM-TT")
return 2
if AUSWAHL is not None:
try:
re.compile(AUSWAHL)
except re.error as e:
print(f"--auswahl ist kein regulärer Ausdruck: {e}")
return 2
if not os.path.isdir(ROOT):
print(f"projects-Ordner fehlt: {ROOT}")
return 2
# Sitzungstitel können Kundennamen tragen, und der Vault wird teilweise veröffentlicht.
if im_vault(OUT):
print(f"Ausgabe im Vault abgelehnt: {OUT} liegt unter {VAULT}")
return 2
os.makedirs(OUT, exist_ok=True)
for proj, sid, agent_id, path in dateien():
agg["dateien"] += 1
try:
s = verarbeite(proj, sid, agent_id, path)
except Exception as e: # Eine kaputte Datei ist ein Hinweis im Bericht, kein Abbruch der ganzen Zählung.
BEFUNDE.append(f"{proj}/{os.path.basename(path)}: {type(e).__name__}")
continue
if agent_id is None:
sessions[(proj, sid)] = s
else:
s["meta"] = lese_meta(path)
subagents.append(s)
print("Dateien", agg["dateien"], "Zeilen", agg["zeilen"], "Sekunden", round(time.time() - t0),
"doppelt (andere Datei)", agg["doppelt_dateiuebergreifend"], "Nachrichten mit Teilausgabe in erster Zeile", agg["nachrichten_mit_teilausgabe"])
md = bericht(t0)
with open(os.path.join(OUT, "auswertung.md"), "w", encoding="utf-8") as f:
f.write(md)
with open(os.path.join(OUT, "auswertung.json"), "w", encoding="utf-8") as f:
json.dump({
"kennzahlen": {k: agg[k] for k in ("dateien", "zeilen", "doppelt_dateiuebergreifend", "nachrichten_mit_teilausgabe", "workflow_calls")},
"teilausgabe_version": dict(agg["teilausgabe_version"]),
"befunde": BEFUNDE,
"modell": {"|".join(k): dict(v) for k, v in agg["modell"].items()},
"effort": {"|".join(k): dict(v) for k, v in agg["effort"].items()},
"tag": {"|".join(k): dict(v) for k, v in agg["tag"].items()},
"tool": {k: dict(v) for k, v in agg["tool"].items()},
"ctx_stufe": {"|".join(k): dict(v) for k, v in agg["ctx_stufe"].items()},
"sessions": [{k: (dict(v) if isinstance(v, collections.Counter) else v) for k, v in s.items()} for s in sessions.values()],
"subagents": [{k: (dict(v) if isinstance(v, collections.Counter) else v) for k, v in s.items()} for s in subagents],
}, f, ensure_ascii=False, indent=0)
if AUSWAHL is not None:
with open(os.path.join(OUT, "auswahl.md"), "w", encoding="utf-8") as f:
f.write(auswahl_bericht())
print(f"Geschrieben: {os.path.join(OUT, 'auswertung.md')}" + (" und auswahl.md" if AUSWAHL is not None else ""))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,99 @@
"""Ordnet die Einträge direkt unter dem Datenordner von Claude Code ein: bekannte Ordner gegen zufällig benannte Temp-Ordner.
Ersteller: Pascal Michel
Erstellt: 2026-10-30
Doku: LIESMICH.md in diesem Ordner
Nur lesend. Gibt Anzahl, Größe, Alter und Endungsmuster aus, keine Dateiinhalte; die Strukturbeispiele
am Schluss nennen bis zu fünf Dateinamen je Ordner.
Was es nicht sieht: Ob ein Zufallsordner noch von einer laufenden Sitzung benutzt wird. Das Alter kommt
aus mtime, und die App fasst beim Neustart viele Dateien an (Harness-Erinnerung
transkript-usage-groesster-wert-je-nachricht). Vor jedem Löschen also laufende Sitzungen über die App
prüfen, nicht über dieses Skript.
Zufällig benannt heißt: 21 Zeichen aus [A-Za-z0-9_-] (nanoid-artig) und nicht in BEKANNT.
Aufruf: python -I -X utf8 claudedata_wurzel.py [Wurzel, Vorgabe D:\\ClaudeData]
"""
import collections
import datetime
import os
import re
import sys
import time
BEKANNT = {"claude", "config", "tmp", "umzug", "codex-review"}
MUSTER = re.compile(r"^[A-Za-z0-9_-]{21}$")
def groesse(p):
total = 0; anzahl = 0; namen = collections.Counter(); neueste = 0
for root, dirs, files in os.walk(p):
for f in files:
fp = os.path.join(root, f)
try:
st = os.stat(fp)
except OSError:
continue
total += st.st_size; anzahl += 1; neueste = max(neueste, st.st_mtime)
ext = os.path.splitext(f)[1].lower() or f[:20]
namen[ext] += 1
return total, anzahl, namen, neueste
def main(argv):
wurzel = argv[1] if len(argv) > 1 else r"D:\ClaudeData"
if not os.path.isdir(wurzel):
print(f"Wurzel fehlt: {wurzel}")
return 2
jetzt = time.time()
with os.scandir(wurzel) as it:
eintraege = list(it)
zufall = [e for e in eintraege if e.is_dir() and MUSTER.match(e.name) and e.name not in BEKANNT]
andere = [e for e in eintraege if e not in zufall]
print(f"Einträge gesamt: {len(eintraege)}; zufällig benannte Ordner: {len(zufall)}; andere: {len(andere)}")
print("\nAndere Einträge:")
for e in sorted(andere, key=lambda x: x.name):
try:
st = e.stat()
print(f" {'D' if e.is_dir() else 'F'} {e.name} mtime {datetime.datetime.fromtimestamp(st.st_mtime):%Y-%m-%d}")
except OSError:
print(" ?", e.name)
gesamt = 0; dateien = 0; alter = collections.Counter(); muster = collections.Counter(); tage_liste = []
topnamen = collections.Counter(); beispiele = {}
for e in zufall:
t, n, namen, neueste = groesse(e.path)
gesamt += t; dateien += n
try:
mt = e.stat().st_mtime
except OSError:
mt = neueste
tage = (jetzt - max(mt, neueste)) / 86400
tage_liste.append(tage)
stufe = "<1 Tag" if tage < 1 else "1-7 Tage" if tage < 7 else "7-30 Tage" if tage < 30 else ">30 Tage"
alter[stufe] += 1
sig = ",".join(f"{k}:{v}" for k, v in sorted(namen.items())[:4])
muster[sig] += 1
for k in namen:
topnamen[k] += 1
beispiele.setdefault(sig, e.name)
print(f"\nZufallsordner: {len(zufall)}, Dateien {dateien}, Größe {gesamt/1e6:.1f} MB")
print("Alter (nach jüngster Änderung):", dict(alter))
if tage_liste:
print(f"ältester {max(tage_liste):.0f} Tage, jüngster {min(tage_liste):.2f} Tage")
print("\nHäufigste Inhaltsmuster (Endung:Anzahl je Ordner):")
for sig, n in muster.most_common(12):
print(f" {n:5d} x {sig or '(leer)'} Beispiel: {beispiele[sig]}")
print("\nHäufigste Dateiendungen über alle Zufallsordner:", dict(topnamen.most_common(12)))
for sig, n in muster.most_common(3):
p = os.path.join(wurzel, beispiele[sig])
print(f"\nStruktur von {beispiele[sig]}:")
for root, dirs, files in os.walk(p):
tiefe = root[len(p):].count(os.sep)
if tiefe > 2:
continue
print(" " * tiefe + os.path.basename(root) + "/", f"({len(dirs)} Ordner, {len(files)} Dateien)", sorted(files)[:5])
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+247
View File
@@ -0,0 +1,247 @@
"""Regressionstreiber für analyse_transkripte.py: fährt die Fälle unter fixture/ und die Mutanten, die jeder Fall fangen soll.
Ersteller: Pascal Michel
Erstellt: 2026-10-30
Doku: LIESMICH.md in diesem Ordner
Was er belegt: die drei Zählregeln (größter Wert je message.id, Deduplizierung über Dateigrenzen,
Haiku-5.5-Stufe über 100k Prompt), die Ablehnung einer Ausgabe im Vault, den fehlenden
projects-Ordner, kaputte Zeilen ohne Absturz und die Auswahl nach Titel. Jeder Fall läuft einzeln
gegen einen eigenen Fixture-Ordner und einen frischen Ausgabeordner im System-Temp; zuerst die
Grundlinie, scheitert sie, messen die übrigen Fälle nichts.
Rotnachweis: Je Mutante wird eine Kopie des Skripts mit genau einer Ersetzung gebaut; die Mutante
gilt als gefangen, wenn der ihr zugeordnete Fall rot wird. Eine Ersetzung, die im Skript nicht
(genau einmal) vorkommt, bricht mit Exit 2 ab, statt still nichts zu prüfen.
Was er nicht sieht: echte Transkripte, Preise (PREISE wird nicht gegen eine Quelle geprüft), die
Berichtstabellen außer den hier abgefragten Zahlen, claudedata_wurzel.py und scratchpads.py.
Ein Fangnetz, kein Nachweis.
Aufruf: python -I -X utf8 faelle.py [--ohne-mutanten]
Exit 0 alle Fälle grün und alle Mutanten gefangen, 1 sonst, 2 bei veraltetem Mutantenmuster.
"""
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HIER = Path(__file__).resolve().parent
SKRIPT = HIER / "analyse_transkripte.py"
FIXTURE = HIER / "fixture"
VAULT = HIER.parents[1]
def lauf(skript, fixture, ausgabe, *extra):
r = subprocess.run([sys.executable, "-I", "-X", "utf8", str(skript), str(fixture), "--ausgabe", str(ausgabe), *extra],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120)
daten = None
j = Path(ausgabe) / "auswertung.json"
if r.returncode == 0 and j.exists():
daten = json.loads(j.read_text(encoding="utf-8"))
return r, daten
def sitzung(d, sid):
return next((s for s in d["sessions"] if s["sid"] == sid), None)
def nahe(a, b):
return abs(a - b) < 1e-9
def f_grundlinie(skript, tmp):
r, d = lauf(skript, FIXTURE / "grundlinie", tmp)
if d is None:
return f"Exit {r.returncode}: {r.stdout[-200:]}{r.stderr[-200:]}"
s = sitzung(d, "sess-g")
if not s or s["turns"] != 1 or s["out"] != 100 or not nahe(s["usd"], 0.006):
return f"erwartet 1 Runde, 100 Ausgabe, 0.006 USD; gesehen {s and (s['turns'], s['out'], s['usd'])}"
if d["kennzahlen"]["doppelt_dateiuebergreifend"] or d["kennzahlen"]["nachrichten_mit_teilausgabe"] or d["befunde"]:
return f"Grundlinie meldet Kopie, Teilausgabe oder Befund: {d['kennzahlen']} {d['befunde']}"
return None
def teilausgabe(ordner, agent, erwartet_teil):
def fall(skript, tmp):
r, d = lauf(skript, FIXTURE / ordner, tmp)
if d is None:
return f"Exit {r.returncode}"
sa = next((x for x in d["subagents"] if x["agent"] == agent), None)
if not sa or sa["turns"] != 1 or sa["out"] != 900:
return f"Subagent: erwartet 1 Runde mit 900 Ausgabe, gesehen {sa and (sa['turns'], sa['out'])}"
teil = d["kennzahlen"]["nachrichten_mit_teilausgabe"]
if teil != erwartet_teil:
return f"Teilausgabe gezählt {teil}, erwartet {erwartet_teil}"
if erwartet_teil and d["teilausgabe_version"] != {"2.1.300": 1}:
return f"Verteilung nach Version falsch: {d['teilausgabe_version']}"
return None
return fall
def kopie(ordner):
def fall(skript, tmp):
r, d = lauf(skript, FIXTURE / ordner, tmp)
if d is None:
return f"Exit {r.returncode}"
runden = sum(s["turns"] for s in d["sessions"])
out = sum(s["out"] for s in d["sessions"])
if runden != 2 or out != 300 or d["kennzahlen"]["doppelt_dateiuebergreifend"] != 1:
return f"erwartet 2 Runden, 300 Ausgabe, 1 Kopie; gesehen {runden}, {out}, {d['kennzahlen']['doppelt_dateiuebergreifend']}"
return None
return fall
def f_haiku_ueber(skript, tmp):
r, d = lauf(skript, FIXTURE / "haiku-ueber-100k", tmp)
if d is None:
return f"Exit {r.returncode}"
s = sitzung(d, "sess-h")
# 150000 x 0.50 + 1000 x 2.50 (Stufe über 100k) plus 50000 x 0.10 + 1000 x 0.50, je /1e6
if not s or not nahe(s["usd"], 0.0775 + 0.0055):
return f"erwartet 0.083 USD, gesehen {s and s['usd']}"
return None
def f_haiku_grenze(skript, tmp):
r, d = lauf(skript, FIXTURE / "haiku-grenze-100k", tmp)
if d is None:
return f"Exit {r.returncode}"
s = sitzung(d, "sess-k")
# Genau 100000 Prompt-Tokens liegen noch in der Grundstufe: 100000 x 0.10 / 1e6
if not s or not nahe(s["usd"], 0.01):
return f"erwartet 0.01 USD, gesehen {s and s['usd']}"
return None
def f_kaputte_zeilen(skript, tmp):
r, d = lauf(skript, FIXTURE / "kaputte-zeilen", tmp)
if d is None:
return f"Exit {r.returncode}: {r.stderr[-200:]}"
s = sitzung(d, "sess-z")
if not s or s["turns"] != 1 or d["befunde"]:
return f"erwartet 1 Runde ohne Befund, gesehen {s and s['turns']} und {d['befunde']}"
return None
def f_auswahl(skript, tmp):
r, d = lauf(skript, FIXTURE / "auswahl", tmp, "--auswahl", "paket probe")
a = Path(tmp) / "auswahl.md"
if d is None or not a.exists():
return f"Exit {r.returncode}, auswahl.md fehlt"
text = a.read_text(encoding="utf-8")
if "Paket Probe eins" not in text or "Anderes Thema" in text or "Treffer: 1 Hauptsitzungen" not in text:
return "auswahl.md trifft nicht genau die Sitzung mit passendem Titel"
return None
def f_ausgabe_im_vault(skript, tmp):
ziel = VAULT / "_verbrauch_auswertung_probe"
if ziel.exists():
return f"Vorbedingung: {ziel} existiert schon"
r, d = lauf(skript, FIXTURE / "grundlinie", ziel)
angelegt = ziel.exists()
if angelegt:
shutil.rmtree(ziel)
if r.returncode != 2 or angelegt:
return f"erwartet Exit 2 ohne angelegten Ordner, gesehen Exit {r.returncode}, angelegt {angelegt}"
return None
def f_projects_fehlt(skript, tmp):
r, d = lauf(skript, Path(tmp) / "gibt-es-nicht", Path(tmp) / "aus")
if r.returncode != 2:
return f"erwartet Exit 2, gesehen {r.returncode}"
return None
FAELLE = [
("grundlinie", f_grundlinie),
("teilausgabe-erste-zeile", teilausgabe("teilausgabe-erste-zeile", "agent-t1", 1)),
("teilausgabe-letzte-zeile", teilausgabe("teilausgabe-letzte-zeile", "agent-l1", 0)),
("kopie-vorn", kopie("kopie-vorn")),
("kopie-hinten", kopie("kopie-hinten")),
("haiku-ueber-100k", f_haiku_ueber),
("haiku-grenze-100k", f_haiku_grenze),
("kaputte-zeilen", f_kaputte_zeilen),
("auswahl", f_auswahl),
("ausgabe-im-vault", f_ausgabe_im_vault),
("projects-fehlt", f_projects_fehlt),
]
# (Name, alter Text, neuer Text, Fall, der rot werden muss)
MUTANTEN = [
("erste Zeile behalten", 'rec["u"][k] = max(rec["u"][k], u.get(k) or 0)', "pass", "teilausgabe-erste-zeile"),
("letzte Zeile statt größter Wert", 'rec["u"][k] = max(rec["u"][k], u.get(k) or 0)', 'rec["u"][k] = u.get(k) or 0', "teilausgabe-letzte-zeile"),
("keine Deduplizierung über Dateien", " if mid in GESEHEN:", " if False:", "kopie-vorn"),
("keine Haiku-Stufe", 'if mk == "claude-haiku-5-5" and ctx > 100000:', 'if mk == "claude-haiku-5-5" and ctx > 10**12:', "haiku-ueber-100k"),
("Haiku-Grenze einschließlich", 'if mk == "claude-haiku-5-5" and ctx > 100000:', 'if mk == "claude-haiku-5-5" and ctx >= 100000:', "haiku-grenze-100k"),
("Teilausgabe nicht nach Version", ' agg["teilausgabe_version"][rec["version"]] += 1\n', "", "teilausgabe-erste-zeile"),
("Zeile ohne Typprüfung", " if not isinstance(o, dict):\n continue\n", "", "kaputte-zeilen"),
("Vault-Sperre fehlt", " if im_vault(OUT):", " if False:", "ausgabe-im-vault"),
("fehlender Ordner nicht gemeldet", " if not os.path.isdir(ROOT):", " if False:", "projects-fehlt"),
("Auswahl ohne Filter", "if s[\"turns_seit\"] > 0 and any(rx.search", "if s[\"turns_seit\"] > 0 and True or any(rx.search", "auswahl"),
]
def fahre(skript, nur=None):
ergebnisse = {}
for name, fall in FAELLE:
if nur and name not in nur:
continue
tmp = tempfile.mkdtemp(prefix="va-fall-")
try:
ergebnisse[name] = fall(skript, tmp)
except Exception as e: # Ein Absturz des Falls ist ein roter Fall, kein Abbruch des Treibers.
ergebnisse[name] = f"Absturz im Fall: {type(e).__name__}: {e}"
finally:
shutil.rmtree(tmp, ignore_errors=True)
if name == "grundlinie" and ergebnisse[name]:
break
return ergebnisse
def main(argv):
rot = 0
erg = fahre(SKRIPT)
for name, _ in FAELLE:
if name not in erg:
print(f"NICHT GEFAHREN {name} (Grundlinie rot)")
rot += 1
continue
print(("ROT " if erg[name] else "gruen ") + name + (f": {erg[name]}" if erg[name] else ""))
rot += bool(erg[name])
if rot or "--ohne-mutanten" in argv:
print(f"{len(FAELLE) - rot} von {len(FAELLE)} Fällen grün")
return 1 if rot else 0
quelle = SKRIPT.read_text(encoding="utf-8")
# Die Kopie liegt im Temp; ohne festgenagelten VAULT hielte sie das Temp für den Vault und lehnte jede Ausgabe ab.
vault_zeile = "VAULT = Path(__file__).resolve().parents[2]"
if quelle.count(vault_zeile) != 1:
print("Mutantenmuster veraltet: VAULT-Zeile")
return 2
quelle = quelle.replace(vault_zeile, f"VAULT = Path({str(VAULT)!r})")
entkommen = 0
for name, alt, neu, ziel in MUTANTEN:
if quelle.count(alt) != 1:
print(f"Mutantenmuster veraltet ({quelle.count(alt)} Treffer): {name}")
return 2
tmp = Path(tempfile.mkdtemp(prefix="va-mut-"))
try:
m = tmp / "analyse_transkripte.py"
m.write_text(quelle.replace(alt, neu), encoding="utf-8")
e = fahre(m, nur={"grundlinie", ziel})
finally:
shutil.rmtree(tmp, ignore_errors=True)
if e.get("grundlinie"):
print(f"UNGUELTIG {name}: Grundlinie der Mutante rot ({e['grundlinie']})")
entkommen += 1
continue
gefangen = bool(e.get(ziel))
entkommen += not gefangen
print(("gefangen " if gefangen else "ENTKOMMEN ") + f"{name} -> {ziel}" + (f" ({e.get(ziel)})" if gefangen else ""))
print(f"{len(FAELLE)} Fälle grün; {len(MUTANTEN) - entkommen} von {len(MUTANTEN)} Mutanten gefangen")
return 1 if entkommen else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,2 @@
{"type": "custom-title", "customTitle": "Paket Probe eins"}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_s1", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "custom-title", "customTitle": "Anderes Thema"}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_s2", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "user", "timestamp": "2026-10-20T09:59:00.000Z", "message": {"role": "user", "content": "synthetisch"}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_g1", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "user", "timestamp": "2026-10-20T09:59:00.000Z", "message": {"role": "user", "content": "synthetisch"}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_k1", "model": "claude-haiku-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 100000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_h1", "model": "claude-haiku-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 150000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1000}, "content": [{"type": "text", "text": "synthetisch"}]}}
{"type": "assistant", "timestamp": "2026-10-20T10:01:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_h2", "model": "claude-haiku-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 50000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 1000}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,3 @@
{kein json
[1]
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_z1", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "user", "timestamp": "2026-10-20T09:59:00.000Z", "message": {"role": "user", "content": "synthetisch"}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_x", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "assistant", "timestamp": "2026-10-20T11:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_y", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 2000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 200}, "content": [{"type": "text", "text": "synthetisch"}]}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_x", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "user", "timestamp": "2026-10-20T09:59:00.000Z", "message": {"role": "user", "content": "synthetisch"}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_x", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_x", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
{"type": "assistant", "timestamp": "2026-10-20T11:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_y", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 2000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 200}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "user", "timestamp": "2026-10-20T09:59:00.000Z", "message": {"role": "user", "content": "synthetisch"}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_t0", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": true, "message": {"id": "msg_t1", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 7}, "content": [{"type": "text", "text": "synthetisch"}]}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:05.000Z", "version": "2.1.300", "effort": "high", "isSidechain": true, "message": {"id": "msg_t1", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 900}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "user", "timestamp": "2026-10-20T09:59:00.000Z", "message": {"role": "user", "content": "synthetisch"}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": false, "message": {"id": "msg_l0", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 100}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,2 @@
{"type": "assistant", "timestamp": "2026-10-20T10:00:00.000Z", "version": "2.1.300", "effort": "high", "isSidechain": true, "message": {"id": "msg_l1", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 900}, "content": [{"type": "text", "text": "synthetisch"}]}}
{"type": "assistant", "timestamp": "2026-10-20T10:00:05.000Z", "version": "2.1.300", "effort": "high", "isSidechain": true, "message": {"id": "msg_l1", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 1000, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 7}, "content": [{"type": "text", "text": "synthetisch"}]}}
@@ -0,0 +1,95 @@
"""Listet die Sitzungs-Scratchpads eines Projekts mit Größe, Titel und letzter Aktivität des Transkripts.
Ersteller: Pascal Michel
Erstellt: 2026-10-30
Doku: LIESMICH.md in diesem Ordner
Nur lesend. Gibt Sitzungskennung (acht Zeichen), Titel, Größe und Alter aus, keine Dateiinhalte.
Was es nicht sieht: welche Sitzungen gerade laufen. Die nennst du mit --laufend (Kennungsanfänge der
Claude-Code-Sitzung, nicht die local_-Kennung der App; Zuordnung nur über den Titel). Das Alter kommt
aus der mtime des Transkripts, die nach einem App-Neustart für viele Sitzungen frisch ist
(Harness-Erinnerung transkript-usage-groesster-wert-je-nachricht).
Aufruf: python -I -X utf8 scratchpads.py [--scratch <Ordner>] [--projekt <Ordner>] [--laufend id8,id8] [--ab-mb 50]
"""
import argparse
import collections
import json
import os
import sys
import time
def groesse_kb(p):
total = 0
for root, dirs, files in os.walk(p):
# Junctions nicht betreten, sonst zählt ein verlinkter Ordner doppelt (os.path.isjunction ab Python 3.12).
dirs[:] = [d for d in dirs if not getattr(os.path, "isjunction", lambda _: False)(os.path.join(root, d))]
for f in files:
try:
total += os.lstat(os.path.join(root, f)).st_size
except OSError:
pass
return total // 1024
def titel_aus(ct):
try:
with open(ct, encoding="utf-8") as f:
d = json.load(f)
except (OSError, ValueError):
return None
return (d.get("customTitle") or d.get("title")) if isinstance(d, dict) else None
def main(argv=None):
ap = argparse.ArgumentParser(description="Sitzungs-Scratchpads mit Größe und letzter Aktivität")
ap.add_argument("--scratch", default=r"D:\ClaudeData\claude\D--Claude")
ap.add_argument("--projekt", default=r"D:\ClaudeData\config\projects\D--Claude")
ap.add_argument("--laufend", default="", help="kommagetrennte Kennungsanfänge laufender Sitzungen")
ap.add_argument("--ab-mb", type=float, default=50, help="nur Scratchpads ab dieser Größe einzeln auflisten")
a = ap.parse_args(argv)
if not os.path.isdir(a.scratch) or not os.path.isdir(a.projekt):
print(f"Ordner fehlt: {a.scratch if not os.path.isdir(a.scratch) else a.projekt}")
return 2
laufend = {x.strip()[:8] for x in a.laufend.split(",") if x.strip()}
jetzt = time.time()
zeilen = []
for name in os.listdir(a.scratch):
d = os.path.join(a.scratch, name)
if not os.path.isdir(d):
continue
tr = os.path.join(a.projekt, name + ".jsonl")
letzte = os.path.getmtime(tr) if os.path.exists(tr) else None
titel = titel_aus(os.path.join(a.projekt, name, "custom-title.json"))
tage = (jetzt - letzte) / 86400 if letzte else None
zeilen.append((groesse_kb(d), name, titel, tage, letzte is not None))
zeilen.sort(reverse=True)
print("| Sitzung | Titel | Scratchpad MB | letzte Transkript-Aktivität | Klasse |")
print("|---|---|---|---|---|")
summen = collections.Counter(); anzahl = collections.Counter()
for kb, name, titel, tage, hat_tr in zeilen:
if name[:8] in laufend:
klasse = "läuft"
elif not hat_tr:
klasse = "kein Transkript"
elif tage <= 7:
klasse = "aktiv ≤ 7 Tage"
elif tage <= 14:
klasse = "ruhend 7-14 Tage"
else:
klasse = "ruhend > 14 Tage"
summen[klasse] += kb; anzahl[klasse] += 1
if kb > a.ab_mb * 1024:
print(f"| {name[:8]} | {(titel or '-')[:45]} | {kb/1024:.0f} | {('vor %.1f Tagen' % tage) if tage is not None else '-'} | {klasse} |")
print()
print("| Klasse | Sitzungen | GB |")
print("|---|---|---|")
for k in summen:
print(f"| {k} | {anzahl[k]} | {summen[k]/1024/1024:.1f} |")
print(f"| Summe | {sum(anzahl.values())} | {sum(summen.values())/1024/1024:.1f} |")
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -2
View File
@@ -5,7 +5,7 @@ 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-10-17
updated: 2026-10-30
---
# Workflow-Prüfung
@@ -41,9 +41,11 @@ Stand 2026-09-24 nach Plankritik, Prüfung und Nachbesserung. Die Zahlen gelten
- **Ableser verlässt den Auftrag** (2026-10-16, Plan `docs/plans/2026-10-16-ableser-abweichung/`): Zweimal bearbeitete der Haiku-Ableser die Nutzeranfrage statt `get_usage` zu lesen und schrieb seine Antwort in `status` und `plan` (wf_7ca17029-421, wf_80616e8e-0e3). `verbrauch.js` meldet diese Form jetzt als `status: abgewichen` mit einer Logzeile, die mit `WARNUNG` beginnt; Plätze wie ohne Ablesung. Neun neue Fälle, sieben davon gegen den alten Stand rot; die zwei Gegenproben (ehrlicher Fehlschlag, `not_applicable`) bleiben auf beiden Ständen grün. Acht neue Mutanten, alle gefangen; der volle Mutantenlauf brauchte knapp 50 Minuten. Die drei echten Antworten aus den Journalen ergaben `abgewichen`, `abgewichen` und `nicht_gelesen`. **Ungemessen:** ob der neue Brückensatz im Auftrag einen echten Ableser vom Abweichen abhält; der Fall misst nur, dass der Satz dasteht. Ein abweichender Ableser hat weiterhin alle Werkzeuge.
- **Ableser als Agententyp** (2026-10-17, Task 02 desselben Plans): Der Ableser läuft als `agentType: verbrauch-ableser` (Haiku, `effort: low`, nur ToolSearch und `get_usage`). Ein Startfehler, etwa ein in der Sitzung noch unbekannter Typ, kommt als `nicht_gelesen` mit `WARNUNG` zurück, nie als Rückfall auf einen Agenten mit allen Werkzeugen. Echtläufe: Vor der Registrierung warf `agent()` „agent type 'verbrauch-ableser' not found“ ohne Agent (`wf_63cb007d-71b`); danach lasen vier von vier Ablesern richtig, mit Effort `low` und nur ToolSearch, `get_usage` und StructuredOutput (`wf_1901f51f-dd3`, `wf_4d8a69f9-51a`). Die Sitzung kannte die neue Agentendatei nach etwa zehn Minuten. 261 Fälle grün, drei neue gegen den alten Stand rot; 157 Mutanten, alle gefangen oder als wirkungslos geführt. **Ungemessen:** ein Ableser, der eine abweichende Nutzeranfrage sieht. Alle vier Echtläufe starteten in Zügen ohne Nutzernachricht, der Harness reichte keine Anfrage durch.
- **Eichung der Stufen** (2026-10-30, Workflow-Review S-11): `verbrauch_reihe.py` über den ganzen Speicher ergab 1.245 Ablesungen und 647 Paare. Getrennt wurde mit einem Wegwerfskript, das die Funktionen von `verbrauch_reihe.py` importiert: Plan aus dem Feld `plan` der Ablesung, Konto aus Wochentag und Stunde des Wochen-`resetsAt`, auf die volle Stunde gerundet, weil der Wert über die Stundengrenze zittert. Zwei Konten: Reset montags 00:00 UTC (Pro bis 2026-09-30, Max ab 2026-10-05) und mittwochs 20:00 UTC (Max ab 2026-10-09). 340 Ablesungen ohne Plan waren gescheiterte oder abgewichene Ablesungen. Max: 9,2 bzw. 10,1 % des 5-Stunden-Fensters und 2,5 bzw. 2,6 % der Woche je Opus-Agentenstunde (187 und 205 Paare, 107 und 125 Opus-Stunden), Median je Paar ab 15 Opus-Minuten 8,6 bzw. 9,0, P90 rund 19. Pro: 14 Paare, zu wenig. Anstieg des Fensters je Paar nach gleichzeitig laufenden Opus-Agenten: einer Median 3, P90 13, Spitze 33 Punkte (211 Paare); zwei 4, 14, 42 (134); drei 5, 9, 18 (45); vier nur 2 Paare. Pascal hat die Stufen daraufhin unverändert bestätigt und Stufe 3 freigegeben. **Ungemessen:** Max zeigt je Opus-Stunde mehr Prozent als Pro; als reiner Planeffekt ist das unplausibel, wahrscheinlich liefen im Max-Zeitraum mehr Sitzungen parallel. Die Kontentrennung kann `verbrauch_reihe.py` selbst nicht.
- **`planung` mit `effort`** (2026-10-30, für die Messung A2): Das Argument wird geprüft (`low` bis `max`), an jeden Planer neben `agentType` weitergereicht und in der Rückgabe genannt, ohne Argument `(Frontmatter)`. Drei neue Fälle, alle drei gegen den alten Stand rot; drei neue Mutanten, einzeln gefahren und alle gefangen. Der volle Mutantenlauf ist dafür nicht gelaufen.
**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.
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 am 2026-10-30 geeicht, aber an einer Messreihe mit dem Verbrauch paralleler Sitzungen. Sie sind ein Fangnetz und kein Nachweis dafür, dass ein Lauf das Fenster nicht reißt.
@@ -614,6 +614,18 @@ fall('plankritik', 'mit kopie, mutanten in Vorgabe: ein Agent werkzeug:mutanten,
return fehler
})
fall('plankritik', 'Modell je Hilfslabel: kopie haiku/low, planwerkzeuge und mutanten sonnet/low', async () => {
const { fehler, soll } = pruefe()
const args = { projekt: 'D:/x', planordner: 'D:/x/plan', materialisieren: { ohne: ['config/secrets.toml'] } }
const { fehler: f, protokoll: p } = await PK(args, {})
soll(f === null, 'Fehler: ' + f)
for (const [l, m] of [['werkzeug:kopie', 'haiku'], ['werkzeug:planwerkzeuge', 'sonnet'], ['werkzeug:mutanten', 'sonnet']]) {
const a = mitLabel(p, l)
soll(a.length === 1 && a[0].modell === m && a[0].effort === 'low' && !a[0].agentType, l + ' ' + JSON.stringify(a.map(x => [x.modell, x.effort, x.agentType])))
}
return fehler
})
fall('plankritik', 'mutanten "aus": kein Agent werkzeug:mutanten', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await PK({ ...PK_ARGS, mutanten: 'aus' }, {})
@@ -832,6 +844,18 @@ fall('welle-mit-pruefung', 'Grundlinie: Welle, Vergleich, Ablesung um die Prüfu
return fehler
})
fall('welle-mit-pruefung', 'Modell je Hilfslabel: kopie haiku/low, vergleich sonnet/low', async () => {
const { fehler, soll } = pruefe()
const { kopie, ...ohneKopie } = WM_ARGS
const { fehler: f, protokoll: p } = await WM({ ...ohneKopie, ohne: ['config/secrets.toml'] }, {})
soll(f === null, 'Fehler: ' + f)
for (const [l, m] of [['werkzeug:kopie', 'haiku'], ['werkzeug:vergleich', 'sonnet']]) {
const a = mitLabel(p, l)
soll(a.length === 1 && a[0].modell === m && a[0].effort === 'low' && !a[0].agentType, l + ' ' + JSON.stringify(a.map(x => [x.modell, x.effort, x.agentType])))
}
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' }, {})
@@ -1065,6 +1089,7 @@ for (const [name, aendern, text] of [
['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/],
['effort ungültig', a => ({ ...a, effort: 'mittel' }), /effort "mittel" ungültig/],
]) {
fall('planung', `${name} wirft vor dem ersten Agenten`, async () => {
const { fehler, soll } = pruefe()
@@ -1087,6 +1112,25 @@ fall('planung', 'Auftrag nennt die Voraussetzungen und ersetzt alle Platzhalter'
return fehler
})
fall('planung', 'effort medium: alle Planer auf medium, Ableser unverändert', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, fehler: f, protokoll: p } = await PL({ ...PL_ARGS, effort: 'medium' }, {})
soll(f === null && e.status === 'fertig', 'Fehler/status: ' + f + ' ' + (e && e.status))
const pl = p.agents.filter(x => x.label.startsWith('planer:'))
soll(pl.length === 7 && pl.every(x => x.effort === 'medium' && x.agentType === 'planer' && x.modell === 'opus'), 'Planer: ' + JSON.stringify(pl.map(x => [x.effort, x.agentType])))
soll(mitLabel(p, 'verbrauch:').every(x => x.effort === undefined), 'Ableser mit effort')
soll(e.effort === 'medium', 'Rückgabe effort ' + e.effort)
return fehler
})
fall('planung', 'ohne effort: Planer ohne effort, Rückgabe nennt das Frontmatter', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, {})
soll(p.agents.filter(x => x.label.startsWith('planer:')).every(x => x.effort === undefined), 'Planer mit effort')
soll(e.effort === '(Frontmatter)', 'Rückgabe effort ' + e.effort)
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) }, {})
@@ -1187,7 +1231,7 @@ fall('quorum', 'Grundlinie: Paket, drei Stimmen gleichzeitig, Mehrheit sperrt, E
soll(f === null, 'Fehler: ' + f)
soll(e && e.status === 'fertig' && e.urteil === 'nicht_frei', 'status/urteil ' + JSON.stringify(e && [e.status, e.urteil, e.grund]))
const erst = p.agents.slice().sort((x, y) => x.start - y.start)[0]
soll(erst && erst.label === 'werkzeug:paket' && erst.modell === 'sonnet' && erst.effort === 'low', 'erster Agent ' + JSON.stringify(erst && [erst.label, erst.modell, erst.effort]))
soll(erst && erst.label === 'werkzeug:paket' && erst.modell === 'haiku' && erst.effort === 'low' && !erst.agentType,'erster Agent ' + JSON.stringify(erst && [erst.label, erst.modell, erst.effort]))
const ab = mitLabel(p, 'verbrauch:').map(x => x.label)
soll(JSON.stringify(ab) === JSON.stringify(['verbrauch:quorum:vor Stimmen', 'verbrauch:quorum:Ende']), 'Ablesungen: ' + JSON.stringify(ab))
const opus = mitLabel(p, 'stimme:opus')[0]
@@ -1198,7 +1242,7 @@ fall('quorum', 'Grundlinie: Paket, drei Stimmen gleichzeitig, Mehrheit sperrt, E
soll(mitLabel(p, 'stimme:haiku').length === 0, 'Haiku trotz gültiger Codex-Stimme')
const start = mitLabel(p, 'codex:start')[0]
soll(start && laufendBeiStart(p, start, 'stimme:') === 2, 'Codex startete nicht neben Opus und Sonnet')
soll(mitLabel(p, 'codex:').length === 3 && mitLabel(p, 'codex:').every(x => x.modell === 'sonnet' && x.effort === 'low' && !x.agentType), 'Codex-Hilfsagenten: ' + JSON.stringify(mitLabel(p, 'codex:').map(x => [x.label, x.modell, x.effort])))
soll(mitLabel(p, 'codex:').length === 3 && mitLabel(p, 'codex:').every(x => x.modell === 'haiku' && x.effort === 'low' && !x.agentType), 'Codex-Hilfsagenten: ' + JSON.stringify(mitLabel(p, 'codex:').map(x => [x.label, x.modell, x.effort])))
const zf = mitLabel(p, 'zusammenfuehren')[0]
const np = mitLabel(p, 'nachpruefung')[0]
soll(zf && zf.modell === 'sonnet' && zf.effort === 'medium' && !zf.agentType, 'Zusammenführen ' + JSON.stringify(zf && [zf.modell, zf.effort, zf.agentType]))
@@ -1436,6 +1480,27 @@ fall('quorum', 'Codex nie fertig: so viele Warterunden wie codex_runden, dann Ab
return fehler
})
fall('quorum', 'Modell je Hilfslabel: paket und alle codex-Labels haiku/low ohne agentType, zusammenfuehren sonnet/medium', async () => {
const { fehler, soll } = pruefe()
const { fehler: f, protokoll: p } = await QR(Q_ARGS, {})
soll(f === null, 'Fehler: ' + f)
for (const l of ['werkzeug:paket', 'codex:start', 'codex:warten:1', 'codex:ende']) {
const a = mitLabel(p, l)[0]
soll(a && a.modell === 'haiku' && a.effort === 'low' && !a.agentType, l + ' ' + JSON.stringify(a && [a.modell, a.effort, a.agentType]))
}
const zf = mitLabel(p, 'zusammenfuehren')[0]
soll(zf && zf.modell === 'sonnet' && zf.effort === 'medium' && !zf.agentType, 'zusammenfuehren ' + JSON.stringify(zf && [zf.modell, zf.effort, zf.agentType]))
return fehler
})
fall('quorum', 'Modell codex:abbruch: haiku/low ohne agentType', async () => {
const { fehler, soll } = pruefe()
const { protokoll: p } = await QR({ ...Q_ARGS, codex_runden: 1 }, { codexWarten: () => Q_WARTEN_OFFEN })
const ab = mitLabel(p, 'codex:abbruch')[0]
soll(ab && ab.modell === 'haiku' && ab.effort === 'low' && !ab.agentType, 'codex:abbruch ' + JSON.stringify(ab && [ab.modell, ab.effort, ab.agentType]))
return fehler
})
fall('quorum', 'Codex nie fertig ohne codex_runden: Vorgabe drei Warterunden', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e, protokoll: p } = await QR(Q_ARGS, { codexWarten: () => Q_WARTEN_OFFEN })
@@ -41,6 +41,8 @@ MUTANTEN = [
("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", "werkzeug:kopie wieder auf sonnet", "den Plan darauf anwenden', '', 'haiku')", "den Plan darauf anwenden', '', 'sonnet')"),
("plankritik", "Planwerkzeuge und Mutantenlauf auf haiku", "zusatz, modell = 'sonnet')", "zusatz, modell = 'haiku')"),
("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 ?"),
@@ -50,6 +52,8 @@ MUTANTEN = [
("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", "werkzeug:kopie wieder auf sonnet", "Taskdateien der Welle darauf anwenden', 'haiku')", "Taskdateien der Welle darauf anwenden', 'sonnet')"),
("welle-mit-pruefung", "Vergleich auf haiku", "zweck, modell = 'sonnet')", "zweck, modell = 'haiku')"),
("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"),
@@ -68,6 +72,9 @@ MUTANTEN = [
("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", "effort nicht durchgereicht", " if (args.effort) opts.effort = args.effort\n", ""),
("planung", "effort ungeprueft", " if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw", " if (false) throw"),
("planung", "Rueckgabe ohne effort", " effort: args.effort || '(Frontmatter)',\n", ""),
("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"),
("plankritik", "Mutantenlauf-Rohausgabe geht nicht an arithmetik", "${mutantenText(ctx.mutantenLauf)}`,", "`,"),
@@ -92,6 +99,8 @@ MUTANTEN = [
("plankritik", "Muster-Satz fehlt (N5)", "musterModus\n ? ' Kein Mutant ist gefahren; OK heißt hier nur, dass das Muster genau einmal in der Datei steht; ob ein Test fällt, ist unbelegt.'\n : ", ""),
("plankritik", "Exit 2 wird wie 0 behandelt (N7)", "exitCode === 2 || exitCode === 3", "false"),
("quorum", "Mehrheit schon bei einer Stimme", "mehrheit: beteiligt.length >= 2", "mehrheit: beteiligt.length >= 1"),
("quorum", "codex:ende wieder auf sonnet", "schema: CODEX, model: 'haiku'", "schema: CODEX, model: 'sonnet'"),
("quorum", "Werkzeug-Hilfsagenten wieder auf sonnet", "schema: LAUF, model: 'haiku'", "schema: LAUF, model: 'sonnet'"),
("quorum", "Bremse vor den Stimmen fehlt", "if (vorStimmen.gebremst) {", "if (false) {"),
("quorum", "Opus-Stimme ohne Ablesung", "const vorStimmen = await ablesen('vor Stimmen', 1)", "const vorStimmen = { gebremst: false, grund: '' }"),
("quorum", "keine Ablesung am Ende", "await ablesen('Ende', 0)\nconst zaehlung = {}", "const zaehlung = {}"),