Automatischer Push: 46 Datei(en) geändert
- .claude/agents/planer.md - .claude/settings.json - .claude/workflows/executer-welle.js - .claude/workflows/plankritik.js - .claude/workflows/planung.js - .claude/workflows/quorum.js - .claude/workflows/verbrauch.js - .claude/workflows/welle-mit-pruefung.js - .vault-tools/shell-wache/ENTWURF.md - .vault-tools/shell-wache/docs/code/shell_wache.md - .vault-tools/shell-wache/docs/plans/2026-10-30-shell-wache/00-uebersicht.md - .vault-tools/shell-wache/docs/plans/2026-10-30-shell-wache/task-01-shell-wache.md - … und 34 weitere Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
f2ccec39d6
commit
13213ea0ac
46 files changed
+2380
-48
No files matched your search
@@ -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.
|
||||
+115
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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"}]}}
|
||||
+2
@@ -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"}]}}
|
||||
+2
@@ -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())
|
||||
@@ -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 = {}"),
|
||||
|
||||
Reference in new issue
Block a user