diff --git a/.claude/agents/planer.md b/.claude/agents/planer.md index c5d0f52..238aa83 100644 --- a/.claude/agents/planer.md +++ b/.claude/agents/planer.md @@ -18,6 +18,7 @@ Du denkst, du tippst nicht. Dein Ergebnis ist ein Plan, den ein Executer ohne R - **Erst lesen, dann planen.** Bestehende Struktur, Namensgebung, Testkonventionen und Abhängigkeiten des Zielprojekts lesen. Der Plan passt sich der vorhandenen Umgebung an, nicht umgekehrt. - **Erwartete pytest-Fehlerzeilen nur mit breiter Ausgabe.** Ohne Terminal kürzt pytest (gemessen an 9.1.1) jede `FAILED`-Zeile der Kurzzusammenfassung auf 80 Zeichen, die Fehlerart ist dann abgeschnitten („Failed: D..."). Nennt der Plan eine erwartete Fehlerzeile wörtlich, steht im Befehl `COLUMNS=300` davor (Git-Bash) bzw. `$env:COLUMNS=300;` (PowerShell). - **Keine Platzhalter.** Kein „analog zu Task 2", kein „hier noch ergänzen", kein Pseudocode. Was im Plan steht, ist tippfertig. +- **Nachbesserung nach einer ausgeführten Welle schreibst du als neue Taskdateien, nicht als Neufassung.** Du hast kein Edit-Werkzeug und schreibst jede überarbeitete Datei komplett neu; am 2026-09-23 im Nexhorizons Hub waren dabei 10 von 17 Befunden der Nachprüfung durch das Umschreiben selbst entstanden. Deshalb: neue `task-NN-nachbesserung-.md` mit `Aus:`/`wird:`-Blöcken gegen den Baum, bestehende Taskdateien bleiben byte-gleich. Was ältere Tasks an den neuen Stand anpassen muss, schreibst du als Folgenliste `nachbesserung--folgen.md` im selben `Aus:`/`wird:`-Format; eingesetzt wird sie vom Koordinator oder einem Executer. Für Korrekturen vor der ersten Ausführung gilt das nicht, außer der Auftrag verlangt es. - **Offene Entscheidungen benennen, nicht überspielen.** Wo der Auftrag mehrdeutig ist: Annahme treffen, im Abschnitt „Getroffene Annahmen" explizit nennen. Nur abbrechen, wenn jede Annahme den Plan unbrauchbar machen würde. ## Aufbau des Plans diff --git a/.claude/skills/plan-executer-pruefung/SKILL.md b/.claude/skills/plan-executer-pruefung/SKILL.md index 2e91fa7..c0d8f23 100644 --- a/.claude/skills/plan-executer-pruefung/SKILL.md +++ b/.claude/skills/plan-executer-pruefung/SKILL.md @@ -29,8 +29,9 @@ wird. Für die Plankritik in Schritt 2, die Welle in Schritt 3 und die Prüfung in Schritt 4 gibt es gespeicherte Workflows mit eingebauten Rollenparametern: `plankritik`, `executer-welle` und `pruefung` unter `.claude/workflows/`, dazu `welle-mit-pruefung` für Schritt 3 und 4 in einem -Aufruf. Aufruf und Argumente stehen jeweils in `whenToUse` im -Kopf der Datei. Sie ersetzen deine eigene Gegenprüfung nicht. +Aufruf und `planung` für die Erstplanung vieler Taskdateien. Aufruf und Argumente stehen jeweils +in `whenToUse` im Kopf der Datei. Wie viele Opus-Agenten sie gleichzeitig starten, regelt +`codeworkflow.md`, „Verbrauch und Opus-Plätze". Sie ersetzen deine eigene Gegenprüfung nicht. Danach je nach Aufgabe zusätzlich: @@ -46,7 +47,8 @@ Danach je nach Aufgabe zusätzlich: 1. **Die Gegenprüfung in Schritt 4.** Ein Agentenbericht ist eine Behauptung, kein Beleg. Du wiederholst den Testlauf selbst, liest die zentralen Artefakte selbst und fährst einen eigenen Lauf mit echten Daten und eigenem Prüfcode. Der `pruefer`-Subagent (`opus`) - verdoppelt das, er ersetzt es nicht. + verdoppelt das, er ersetzt es nicht. Du fährst sie, während er läuft, nur lesend + (`codeworkflow.md`, Schritt 4). 2. **Den Commit.** Subagenten committen nie — paralleles `git commit` kollidiert am Index-Lock. ## Grenzen diff --git a/.claude/workflows/plankritik.js b/.claude/workflows/plankritik.js index 1bd2afe..57f19ef 100644 --- a/.claude/workflows/plankritik.js +++ b/.claude/workflows/plankritik.js @@ -1,10 +1,10 @@ export const meta = { name: 'plankritik', - description: 'Liest einen Planordner mit getrennten pruefer-Linsen, fasst doppelte Befunde zusammen und lässt jeden wichtigen oder blockierenden Befund von einem zweiten pruefer zu widerlegen versuchen; höchstens zwei Opus gleichzeitig', - whenToUse: 'Schritt 2 der Standardschleife vor dem ersten Executer, Pflicht ab etwa drei Tasks (AI/Regelwerk/codeworkflow.md, "Plankritik vor dem ersten Executer"). Args: {projekt, planordner, spec?, kopie?, materialisieren?: {ohne: [relpfade], tasks?: [taskdateien]}, suite?, linsen?, verifizieren?: true, verifizieren_ab?: "wichtig"|"blockierend", effort?, werkzeuge?, zusatz?}. Ohne kopie baut materialisieren sie selbst (kopie_bauen.py plus plan_materialisiere.py); ohne() muss Geheimnisdateien und große Datenordner nennen.', + description: 'Liest einen Planordner mit getrennten pruefer-Linsen, gleichzeitig nach Verbrauch bis vier, fasst doppelte Befunde zusammen und lässt jeden wichtigen oder blockierenden Befund von einem zweiten pruefer zu widerlegen versuchen, höchstens zwei gleichzeitig', + whenToUse: 'Schritt 2 der Standardschleife vor dem ersten Executer, Pflicht ab etwa drei Tasks (AI/Regelwerk/codeworkflow.md, "Plankritik vor dem ersten Executer"). Args: {projekt, planordner, spec?, kopie?, materialisieren?: {ohne: [relpfade], tasks?: [taskdateien]}, suite?, linsen?, plaetze?: 1..4, verifizieren?: true, verifizieren_ab?: "wichtig"|"blockierend", effort?, werkzeuge?: Ordner der Planwerkzeuge (Pfad, keine Ausgabe; Vorgabe D:/Claude/.vault-tools/plan-kopie), zusatz?}. Ohne kopie baut materialisieren sie selbst (kopie_bauen.py plus plan_materialisiere.py); ohne() muss Geheimnisdateien und große Datenordner nennen. plaetze begrenzt die gleichzeitigen Linsen; verbrauch.js senkt sie nach dem 5-Stunden-Fenster und der Woche. Rückgabe mit status fertig oder gebremst; ein gebremster Lauf wird neu gestartet, nicht fortgesetzt.', phases: [ { title: 'Vorbereitung', detail: 'Hilfsagent: Kopie bauen, Plan anwenden, Planwerkzeuge fahren' }, - { title: 'Plankritik', detail: 'je Linse ein pruefer, paarweise' }, + { title: 'Plankritik', detail: 'je Linse ein pruefer, gleichzeitig nach Verbrauch' }, { title: 'Widerlegung', detail: 'je wichtiger Befund ein zweiter pruefer, höchstens zwei gleichzeitig' }, ], } @@ -13,8 +13,15 @@ export const meta = { // Frontmatter von .claude/agents/pruefer.md; args.effort überschreibt ihn nur bewusst. // Der Hilfsagent hat keinen agentType und bekommt deshalb seinen Effort mit (low: gemessen // gleich treu wie high bei mechanischer Arbeit, codeworkflow.md "Effort je Rolle"). +// Opus-Plätze: Vor den Linsen und vor der Widerlegung liest verbrauch.js den Planverbrauch ab. +// Die Linsen laufen mit so vielen Plätzen, wie verbrauch.js erlaubt (höchstens so viele wie +// Linsen, höchstens LINSEN_HOECHSTENS), die Widerlegung mit höchstens zwei. Gebremst heißt: Die +// Phase startet nicht, und der Lauf kommt mit status gebremst zurück. const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] -const OPUS_PARALLEL = 2 +const LINSEN_HOECHSTENS = 4 +const WIDERLEGUNG_HOECHSTENS = 2 +const OHNE_ABLESUNG = 2 +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' const RANG = { blockierend: 0, wichtig: 1, klein: 2 } // Einfache Anführungszeichen: In Bash bleiben darin $, Backtick und Backslash wörtlich. const q = s => "'" + String(s).replace(/'/g, "'\\''") + "'" @@ -76,9 +83,11 @@ function pruefeArgs(a) { if (a.materialisieren && a.materialisieren.tasks !== undefined && !Array.isArray(a.materialisieren.tasks)) throw new Error('plankritik: materialisieren.tasks muss eine Liste von Taskdateien sein') const unbekannt = (a.linsen || []).filter(l => !LINSEN[l]) if (unbekannt.length) throw new Error('plankritik: unbekannte Linse: ' + unbekannt.join(', ') + '; bekannt: ' + Object.keys(LINSEN).join(', ')) + if (a.linsen && new Set(a.linsen).size !== a.linsen.length) throw new Error('plankritik: eine Linse steht doppelt in linsen: ' + a.linsen.join(', ')) if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('plankritik: effort "' + a.effort + '" ungültig') if (a.verifizieren_ab !== undefined && !['wichtig', 'blockierend'].includes(a.verifizieren_ab)) throw new Error('plankritik: verifizieren_ab muss wichtig oder blockierend sein') - if (a.materialisieren && !(Array.isArray(a.materialisieren.ohne) && a.materialisieren.ohne.length > 0 && a.materialisieren.ohne.every(x => typeof x === 'string' && x))) throw new Error('plankritik: materialisieren.ohne muss eine nicht leere Liste sein — Ausschlüsse als Pfade vom Projektwurzelordner, mindestens .git (falls vorhanden) und Geheimnisdateien') + if (a.plaetze !== undefined && !(Number.isInteger(a.plaetze) && a.plaetze >= 1 && a.plaetze <= LINSEN_HOECHSTENS)) throw new Error('plankritik: plaetze muss eine ganze Zahl von 1 bis ' + LINSEN_HOECHSTENS + ' sein') + if (a.materialisieren && !(Array.isArray(a.materialisieren.ohne) && a.materialisieren.ohne.length > 0 && a.materialisieren.ohne.every(x => typeof x === 'string' && x))) throw new Error('plankritik: materialisieren.ohne muss eine nicht leere Liste sein, Ausschlüsse als Pfade vom Projektwurzelordner, mindestens .git (falls vorhanden) und Geheimnisdateien') } // Höchstens n Aufgaben gleichzeitig, ohne Leerlauf zwischen Paaren. @@ -95,6 +104,26 @@ async function hoechstens(n, items, fn) { return ergebnisse } +// Eine Hilfsfunktion ablesen() steht auch in planung.js (gleiche Form) sowie in pruefung.js und +// welle-mit-pruefung.js (dort nur messend, ohne Rückgabe). Die Regel (Fenster, Stufen, Rückfall) +// steht nur in verbrauch.js; hier steht nur der Rückfall für den Fall, dass verbrauch.js selbst +// nicht läuft, etwa weil dieser Workflow als Kind läuft. +const ablesungen = [] +async function ablesen(anlass, wunsch) { + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'plankritik:' + anlass, wunsch }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + if (!r || typeof r.plaetze !== 'number') { + r = { status: 'nicht_gelesen', quelle: 'keine', fuenf_stunden: null, woche: null, plaetze: Math.min(wunsch, OHNE_ABLESUNG), gebremst: false, grund: (r && r.grund) || 'verbrauch.js ohne verwertbares Ergebnis' } + } + ablesungen.push({ anlass, ...r }) + log(`Verbrauch ${anlass}: ${r.status}, ${r.plaetze} Plätze${r.gebremst ? ', gebremst' : ''} (${r.grund})`) + return r +} + function werkzeugAgent(label, phase, befehl, zweck) { return agent(`Du führst einen Werkzeugbefehl aus und gibst seine Ausgabe roh zurück. Zweck: ${zweck} @@ -105,8 +134,14 @@ ${befehl} { label, phase, schema: LAUF, model: 'sonnet', effort: 'low' }) } +function testort(ctx) { + return ctx.kopie + ? `Tests fährst du nur in der Kopie ${ctx.kopie}, nie im echten Projektbaum: Dort kann Betrieb laufen, und andere Prüfer arbeiten gleichzeitig. Mutanten legst du nur in einer eigenen Kopie dieser Kopie an, in einem neuen Ordner unter dem System-Temp, nie in ${ctx.kopie} selbst.` + : 'Tests fährst du in dieser Plankritik nicht, auch nicht im echten Projektbaum: Dort kann Betrieb laufen, und andere Prüfer arbeiten gleichzeitig.' +} + function auftrag(a, linse, ctx) { - return `Lies den Plan im Ordner ${a.planordner} für das Projekt ${a.projekt}, bevor ein Executer startet. Du änderst keine Datei im Projekt und keine im Plan. + return `Lies den Plan im Ordner ${a.planordner} für das Projekt ${a.projekt}, bevor ein Executer startet. Du änderst keine Datei im Projekt und keine im Plan. Eine Umsetzung gibt es noch nicht, also auch keinen Executer-Bericht, dessen Testlauf du wiederholen könntest. ${testort(ctx)} ${LINSEN[linse](a, ctx)} @@ -143,7 +178,7 @@ function widerlegungsauftrag(a, g, ctx) { ${texte} -Versuche ihn zu widerlegen. Lies die Stelle selbst und reproduziere den Befund mit einem eigenen Befehl${ctx.kopie ? `; der Plan ist auf die Kopie ${ctx.kopie} angewendet, dort darfst du Tests fahren` : ''}. Du änderst keine Datei im Projekt und keine im Plan. +Versuche ihn zu widerlegen. Lies die Stelle selbst und reproduziere den Befund mit einem eigenen Befehl. Du änderst keine Datei im Projekt und keine im Plan. ${testort(ctx)} - "bestaetigt" nur mit eigener Reproduktion oder einem wörtlichen Beleg aus der Quelle. - "widerlegt" nur mit einem Gegenbeleg: ein Befehl mit Ausgabe oder ein Zitat, das die Aussage falsch macht. - Sonst "offen". @@ -171,6 +206,10 @@ function kopieAusLauf(r, wozu) { return m[1] } +function tempText() { + return ctx.temp ? `${ctx.temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn nicht mehr gebraucht` : null +} + phase('Vorbereitung') if (ctx.kopie && args.materialisieren) log('kopie ist gesetzt, materialisieren wird übergangen') if (!ctx.kopie && args.materialisieren) { @@ -186,14 +225,38 @@ if (!ctx.kopie && args.materialisieren) { { const befehl = `${PY} ${q(W + '/plan_zeichen.py')} ${q(args.planordner)}; echo "EXIT plan_zeichen: $?"; ls ${q(args.planordner)}/task-*.md >/dev/null 2>&1 && { ${PY} ${q(W + '/plan_bloecke.py')} vor ${q(args.planordner)}/task-*.md --projekt ${q(args.projekt)}; echo "EXIT plan_bloecke: $?"; } || echo "plan_bloecke: keine task-*.md im Planordner, nicht gefahren"` const r = await werkzeugAgent('werkzeug:planwerkzeuge', 'Vorbereitung', befehl, 'Codeblöcke auf unsichtbare Zeichen und Aus-Blöcke gegen den Baum prüfen') - if (r && r.laeufe && r.laeufe[0]) ctx.werkzeuge = r.laeufe[0].ausgabe + // Nur wenn beide Werkzeuge mit 0 endeten, gilt die Ausgabe als gelaufen (2026-09-24: ein Protokollsatz statt + // Pfad in args.werkzeuge endete mit Errno 2 und ging trotzdem als Rohausgabe an die Linse). Der Exit des + // ganzen Befehls hilft dabei nicht, er ist der des letzten echo; gelesen werden die EXIT-Zeilen. + const aus = r && r.laeufe && r.laeufe[0] ? String(r.laeufe[0].ausgabe || '') : '' + const gelaufen = /EXIT plan_zeichen: 0(?!\d)/.test(aus) && (/EXIT plan_bloecke: 0(?!\d)/.test(aus) || /plan_bloecke: keine task-/.test(aus)) + if (gelaufen) ctx.werkzeuge = aus else log('Planwerkzeuge: Hilfsagent ohne Ergebnis; Linse bezeichner fährt sie selbst') vorbereitung.push(r) } phase('Plankritik') const gewaehlt = args.linsen && args.linsen.length ? args.linsen : Object.keys(LINSEN) -const kritiken = await hoechstens(OPUS_PARALLEL, gewaehlt, linse => { +const vorLinsen = await ablesen('vor Linsen', Math.min(args.plaetze || gewaehlt.length, gewaehlt.length, LINSEN_HOECHSTENS)) +if (vorLinsen.gebremst) { + log('Plankritik gebremst vor den Linsen: ' + vorLinsen.grund) + return { + status: 'gebremst', + gebremst_vor: 'Linsen', + zaehlung: { roh: 0, zusammengefasst: 0, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: 0 }, + kopie: ctx.kopie, + temp_ordner: tempText(), + befunde: [], + selbst_ausgefuehrt: [], + nicht_pruefbar: [], + vorbereitung, + ohne_ergebnis: gewaehlt, + verbrauch: ablesungen, + plaetze: { linsen: 0, widerlegung: 0 }, + naechster_schritt: 'Nach der Zurücksetzung des Fensters neu starten, nicht mit resumeFromRunId fortsetzen: Die Ablesung käme sonst aus dem Zwischenspeicher.', + } +} +const kritiken = await hoechstens(vorLinsen.plaetze, gewaehlt, linse => { const opts = { label: 'plankritik:' + linse, phase: 'Plankritik', schema: BEFUNDE, agentType: 'pruefer', model: 'opus' } if (args.effort) opts.effort = args.effort return agent(auftrag(args, linse, ctx), opts).then(r => ({ linse, ergebnis: r })) @@ -206,34 +269,50 @@ const roh = ergebnisse.flatMap(e => e.ergebnis.befunde.map(b => ({ linse: e.lins const gruppen = zusammenfassen(roh) const ab = RANG[args.verifizieren_ab || 'wichtig'] const zuPruefen = args.verifizieren === false ? [] : gruppen.filter(g => RANG[g.schweregrad] <= ab) -const nichtGeprueft = gruppen.length - zuPruefen.length -log(`${roh.length} Befunde, zusammengefasst ${gruppen.length}; ${zuPruefen.length} gehen in die Widerlegung, ${nichtGeprueft} nicht (${args.verifizieren === false ? 'abgeschaltet' : 'unter der Schwelle ' + (args.verifizieren_ab || 'wichtig')})`) +log(`${roh.length} Befunde, zusammengefasst ${gruppen.length}; ${zuPruefen.length} gehen in die Widerlegung, ${gruppen.length - zuPruefen.length} nicht (${args.verifizieren === false ? 'abgeschaltet' : 'unter der Schwelle ' + (args.verifizieren_ab || 'wichtig')})`) phase('Widerlegung') -const urteile = await hoechstens(OPUS_PARALLEL, zuPruefen, (g, i) => { - const opts = { label: `widerlegung:${i + 1}:${dateiname(g.datei)}:${g.zeile}`, phase: 'Widerlegung', schema: URTEIL, agentType: 'pruefer', model: 'opus' } - if (args.effort) opts.effort = args.effort - return agent(widerlegungsauftrag(args, g, ctx), opts) -}) -zuPruefen.forEach((g, i) => { - if (!urteile[i]) log(`Widerlegung ${i + 1} (${g.datei}:${g.zeile}) ohne Ergebnis; Befund bleibt offen`) - g.widerlegung = urteile[i] || { urteil: 'offen', schweregrad: g.schweregrad, bedingung: '', begruendung: 'Widerlegungsagent ohne Ergebnis', beleg: '' } -}) +let gebremstVor = null +let plaetzeWiderlegung = 0 +if (zuPruefen.length) { + const vorWiderlegung = await ablesen('vor Widerlegung', Math.min(WIDERLEGUNG_HOECHSTENS, zuPruefen.length)) + if (vorWiderlegung.gebremst) { + gebremstVor = 'Widerlegung' + log('Widerlegung gebremst, die Befunde bleiben ungeprüft: ' + vorWiderlegung.grund) + } else plaetzeWiderlegung = vorWiderlegung.plaetze +} +if (!gebremstVor) { + const urteile = await hoechstens(plaetzeWiderlegung, zuPruefen, (g, i) => { + const opts = { label: `widerlegung:${i + 1}:${dateiname(g.datei)}:${g.zeile}`, phase: 'Widerlegung', schema: URTEIL, agentType: 'pruefer', model: 'opus' } + if (args.effort) opts.effort = args.effort + return agent(widerlegungsauftrag(args, g, ctx), opts) + }) + zuPruefen.forEach((g, i) => { + if (!urteile[i]) log(`Widerlegung ${i + 1} (${g.datei}:${g.zeile}) ohne Ergebnis; Befund bleibt offen`) + g.widerlegung = urteile[i] || { urteil: 'offen', schweregrad: g.schweregrad, bedingung: '', begruendung: 'Widerlegungsagent ohne Ergebnis', beleg: '' } + }) +} +await ablesen('Ende', 0) const URTEILRANG = { bestaetigt: 0, offen: 1, undefined: 2, widerlegt: 3 } const befunde = gruppen - .map(g => ({ urteil: g.widerlegung ? g.widerlegung.urteil : 'nicht geprüft', ...g })) + .map(g => ({ urteil: g.widerlegung ? g.widerlegung.urteil : gebremstVor && zuPruefen.includes(g) ? 'nicht geprüft (gebremst)' : 'nicht geprüft', ...g })) .sort((x, y) => (URTEILRANG[x.widerlegung && x.widerlegung.urteil] - URTEILRANG[y.widerlegung && y.widerlegung.urteil]) || (RANG[x.schweregrad] - RANG[y.schweregrad])) -const zaehlung = { roh: roh.length, zusammengefasst: gruppen.length, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: nichtGeprueft } +const zaehlung = { roh: roh.length, zusammengefasst: gruppen.length, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: gruppen.filter(g => !g.widerlegung).length } befunde.forEach(b => { if (b.widerlegung) zaehlung[b.widerlegung.urteil]++ }) return { + status: gebremstVor ? 'gebremst' : 'fertig', + gebremst_vor: gebremstVor, zaehlung, kopie: ctx.kopie, - temp_ordner: ctx.temp ? `${ctx.temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn nicht mehr gebraucht` : null, + temp_ordner: tempText(), befunde, selbst_ausgefuehrt: ergebnisse.map(e => ({ linse: e.linse, befehle: e.ergebnis.selbst_ausgefuehrt })), nicht_pruefbar: ergebnisse.flatMap(e => e.ergebnis.nicht_pruefbar.map(x => e.linse + ': ' + x)), vorbereitung, ohne_ergebnis: ohneErgebnis, + verbrauch: ablesungen, + plaetze: { linsen: vorLinsen.plaetze, widerlegung: plaetzeWiderlegung }, + naechster_schritt: gebremstVor ? 'Die Widerlegung nach der Zurücksetzung des Fensters nachholen: plankritik neu starten, nicht mit resumeFromRunId fortsetzen.' : 'Bestätigte und offene Befunde am Plan korrigieren, danach die Kopie neu bauen.', } diff --git a/.claude/workflows/planung.js b/.claude/workflows/planung.js new file mode 100644 index 0000000..34c4291 --- /dev/null +++ b/.claude/workflows/planung.js @@ -0,0 +1,211 @@ +export const meta = { + name: 'planung', + description: 'Erstplanung: je Task ein planer, gestartet, sobald die Taskdateien seiner Voraussetzungen geschrieben sind; Opus-Plätze nach Verbrauch, vorgegeben zwei', + whenToUse: 'Schritt 2 der Standardschleife, wenn 00-uebersicht.md mit der Tasktabelle steht und viele Taskdateien zu schreiben sind (AI/Regelwerk/codeworkflow.md, "Verbrauch und Opus-Plätze"). Nicht für Nachbesserungen. Args: {projekt, planordner, gemeinsam, taskauftrag, tasks: [{nr, thema, datei, modell, voraussetzungen: [nr], welle?}], erledigt?: [nr], plaetze?: 1..4, nachlesen_alle?: 1..20, zusatz?}. plaetze ist der Wunsch, verbrauch.js senkt ihn; ohne plaetze gilt zwei, drei oder vier erst nach der Eichung der Stufen. taskauftrag darf {NR}, {THEMA}, {DATEI}, {MODELL}, {WELLE} und {VORAUSSETZUNGEN} enthalten. Rückgabe mit status fertig, unvollstaendig, gebremst oder abgebrochen; offene Tasks mit erledigt in einem neuen Lauf nachholen, nicht mit resumeFromRunId. Die Plankritik läuft danach als eigener Workflow.', + phases: [{ title: 'Taskdateien', detail: 'planer je Task, Start nach den Voraussetzungen, Plätze nach Verbrauch' }], +} + +// Rollenparameter: model wird immer gesetzt (Stamm), der Effort kommt aus dem Frontmatter von +// .claude/agents/planer.md. Ein Task startet erst, wenn die Planer aller seiner Voraussetzungen +// ein Ergebnis geliefert haben; sonst erfindet er Namen, die ein Vorgänger anders schreibt +// (codeworkflow.md, "Verbrauch und Opus-Plätze"). Die Zahl gleichzeitiger Planer liest +// verbrauch.js am Anfang und nach je nachlesen_alle fertigen Planern ab. Gebremst heißt: Kein +// Planer startet mehr, die laufenden enden, der Rest kommt als offen zurück. Nach drei Planern +// ohne Ergebnis startet keiner mehr, meist ist dann das Kontingent erschöpft. Ein Ergebnis zählt +// nur, wenn es die erwartete Taskdatei nennt; sonst gilt der Task als gescheitert. +// VORGABE_PLAETZE ist Stufe 1 der Freigabe vom 2026-09-24 (Pool mit zwei Plätzen). Stufe 3 mit +// drei bis vier Plätzen für die Erstplanung kommt erst nach der Eichung der Stufen in verbrauch.js. +const MODELLE = ['haiku', 'sonnet', 'opus'] +const HOECHSTENS = 4 +const VORGABE_PLAETZE = 2 +const OHNE_ABLESUNG = 2 +const ABBRUCH_NACH = 3 +const NACHLESEN_ALLE = 4 +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' + +const TASK_SCHEMA = { + type: 'object', + properties: { + datei: { type: 'string' }, + zieldateien: { type: 'array', items: { type: 'string' } }, + testdateien: { type: 'array', items: { type: 'string' } }, + zeilen_code: { type: 'integer' }, + abweichungen_von_uebersicht: { type: 'array', items: { type: 'string' } }, + offene_punkte: { type: 'array', items: { type: 'string' } }, + }, + required: ['datei', 'zieldateien', 'testdateien', 'abweichungen_von_uebersicht', 'offene_punkte'], +} + +function pruefeArgs(a) { + if (!a || typeof a !== 'object') throw new Error('planung: args fehlen') + const fehlt = ['projekt', 'planordner', 'gemeinsam', 'taskauftrag', 'tasks'].filter(k => !a[k]) + if (fehlt.length) throw new Error('planung: args fehlen: ' + fehlt.join(', ')) + if (!Array.isArray(a.tasks) || a.tasks.length === 0) throw new Error('planung: tasks muss eine nicht leere Liste sein') + const nummern = new Set() + const dateien = new Set() + a.tasks.forEach((t, i) => { + if (!t || typeof t !== 'object') throw new Error(`planung: tasks[${i}] ist kein Objekt`) + if (!Number.isInteger(t.nr)) throw new Error(`planung: tasks[${i}]: nr muss eine ganze Zahl sein`) + if (nummern.has(t.nr)) throw new Error(`planung: Task ${t.nr} steht doppelt in tasks`) + nummern.add(t.nr) + for (const k of ['thema', 'datei', 'modell']) if (typeof t[k] !== 'string' || !t[k]) throw new Error(`planung: Task ${t.nr}: ${k} fehlt`) + if (!MODELLE.includes(t.modell)) throw new Error(`planung: Task ${t.nr}: modell "${t.modell}" nicht in ${MODELLE.join('/')}`) + if (dateien.has(t.datei)) throw new Error(`planung: Datei ${t.datei} steht bei zwei Tasks`) + dateien.add(t.datei) + if (!Array.isArray(t.voraussetzungen) || !t.voraussetzungen.every(n => Number.isInteger(n))) throw new Error(`planung: Task ${t.nr}: voraussetzungen muss eine Liste ganzer Zahlen sein`) + if (new Set(t.voraussetzungen).size !== t.voraussetzungen.length) throw new Error(`planung: Task ${t.nr}: eine Voraussetzung steht doppelt`) + }) + a.tasks.forEach(t => t.voraussetzungen.forEach(n => { + if (!nummern.has(n)) throw new Error(`planung: Task ${t.nr}: Voraussetzung ${n} gibt es nicht`) + })) + if (a.erledigt !== undefined && !(Array.isArray(a.erledigt) && a.erledigt.every(n => nummern.has(n)))) throw new Error('planung: erledigt muss eine Liste bekannter Tasknummern sein') + if (a.plaetze !== undefined && !(Number.isInteger(a.plaetze) && a.plaetze >= 1 && a.plaetze <= HOECHSTENS)) throw new Error('planung: plaetze muss eine ganze Zahl von 1 bis ' + HOECHSTENS + ' sein') + if (a.nachlesen_alle !== undefined && !(Number.isInteger(a.nachlesen_alle) && a.nachlesen_alle >= 1 && a.nachlesen_alle <= 20)) throw new Error('planung: nachlesen_alle muss eine ganze Zahl von 1 bis 20 sein') + // Zyklen: Kahn-Verfahren über die Voraussetzungen; was übrig bleibt, hängt im Kreis. + const offen = new Map(a.tasks.map(t => [t.nr, t.voraussetzungen.length])) + const bereit = a.tasks.filter(t => t.voraussetzungen.length === 0).map(t => t.nr) + let geschafft = 0 + while (bereit.length) { + const nr = bereit.pop() + geschafft++ + for (const t of a.tasks) if (t.voraussetzungen.includes(nr)) { + offen.set(t.nr, offen.get(t.nr) - 1) + if (offen.get(t.nr) === 0) bereit.push(t.nr) + } + } + if (geschafft < a.tasks.length) throw new Error('planung: Zyklus in den Voraussetzungen bei Task ' + [...offen].filter(([, n]) => n > 0).map(([nr]) => nr).join(', ')) +} + +// Eine Hilfsfunktion ablesen() steht auch in plankritik.js (gleiche Form) sowie in pruefung.js +// und welle-mit-pruefung.js (dort nur messend, ohne Rückgabe). Die Regel (Fenster, Stufen, +// Rückfall) steht nur in verbrauch.js. +const ablesungen = [] +async function ablesen(anlass, wunsch) { + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'planung:' + anlass, wunsch }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + if (!r || typeof r.plaetze !== 'number') { + r = { status: 'nicht_gelesen', quelle: 'keine', fuenf_stunden: null, woche: null, plaetze: Math.min(wunsch, OHNE_ABLESUNG), gebremst: false, grund: (r && r.grund) || 'verbrauch.js ohne verwertbares Ergebnis' } + } + ablesungen.push({ anlass, ...r }) + log(`Verbrauch ${anlass}: ${r.status}, ${r.plaetze} Plätze${r.gebremst ? ', gebremst' : ''} (${r.grund})`) + return r +} + +function auftrag(a, t, jeNr) { + const vor = t.voraussetzungen.map(n => jeNr.get(n).datei) + const text = a.taskauftrag + .split('{NR}').join(String(t.nr).padStart(2, '0')) + .split('{THEMA}').join(t.thema) + .split('{DATEI}').join(t.datei) + .split('{WELLE}').join(t.welle === undefined ? '' : String(t.welle)) + .split('{MODELL}').join(t.modell) + .split('{VORAUSSETZUNGEN}').join(vor.length ? vor.join(', ') : '(keine)') + const lies = vor.length ? ` Die Taskdateien deiner Voraussetzungen liegen fertig im Planordner: ${vor.join(', ')}. Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst.` : '' + return `${a.gemeinsam} + +${text} + +Schreib genau eine Datei: ${a.planordner}/${t.datei}.${lies} +${a.zusatz ? a.zusatz + '\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.` +} + +pruefeArgs(args) +phase('Taskdateien') +const jeNr = new Map(args.tasks.map(t => [t.nr, t])) +const status = new Map(args.tasks.map(t => [t.nr, (args.erledigt || []).includes(t.nr) ? 'erledigt' : 'offen'])) +const reihenfolge = [...args.tasks].sort((x, y) => ((x.welle || 0) - (y.welle || 0)) || (x.nr - y.nr)) +const wunsch = args.plaetze || VORGABE_PLAETZE +const nachlesenAlle = args.nachlesen_alle || NACHLESEN_ALLE +const geschrieben = [] +const gescheitert = [] +const plaetzeVerlauf = [] +const laufend = new Map() +let gebremst = false +let abgebrochen = false +let plaetze = 0 +let fertigSeitAblesung = 0 + +const nochOffen = () => [...status.values()].some(s => s === 'offen') + +function starte(t) { + status.set(t.nr, 'laeuft') + const opts = { label: `planer:task-${String(t.nr).padStart(2, '0')}`, phase: 'Taskdateien', schema: TASK_SCHEMA, agentType: 'planer', model: 'opus' } + laufend.set(t.nr, agent(auftrag(args, t, jeNr), opts).then(r => ({ nr: t.nr, r }), e => ({ nr: t.nr, r: null, fehler: e && e.message }))) +} + +if (nochOffen()) { + const v = await ablesen('Anfang', wunsch) + plaetzeVerlauf.push(v.plaetze) + if (v.gebremst) gebremst = true + else plaetze = v.plaetze +} + +while (true) { + // Tasks hinter einer gescheiterten oder blockierten Voraussetzung starten nie. + let geaendert = true + while (geaendert) { + geaendert = false + for (const t of args.tasks) { + if (status.get(t.nr) === 'offen' && t.voraussetzungen.some(n => ['gescheitert', 'blockiert'].includes(status.get(n)))) { + status.set(t.nr, 'blockiert') + geaendert = true + } + } + } + if (!gebremst && !abgebrochen) { + for (const t of reihenfolge) { + if (laufend.size >= plaetze) break + if (status.get(t.nr) === 'offen' && t.voraussetzungen.every(n => ['fertig', 'erledigt'].includes(status.get(n)))) starte(t) + } + } + if (laufend.size === 0) break + const { nr, r: roh, fehler: grund } = await Promise.race(laufend.values()) + laufend.delete(nr) + const passt = roh && typeof roh.datei === 'string' && ('/' + roh.datei.replace(/\\/g, '/')).endsWith('/' + jeNr.get(nr).datei) + const r = passt ? roh : null + const fehler = grund || (roh && !passt ? `Ergebnis nennt ${JSON.stringify(roh.datei)} statt ${jeNr.get(nr).datei}` : null) + if (r) { + status.set(nr, 'fertig') + geschrieben.push({ nr, ...r }) + } else { + status.set(nr, 'gescheitert') + gescheitert.push(nr) + log(`Task ${nr}: planer ohne Ergebnis${fehler ? ' (' + fehler + ')' : ''}`) + if (gescheitert.length >= ABBRUCH_NACH && !abgebrochen) { + abgebrochen = true + log(`${gescheitert.length} Planer ohne Ergebnis, vermutlich Kontingent: kein weiterer Start`) + } + } + fertigSeitAblesung++ + if (!gebremst && !abgebrochen && fertigSeitAblesung >= nachlesenAlle && nochOffen()) { + fertigSeitAblesung = 0 + const v = await ablesen(`nach ${geschrieben.length + gescheitert.length} Tasks`, wunsch) + plaetzeVerlauf.push(v.plaetze) + if (v.gebremst) { + gebremst = true + log('Planung gebremst: kein weiterer Start, die laufenden Planer enden') + } else plaetze = v.plaetze + } +} +if (geschrieben.length + gescheitert.length > 0) await ablesen('Ende', 0) + +const liste = s => args.tasks.filter(t => status.get(t.nr) === s).map(t => t.nr) +const blockiert = liste('blockiert') +const offen = liste('offen') +const zustand = gebremst ? 'gebremst' : abgebrochen ? 'abgebrochen' : gescheitert.length || blockiert.length || offen.length ? 'unvollstaendig' : 'fertig' +return { + status: zustand, + geschrieben, + gescheitert, + blockiert, + offen, + verbrauch: ablesungen, + plaetze_verlauf: plaetzeVerlauf, + naechster_schritt: zustand === 'fertig' + ? 'plankritik über den ganzen Planordner.' + : 'Gescheiterte, blockierte und offene Tasks in einem neuen Lauf nachholen, mit erledigt: [' + args.tasks.filter(t => ['fertig', 'erledigt'].includes(status.get(t.nr))).map(t => t.nr).join(', ') + ']; nicht mit resumeFromRunId fortsetzen.', +} diff --git a/.claude/workflows/pruefung.js b/.claude/workflows/pruefung.js index 6f23fbe..8fa8948 100644 --- a/.claude/workflows/pruefung.js +++ b/.claude/workflows/pruefung.js @@ -1,14 +1,18 @@ export const meta = { name: 'pruefung', - description: 'Lässt eine fertige Welle vom pruefer mit dem vollen Pflichtprogramm gegen Plan und Zielumgebung prüfen; kritische Änderungen mit xhigh', - whenToUse: 'Schritt 4 der Standardschleife nach einer Welle, zusätzlich zur eigenen Gegenprüfung des Koordinators (AI/Regelwerk/codeworkflow.md). Args: {projekt, plan, tasks?, suite?, version?, kritisch?, abschluss?, schwerpunkt?, effort?}. kritisch für sicherheits- oder datenrelevante Änderungen; abschluss fragt, ob eine weitere Runde noch Substanz hat.', + description: 'Lässt eine fertige Welle vom pruefer mit dem vollen Pflichtprogramm gegen Plan und Zielumgebung prüfen; kritische Änderungen mit xhigh; schreibt den Planverbrauch vor und nach der Prüfung mit', + whenToUse: 'Schritt 4 der Standardschleife nach einer Welle, zusätzlich zur eigenen Gegenprüfung des Koordinators (AI/Regelwerk/codeworkflow.md). Args: {projekt, plan, tasks?, suite?, version?, kritisch?, abschluss?, schwerpunkt?, effort?, verbrauch?}. kritisch für sicherheits- oder datenrelevante Änderungen; abschluss fragt, ob eine weitere Runde noch Substanz hat; verbrauch: false schaltet die Ablesung ab, welle-mit-pruefung liest dann selbst.', phases: [{ title: 'Prüfung', detail: 'ein pruefer' }], } // Rollenparameter: model wird immer gesetzt (Stamm). Der Effort kommt aus dem Frontmatter // von .claude/agents/pruefer.md (high). kritisch hebt auf xhigh: In der Messung vom // 2026-09-23 fuhr nur xhigh einen großen eigenen Datenlauf (codeworkflow.md, "Effort je Rolle"). +// Verbrauch: Vor und nach dem pruefer liest verbrauch.js den Planverbrauch ab, nur zum Messen +// (wunsch 0); gebremst wird hier nicht. Als Kind von welle-mit-pruefung kann dieser Workflow +// keinen weiteren aufrufen; dort liest der Elternworkflow und gibt verbrauch: false mit. const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' const BERICHT = { type: 'object', @@ -42,6 +46,7 @@ function pruefeArgs(a) { if (fehlt.length) throw new Error('pruefung: args fehlen: ' + fehlt.join(', ')) if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('pruefung: effort "' + a.effort + '" ungültig') if (a.tasks !== undefined && !Array.isArray(a.tasks)) throw new Error('pruefung: tasks muss eine Liste sein') + if (a.verbrauch !== undefined && typeof a.verbrauch !== 'boolean') throw new Error('pruefung: verbrauch muss true oder false sein') } function auftrag(a) { @@ -52,15 +57,32 @@ ${a.version ? `Aussagen über fremde Software belegst du am tatsächlich eingese ${a.abschluss ? 'Das ist die Abschlussprüfung dieser Runde. Sag im Feld weitere_runde, ob eine weitere Runde noch Substanz hat oder nur noch Text umschichtet, und warum.\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.` } +// Eine messende Hilfsfunktion ablesen() steht auch in welle-mit-pruefung.js; plankritik.js und +// planung.js haben eine mit Wunsch und Rückgabe. Die Regel (Fenster, Stufen, Rückfall) steht nur +// in verbrauch.js. +const ablesungen = [] +async function ablesen(anlass) { + if (args.verbrauch === false) return + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'pruefung:' + anlass, wunsch: 0 }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + ablesungen.push({ anlass, ...(r || { status: 'nicht_gelesen', grund: 'verbrauch.js ohne Ergebnis' }) }) +} + pruefeArgs(args) phase('Prüfung') const opts = { label: 'pruefung' + (args.kritisch ? ':kritisch' : ''), phase: 'Prüfung', schema: BERICHT, agentType: 'pruefer', model: 'opus' } if (args.effort) opts.effort = args.effort else if (args.kritisch) opts.effort = 'xhigh' +await ablesen('vor Prüfung') const bericht = await agent(auftrag(args), opts) +await ablesen('nach Prüfung') if (!bericht) throw new Error('pruefung: der pruefer hat keinen Bericht geliefert') const RANG = { blockierend: 0, wichtig: 1, klein: 2 } const zaehlung = { blockierend: 0, wichtig: 0, klein: 0 } bericht.befunde.forEach(b => { zaehlung[b.schweregrad]++ }) -return { effort: opts.effort || '(Frontmatter)', zaehlung, ...bericht, befunde: [...bericht.befunde].sort((x, y) => RANG[x.schweregrad] - RANG[y.schweregrad]) } +return { effort: opts.effort || '(Frontmatter)', zaehlung, ...bericht, befunde: [...bericht.befunde].sort((x, y) => RANG[x.schweregrad] - RANG[y.schweregrad]), verbrauch: ablesungen } diff --git a/.claude/workflows/verbrauch.js b/.claude/workflows/verbrauch.js new file mode 100644 index 0000000..bca5581 --- /dev/null +++ b/.claude/workflows/verbrauch.js @@ -0,0 +1,118 @@ +export const meta = { + name: 'verbrauch', + description: 'Liest den Planverbrauch (5-Stunden-Fenster, Woche) über get_usage und rechnet daraus, wie viele Opus-Agenten gleichzeitig laufen dürfen', + whenToUse: 'Vor jeder Opus-Staffel eines Workflows, als Kind aufgerufen: workflow({ scriptPath: "D:/Claude/.claude/workflows/verbrauch.js" }, { anlass, wunsch }). Args: {anlass, wunsch: ganze Zahl ab 0, probe?}. wunsch 0 heißt nur messen. Rückgabe {status: ok|nicht_gelesen|unbekannte_fenster, quelle, fuenf_stunden, woche, woche_opus, plaetze, gebremst, grund, fenster}. Ohne lesbare Ablesung gilt die alte Obergrenze zwei. Regel: AI/Regelwerk/codeworkflow.md, "Verbrauch und Opus-Plätze".', + phases: [{ title: 'Verbrauch', detail: 'ein Haiku-Agent liest get_usage ab, das Skript entscheidet' }], +} + +// Geltungsbereich (AI/Regelwerk/pruefwerkzeuge.md): Das Skript liest den Planverbrauch und +// rechnet daraus, wie viele Opus-Agenten gleichzeitig laufen dürfen. Es ist eine Bremse, kein +// Nachweis. Woher die Antworten kommen: +// - Welche Fenster zählen und wie sie heißen: Quelle. get_usage in der Desktop-App lieferte am +// 2026-09-24 "5-hour limit", "Weekly · all models" und "Weekly · Fable", je mit percentUsed +// und resetsAt. Von 19 typisierten Limit-Abbrüchen im Session-Speicher (22.08. bis 20.09.) +// waren 16 five_hour und 3 seven_day, keiner seven_day_opus. Ein Fenster "Weekly · Opus" zählt +// trotzdem wie die Woche, falls es auftaucht, weil die Agenten, um die es geht, auf Opus laufen. +// - Die Stufen: Projektvorgabe, vorläufig (Pascal, 2026-09-24). Sie stehen nur hier und werden +// nach der Messreihe mit .vault-tools/workflow-pruefung/verbrauch_reihe.py nachgestellt. +// - Ohne lesbare Ablesung: Projektvorgabe, die bisherige Stammregel von zwei Opus-Agenten. +// Was das Skript nicht sieht: Agenten, die nach der Ablesung in anderen Sitzungen starten; +// laufende Agenten, die nach der Ablesung weiter verbrauchen; Sitzungen ohne get_usage +// (außerhalb der Desktop-App). Das Urteil rechnet das Skript, der Haiku-Agent liest nur ab. + +const HARTE_OBERGRENZE = 4 +const OHNE_ABLESUNG = 2 +// Je Stufe [Schwelle in Prozent, Plätze unterhalb der Schwelle]; ab der letzten Schwelle 0. +const STUFEN_FUENF_STUNDEN = [[50, 4], [70, 2], [85, 1]] +const STUFEN_WOCHE = [[70, 4], [85, 2], [95, 1]] + +const FUENF_STUNDEN = /^\s*5[- ]?hour/i +const WOCHE_ALLE = /^\s*weekly\b.*\ball models\b/i +const WOCHE_OPUS = /^\s*weekly\b.*\bopus\b/i + +const ABLESUNG = { + type: 'object', + properties: { + verfuegbar: { type: 'boolean' }, + status: { type: 'string' }, + plan: { type: 'string' }, + fenster: { + type: 'array', + items: { + type: 'object', + properties: { label: { type: 'string' }, percentUsed: { type: 'number' }, resetsAt: { type: 'string' } }, + required: ['label', 'percentUsed', 'resetsAt'], + }, + }, + fehler: { type: 'string' }, + }, + required: ['verfuegbar', 'status', 'plan', 'fenster', 'fehler'], +} + +const AUFTRAG = `Lade mit dem Werkzeug ToolSearch das Werkzeug mcp__ccd_session_mgmt__get_usage (Abfrage "select:mcp__ccd_session_mgmt__get_usage") und rufe es einmal ohne Parameter auf. +Übertrage aus dem Ergebnis plan.status nach status und plan.plan nach plan. Übertrage jeden Eintrag aus plan.windows mit label, percentUsed und resetsAt unverändert nach fenster, auch Einträge, die dir unwichtig erscheinen. Setze verfuegbar auf true und fehler auf "". +Ist das Werkzeug nicht verfügbar oder scheitert der Aufruf, setze verfuegbar auf false, status auf "fehler", plan auf "", fenster auf [] und schreibe die Fehlermeldung wörtlich nach fehler. +Tu sonst nichts. Übertrage das Ergebnis in die Felder des StructuredOutput-Werkzeugs.` + +function pruefeArgs(a) { + if (!a || typeof a !== 'object') throw new Error('verbrauch: args fehlen') + if (typeof a.anlass !== 'string' || !a.anlass.trim()) throw new Error('verbrauch: anlass fehlt') + if (!Number.isInteger(a.wunsch) || a.wunsch < 0) throw new Error('verbrauch: wunsch muss eine ganze Zahl ab 0 sein, ist ' + JSON.stringify(a.wunsch)) + if (a.probe !== undefined && (a.probe === null || typeof a.probe !== 'object' || Array.isArray(a.probe))) throw new Error('verbrauch: probe muss ein Objekt sein') +} + +function stufe(prozent, stufen) { + for (const [schwelle, plaetze] of stufen) if (prozent < schwelle) return plaetze + return 0 +} + +// Ordnet die Fenster ein. Ein Fenster, das als 5 Stunden oder Woche erkannt wird, aber keine +// Prozentzahl ab 0 trägt, ist ein Befund und wird nicht still übergangen. Über 100 % heißt +// überschritten und bremst wie jeder Wert über der letzten Stufe. +function einordnen(fenster) { + if (!Array.isArray(fenster)) return { fehler: 'fenster ist keine Liste' } + const e = { fuenf: null, woche: null, opus: null, andere: [] } + for (const f of fenster) { + if (!f || typeof f !== 'object' || typeof f.label !== 'string') return { fehler: 'Fenster ohne label: ' + JSON.stringify(f) } + const art = FUENF_STUNDEN.test(f.label) ? 'fuenf' : WOCHE_ALLE.test(f.label) ? 'woche' : WOCHE_OPUS.test(f.label) ? 'opus' : null + if (!art) { e.andere.push(f.label); continue } + const p = f.percentUsed + if (typeof p !== 'number' || !Number.isFinite(p) || p < 0) return { fehler: `${f.label}: percentUsed ${JSON.stringify(p)} ist keine Zahl ab 0` } + const neu = { prozent: p, zuruecksetzung: typeof f.resetsAt === 'string' ? f.resetsAt : null } + if (!e[art] || e[art].prozent < p) e[art] = neu + } + const fehlt = [!e.fuenf && '5-hour limit', !e.woche && 'Weekly all models'].filter(Boolean) + if (fehlt.length) return { fehler: 'Fenster fehlt: ' + fehlt.join(', ') + (e.andere.length ? '; gelesen: ' + e.andere.join(', ') : '') } + return e +} + +pruefeArgs(args) +phase('Verbrauch') +const wunsch = args.wunsch +const deckel = wunsch > HARTE_OBERGRENZE ? `; wunsch ${wunsch} auf die harte Obergrenze ${HARTE_OBERGRENZE} gesenkt` : '' +let quelle = 'probe' +let ablesung = args.probe +if (ablesung === undefined) { + quelle = 'agent' + ablesung = await agent(AUFTRAG, { label: 'verbrauch:' + args.anlass, phase: 'Verbrauch', schema: ABLESUNG, model: 'haiku' }) +} + +function ohneAblesung(status, grund) { + const plaetze = Math.min(wunsch, OHNE_ABLESUNG) + log(`Verbrauch ${args.anlass}: ${status} (${grund}); ${plaetze} Plätze nach der alten Obergrenze`) + return { status, quelle, fuenf_stunden: null, woche: null, woche_opus: null, plaetze, gebremst: false, grund: grund + deckel, fenster: ablesung && Array.isArray(ablesung.fenster) ? ablesung.fenster : null } +} + +if (!ablesung || typeof ablesung !== 'object') return ohneAblesung('nicht_gelesen', 'kein Ergebnis vom Ableseagenten') +if (ablesung.verfuegbar !== true || ablesung.status !== 'ok') return ohneAblesung('nicht_gelesen', `get_usage: verfuegbar ${ablesung.verfuegbar}, status ${ablesung.status}${ablesung.fehler ? ', ' + ablesung.fehler : ''}`) +const e = einordnen(ablesung.fenster) +if (e.fehler) return ohneAblesung('unbekannte_fenster', e.fehler) + +const wocheProzent = Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0) +const grenzeFuenf = stufe(e.fuenf.prozent, STUFEN_FUENF_STUNDEN) +const grenzeWoche = stufe(wocheProzent, STUFEN_WOCHE) +const plaetze = Math.min(wunsch, grenzeFuenf, grenzeWoche, HARTE_OBERGRENZE) +const gebremst = wunsch > 0 && plaetze === 0 +const grund = `5 Stunden ${e.fuenf.prozent} % (bis ${grenzeFuenf} Plätze), Woche ${wocheProzent} % (bis ${grenzeWoche} Plätze)${e.opus ? ', Opus-Woche ' + e.opus.prozent + ' %' : ''}${deckel}` +log(`Verbrauch ${args.anlass}: ${grund}; ${plaetze} von ${wunsch} Plätzen${gebremst ? ', gebremst' : ''}`) +return { status: 'ok', quelle, fuenf_stunden: e.fuenf, woche: e.woche, woche_opus: e.opus, plaetze, gebremst, grund, fenster: ablesung.fenster } diff --git a/.claude/workflows/welle-mit-pruefung.js b/.claude/workflows/welle-mit-pruefung.js index 565f556..852098c 100644 --- a/.claude/workflows/welle-mit-pruefung.js +++ b/.claude/workflows/welle-mit-pruefung.js @@ -1,7 +1,7 @@ export const meta = { name: 'welle-mit-pruefung', - description: 'Schritt 3 und 4 in einem Aufruf: Kopie mit angewendetem Plan vor der Welle, executer-welle, Byte-Vergleich und Suite, danach pruefung mit den Rohbelegen', - whenToUse: 'Nach der Plankritik, sobald eine Welle mit disjunkten Zieldateien steht (AI/Regelwerk/codeworkflow.md, Schritte 3 und 4). Args: {projekt, planordner, tasks: [{datei, modell, effort?}], kopie? oder ohne: [relpfade, nicht leer], prosa?: [relpfade], suite?, kritisch?, abschluss?, effort?, version?, schwerpunkt?, rot_nachweis_erbracht?, zusatz?, werkzeuge?}. Ohne kopie wird sie vor der Welle gebaut; ohne nennt dann .git und Geheimnisdateien. Ersetzt die eigene Gegenprüfung des Koordinators nicht.', + description: 'Schritt 3 und 4 in einem Aufruf: Kopie mit angewendetem Plan vor der Welle, executer-welle, Byte-Vergleich und Suite, danach pruefung mit den Rohbelegen; mit bis "vergleich" endet er vor der Prüfung', + whenToUse: 'Nach der Plankritik, sobald eine Welle mit disjunkten Zieldateien steht (AI/Regelwerk/codeworkflow.md, Schritte 3 und 4). Args: {projekt, planordner, tasks: [{datei, modell, effort?}], kopie? oder ohne: [relpfade, nicht leer], prosa?: [relpfade], suite?, kritisch?, abschluss?, effort?, version?, schwerpunkt?, rot_nachweis_erbracht?, zusatz?, werkzeuge?, bis?: "vergleich"}. Ohne kopie wird sie vor der Welle gebaut; ohne nennt dann .git und Geheimnisdateien. Mit bis "vergleich" kommt pruefung_args zurück: pruefung damit selbst starten und währenddessen die eigene Gegenprüfung fahren. Ersetzt die eigene Gegenprüfung des Koordinators nicht.', phases: [ { title: 'Kopie', detail: 'Hilfsagent: Stand vor der Welle kopieren und den Plan der Welle anwenden' }, { title: 'Welle', detail: 'executer-welle' }, @@ -14,8 +14,12 @@ export const meta = { // Aufträge und Rollenparameter an genau einer Stelle stehen. Der Hilfsagent hat keinen // agentType und bekommt seinen Effort deshalb mit (low). Opus läuft nur in den Kindern, // und die laufen nacheinander; die Obergrenze von zwei hält executer-welle selbst. +// Verbrauch: Vor und nach der Prüfung liest dieser Workflow den Planverbrauch über verbrauch.js +// ab (nur messen); das Kind pruefung bekommt verbrauch: false, weil ein Kind keinen weiteren +// Workflow aufrufen kann. const MODELLE = ['haiku', 'sonnet', 'opus'] const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' // Einfache Anführungszeichen: In Bash bleiben darin $, Backtick und Backslash wörtlich. const q = s => "'" + String(s).replace(/'/g, "'\\''") + "'" const PY = 'PYTHONIOENCODING=utf-8 python -X utf8' @@ -42,6 +46,7 @@ function pruefeArgs(a) { }) if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('welle-mit-pruefung: effort "' + a.effort + '" ungültig') if (a.prosa !== undefined && !Array.isArray(a.prosa)) throw new Error('welle-mit-pruefung: prosa muss eine Liste sein') + if (a.bis !== undefined && a.bis !== 'vergleich') throw new Error('welle-mit-pruefung: bis kennt nur "vergleich"') if (!a.kopie) { const ok = Array.isArray(a.ohne) && a.ohne.length > 0 && a.ohne.every(x => typeof x === 'string' && x) if (!ok) throw new Error('welle-mit-pruefung: kopie oder ohne angeben. ohne ist eine nicht leere Liste von Ausschlüssen für kopie_bauen.py, als Pfade vom Projektwurzelordner, mindestens .git (falls vorhanden) und Geheimnisdateien') @@ -64,6 +69,20 @@ function kopfUndEnde(text, kopf, ende) { return text.slice(0, kopf) + '\n[... gekürzt ...]\n' + text.slice(-ende) } +// Eine messende Hilfsfunktion ablesen() steht auch in pruefung.js; plankritik.js und planung.js +// haben eine mit Wunsch und Rückgabe. Die Regel (Fenster, Stufen, Rückfall) steht nur in +// verbrauch.js. +const ablesungen = [] +async function ablesen(anlass) { + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + ablesungen.push({ anlass, ...(r || { status: 'nicht_gelesen', grund: 'verbrauch.js ohne Ergebnis' }) }) +} + pruefeArgs(args) const W = args.werkzeuge || 'D:/Claude/.vault-tools/plan-kopie' const taskdateien = args.tasks.map(t => t.datei) @@ -113,8 +132,8 @@ let pruefung = null let pruefungGrund = null if (!welle) pruefungGrund = 'keine Welle' else if (welle.ohne_bericht && welle.ohne_bericht.length) pruefungGrund = 'Welle unvollständig, ohne Bericht: ' + welle.ohne_bericht.join(', ') -if (pruefungGrund) log('Prüfung übersprungen: ' + pruefungGrund) -else { +let pruefungArgs = null +if (!pruefungGrund) { const vl = belege.vergleich && belege.vergleich.laeufe && belege.vergleich.laeufe[0] const vergleichstext = vl ? kopfUndEnde(vl.ausgabe, 1500, 2500) : '' const schwerpunkt = [ @@ -122,25 +141,55 @@ else { welle.mit_abweichung.length ? `Diese Tasks melden Abweichungen vom Plan: ${welle.mit_abweichung.join(', ')}.` : '', vergleichstext ? `Rohausgabe von Byte-Vergleich und Suite nach der Welle (vom Hilfsagenten, nicht von dir gefahren):\n${vergleichstext}` : '', ].filter(Boolean).join('\n') + pruefungArgs = { + projekt: args.projekt, plan: args.planordner, tasks: taskdateien, suite: args.suite, + kritisch: args.kritisch, abschluss: args.abschluss, version: args.version, effort: args.effort, schwerpunkt, + } +} + +const tempText = temp ? `${temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn der Diff nicht mehr gebraucht wird` : null + +if (args.bis === 'vergleich') { + if (pruefungGrund) log('Prüfung wäre übersprungen worden: ' + pruefungGrund) + return { + status: 'bis_vergleich', + kopie, + temp_ordner: tempText, + welle, + vergleich: belege.vergleich || null, + kopie_lauf: belege.kopie || null, + pruefung_args: pruefungArgs, + pruefung_grund: pruefungGrund, + fehler, + verbrauch: ablesungen, + naechster_schritt: pruefungArgs + ? 'pruefung mit pruefung_args als eigenen Workflow im Hintergrund starten. Währenddessen die eigene Gegenprüfung nach Schritt 4, nur lesend: nichts in Projektbaum oder Planordner schreiben, Rot- und Mutantentests nur auf einer Kopie, den pruefer nicht abbrechen. Plankorrektur und Commit erst nach seinem Bericht.' + : 'Keine Prüfung möglich (' + pruefungGrund + '). Welle nachziehen, dann erneut.', + } +} + +if (pruefungGrund) log('Prüfung übersprungen: ' + pruefungGrund) +else { + await ablesen('vor Prüfung') try { - pruefung = await workflow('pruefung', { - projekt: args.projekt, plan: args.planordner, tasks: taskdateien, suite: args.suite, - kritisch: args.kritisch, abschluss: args.abschluss, version: args.version, effort: args.effort, schwerpunkt, - }) + pruefung = await workflow('pruefung', { ...pruefungArgs, verbrauch: false }) } catch (e) { fehler.push('pruefung: ' + (e && e.message)) pruefungGrund = 'pruefung gescheitert: ' + (e && e.message) log(pruefungGrund) } + await ablesen('nach Prüfung') } return { + status: pruefung ? 'fertig' : 'ohne_pruefung', kopie, - temp_ordner: temp ? `${temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn der Diff nicht mehr gebraucht wird` : null, + temp_ordner: tempText, welle, vergleich: belege.vergleich || null, kopie_lauf: belege.kopie || null, pruefung: pruefung || { uebersprungen: pruefungGrund }, fehler, + verbrauch: ablesungen, naechster_schritt: 'Eigene Gegenprüfung nach Schritt 4: Testlauf selbst wiederholen, zentrale Artefakte selbst lesen, eigener Lauf mit echten Daten; bei Abweichung im Byte-Vergleich diff auf die ganze Datei. Befunde am Plan korrigieren, nicht am Code.', } diff --git a/.vault-tools/plan-kopie/LIESMICH.md b/.vault-tools/plan-kopie/LIESMICH.md index a9f6b24..a80f25d 100644 --- a/.vault-tools/plan-kopie/LIESMICH.md +++ b/.vault-tools/plan-kopie/LIESMICH.md @@ -5,7 +5,7 @@ title: "Planwerkzeuge: Plan auf eine Kopie anwenden und jede Welle dagegen vergl summary: "Skripte für die Standardschleife bei Plänen mit ausgeschriebenem Code — schlanke Kopie bauen, Plan auf die Kopie anwenden, je Welle Menge und Bytes vergleichen, Aus-wird-Blöcke prüfen, Codeblöcke auf unsichtbare Zeichen absuchen, Escape-Folgen per Codepunkt setzen. Entstanden 2026-09-17 in der Trading Plattform (Teil 3b)." tags: [vault-tools, codeworkflow] created: 2026-09-17 -updated: 2026-09-20 +updated: 2026-09-24 --- # Planwerkzeuge für die Kopie mit angewendetem Plan @@ -21,12 +21,14 @@ Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]] („Die Ko 5. **Nach jeder Welle:** `welle_vergleich.py --projekt --mat [--prosa …] -- `. Prüft, ob genau die Zieldateien geändert sind und ob jede byte-gleich mit der Kopie ist. Es zeigt je Datei nur die erste Abweichung — danach `diff` auf die ganze Datei. Eigene Plankorrekturen im Baum meldet es zu Recht als „außerhalb der Zieldateien". 6. **Escape-Folgen setzen:** `plan_escape.py …` ersetzt echte Zeichen in einer Zeile durch ihre Escape-Folge, ohne dass eine Schreibschicht mitredet. -7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py [--fixturen ] [--node-modules ] [--conftest-als-test]`. `tests/conftest.py` gilt dabei als Produktivseite und wird zurückgesetzt; nur wenn die neue Fixture eines Tasks dort liegt, lässt `--conftest-als-test` sie stehen (Beleg unten, 2026-09-22). `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich. +7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py [--fixturen ] [--node-modules ] [--conftest-als-test] [--venv ] [--frontend ] [--projektname ]`. **Für ein anderes Projekt als die Trading Plattform braucht es die drei letzten Schalter (seit 2026-09-24):** Ihre Vorgaben sind die der Trading Plattform (`~/venvs/trading`, `dashboard`, `trading-plattform`). Jedes andere Projekt nennt seine Werte, für den Nexhorizons Hub `--venv '~/venvs/hub' --frontend frontend --projektname 'active - Nexhorizons Hub'`, das Venv in einfachen Anführungszeichen, sonst macht die Git-Bash aus `~` einen Windows-Pfad. Das Skript druckt die benutzten Werte in der ersten Zeile. Die Vorgabe `trading-plattform` passt nur auf Pläne mit Arbeitsbaum-Pfaden; ältere Trading-Pläne mit absoluten Pfaden unter `active - Trading Plattform` brauchen `--projektname 'active - Trading Plattform'`, sonst endet der Lauf mit einem `ValueError`. Weiter fest verdrahtet bleiben die Distribution `Debian`, die Ordner `tests/` und `tests/fixtures/`, die Endungen `.test.ts`/`.test.tsx` und `npx.cmd vitest`. **Vorbedingung:** Das Projekt ist im Venv nicht editierbar installiert (`pip install -e`); sonst löst der Finder von setuptools ein fehlendes Untermodul gegen das echte Projekt auf, und ein Test ohne Umsetzung läuft grün. `tests/conftest.py` gilt dabei als Produktivseite und wird zurückgesetzt; nur wenn die neue Fixture eines Tasks dort liegt, lässt `--conftest-als-test` sie stehen (Beleg unten, 2026-09-22). `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich. ## Was belegt ist Seit 2026-09-18 nimmt `welle_vergleich.py` unter `## Zieldateien` auch Pfade relativ zum Projekt (ohne Laufwerk und ohne Wurzel), weil Pläne für einen Git-Worktree keine absoluten Pfade nennen; ein `..` darin ist ein Befund wie ein fremder absoluter Pfad. Jede Zeile braucht weiter die Klammer hinter dem Pfad (`(neu)`, `(geändert)`, `(gelöscht)`). `plan_materialisiere.py` erkennt eine Zieldatei ohne Schrägstrich nur mit den Endungen py, ts, tsx, toml, md, css und json — für `compose.yaml` in der Projektwurzel schreibt die Überschrift `./compose.yaml`. Der Treiber fährt dazu drei weitere Fälle (relative Grundlinie, relative Abweichung, `..` nach außen), zusammen 17. +Seit 2026-09-24 hat `rot_nachweis.py` die Schalter `--venv`, `--frontend` und `--projektname`; vorher waren `~/venvs/trading`, `dashboard` und `trading-plattform/` fest verdrahtet. Ein Hub-Lauf im Trading-Venv wäre für die Tasks mit `argon2-cffi` und `pyotp` rot aus einem fehlenden Paket gewesen, nicht aus der fehlenden Umsetzung. Fehlt das Venv in WSL, steht beim Task jetzt `VORAUSSETZUNG FEHLT` statt `ROT`, und die Schlusszeile zählt diese Läufe getrennt. Belegt am selben Tag: Der Vorgabe-Befehl ist byte-gleich mit der alten Fassung, falsche Schalterwerte enden mit Exit 3, und der erste echte Lauf über 38 Tasks des Hub meldete keinen grün ohne Umsetzung. Im selben Lauf zeigte sich eine zweite Lücke: Beim Zurücksetzen blieben Ordner stehen, die erst der Task angelegt hatte. Ein leeres `hub/auth/` liest Python als Namespace-Paket, und die Fehlerzeile nannte `hub.auth.passwort` statt `hub.auth`. Jetzt gehen solche Ordner mit, sobald sie leer sind; derselbe Lauf für die Tasks 7, 9 und 11 zeigt danach genau die Zeile aus dem Plan. Die unabhängige Prüfung desselben Tages fand eine dritte Lücke: Ein vergessenes `--venv` fiel still auf das Trading-Venv zurück, und Task 7 stand als `ROT … No module named 'argon2'` da. Seitdem wird eine erste Fehlerzeile, die ein Modul außerhalb des Projekts und außerhalb der Zieldateien vermisst, als `VORAUSSETZUNG FEHLT` gemeldet. Das trifft auch den Ende-zu-Ende-Task des Hub, dessen Tests `playwright` brauchen. Mit den Vorgaben lieferte das Skript für die sieben Tasks des Trading-Pakets K vor und nach allen Änderungen dieselben Zeilen. Zwei weitere Lücken desselben Tages, beide ein ROT, das nichts misst: **Ein Arbeitsordner mit Windows-Kurznamen** (`C:/Users/PASCAL~1/…`) ließ vitest dort starten, die Dateien aber über den langen Namen auflösen, und jede Oberflächen-Testdatei endete als `Cannot find module '/src/…'`. Das Skript löst den Arbeitsordner jetzt auf den langen Namen auf; danach zeigte Trading-Task 395 wieder `9 failed | 52 passed` statt `no tests`, und die Hub-Tasks 24, 25, 29 und 37 die Zeile aus dem Plan (`Error: Failed to resolve import "./kontrast" …`). Die erste `Error:`-Zeile steht seitdem mit in der Ergebniszeile, weil die `FAIL`-Zeile allein den Grund nicht zeigt. **Ein Test, der über Vite eine Datei aus `node_modules` lädt** (`?raw`), scheitert hinter der Junction mit `Denied ID`, weil Vite nichts außerhalb seines Wurzelordners ausliefert; das Skript meldet das als `VORAUSSETZUNG FEHLT`. Für solche Tasks läuft der Nachweis in einer Kopie mit echtem `node_modules` (242 MB, gut 20 Sekunden). + Seit 2026-09-22 behandelt `rot_nachweis.py` `tests/conftest.py` als Produktivseite. Vorher zählte alles unter `tests/` als Testdatei — lag die Korrektur eines Tasks selbst in `conftest.py` (Trading Plattform, Task 369: die Ersatzfunktionen der Netzsperre trugen eine andere Vorgabe als die Originale), wendete der Lauf „ohne Umsetzung" sie mit an und meldete `GRUEN pytest: 147 passed` — der eine Zustand, in dem der Nachweis nichts misst, sah aus wie ein sauberer Befund. Gemessen an derselben Basis-Kopie und demselben Task, altes gegen neues Skript: `GRUEN 147 passed` → `ROT 3 failed, 144 passed` mit der gemeinten Fehlerzeile (`assert ('', '') == ('probe-thema', '')`). Die Gegenprobe für den anderen Fall — eine neue Fixture des Tasks liegt in `conftest.py` (Task 368) — mit `--conftest-als-test` `ROT 1 failed, 11 passed` wie vor der Änderung, ohne Schalter `ROT 6 failed, 6 passed` mit `AttributeError: module 'conftest' has no attribute '_gesperrte_pfade'` als erster Fehlerzeile: rot aus dem falschen Grund, aber sichtbar, und das Skript sagt je Task, wie es `conftest.py` behandelt hat. Was es weiter nicht sieht: ob ein Task beides in `conftest.py` legt, Fixture und Korrektur — dann ist keine der beiden Einstellungen richtig, und der Nachweis läuft von Hand. Seit 2026-09-20 quotet `rot_nachweis.py` den Arbeitsordner für den WSL-Aufruf (`shlex.quote`). Ein Temp-Pfad mit Leerzeichen — der Benutzername steckt im Scratchpad-Pfad — ließ vorher das `cd` scheitern: Alle sieben Tasks eines Plans der Trading Plattform standen als „ROT pytest: keine Summenzeile" da, ohne dass ein einziger Test lief. Nach der Korrektur derselbe Lauf mit echten Summenzeilen (`3 failed, 17 passed`, `1 error`, …). Wer „keine Summenzeile" bei **jedem** Task sieht, hat kein Rot gemessen, sondern ein kaputtes Werkzeug. diff --git a/.vault-tools/plan-kopie/plan_zeichen.py b/.vault-tools/plan-kopie/plan_zeichen.py index f48d55b..f152452 100644 --- a/.vault-tools/plan-kopie/plan_zeichen.py +++ b/.vault-tools/plan-kopie/plan_zeichen.py @@ -16,6 +16,9 @@ from pathlib import Path def main() -> int: + # Die Windows-Konsole kodiert cp1252; ein sichtbares Zeichen wie U+2192 in der Zusammenfassung + # liess das Skript sonst mit UnicodeEncodeError abbrechen (2026-09-24, Plan AF der Trading Plattform). + sys.stdout.reconfigure(errors="backslashreplace") plan = Path(sys.argv[1]) dateien = sorted(plan.glob("*.md")) if not dateien: diff --git a/.vault-tools/plan-kopie/rot_nachweis.py b/.vault-tools/plan-kopie/rot_nachweis.py index 06147d1..e214caa 100644 --- a/.vault-tools/plan-kopie/rot_nachweis.py +++ b/.vault-tools/plan-kopie/rot_nachweis.py @@ -20,16 +20,29 @@ Geltungsbereich und Luecken: Sammelfehler -- das ist ROT mit "fixture ... not found" in der ersten Fehlerzeile, also sichtbar. Wer diesen Fall hat, ruft mit --conftest-als-test auf; dann gilt conftest.py wie frueher als Testdatei. Das Skript sagt je Task, wie es conftest.py behandelt hat. -- Python-Tests laufen in WSL (Debian, ~/venvs/trading), Oberflaechentests unter Windows mit npx vitest; - dafuer setzt das Skript eine Junction auf das node_modules des Projekts und loest sie vor dem Loeschen. +- Python-Tests laufen in WSL (Debian, Venv aus --venv), Oberflaechentests unter Windows mit npx vitest im + Ordner aus --frontend; dafuer setzt das Skript eine Junction auf das node_modules des Projekts und loest + sie vor dem Loeschen. Die Vorgaben der drei Schalter sind die der Trading Plattform (~/venvs/trading, + dashboard, trading-plattform/); jedes andere Projekt nennt seine Werte (seit 2026-09-24, Nexhorizons Hub). + Fehlt das Venv in WSL oder der Frontend-Ordner in der Kopie, steht beim Task VORAUSSETZUNG FEHLT statt + ROT -- sonst saehe ein Lauf im falschen Venv aus wie ein sauberer Rot-Nachweis. Dasselbe gilt, wenn die + erste pytest-Fehlerzeile ein Modul vermisst, das weder im Projekt liegt noch vom Task angelegt wird + (vergessenes --venv, eine Abhaengigkeit, die nur der Stack hat). Das greift nur an der ersten + Fehlerzeile; ein fremdes Modul, das erst hinter einem Projektmodul fehlt, sieht das Skript nicht. +- Vorbedingung: Das Projekt ist im Venv NICHT editierbar installiert (pip install -e). Sonst loest der + Finder von setuptools ein fehlendes Untermodul gegen das echte Projekt auf, und ein Test ohne Umsetzung + liefe gruen (Pruefung 2026-09-24, setuptools editable_wheel.py). - Erzeugte Fixtures (der Plan laesst sie mit altem Code erzeugen) holt --fixturen aus einem Ordner nach, wenn sie nach dem Anwenden fehlen. - Es sieht nicht, WELCHE Tests rot sein muessten; es zaehlt und zeigt die erste Fehlerzeile. - Die Basis ist eine Kopie, die der Aufrufer VOR der ersten Welle gezogen hat; das Skript fasst das Projekt selbst nie an. -Aufruf: python rot_nachweis.py [--fixturen ] [--node-modules ] [--conftest-als-test] +Aufruf: python rot_nachweis.py [--fixturen ] [--node-modules ] [--conftest-als-test] [--venv ] [--frontend ] [--projektname ] --node-modules nennt das node_modules des Projekts, wenn die Basis-Kopie keines traegt. +--venv nennt das Venv in WSL (Vorgabe ~/venvs/trading), --frontend den Oberflaechenordner relativ zum +Projekt (Vorgabe dashboard), --projektname den Ordnernamen, hinter dem absolute Zielpfade abgeschnitten +werden (Vorgabe trading-plattform). --conftest-als-test laesst tests/conftest.py als Testdatei stehen (nur, wenn die neue Fixture des Tasks dort liegt). spec.json: [{"task": "task-141-....md", "vor": ["task-...md", ...]}, ...] """ @@ -46,10 +59,16 @@ from pathlib import Path HIER = Path(__file__).resolve().parent ZIEL = re.compile(r"^- `(.+?)`") PROJEKTNAME = "trading-plattform/" +VENV = "~/venvs/trading" +FRONTEND = "dashboard" CONFTEST = "tests/conftest.py" +# Das Venv steht ungequotet im bash-Aufruf, damit ~ expandiert; deshalb nur diese Zeichen. +VENV_MUSTER = re.compile(r"^~?[A-Za-z0-9._/-]+\Z") +FRONTEND_MUSTER = re.compile(r"^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*\Z") +FEHLENDES_MODUL = re.compile(r"No module named '([^']+)'") -def zieldateien(taskdatei: Path) -> list[str]: +def zieldateien(taskdatei: Path, projektname: str = PROJEKTNAME) -> list[str]: """Zieldateien relativ zum Projekt. Absolute Pfade werden hinter dem Projektordner abgeschnitten.""" drin = False ziele: list[str] = [] @@ -61,10 +80,10 @@ def zieldateien(taskdatei: Path) -> list[str]: if treffer: pfad = treffer.group(1).replace("\\", "/").removeprefix("./") if re.match(r"^[A-Za-z]:/", pfad): - stelle = pfad.lower().find(PROJEKTNAME) + stelle = pfad.lower().find(projektname) if stelle < 0: raise ValueError(f"{taskdatei.name}: absoluter Pfad ausserhalb des Projekts: {pfad}") - pfad = pfad[stelle + len(PROJEKTNAME) :] + pfad = pfad[stelle + len(projektname) :] ziele.append(pfad) return ziele @@ -92,13 +111,57 @@ def wsl_pfad(pfad: Path) -> str: return f"/mnt/{text[0].lower()}{text[2:]}" -def loesche(ordner: Path) -> None: - junction = ordner / "dashboard" / "node_modules" +def loesche(ordner: Path, frontend: str = FRONTEND) -> None: + junction = ordner / frontend / "node_modules" if junction.exists(): os.rmdir(junction) # loest nur die Verknuepfung, nie das Ziel shutil.rmtree(ordner, ignore_errors=True) +def pytest_befehl(rot: Path, python_tests: list[str], venv: str = VENV) -> str: + # shlex.quote: Ein Arbeitsordner mit Leerzeichen (Benutzername im Temp-Pfad) liess das cd + # scheitern; jeder Task stand dann als "ROT pytest: keine Summenzeile" da, ohne dass ein Test + # lief (gefunden 2026-09-20). + return f"cd {shlex.quote(wsl_pfad(rot))} && {venv}/bin/python -m pytest {' '.join(python_tests)} -q -p no:cacheprovider 2>&1 | tail -60" + + +def venv_da(venv: str) -> bool: + lauf = subprocess.run(["wsl.exe", "-d", "Debian", "--", "bash", "-lc", f"test -x {venv}/bin/python && echo VENV_DA"], + capture_output=True, text=True, encoding="utf-8", errors="replace") + return "VENV_DA" in lauf.stdout + + +def fremdes_modul(fehlerzeile: str, ziele: list[str], kopie: Path) -> str | None: + """Das fehlende Modul, wenn es weder im Projekt liegt noch vom Task angelegt wird. + + Ein solcher Fehler misst das Venv, nicht die fehlende Umsetzung: ein vergessenes --venv + (No module named 'argon2' im Trading-Venv) oder eine Abhaengigkeit, die nur der Stack hat + (playwright). Gefunden 2026-09-24 in der Pruefung des Werkzeugs. + """ + treffer = FEHLENDES_MODUL.search(fehlerzeile) + if not treffer: + return None + oben = treffer.group(1).split(".")[0] + eigene = set() + for ziel in ziele: + eigene.update(Path(ziel).parts[:-1]) + eigene.add(Path(ziel).stem) + if oben in eigene or any(kopie.rglob(f"{oben}.py")) or any(p.is_dir() for p in kopie.rglob(oben)): + return None + return treffer.group(1) + + +def nimm_wert(argumente: list[str], schalter: str) -> str | None: + if schalter not in argumente: + return None + stelle = argumente.index(schalter) + if stelle + 1 >= len(argumente) or argumente[stelle + 1].startswith("--"): + raise ValueError(f"{schalter} ohne Wert") + wert = argumente[stelle + 1] + del argumente[stelle : stelle + 2] + return wert + + def erste_fehlerzeile(ausgabe: str) -> str: for zeile in ausgabe.splitlines(): kern = zeile.strip() @@ -109,16 +172,23 @@ def erste_fehlerzeile(ausgabe: str) -> str: def main() -> int: argumente = sys.argv[1:] - fixturen = None - if "--fixturen" in argumente: - stelle = argumente.index("--fixturen") - fixturen = Path(argumente[stelle + 1]) - del argumente[stelle : stelle + 2] - node_modules_arg = None - if "--node-modules" in argumente: - stelle = argumente.index("--node-modules") - node_modules_arg = Path(argumente[stelle + 1]) - del argumente[stelle : stelle + 2] + try: + fixturen_arg = nimm_wert(argumente, "--fixturen") + node_modules_wert = nimm_wert(argumente, "--node-modules") + venv = nimm_wert(argumente, "--venv") or VENV + frontend = (nimm_wert(argumente, "--frontend") or FRONTEND).replace("\\", "/").strip("/") + projektname = (nimm_wert(argumente, "--projektname") or PROJEKTNAME).replace("\\", "/").strip("/").lower() + "/" + except ValueError as fehler: + print(f"AUFRUF FALSCH: {fehler}") + return 3 + fixturen = Path(fixturen_arg) if fixturen_arg else None + node_modules_arg = Path(node_modules_wert) if node_modules_wert else None + if not VENV_MUSTER.match(venv): + print(f"AUFRUF FALSCH: --venv {venv!r} -- erlaubt sind nur ~, Buchstaben, Ziffern und ._/-") + return 3 + if not FRONTEND_MUSTER.match(frontend) or ".." in frontend.split("/"): + print(f"AUFRUF FALSCH: --frontend {frontend!r} -- ein Ordner relativ zum Projekt, ohne ..") + return 3 conftest_als_test = False if "--conftest-als-test" in argumente: conftest_als_test = True @@ -126,23 +196,30 @@ def main() -> int: if len(argumente) != 4: print(__doc__) return 3 + print(f"Einstellungen: venv={venv} frontend={frontend} projektname={projektname}") basis, planordner, arbeit, spec_pfad = (Path(a) for a in argumente) if not basis.is_dir() or not planordner.is_dir() or not spec_pfad.is_file(): print("VORAUSSETZUNG FEHLT: Basis-Kopie, Planordner oder Spec") return 3 arbeit.mkdir(parents=True, exist_ok=True) + # Den langen Namen erzwingen: Kommt der Arbeitsordner als Windows-Kurzname (PASCAL~1), startet + # vitest dort, loest die Dateien aber ueber den langen Namen auf und meldet "Cannot find module" + # fuer die eigene Testdatei -- ein ROT, das nichts misst (2026-09-24, Hub-Tasks 24, 25, 29, 37 + # und Trading-Task 395). + arbeit = arbeit.resolve() node_modules = None - for kandidat in (node_modules_arg, basis / "dashboard" / "node_modules"): + for kandidat in (node_modules_arg, basis / frontend / "node_modules"): if kandidat is not None and kandidat.exists(): node_modules = kandidat break + venv_geprueft: bool | None = None # erst pruefen, wenn ein Task Python-Tests hat ergebnisse = [] for eintrag in json.loads(spec_pfad.read_text(encoding="utf-8")): task, voraus = eintrag["task"], eintrag.get("vor", []) name = task.split("-")[1] if "-" in task else task vor, rot = arbeit / f"{name}_vor", arbeit / f"{name}_rot" for ordner in (vor, rot): - loesche(ordner) + loesche(ordner, frontend) shutil.copytree(basis, vor, ignore=shutil.ignore_patterns("node_modules", "__pycache__")) fehler = wende_an(planordner, vor, voraus) shutil.copytree(vor, rot) @@ -151,7 +228,7 @@ def main() -> int: ergebnisse.append((task, "ANWENDEN GESCHEITERT", fehler.replace("\n", " | "))) print(f"{task}: ANWENDEN GESCHEITERT: {fehler[-300:]}") continue - ziele = zieldateien(planordner / task) + ziele = zieldateien(planordner / task, projektname) tests = [z for z in ziele if ist_test(z, conftest_als_test) and not z.startswith("tests/fixtures/")] if CONFTEST in ziele: # Sichtbar machen, was mit conftest.py geschah -- der Koordinator liest die erste Fehlerzeile @@ -168,14 +245,22 @@ def main() -> int: shutil.copyfile(vor / ziel, rot / ziel) elif (rot / ziel).is_file(): (rot / ziel).unlink() + # Ordner, die erst der Task angelegt hat, gehen mit, sobald sie leer sind: Ein leeres + # hub/auth/ liest Python als Namespace-Paket, und die Fehlerzeile nannte dann das + # Untermodul statt des fehlenden Pakets (2026-09-24, Nexhorizons Hub, Tasks 7, 9, 11). + ordner = (rot / ziel).parent + while ordner != rot and not (vor / ordner.relative_to(rot)).exists() and not any(ordner.iterdir()): + ordner.rmdir() + ordner = ordner.parent python_tests = [t for t in tests if t.endswith(".py")] ober_tests = [t for t in tests if not t.endswith(".py")] zeilen = [] - if python_tests: - # shlex.quote: Ein Arbeitsordner mit Leerzeichen (Benutzername im Temp-Pfad) liess das cd - # scheitern; jeder Task stand dann als "ROT pytest: keine Summenzeile" da, ohne dass ein Test - # lief (gefunden 2026-09-20). - befehl = f"cd {shlex.quote(wsl_pfad(rot))} && ~/venvs/trading/bin/python -m pytest {' '.join(python_tests)} -q -p no:cacheprovider 2>&1 | tail -60" + if python_tests and venv_geprueft is None: + venv_geprueft = venv_da(venv) + if python_tests and not venv_geprueft: + zeilen.append(f"VORAUSSETZUNG FEHLT: {venv}/bin/python ist in WSL nicht ausfuehrbar") + elif python_tests: + befehl = pytest_befehl(rot, python_tests, venv) lauf = subprocess.run(["wsl.exe", "-d", "Debian", "--", "bash", "-lc", befehl], capture_output=True, text=True, encoding="utf-8", errors="replace") ausgabe = lauf.stdout @@ -184,15 +269,22 @@ def main() -> int: # nicht gesammelt (etwa ein ImportError in tests/conftest.py) -- das ist rot, nicht gruen. # Gefunden am 2026-09-18: Task 172 stand als GRUEN da, obwohl kein einziger Test lief. rot_ja = bool(re.search(r"\d+ (failed|error)", summe)) or summe == "keine Summenzeile" - zeilen.append(("ROT " if rot_ja else "GRUEN") + f" pytest: {summe} | {erste_fehlerzeile(ausgabe)}") + erste = erste_fehlerzeile(ausgabe) + fremd = fremdes_modul(erste, ziele, rot) if rot_ja else None + if fremd: + zeilen.append(f"VORAUSSETZUNG FEHLT: Modul {fremd!r} liegt weder im Projekt noch im Venv {venv} | pytest: {summe} | {erste}") + else: + zeilen.append(("ROT " if rot_ja else "GRUEN") + f" pytest: {summe} | {erste}") if ober_tests: if node_modules is None: zeilen.append("VORAUSSETZUNG FEHLT: node_modules in der Basis-Kopie") + elif not (rot / frontend).is_dir(): + zeilen.append(f"VORAUSSETZUNG FEHLT: Frontend-Ordner {frontend} fehlt in der Kopie") else: import _winapi - _winapi.CreateJunction(str(node_modules.resolve()), str(rot / "dashboard" / "node_modules")) - relativ = [t.removeprefix("dashboard/") for t in ober_tests] - lauf = subprocess.run(["npx.cmd", "vitest", "run", *relativ], cwd=rot / "dashboard", + _winapi.CreateJunction(str(node_modules.resolve()), str(rot / frontend / "node_modules")) + relativ = [t.removeprefix(f"{frontend}/") for t in ober_tests] + lauf = subprocess.run(["npx.cmd", "vitest", "run", *relativ], cwd=rot / frontend, capture_output=True, text=True, encoding="utf-8", errors="replace") ausgabe = re.sub(r"\x1b\[[0-9;]*m", "", lauf.stdout + lauf.stderr) summe = next((z.strip() for z in ausgabe.splitlines() if z.strip().startswith("Tests ")), "") @@ -201,16 +293,30 @@ def main() -> int: # das Ende der Ausgabe zeigen, statt "rot" zu behaupten, ohne zu wissen woran. summe = "keine Summenzeile; Ende der Ausgabe: " + " / ".join(z.strip() for z in ausgabe.splitlines()[-6:] if z.strip())[:300] rot_ja = "failed" in summe or lauf.returncode != 0 - zeilen.append(("ROT " if rot_ja else "GRUEN") + f" vitest: {summe} | {erste_fehlerzeile(ausgabe)}") + if "Denied ID" in ausgabe: + # Vite liefert keine Datei ausserhalb seines Wurzelordners, und durch die Junction + # liegt node_modules ausserhalb der Kopie: Ein Test mit "?raw" aus node_modules + # scheitert dann als ganze Datei (2026-09-24, Nexhorizons Hub, Task 42). + zeilen.append(f"VORAUSSETZUNG FEHLT: Vite verweigert eine Datei hinter der Junction auf node_modules; mit echter Kopie von node_modules fahren | vitest: {summe}") + else: + # Bei einer Datei, die schon am Import scheitert, steht vor der Fehlermeldung nur + # die FAIL-Zeile; die Meldung selbst zeigt erst, ob der Grund der gemeinte ist. + erste = erste_fehlerzeile(ausgabe) + meldung = next((z.strip()[:220] for z in ausgabe.splitlines() + if z.strip().startswith(("Error:", "AssertionError", "TypeError", "ReferenceError", "SyntaxError"))), "") + zusatz = f" | {meldung}" if meldung and meldung not in erste else "" + zeilen.append(("ROT " if rot_ja else "GRUEN") + f" vitest: {summe} | {erste}{zusatz}") if not tests: zeilen.append("ohne Testdatei unter den Zieldateien") for zeile in zeilen: print(f"{task}: {zeile}") ergebnisse.append((task, zeile[:5].strip(), zeile)) for ordner in (vor, rot): - loesche(ordner) + loesche(ordner, frontend) gruen = [e for e in ergebnisse if e[1] == "GRUEN"] - print(f"\n{len(ergebnisse)} Laeufe, davon {len(gruen)} gruen ohne Umsetzung.") + ohne_lauf = [e for e in ergebnisse if e[1] in ("VORAU", "ANWENDEN GESCHEITERT")] + print(f"\n{len(ergebnisse)} Laeufe, davon {len(gruen)} gruen ohne Umsetzung und {len(ohne_lauf)} ohne Lauf " + "(Voraussetzung fehlt oder Anwenden gescheitert).") return 0 diff --git a/.vault-tools/trading-plattform-pruefung/LIESMICH.md b/.vault-tools/trading-plattform-pruefung/LIESMICH.md index 8683527..637ed41 100644 --- a/.vault-tools/trading-plattform-pruefung/LIESMICH.md +++ b/.vault-tools/trading-plattform-pruefung/LIESMICH.md @@ -5,7 +5,7 @@ title: "Trading Plattform: Prüfwerkzeuge des Koordinators fürs Dashboard" summary: "Eigener Lauf mit echten Daten zum Dashboard der Trading Plattform — Vergleich der Endpunkte gegen Vollparse und pandas, zwei WebSocket-Verbindungen, Browser-Lauf und Wiederaufbau mit Playwright, gefahrlose Proben der zwei Skripte, Mutantenläufe. Entstanden 2026-09-17 (Teil 3b), erweitert 2026-09-18 (Teil 10: Anmeldung, Angriffsfälle, Beobachtung, Kerzenlatenz), bewusst nicht im Projekt-Repo." tags: [vault-tools, trading-plattform] created: 2026-09-17 -updated: 2026-09-21 +updated: 2026-09-24 --- # Prüfwerkzeuge fürs Dashboard der Trading Plattform @@ -103,3 +103,43 @@ Die Werkzeuge nennen `config/secrets.toml` nicht mehr fest. Wer eine Projektklas |---|---|---| | `handelstag_auswertung.py` | seit Paket M mit mehreren Instrumenten: Instrumente aus dem Protokollkopf; je Instrument Entscheidungen, Orders und die aus der Folge erwartete Zahl (Stufen, Versatz, Entscheidungen, Schlussbein, ohne geteilte Beine), erreichte Positionen und Takte mit frischer Kerze; groesste Zahl von Orders in einem Takt, Ereignisse je Art, Positionen je Instrument am Ende, BEFUND fuer jede nicht gefuellte Orderzeile; das Taktfeld orders gilt als Orders dieses Takts; `--broker` und `--kerzen` fragen alle Symbole des Kopfs, jede Order gegen die Kerze ihres Symbols | wie bisher: `python handelstag_auswertung.py [--broker] [--kerzen]` aus dem Projektordner | | `m_probeaufruf.py` | lesender Probeaufruf vor dem Einspielen: `GET /v2/assets` roh (Schluessel und Typen) und ueber `AlpacaBroker.anlage`, `trades/latest` mit allen Symbolen roh und ueber `letzte_kurse`, Kerzen gebuendelt gegen einzeln, Seitenverhalten mit kleiner Seite; Exit 0 nur, wenn alles wie angenommen ist und keine Aktie die Startpruefung einer Folge mit Short verfehlt | aus einem Arbeitsbaum mit Paket M in WSL: `python m_probeaufruf.py ...` | + +## Betrieb ohne täglichen Klick (2026-09-24): Abendkontrolle und Rückweg + +Pascal am 2026-09-24: eine dauerhafte Abendkontrolle, die Neustarts übersteht und keine Updates von Claude Code oder Desktop blockiert, und zehn Aktien bis Mittwoch 2026-09-30. Beides läuft als Windows-Aufgabe mit `pythonw.exe`, ohne Claude-Prozess. + +| Datei | Was sie tut | Aufruf | +|---|---|---| +| `abendkontrolle_wsl.py` | liest einen Handelstag und urteilt im Code: Handelstag laut `GET /v2/calendar`, Protokoll des scharfen Runners, letzte Zeile „Ende: …, Exit n" im Journal (jeder Exit ungleich 0 ist ein Befund), BEFUND-Zeilen von `handelstag_auswertung.py --broker --kerzen`, offene Positionen beim Broker, mindestens ein Takt `aktiv`, Tagesbericht, Dashboard und Zeitgeber, Killswitch nicht `gesperrt`; bei Befund eine Meldung mit hoher Priorität über den Melder des Projekts. Gibt eine JSON-Zeile aus, nie Beträge | in WSL: `python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung]`; nur eine Meldung: `--meldung TITEL TEXT` | +| `abendkontrolle.py` | Windows-Seite: prüft jeden Werktag seit 2026-09-24 ohne Ergebnisdatei (höchstens sieben Tage zurück, heute erst ab 22:30), legt das Ergebnis unter `data/paper/abendkontrollen/.json` im Projekt ab und schreibt eine Zeile in `Inbox/.md` unter „Agentenprotokoll", bei Befund mit `#needs-review` | `python abendkontrolle.py [--tag JJJJ-MM-TT]` | +| `abendkontrolle_einrichten.ps1` | Aufgabe „Trading Plattform - Abendkontrolle": werktags 22:45 und fünf Minuten nach jeder Anmeldung, nur angemeldet, verpasste Läufe beim nächsten Start | `powershell -NoProfile -File abendkontrolle_einrichten.ps1 [-Aktion entfernen]` | +| `abendkontrolle_angriffe.py`, `abendkontrolle_ersatz_auswertung.py` | 44 Fälle in WSL (Kalender, Protokoll, Ende je Lauf samt mittags abgebrochenem und abendlichem Lauf, Journal, Auswertung, Broker, Maske, Bericht, Dienste, Killswitch, getrennte Fragen, Meldeweg gegen `ntfy_probe_server.py` und gegen einen toten Port, Bedienfehler; Reihenfolge an zwei Positionen) und 20 unter Windows (Notiz, Überschrift mit Zusatz, CRLF, kein UTF-8, Ergebnisform, Abbruch wird wiederholt, keine Nachbardatei, fällige Tage), ohne Netz. Am 2026-09-24 nach der unabhängigen Prüfung: 0 Abweichungen | `python abendkontrolle_angriffe.py` einmal in WSL (Venv), einmal unter Windows | +| `rueckweg_aapl.py`, `rueckweg_aapl_einrichten.ps1` | einmal: `config/paper-aapl-register.toml` wird `config/paper.toml`, lokal committet (nicht gepusht) — nur nach 2026-09-30 22:05, bei stehendem Runner, sauberem Projektordner mit `master`, wenn dort noch die Konfiguration mit `quelle = "folge"` liegt und beide Dateien ausser `[agent]`, den Instrumenten und dem Tagesverlust dieselben Schluessel und Werte tragen, in beide Richtungen verglichen (sonst schaltete der Tausch etwa ein abgeschaltetes `[auto_fortsetzen]` still wieder ein); ein halb erledigter Tausch wird zu Ende committet. Sonst Befund in der Notiz und eine Meldung. Aufgabe „Trading Plattform - Rueckweg AAPL" am 2026-09-30 22:50, zweiter Versuch 2026-10-01 22:10 | `python rueckweg_aapl.py`; Einrichten wie oben | +| `rueckweg_aapl_angriffe.py` | 22 Fälle auf einem Wegwerf-Git-Repo (dazu halb getauscht, Abschnitt nur in `paper.toml`, Automatik nur dort abgeschaltet, Abschnitt nur dort gelöscht, anderer Wert, anderer Zweig); am 2026-09-24 0 Abweichungen | `python rueckweg_aapl_angriffe.py` unter Windows | +| `abendkontrolle_mutanten.py` | baut je einen Fehler in eine Wegwerfkopie und verlangt, dass der Angriffstreiber rot wird: 16 Mutanten von `abendkontrolle_wsl.py` (in WSL), 9 von `abendkontrolle.py` und 12 von `rueckweg_aapl.py` (unter Windows). Am 2026-09-24: 37 von 37 gefangen | `python abendkontrolle_mutanten.py` einmal in WSL, einmal unter Windows | + +## Einspielen (2026-09-24): Werkzeuge aus dem Scratchpad hierher + +| Datei | Was sie tut | Aufruf | +|---|---|---| +| `dashboard_neustart.sh` | `sudo systemctl restart trading-dashboard.service` nur bei stehendem Runner, danach Tor 6b (Journal der letzten Minute ohne `PermissionError`/`Traceback`, sonst Exit 27), `/api/gesund` und die Oberfläche. Pascals dauerhafte Freigabe gilt für genau diesen Neustart außerhalb der Handelszeit | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/dashboard_neustart.sh` | +| `geheimnis_scan.py` | vor jedem Push: Werte ab 12 Zeichen aus Geheimnis- und Zugangsdatei gegen `git log -p ..`, nur Schlüsselnamen und Trefferzahlen; dazu, ob eine der drei Geheimnisdateien verfolgt wird. Regressionsnachweis mit `SCAN_PROBE=` (am 2026-09-24: 66 Treffer für einen bekannten Text, 0 für die echten Werte) | in WSL: `python geheimnis_scan.py [ …]` | + +## IBKR-Zugang (2026-09-24): Paper-Konto über das Gateway im Container + +Pascal am 2026-09-24: Zugang zum IBKR-Paper-Konto vorbereiten, Gateway als Container in WSL, erster Test nach 22:00. Nichts davon handelt. + +| Datei | Was sie tut | Aufruf | +|---|---|---| +| `ibkr_zugang_eintragen.sh` | Pascal trägt Paper-Benutzername, Passwort (ohne Anzeige, zweimal) und Kontonummer ein; Ablage `/home/claude/.config/trading-plattform/ibkr/` (0700/0600); ausgegeben werden nur Längen. Mit erfundenen Werten in einem Wegwerf-Ordner geprüft (`IBKR_ZIEL`) | `wsl -d Debian -u claude -- bash …/ibkr_zugang_eintragen.sh` | +| `ibkr_gateway_test.sh` | Gateway-Container `ghcr.io/gnzsnz/ib-gateway` (auf eine Prüfsumme festgelegt), Paper, `READ_ONLY_API=yes`, Port nur auf `127.0.0.1:4004`; Schritte `laden`, `pruefen`, `starten`, `log` (Benutzername maskiert), `probe`, `stoppen`; verweigert zwischen 15 und 22 Uhr Berlin | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/ibkr_gateway_test.sh ` | +| `ibkr_probe.py` | lesende Probe über `nautilus-ibapi` im eigenen Venv `~/venvs/ibkr-probe`: Verbindung, Konto (nur Anfang und Länge), Kontoart, Währung, Positionen (Zahl), verzögerter AAPL-Kurs (nur die Kursart), Fehlercodes; nie ein Betrag. Ohne Gateway geprüft: „keine Verbindung", Code 502, Exit 3 | `~/venvs/ibkr-probe/bin/python …/ibkr_probe.py [--port 4004]` | + +## Paket AF (2026-09-24): Fortsetzen zum Tagesstart ohne Klick + +| Datei | Was sie tut | Aufruf | +|---|---|---| +| `af_gegenfall.py` | faehrt die echte PaperSitzung eines Arbeitsbaums mit AF und der ausgelieferten `config/paper.toml` gegen einen ProbeBroker, Kerzen aus einer Datei von `hole_kerzen.py` fuer jede Aktie, Wegwerf-Register mit der Neustartregel, ohne Netz; der Fall `restposition` (10 Stueck in der letzten Aktie) und der Fall `mitten` (Start 60 Marktminuten nach der Oeffnung) muessen `nicht_fortgesetzt` mit dem Grund in Protokoll und Meldung zeigen, der Fall `flach` ist die Gegenprobe mit `fortgesetzt`, Ausloeser `automatik` und Orders; Exit 0 nur ohne BEFUND | aus dem Arbeitsbaum in WSL: `python af_gegenfall.py `, Fall `restposition`, `mitten` oder `flach`; die Kerzen vorher einmal lesend mit `hole_kerzen.py ` | +| `af_registerprobe.py` | fragt die echte Funktion `pruefe_register` (Paket AF) gegen `data/registry.db`, nur lesend (`mode=ro`; die Klasse `Registry` fuehrte beim Oeffnen das Schema aus und schriebe); druckt Zustand, die vier juengsten Killswitch-Ereignisse (Stufe, Ausloeser, Zeit) und den Befund; Exit 0 erfuellt, 1 nicht erfuellt, 3 Register fehlt. Belegt am 2026-09-24 an drei gespielten Registern (bereit / Pascals Anhalten ohne Fortsetzen / aktiv) und an einer Kopie des echten; die mtime des echten blieb gleich. Braucht den Code mit AF im genannten Ordner | in WSL im Projektordner nach dem Dashboard-Neustart: `~/venvs/trading/bin/python af_registerprobe.py .` | + +Was die uebrigen Werkzeuge mit AF sehen: `handelstag_auswertung.py` braucht keine Aenderung, es zaehlt das Ereignis `auto_fortsetzen` unter `ereignisse je art` und druckt seinen Text. `abendkontrolle_wsl.py` liest Zustand, Seit und Ausloeser des Killswitch und berichtet jede Zeile `auto_fortsetzen` als Hinweis (die ersten 200 Zeichen des Feldes text, das mit dem Ergebnis beginnt). `neustart_echtlauf.py` setzt `angehalten`/`neustart` ohne Ereignis, `pause_echtlauf.py` einen Ausloeser `echtlauf`: Die Sitzung setzt dort nicht fort, schreibt aber je Lauf eine Zeile `auto_fortsetzen` und in "Paper-Runner wartet" eine Zeile mehr. `generalprobe_echtlauf.py` und `killswitch_simulation.py` laden `config/paper.toml` und klicken nach Plan; gegen einen Baum mit AF setzt die Sitzung dort mit dem Schalter selbst fort, bevor der geplante Klick kommt. Wer diese vier fuer einen Vergleich mit Paket M faehrt, setzt vorher `auto_fortsetzen=False` in ihr `replace(lade_paper_config(...), ...)`; `neustart_echtlauf.py` und `pause_echtlauf.py` rufen `lade_paper_config` ohne `replace` auf — dort in der Konfiguration ihrer Kopie `[auto_fortsetzen] aktiv = false` setzen. diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py new file mode 100644 index 0000000..b16f72a --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py @@ -0,0 +1,215 @@ +"""Startet die Abendkontrolle des Paper-Betriebs und schreibt ihr Ergebnis in die Tagesnotiz. + +Koordinator-Werkzeug, nicht fuers Repo. Laeuft unter Windows mit pythonw.exe als geplante Aufgabe +"Trading Plattform - Abendkontrolle" (abendkontrolle_einrichten.ps1): werktags 22:45 und fuenf Minuten +nach jeder Anmeldung. Auftrag Pascal 2026-09-24: dauerhaft, uebersteht Neustarts, blockiert keine +Updates -- deshalb eine Windows-Aufgabe (bleibt ueber Neustarts, holt verpasste Laeufe nach) und kein +Claude-Prozess (haelt keine Datei von Claude Code oder Desktop offen). + +Ablauf: Jeder Werktag seit ERSTER_TAG, hoechstens NACHHOLEN_TAGE zurueck, ohne Ergebnisdatei in +ABLAGE wird geprueft; der heutige erst ab FRUEHESTENS (der Bericht der Tagesauswertung kommt 22:20). +Je Tag: abendkontrolle_wsl.py in WSL (liest nur, meldet bei Befund selbst ueber ntfy), dann zuerst das +Ergebnis als JSON nach ABLAGE und danach eine Zeile in Inbox/.md unter der ersten Ueberschrift, +die mit "## Agentenprotokoll" beginnt. +- Liefert der WSL-Teil kein Ergebnis oder endet er mit Exit 2 (abgebrochen), bleibt die Ergebnisdatei + aus, die Notiz bekommt einen Befund mit #needs-review, und der naechste Lauf versucht den Tag erneut. +- Scheitert nur die Notiz (etwa eine Datei, die kein UTF-8 ist), bleibt es bei der Ergebnisdatei und + einer Zeile in lauf.log: Die Kontrolle ist gelaufen und hat ihre Meldung schon geschickt; ein zweiter + Lauf schickte sie nur noch einmal. +- Ein Tag, der laenger als NACHHOLEN_TAGE scheitert, faellt aus dem Fenster (lauf.log behaelt ihn). +Die Notiz wird ueber eine Nachbardatei und os.replace geschrieben: Ein Abbruch mitten im Schreiben +hinterlaesst die alte Datei, nicht eine halbe. Gegen eine Claude-Sitzung, die dieselbe Notiz im selben +Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millisekunden) -- benannt. + +Annahme, benannt: Die Uhr dieses Rechners geht nach Berliner Zeit (tzutil: W. Europe Standard Time, +gelesen am 2026-09-24); ohne das Paket tzdata kennt Python unter Windows keine Zeitzonen-Datenbank. +Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl ] +""" + +import argparse +import json +import os +import subprocess +import sys +import traceback +from datetime import date +from datetime import datetime +from datetime import time +from datetime import timedelta +from pathlib import Path + +VAULT = Path(r"D:\Claude") +PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform" +ABLAGE_TEIL = PROJEKT_TEIL / "data" / "paper" / "abendkontrollen" +WSL = r"C:\Windows\System32\wsl.exe" +WSL_PY = "/home/claude/venvs/trading/bin/python" +WSL_SKRIPT = "/mnt/d/Claude/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py" +ERSTER_TAG = date(2026, 9, 24) +NACHHOLEN_TAGE = 7 +FRUEHESTENS = time(22, 30) +WSL_TIMEOUT_S = 1200 +KEIN_FENSTER = 0x08000000 # CREATE_NO_WINDOW: pythonw hat keine Konsole, wsl.exe soll keine oeffnen +UEBERSCHRIFT = "## Agentenprotokoll" + + +def faellige_tage(jetzt: datetime, ablage: Path) -> list[str]: + tage = [] + for zurueck in range(NACHHOLEN_TAGE, -1, -1): + tag = jetzt.date() - timedelta(days=zurueck) + if tag < ERSTER_TAG or tag.weekday() >= 5: + continue + if tag == jetzt.date() and jetzt.time() < FRUEHESTENS: + continue + if (ablage / f"{tag.isoformat()}.json").exists(): + continue + tage.append(tag.isoformat()) + return tage + + +def fahre_wsl(tag: str) -> dict: + lauf = subprocess.run( + [WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--tag", tag], + capture_output=True, timeout=WSL_TIMEOUT_S, creationflags=KEIN_FENSTER, + ) + ausgabe = lauf.stdout.decode("utf-8", errors="replace") + for zeile in reversed(ausgabe.splitlines()): + zeile = zeile.strip() + if zeile.startswith("{"): + try: + return json.loads(zeile) + except json.JSONDecodeError: + break + fehler = lauf.stderr.decode("utf-8", errors="replace").strip().splitlines()[-3:] + raise RuntimeError(f"keine JSON-Zeile vom WSL-Teil (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})") + + +def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str: + kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)" + befunde = ergebnis.get("befunde") or [] + fakten = ergebnis.get("fakten") or {} + hinweise = ergebnis.get("hinweise") or [] + if fakten.get("handelstag") is False and not befunde: + return f"{kopf}: kein Handelstag laut Alpaca-Kalender." + teile = [] + ende = fakten.get("ende") or {} + if ende: + teile.append( + f"Ende {ende.get('grund')}, Exit {ende.get('exit')}, Orders {ende.get('orders')}, " + f"Position {ende.get('position')}" + ) + laeufe = fakten.get("laeufe") or [] + if laeufe: + teile.append(f"{len(laeufe)} Lauf/Läufe, {sum(l.get('takte_aktiv', 0) for l in laeufe)} Takte aktiv") + if "broker_offen" in fakten: + teile.append(f"offene Positionen beim Broker {fakten['broker_offen']}") + if "bericht" in fakten: + teile.append("Tagesbericht da" if fakten["bericht"] else "Tagesbericht fehlt") + killswitch = fakten.get("killswitch") or {} + if killswitch: + teile.append(f"Killswitch {killswitch.get('zustand')} ({killswitch.get('ausloeser')})") + zusammen = "; ".join(teile) + if not befunde: + text = f"{kopf}: ohne Befund. {zusammen}." + else: + text = f"{kopf}: **{len(befunde)} Befund(e)** #needs-review. {zusammen}." + text += "".join(f"\n - {befund}" for befund in befunde) + if fakten.get("meldung"): + text += f"\n - Meldung ans Telefon: {fakten['meldung']}" + text += "".join(f"\n - Hinweis: {hinweis}" for hinweis in hinweise[:5]) + return text + + +def schreibe_notiz(vault: Path, tag: str, zeile: str) -> None: + notiz = vault / "Inbox" / f"{tag}.md" + if notiz.exists(): + text = notiz.read_bytes().decode("utf-8") # kein UTF-8: Ausnahme, die Notiz bleibt unberuehrt + else: + text = ( + f"---\ntype: daily\ntitle: \"{tag}\"\nstatus: open\nsummary: \"Tagesnotiz vom {tag}.\"\n" + f"tags: [daily]\ncreated: {tag}\nupdated: {tag}\n---\n\n# {tag}\n" + ) + # Zeilenenden der Datei bleiben, wie sie sind; gesucht wird ohne \r. + zeilen = text.split("\n") + bereinigt = [z.rstrip("\r").rstrip() for z in zeilen] + cr = "\r" if any(z.endswith("\r") for z in zeilen) else "" + # Die erste Ueberschrift, die mit "## Agentenprotokoll" beginnt ("## Agentenprotokoll — …" kommt vor). + start = next((i for i, z in enumerate(bereinigt) if z == UEBERSCHRIFT or z.startswith(UEBERSCHRIFT + " ")), None) + if start is None: + nl = cr + "\n" + text = text.rstrip("\r\n") + nl + nl + UEBERSCHRIFT + nl + nl + nl.join(zeile.split("\n")) + nl + else: + ende = next((i for i in range(start + 1, len(zeilen)) if bereinigt[i].startswith("## ")), len(zeilen)) + # Hinter den letzten nicht leeren Eintrag des Abschnitts; die Leerzeilen danach ersetzt eine. + einfuegen = ende + while einfuegen > start + 1 and not bereinigt[einfuegen - 1]: + einfuegen -= 1 + block = [z + cr for z in zeile.split("\n")] + if einfuegen == start + 1: + block = [cr] + block + zeilen[einfuegen:ende] = block + ([cr] if ende < len(zeilen) else [""]) + text = "\n".join(zeilen) + if not text.endswith("\n"): + text += "\n" + neu = notiz.with_name(notiz.name + ".abendkontrolle.tmp") + neu.write_bytes(text.encode("utf-8")) + os.replace(neu, notiz) + + +def logge(ablage: Path, text: str) -> None: + ablage.mkdir(parents=True, exist_ok=True) + with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as datei: + datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n") + + +def main(argv=None) -> int: + zerleger = argparse.ArgumentParser(allow_abbrev=False) + zerleger.add_argument("--tag") + zerleger.add_argument("--vault", default=str(VAULT)) + zerleger.add_argument("--ohne-wsl", help="Ergebnis aus einer JSON-Datei statt aus WSL (Angriffsfaelle)") + zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)") + argumente = zerleger.parse_args(argv) + vault = Path(argumente.vault) + ablage = vault / ABLAGE_TEIL + jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now() + tage = [argumente.tag] if argumente.tag else faellige_tage(jetzt, ablage) + logge(ablage, f"start, faellig: {tage}") + code = 0 + for tag in tage: + uhrzeit = jetzt.strftime("%H:%M") if tag == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}" + try: + if argumente.ohne_wsl: + ergebnis = json.loads(Path(argumente.ohne_wsl).read_text(encoding="utf-8")) + else: + ergebnis = fahre_wsl(tag) + if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list): + raise RuntimeError("Ergebnis des WSL-Teils in unerwarteter Form") + except Exception as ausnahme: + code = 1 + logge(ablage, f"{tag}: gescheitert {ausnahme!r}\n{traceback.format_exc()}") + try: + schreibe_notiz( + vault, tag, + f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch): **gescheitert** " + f"({type(ausnahme).__name__}: {str(ausnahme)[:200]}) #needs-review. Der nächste Lauf versucht es erneut.", + ) + except Exception as zweite: + logge(ablage, f"{tag}: auch die Notiz scheiterte {zweite!r}") + continue + # Ein Abbruch im WSL-Teil (Exit 2) wird wiederholt; alles andere gilt als gelaufen. + if ergebnis.get("exit") != 2: + (ablage / f"{tag}.json").write_text( + json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n" + ) + else: + code = 1 + logge(ablage, f"{tag}: {len(ergebnis['befunde'])} Befund(e), exit {ergebnis.get('exit')}") + try: + schreibe_notiz(vault, tag, zeile_fuer(ergebnis, uhrzeit)) + except Exception as ausnahme: + code = 1 + logge(ablage, f"{tag}: Notiz nicht geschrieben {ausnahme!r}") + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py new file mode 100644 index 0000000..9d66a9e --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py @@ -0,0 +1,406 @@ +"""Angriffsfaelle fuer die Abendkontrolle (abendkontrolle_wsl.py in WSL, abendkontrolle.py unter Windows). + +Koordinator-Werkzeug, nicht fuers Repo. Jeder Fall laeuft einzeln auf einer Wegwerfkopie, zuerst die +Grundlinie; ohne Netz (--ohne-broker/--ohne-kerzen, Kalender und Journal aus Dateien, eine Ersatz- +Auswertung fuer die Brokerzeile) und ohne Meldung (--ohne-meldung). Faelle an zwei Listenpositionen, +wo die Reihenfolge zaehlen kann (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 3). +Aufruf in WSL (Venv) und unter Windows: python abendkontrolle_angriffe.py +Exit 0, wenn jeder Fall so ausgeht wie erwartet; sonst 1 mit der Liste der Abweichungen. +""" + +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent +TAG = "2026-09-23" +ECHTES_PROJEKT = Path("/mnt/d/Claude/Code Projekte/active - Trading Plattform") +ECHTES_PROTOKOLL = ECHTES_PROJEKT / "artifacts" / "paper" / "2026-09-23T131023-paper.jsonl" +KALENDER_TAG = [{"date": TAG, "open": "09:30", "close": "16:00"}] +JOURNAL_GUT = ( + "Ende: boersenschluss, Position 0, Orders 2, Killswitch aktiv, Exit 0\n" + "trading-paper.service: Deactivated successfully.\n" +) +DIENSTE_GUT = { + "trading-dashboard.service": "active", + "trading-paper.timer": "active", + "trading-auswertung.timer": "active", +} +abweichungen: list[str] = [] + + +# --- WSL-Teil ---------------------------------------------------------------------------------- + +def baue_projekt(wurzel: Path, killswitch: str | None = "aktiv") -> Path: + projekt = wurzel / "projekt" + (projekt / "artifacts" / "paper").mkdir(parents=True) + (projekt / "data" / "paper" / "auswertungen").mkdir(parents=True) + (projekt / "config").mkdir() + # Die Auswertung importiert trading.geheimnisse aus dem Arbeitsverzeichnis. + (projekt / "trading").symlink_to(ECHTES_PROJEKT / "trading") + shutil.copyfile(ECHTES_PROTOKOLL, projekt / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl") + (projekt / "data" / "paper" / "auswertungen" / f"{TAG}.md").write_text("# Bericht\n", encoding="utf-8") + with sqlite3.connect(projekt / "data" / "registry.db") as db: + db.execute("CREATE TABLE killswitch_zustand (id INTEGER PRIMARY KEY, zustand TEXT, seit TEXT, ausloeser TEXT)") + if killswitch is not None: + db.execute("INSERT INTO killswitch_zustand VALUES (1, ?, '2026-09-23T13:31:00+00:00', 'pascal')", (killswitch,)) + (wurzel / "kalender.json").write_text(json.dumps(KALENDER_TAG), encoding="utf-8") + (wurzel / "journal.txt").write_text(JOURNAL_GUT, encoding="utf-8") + (wurzel / "dienste.json").write_text(json.dumps(DIENSTE_GUT), encoding="utf-8") + return projekt + + +def fahre(wurzel: Path, projekt: Path, extra_env: dict | None = None, tag: str = TAG, meldung: bool = False) -> dict: + befehl = [ + sys.executable, str(HIER / "abendkontrolle_wsl.py"), "--tag", tag, "--projekt", str(projekt), + "--kalender", str(wurzel / "kalender.json"), "--journal", str(wurzel / "journal.txt"), + "--dienste", str(wurzel / "dienste.json"), "--ohne-kerzen", + ] + if not meldung: + befehl.append("--ohne-meldung") + # Mit der Ersatz-Auswertung darf die Brokerzeile geprueft werden: Der Ersatz geht nicht ans Netz. + if not (extra_env and "ABENDKONTROLLE_AUSWERTUNG" in extra_env): + befehl.append("--ohne-broker") + env = {k: v for k, v in os.environ.items() if k != "ABENDKONTROLLE_AUSWERTUNG"} + env.update(extra_env or {}) + lauf = subprocess.run(befehl, capture_output=True, text=True, env=env, timeout=300) + try: + ergebnis = json.loads(lauf.stdout.strip().splitlines()[-1]) + except (IndexError, json.JSONDecodeError): + return {"befunde": [f"KEINE JSON-AUSGABE exit {lauf.returncode}: {lauf.stderr[-300:]}"], "exit": lauf.returncode} + ergebnis["_exit"] = lauf.returncode + return ergebnis + + +def erwarte(name: str, ergebnis: dict, *, befund: str | None, anzahl: int | None = None, exit_code: int = 0, + auch: tuple = (), nicht: tuple = (), fakt=None) -> None: + befunde = ergebnis.get("befunde", []) + alles = " | ".join(befunde) + ok = True + if befund is None and befunde: + ok = False + if befund is not None and not any(befund in b for b in befunde): + ok = False + if any(teil not in alles for teil in auch) or any(teil in alles for teil in nicht): + ok = False + if anzahl is not None and len(befunde) != anzahl: + ok = False + if ergebnis.get("_exit", ergebnis.get("exit")) != exit_code: + ok = False + if fakt is not None: + try: + ok = ok and bool(fakt(ergebnis.get("fakten") or {}, ergebnis)) + except Exception: + ok = False + zeile = f"{'OK ' if ok else 'FEHL'} {name}: {len(befunde)} Befund(e) {befunde[:3]} meldung={(ergebnis.get('fakten') or {}).get('meldung')}" + print(zeile) + if not ok: + abweichungen.append(zeile) + + +def wsl_faelle() -> None: + def fall(name, vorbereiten, *, befund, anzahl=None, exit_code=0, env=None, tag=TAG, meldung=False, + auch=(), nicht=(), fakt=None): + with tempfile.TemporaryDirectory() as tmp: + wurzel = Path(tmp) + projekt = baue_projekt(wurzel) + extra = vorbereiten(wurzel, projekt) + umgebung = {**(env or {}), **(extra if isinstance(extra, dict) else {})} + erwarte(name, fahre(wurzel, projekt, umgebung, tag, meldung), befund=befund, anzahl=anzahl, + exit_code=exit_code, auch=auch, nicht=nicht, fakt=fakt) + + nichts = lambda w, p: None # noqa: E731 + prot = lambda p: p / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl" # noqa: E731 + + def schreibe(pfad: Path, text: str) -> None: + pfad.write_text(text, encoding="utf-8") + + def zeilen_aendern(p: Path, aendern) -> None: + zeilen = prot(p).read_text(encoding="utf-8").splitlines() + schreibe(prot(p), "\n".join(aendern(zeilen)) + "\n") + + fall("Grundlinie", nichts, befund=None, anzahl=0) + # 1 Kalender + # Ein Feiertag ohne Protokoll: kein Befund. Haelt die Kontrolle ihn faelschlich fuer einen Handelstag, + # fehlt das Protokoll und es gibt einen -- so trennt der Fall "kein Handelstag" von "immer Handelstag". + def feiertag(inhalt): + def vorbereiten(w, p): + schreibe(w / "kalender.json", inhalt) + prot(p).unlink() + return vorbereiten + fall("Kalender leer (Feiertag, Wochenende)", feiertag("[]"), befund=None, anzahl=0) + fall("Kalender nennt nur einen anderen Tag", feiertag(json.dumps([{"date": "2026-09-22"}])), befund=None, anzahl=0) + fall("Kalender als Objekt", lambda w, p: schreibe(w / "kalender.json", "{}"), befund="Kalender in unerwarteter Form") + fall("Kalender Eintrag ohne date, erste Position", + lambda w, p: schreibe(w / "kalender.json", json.dumps([{"open": "09:30"}] + KALENDER_TAG)), + befund="Kalender in unerwarteter Form") + fall("Kalender Eintrag ohne date, letzte Position", + lambda w, p: schreibe(w / "kalender.json", json.dumps(KALENDER_TAG + [{"date": 5}])), + befund="Kalender in unerwarteter Form") + fall("Kalender-Datei fehlt", lambda w, p: (w / "kalender.json").unlink(), befund="Kalender, und damit") + fall("Kalender kein JSON", lambda w, p: schreibe(w / "kalender.json", ""), befund="Kalender, und damit") + # 2 Protokoll + fall("kein Protokoll", lambda w, p: prot(p).unlink(), befund="der Runner lief nicht") + fall("nur Probeprotokoll", lambda w, p: prot(p).rename(prot(p).with_name(f"{TAG}T131023-probe.jsonl")), + befund="der Runner lief nicht") + # 6 Handel + fall("nie aktiv", + lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for s in z]), + befund="Tag ohne Handel") + fall("kaputte Zeile vorne", lambda w, p: zeilen_aendern(p, lambda z: [z[0], "{kaputt", *z[1:]]), befund="nicht lesbar") + fall("kaputte Zeile hinten", lambda w, p: zeilen_aendern(p, lambda z: [*z, "[1, 2]"]), befund="nicht lesbar") + # 3 Journal + fall("Journal ohne Ende", lambda w, p: schreibe(w / "journal.txt", "gestartet\n"), befund="keine Zeile 'Ende") + fall("Journal leer", lambda w, p: schreibe(w / "journal.txt", ""), befund="keine Zeile 'Ende") + fall("Journal fehlt", lambda w, p: (w / "journal.txt").unlink(), befund="Journal nicht geprueft") + fall("Exit 4", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Exit 0", "Exit 4")), befund="Exit 4") + fall("Exit 4 im ersten von zwei Laeufen", + lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Exit 0", "Exit 4") + JOURNAL_GUT), befund="Lauf 1 von 2") + fall("Exit 4 im letzten von zwei Laeufen", + lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT + JOURNAL_GUT.replace("Exit 0", "Exit 4")), befund="Lauf 2 von 2") + fall("Grund stoerung", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Ende: boersenschluss", "Ende: stoerung")), + befund="nicht mit boersenschluss") + fall("Position 10", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Position 0", "Position 10")), + befund="Position 10") + fall("Failed with result", + lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT + "trading-paper.service: Failed with result 'exit-code'.\n"), + befund="Failed with result") + # 4 Auswertung + fall("Orderzeile nicht gefuellt", + lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"status": "filled"', '"status": "canceled"', 1) if '"art": "order"' in s else s for s in z]), + befund="nicht gefuellt") + fall("Schlussposition 10 im Protokoll", + lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"position": 0', '"position": 10') if '"art": "ende"' in s else s for s in z]), + befund="Schlussposition ungleich null") + # 5 Broker (Ersatz-Auswertung, kein Netz) + ersatz = HIER / "abendkontrolle_ersatz_auswertung.py" + fall("Broker 2 offen", nichts, befund="2 offene Position", + env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "offene positionen beim broker jetzt: 2"}, + ) + fall("Broker 0 offen", nichts, befund=None, anzahl=0, + env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "offene positionen beim broker jetzt: 0"}) + fall("Auswertung Exit 3", nichts, befund="Exit 3", + env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "VORAUSSETZUNG FEHLT: x", "ERSATZ_EXIT": "3"}) + fall("Auswertung Kontowert in BEFUND wird maskiert", nichts, befund="kontowert [x]", + env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "BEFUND: kontowert 12345.67 zu tief"}) + # 7 Bericht + fall("Bericht fehlt", lambda w, p: (p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink(), befund="Tagesbericht") + # 8 Dienste + fall("Dashboard inaktiv (erste Position)", + lambda w, p: schreibe(w / "dienste.json", json.dumps({**DIENSTE_GUT, "trading-dashboard.service": "failed"})), + befund="trading-dashboard.service ist nicht aktiv") + fall("Auswertungs-Zeitgeber fehlt (letzte Position)", + lambda w, p: schreibe(w / "dienste.json", json.dumps({k: v for k, v in DIENSTE_GUT.items() if "auswertung" not in k})), + befund="trading-auswertung.timer ist nicht aktiv") + fall("Dienste als Liste", lambda w, p: schreibe(w / "dienste.json", "[]"), befund="Dienste: Zustand nicht lesbar") + # 9 Killswitch + def killswitch(zustand): + def vorbereiten(w, p): + with sqlite3.connect(p / "data" / "registry.db") as db: + if zustand is None: + db.execute("DELETE FROM killswitch_zustand") + else: + db.execute("UPDATE killswitch_zustand SET zustand = ?", (zustand,)) + return vorbereiten + fall("Killswitch gesperrt", killswitch("gesperrt"), befund="gesperrt") + fall("Killswitch ohne Zeile", killswitch(None), befund="kein Killswitch-Zustand") + fall("Register fehlt", lambda w, p: (p / "data" / "registry.db").unlink(), befund="registry.db fehlt") + # 3 Ende je Lauf: mittags abgebrochen, der zweite Lauf bleibt angehalten (Befund des Pruefers W2) + def mittags_abgebrochen(w, p): + zeilen = prot(p).read_text(encoding="utf-8").splitlines() + takte = [z for z in zeilen if '"art": "takt"' in z] + ende = json.loads(next(z for z in zeilen if '"art": "ende"' in z)) + lauf1 = [zeilen[0], *takte[:30], json.dumps({**ende, "grund": "abbruch"})] + lauf2 = [zeilen[0], *[z.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for z in takte[30:]], json.dumps(ende)] + schreibe(prot(p), "\n".join(lauf1) + "\n") + schreibe(p / "artifacts" / "paper" / f"{TAG}T160000-paper.jsonl", "\n".join(lauf2) + "\n") + schreibe(w / "journal.txt", JOURNAL_GUT.replace("Ende: boersenschluss", "Ende: abbruch") + JOURNAL_GUT) + fall("mittags abgebrochen, danach angehalten", mittags_abgebrochen, befund="Lauf endete mit abbruch", + auch=("Lauf 1 von 2) endete nicht mit boersenschluss",)) + + # Lauf nach Boersenschluss (WSL-Neustart abends): kein Befund (Befund des Pruefers W3) + def nach_schluss(w, p): + kopf = prot(p).read_text(encoding="utf-8").splitlines()[0] + ende = {"art": "ende", "zeit": f"{TAG}T21:06:40+00:00", "grund": "wartezeit", "fehler": None, "position": 0, + "orders": 0, "killswitch": "angehalten", "schritte": 0} + schreibe(p / "artifacts" / "paper" / f"{TAG}T210500-paper.jsonl", kopf + "\n" + json.dumps(ende) + "\n") + schreibe(w / "journal.txt", JOURNAL_GUT + "Ende: wartezeit, Position 0, Orders 0, Killswitch angehalten, Exit 3\n") + fall("Lauf nach Boersenschluss (wartezeit, Exit 3)", nach_schluss, befund=None, anzahl=0) + fall("nur ein Lauf mit wartezeit an einem Handelstag", lambda w, p: ( + nach_schluss(w, p), prot(p).unlink(), + schreibe(w / "journal.txt", "Ende: wartezeit, Position 0, Orders 0, Killswitch angehalten, Exit 3\n")), + befund="keine Zeile 'Ende", auch=("kein Lauf des scharfen Runners in der Handelszeit",)) + # 9 Killswitch: unbekannter Zustand, und eine Frage, die scheitert, haelt die anderen nicht auf + fall("Killswitch unbekannter Zustand", killswitch("kaputt"), befund="unbekanntem Zustand") + + def register_ohne_tabelle(w, p): + (p / "data" / "registry.db").unlink() + sqlite3.connect(p / "data" / "registry.db").close() + (p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink() + fall("Register ohne Tabelle, Bericht fehlt", register_ohne_tabelle, befund="Killswitch nicht geprueft", + auch=("Tagesbericht",)) + # Maske auch ohne Geldwort (Befund des Pruefers W4) + fall("Maske ohne Geldwort", nichts, befund="Wert", + env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), + "ERSATZ_AUSGABE": "BEFUND: Wert 100123.45 und 172.3456 und -4.56 USD\noffene positionen beim broker jetzt: 0"}, + nicht=("100123", "172.3456", "4.56")) + + # Meldeweg (Befund des Pruefers B1): ein Fehlschlag darf nicht als "gesendet" stehen + def mit_melder(port): + def vorbereiten(w, p): + text = (ECHTES_PROJEKT / "config" / "paper.toml").read_text(encoding="utf-8") + anker = 'ntfy_url = "https://ntfy.sh"' + if text.count(anker) != 1: + raise RuntimeError("Anker ntfy_url fehlt in config/paper.toml") + schreibe(p / "config" / "paper.toml", text.replace(anker, f'ntfy_url = "http://127.0.0.1:{port}"\nklartext_lokal = true')) + schreibe(w / "secrets.toml", '[ntfy]\naktiv = true\nthema = "abendkontrolle-angriff"\n') + (p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink() # ein Befund, damit gemeldet wird + return {"TRADING_GEHEIMNISSE": str(w / "secrets.toml")} + return vorbereiten + fall("Meldung scheitert (niemand hoert)", mit_melder(9), befund="Tagesbericht", meldung=True, + fakt=lambda f, e: str(f.get("meldung", "")).startswith("nicht gesendet")) + + with tempfile.TemporaryDirectory() as tmp: + mitschrift = Path(tmp) / "mitschrift.jsonl" + port = 18765 + server = subprocess.Popen([sys.executable, str(HIER / "ntfy_probe_server.py"), str(port), str(mitschrift)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + import socket + import time + for _ in range(50): + try: + socket.create_connection(("127.0.0.1", port), timeout=0.2).close() + break + except OSError: + time.sleep(0.1) + fall("Meldung kommt an", mit_melder(port), befund="Tagesbericht", meldung=True, + fakt=lambda f, e: f.get("meldung") == "gesendet" and "Abendkontrolle" in mitschrift.read_text(encoding="utf-8")) + finally: + server.terminate() + server.wait(timeout=10) + + # Bedienfehler + fall("Tag falsch", nichts, befund="abgebrochen", exit_code=2, tag="2026-13-40") + + +# --- Windows-Teil -------------------------------------------------------------------------------- + +def windows_faelle() -> None: + sys.path.insert(0, str(HIER)) + import abendkontrolle as ak + from datetime import datetime + + gut = {"tag": TAG, "befunde": [], "hinweise": ["auto_fortsetzen: fortgesetzt"], "exit": 0, + "fakten": {"handelstag": True, "ende": {"grund": "boersenschluss", "exit": "0", "orders": "79", "position": "0"}, + "laeufe": [{"takte_aktiv": 375}], "broker_offen": 0, "bericht": True, + "killswitch": {"zustand": "aktiv", "ausloeser": "automatik"}}} + + def lauf(name, notiz_vorher, ergebnis, *, jetzt="2026-09-23T22:45:00", pruefe): + with tempfile.TemporaryDirectory() as tmp: + vault = Path(tmp) + (vault / "Inbox").mkdir() + notiz = vault / "Inbox" / f"{TAG}.md" + if notiz_vorher is not None: + notiz.write_bytes(notiz_vorher if isinstance(notiz_vorher, bytes) else notiz_vorher.encode("utf-8")) + eingabe = vault / "ergebnis.json" + eingabe.write_text(ergebnis if isinstance(ergebnis, str) else json.dumps(ergebnis), encoding="utf-8") + code = ak.main(["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", jetzt]) + text = notiz.read_bytes().decode("utf-8", errors="replace") if notiz.exists() else "" + marke = (vault / ak.ABLAGE_TEIL / f"{TAG}.json").exists() + try: + ok, grund = pruefe(code, text, marke) + except Exception as ausnahme: # der Treiber selbst bricht nicht ab + ok, grund = False, f"Pruefung warf {ausnahme!r}; Notiz {text[-120:]!r}" + zeile = f"{'OK ' if ok else 'FEHL'} W {name}: {grund}" + print(zeile) + if not ok: + abweichungen.append(zeile) + + kopf = "---\ntype: daily\n---\n\n# 2026-09-23\n\n" + lauf("neue Notiz, ohne Befund", None, gut, pruefe=lambda c, t, m: ( + c == 0 and m and "## Agentenprotokoll" in t and "ohne Befund" in t and "type: daily" in t, f"code {c} marke {m}")) + lauf("Abschnitt vor einer weiteren Ueberschrift", kopf + "## Agentenprotokoll\n\n- alt\n\n## Danach\n\ntext\n", gut, + pruefe=lambda c, t, m: (t.index("ohne Befund") < t.index("## Danach") and "- alt\n- 22:45" in t + and "\n\n## Danach" in t and "\n\n\n" not in t, repr(t[-80:]))) + lauf("Abschnitt am Ende", kopf + "## Agentenprotokoll\n\n- alt\n", gut, + pruefe=lambda c, t, m: (t.endswith(".\n - Hinweis: auto_fortsetzen: fortgesetzt\n") and "- alt\n- 22:45" in t, repr(t[-60:]))) + lauf("ohne Abschnitt", kopf + "## Anderes\n\nx\n", gut, + pruefe=lambda c, t, m: (t.count("## Agentenprotokoll") == 1 and t.index("## Anderes") < t.index("## Agentenprotokoll"), repr(t[-60:]))) + lauf("Abschnitt direkt vor naechster Ueberschrift", kopf + "## Agentenprotokoll\n## Danach\n", gut, + pruefe=lambda c, t, m: ("## Agentenprotokoll\n\n- 22:45" in t and "\n\n## Danach" in t, repr(t))) + lauf("CRLF-Notiz", (kopf + "## Agentenprotokoll\n\n- alt\n").replace("\n", "\r\n"), gut, + pruefe=lambda c, t, m: ("\n" not in t.replace("\r\n", ""), "gemischte Zeilenenden" if "\n" in t.replace("\r\n", "") else "ok")) + mit_befund = {**gut, "befunde": ["Runner endete mit Exit 4", "Beim Broker stehen 2 offene Position(en)"]} + lauf("mit Befund", None, mit_befund, pruefe=lambda c, t, m: ( + "**2 Befund(e)** #needs-review" in t and " - Runner endete mit Exit 4" in t and m, repr(t[-160:]))) + lauf("Ergebnis ohne befunde", None, {"tag": TAG}, pruefe=lambda c, t, m: ( + c == 1 and not m and "**gescheitert**" in t and "#needs-review" in t, f"code {c} marke {m}")) + lauf("Ergebnis kein JSON", None, "nicht json", pruefe=lambda c, t, m: (c == 1 and not m and "**gescheitert**" in t, f"code {c}")) + lauf("kein Handelstag", None, {"tag": TAG, "befunde": [], "hinweise": [], "fakten": {"handelstag": False}}, + pruefe=lambda c, t, m: ("kein Handelstag" in t and m, repr(t[-60:]))) + # Befunde des Pruefers: Ueberschrift mit Zusatz, Notiz ohne UTF-8, Abbruch mit JSON, Nachbardatei + lauf("Ueberschrift mit Zusatz", kopf + "## Agentenprotokoll — Sitzung 9\n\n- alt\n\n## Offene Punkte\n\n- x\n", gut, + pruefe=lambda c, t, m: (t.count("## Agentenprotokoll") == 1 and t.index("ohne Befund") < t.index("## Offene Punkte"), repr(t[-120:]))) + kaputt = "---\ntype: daily\n---\n\n# Tag äöü\n".encode("cp1252") + lauf("Notiz kein UTF-8", kaputt, mit_befund, pruefe=lambda c, t, m: (m and c == 1, f"code {c} marke {m}")) + lauf("Abbruch mit JSON (exit 2) wird wiederholt", None, + {"tag": TAG, "befunde": ["Abendkontrolle abgebrochen: X"], "hinweise": [], "fakten": {}, "exit": 2}, + pruefe=lambda c, t, m: (not m and "**1 Befund(e)**" in t and c == 1, f"code {c} marke {m}")) + with tempfile.TemporaryDirectory() as tmp: + vault = Path(tmp) + (vault / "Inbox").mkdir() + eingabe = vault / "e.json" + eingabe.write_text(json.dumps(gut), encoding="utf-8") + ak.main(["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", "2026-09-23T22:45:00"]) + rest = [p.name for p in (vault / "Inbox").iterdir() if not p.name.endswith(".md")] + ok = not rest + print(f"{'OK ' if ok else 'FEHL'} W keine Nachbardatei uebrig: {rest}") + if not ok: + abweichungen.append("Nachbardatei uebrig") + + # faellige_tage + from datetime import date + with tempfile.TemporaryDirectory() as tmp: + ablage = Path(tmp) + def tage(jetzt): + return ak.faellige_tage(datetime.fromisoformat(jetzt), ablage) + faelle = [ + ("Do vor 22:30: heute nicht", "2026-09-24T21:00", ["2026-09-24"], False), + ("Do nach 22:30: heute", "2026-09-24T22:45", ["2026-09-24"], True), + ("Mo frueh: Fr nachholen, Wochenende nicht", "2026-09-28T08:00", ["2026-09-25"], True), + ("Mo frueh: nichts vor ERSTER_TAG", "2026-09-28T08:00", ["2026-09-23"], False), + ] + for name, jetzt, erwartet, drin in faelle: + ergebnis = tage(jetzt) + ok = all((tag in ergebnis) == drin for tag in erwartet) and not any( + date.fromisoformat(t).weekday() >= 5 for t in ergebnis) + zeile = f"{'OK ' if ok else 'FEHL'} T {name}: {ergebnis}" + print(zeile) + if not ok: + abweichungen.append(zeile) + (ablage / "2026-09-25.json").write_text("{}", encoding="utf-8") + ergebnis = tage("2026-09-28T08:00") + ok = "2026-09-25" not in ergebnis + print(f"{'OK ' if ok else 'FEHL'} T Ergebnisdatei da: nicht erneut: {ergebnis}") + if not ok: + abweichungen.append("Ergebnisdatei ignoriert") + ergebnis = tage("2026-10-12T08:00") + ok = min(ergebnis) >= "2026-10-05" + print(f"{'OK ' if ok else 'FEHL'} T hoechstens sieben Tage zurueck: {ergebnis}") + if not ok: + abweichungen.append("Nachholfenster") + + +if __name__ == "__main__": + if sys.platform == "win32": + windows_faelle() + else: + wsl_faelle() + print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)") + sys.exit(1 if abweichungen else 0) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 b/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 new file mode 100644 index 0000000..1435684 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 @@ -0,0 +1,43 @@ +# Richtet die Windows-Aufgabe "Trading Plattform - Abendkontrolle" ein oder entfernt sie. +# Koordinator-Werkzeug, nicht fuers Repo. Auftrag Pascal 2026-09-24: taegliche Abendkontrolle, +# dauerhaft, uebersteht Neustarts, blockiert keine Updates von Claude Code oder Desktop. +# +# Zwei Ausloeser: werktags 22:45 (nach der Tagesauswertung um 22:20) und fuenf Minuten nach jeder +# Anmeldung (holt einen verpassten Abend nach; abendkontrolle.py prueft nur Tage ohne Ergebnisdatei). +# Nur bei angemeldetem Benutzer (Interactive): WSL und der ganze Paper-Betrieb brauchen die Anmeldung +# ohnehin (Aufgabe "Trading Plattform - WSL wachhalten"). pythonw.exe startet ohne Fenster. +# Voller Interpreterpfad: -Execute "python" endet in der Aufgabenplanung mit 0x80070002. +# +# Aufruf: powershell -NoProfile -File abendkontrolle_einrichten.ps1 [-Aktion einrichten|entfernen] +param( + [ValidateSet("einrichten", "entfernen")] + [string]$Aktion = "einrichten" +) + +$Name = "Trading Plattform - Abendkontrolle" +if ($Aktion -eq "entfernen") { + Unregister-ScheduledTask -TaskName $Name -Confirm:$false + return +} + +$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe" +$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\abendkontrolle.py" +if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" } +if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" } + +$Benutzer = "$env:USERDOMAIN\$env:USERNAME" +$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '"') -WorkingDirectory "D:\Claude" +$Abends = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday, Tuesday, Wednesday, Thursday, Friday -At "22:45" +$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer +$Anmeldung.Delay = "PT5M" +$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited +$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 45) -MultipleInstances IgnoreNew +Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger @($Abends, $Anmeldung) -Principal $Wer ` + -Settings $Regeln -Force ` + -Description "Abendkontrolle des Paper-Betriebs: liest nur, schreibt in die Tagesnotiz, meldet Befunde per ntfy. Koordinator-Werkzeug, siehe abendkontrolle.py." | Out-Null + +$Aufgabe = Get-ScheduledTask -TaskName $Name +"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State +$Aufgabe.Triggers | ForEach-Object { "Ausloeser: {0} {1} {2}" -f $_.CimClass.CimClassName, $_.StartBoundary, $_.Delay } +"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_ersatz_auswertung.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_ersatz_auswertung.py new file mode 100644 index 0000000..0ae9bf4 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_ersatz_auswertung.py @@ -0,0 +1,7 @@ +"""Ersatz fuer handelstag_auswertung.py in den Angriffsfaellen der Abendkontrolle: druckt ERSATZ_AUSGABE, +endet mit ERSATZ_EXIT. Geht nicht ans Netz. Koordinator-Werkzeug, nicht fuers Repo.""" +import os +import sys + +print(os.environ.get("ERSATZ_AUSGABE", "")) +sys.exit(int(os.environ.get("ERSATZ_EXIT", "0"))) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py new file mode 100644 index 0000000..97c82fc --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py @@ -0,0 +1,98 @@ +"""Mutanten der Abendkontrolle und des Rueckwegs: jeder muss seinen Angriffstreiber rot machen. + +Koordinator-Werkzeug, nicht fuers Repo. Jeder Mutant laeuft auf einer Wegwerfkopie dieses Verzeichnisses. +In WSL (Venv) die Mutanten von abendkontrolle_wsl.py, unter Windows die von abendkontrolle.py und +rueckweg_aapl.py. Aufruf: python abendkontrolle_mutanten.py Exit 0, wenn keiner ueberlebt. +Ein Mutant, der das Verhalten nicht aendert, ist keine Luecke (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 8). +""" + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent + +WSL_MUTANTEN = [ + ("Exit-Vergleich verkehrt", "abendkontrolle_wsl.py", 'if satz["exit"] != "0":', 'if satz["exit"] == "9":'), + ("Handelstag immer wahr", "abendkontrolle_wsl.py", 'return any(eintrag["date"] == tag for eintrag in liste)', "return True"), + ("gesperrt nicht geprueft", "abendkontrolle_wsl.py", 'if zeile[0] == "gesperrt":', 'if zeile[0] == "nie":'), + ("unbekannter Zustand still", "abendkontrolle_wsl.py", "elif zeile[0] not in KILLSWITCH_ZUSTAENDE:", "elif False:"), + ("Broker-Schwelle", "abendkontrolle_wsl.py", "if offen[-1] != 0:", "if offen[-1] > 5:"), + ("Probe zaehlt mit", "abendkontrolle_wsl.py", 'glob(f"{tag}T*-paper.jsonl")', 'glob(f"{tag}T*.jsonl")'), + ("Maske aus", "abendkontrolle_wsl.py", " text = muster.sub(ersatz, text)", " pass"), + ("nie-aktiv aus", "abendkontrolle_wsl.py", 'if satz.get("killswitch") == "aktiv":', "if True:"), + ("Bericht nicht geprueft", "abendkontrolle_wsl.py", "if not bericht.is_file():\n k.befund", "if False:\n k.befund"), + ("Failed-Zeile aus", "abendkontrolle_wsl.py", 'if "Failed with result" in text:', 'if "nie da" in text:'), + ("nur letzter Lauf im Journal", "abendkontrolle_wsl.py", "for nummer, satz in enumerate(enden, start=1):", + "for nummer, satz in list(enumerate(enden, start=1))[-1:]:"), + ("Melder-Rueckgabe verworfen", "abendkontrolle_wsl.py", + 'return "gesendet" if fehler is None else f"nicht gesendet: {fehler}"', 'return "gesendet"'), + ("Ende je Protokoll nicht bewertet", "abendkontrolle_wsl.py", + " if ende.get(\"grund\") != \"boersenschluss\":\n k.befund(f\"{lauf['datei']}", + " if False:\n k.befund(f\"{lauf['datei']}"), + ("wartezeit im Journal mitgezaehlt", "abendkontrolle_wsl.py", + 'enden = [satz for satz in alle if satz["grund"] != "wartezeit"]', "enden = alle"), + ("Lauf nach Schluss als Handelslauf", "abendkontrolle_wsl.py", + 'return bool(lauf["ende"]) and lauf["ende"].get("grund") == "wartezeit" and lauf["takte"] == 0', "return False"), + ("Fragen nicht voneinander getrennt", "abendkontrolle_wsl.py", + " except Exception as ausnahme:\n self.befund(f\"{name} nicht geprueft", + " except ZeroDivisionError as ausnahme:\n self.befund(f\"{name} nicht geprueft"), +] +WIN_MUTANTEN = [ + ("keine Ergebnisdatei", "abendkontrolle.py", '(ablage / f"{tag}.json").write_text(', '(ablage / f"{tag}.nix").write_text('), + ("Wochenende mit", "abendkontrolle.py", "if tag < ERSTER_TAG or tag.weekday() >= 5:", "if tag < ERSTER_TAG:"), + ("heute vor 22:30", "abendkontrolle.py", "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:", "if False:"), + ("Befund ohne Markierung", "abendkontrolle.py", "**{len(befunde)} Befund(e)** #needs-review", "{len(befunde)} Befund(e)"), + ("CRLF vergessen", "abendkontrolle.py", 'cr = "\\r" if any(z.endswith("\\r") for z in zeilen) else ""', 'cr = ""'), + ("Formpruefung aus", "abendkontrolle.py", + 'if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list):', "if False:"), + ("Ueberschrift nur exakt", "abendkontrolle.py", + "if z == UEBERSCHRIFT or z.startswith(UEBERSCHRIFT + \" \")), None)", "if z == UEBERSCHRIFT), None)"), + ("Abbruch gilt als erledigt", "abendkontrolle.py", 'if ergebnis.get("exit") != 2:', "if True:"), + ("nicht atomar", "abendkontrolle.py", " os.replace(neu, notiz)", " notiz.write_bytes(text.encode(\"utf-8\"))"), + ("Zeit egal", "rueckweg_aapl.py", "if jetzt < FRUEHESTENS:", "if False:"), + ("Runner egal", "rueckweg_aapl.py", " if runner_aktiv(runner_vorgabe):", " if False and runner_aktiv(runner_vorgabe):"), + ("unlesbar gilt als steht", "rueckweg_aapl.py", + 'raise RuntimeError(f"Zustand des Runners nicht lesbar: {zustand!r}")', "return False"), + ("schmutziger Ordner egal", "rueckweg_aapl.py", " else:\n if geaendert:", " else:\n if False:"), + ("folge egal", "rueckweg_aapl.py", 'if MEHRERE not in ziel.read_text(encoding="utf-8"):', "if False:"), + ("Ergebnisdatei egal", "rueckweg_aapl.py", 'if (ablage / "rueckweg.json").exists():', "if False:"), + ("kein Commit", "rueckweg_aapl.py", '("add", ZIEL.as_posix()), ("commit", "-m", NACHRICHT)', '("add", ZIEL.as_posix()),'), + ("halb getauscht gilt als fertig", "rueckweg_aapl.py", + " if not geaendert:\n return \"config/paper.toml ist schon", + " if True:\n return \"config/paper.toml ist schon"), + ("Schluesselvergleich aus", "rueckweg_aapl.py", " if fehlend:", " if False:"), + ("Zweig egal", "rueckweg_aapl.py", " if zweig != ZWEIG:", " if False:"), + ("Wertevergleich aus", "rueckweg_aapl.py", " elif neu[name] != alt[name]:", " elif False:"), + ("Gegenrichtung aus", "rueckweg_aapl.py", " elif name not in alt:", " elif False:"), +] +TREIBER = { + "abendkontrolle_wsl.py": "abendkontrolle_angriffe.py", + "abendkontrolle.py": "abendkontrolle_angriffe.py", + "rueckweg_aapl.py": "rueckweg_aapl_angriffe.py", +} + +mutanten = WIN_MUTANTEN if sys.platform == "win32" else WSL_MUTANTEN +ueberlebt = [] +for name, datei, alt, neu in mutanten: + with tempfile.TemporaryDirectory() as tmp: + ziel = Path(tmp) / "pruefung" + shutil.copytree(HIER, ziel, ignore=shutil.ignore_patterns("__pycache__")) + pfad = ziel / datei + text = pfad.read_text(encoding="utf-8") + if text.count(alt) != 1: + print(f"ANKER {text.count(alt)}-mal: {name}") + ueberlebt.append(name) + continue + pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n") + lauf = subprocess.run([sys.executable, str(ziel / TREIBER[datei])], capture_output=True, text=True, + timeout=1800, encoding="utf-8", errors="replace", cwd=tmp) + faelle = [z for z in lauf.stdout.splitlines() if z.startswith("FEHL")] + rot = lauf.returncode != 0 + print(f"{'ROT (gefangen)' if rot else 'GRUEN (UEBERLEBT)'} {name}: {[f[:110] for f in faelle[:2]]}") + if not rot: + ueberlebt.append(name) +print(f"ERGEBNIS: {len(ueberlebt)} ueberlebt von {len(mutanten)} {ueberlebt}") +sys.exit(1 if ueberlebt else 0) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py new file mode 100644 index 0000000..471c327 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py @@ -0,0 +1,475 @@ +"""Abendkontrolle eines Paper-Handelstags: liest nur, urteilt im Code, meldet nur bei Befund. + +Koordinator-Werkzeug, nicht fuers Repo. Laeuft in WSL im Venv des Projekts; gestartet von +abendkontrolle.py (Windows-Aufgabe "Trading Plattform - Abendkontrolle", werktags 22:45 Berlin und +bei jeder Anmeldung). Auftrag Pascal 2026-09-24: taegliche Abendkontrolle, dauerhaft, uebersteht +Neustarts und blockiert keine Updates von Claude Code oder Desktop -- deshalb ohne Claude-Prozess. + +Entscheidungsliste (Frage -> Herkunft der Antwort): + 1. Ist ein Handelstag? Quelle: GET /v2/calendar?start=&end=, gemessen am + 2026-09-24: eine Liste von Objekten mit 'date' (JJJJ-MM-TT) und + 'close' (HH:MM New York); Feiertage fehlen, das Wochenende ist + leer, verkuerzte Tage tragen ein frueheres 'close'. Antwort + nicht lesbar -> Befund, und die Fragen 2 bis 7 bleiben offen. + 2. Lief ein scharfer Runner? Quelle: scripts/paper.py:554, Protokoll + artifacts/paper/-paper.jsonl. Der Start um 09:10 + New York traegt das Datum des Handelstags (gilt, solange + max_wartezeit_h unter etwa 13 h bleibt; heute 8). Keines -> Befund. + 3. Wie endete jeder Lauf? Quelle: scripts/paper.py:517, Zeile "Ende: , Position , + Orders , Killswitch , Exit " im Journal, und die Zeile + 'ende' jedes Protokolls. Projektvorgabe: jeder Lauf endet mit + boersenschluss und Exit 0, der letzte mit Position 0. Ausgenommen + ist ein Lauf ausserhalb der Handelszeit (Ende wartezeit, im + Protokoll ohne Takt; gemessen am 2026-09-20 nach WSL-Neustarts): + nur ein Hinweis. Ein Lauf, der mittags mit abbruch endet, ist ein + Befund, auch wenn der naechste sauber schliesst. Keine Endezeile + im Journal -> Befund; "Failed with result" -> Befund. + 4. Was sagt das Protokoll? Quelle: handelstag_auswertung.py (dieses Verzeichnis); jede + Zeile, die mit "BEFUND" beginnt, ist ein Befund; Exit ungleich 0 + ebenfalls. + 5. Ist das Konto glatt? Quelle: dieselbe Auswertung mit --broker, Zeile "offene + positionen beim broker jetzt: ". n != 0 -> Befund; fehlt die + Zeile -> Befund (die Frage blieb unbeantwortet). + 6. Hat der Runner gehandelt? Projektvorgabe (Pascal 2026-09-24, Paket AF): an einem Handelstag + steht der Killswitch in mindestens einem Takt auf aktiv. Kein + solcher Takt -> Befund "Tag ohne Handel". Takte, die nach dem + ersten aktiven nicht mehr aktiv sind (Watchdog, Pascals Anhalten), + sind ein Hinweis: Der Runner hat dafuer schon selbst gemeldet. + Ereignisse auto_fortsetzen werden berichtet, nicht bewertet. + 7. Liegt der Tagesbericht? Quelle: scripts/tagesauswertung.py:58 und :192, + data/paper/auswertungen/.md. Fehlt -> Befund. + 8. Laufen Dashboard und Zeitgeber? Projektvorgabe (Pakete F und Z): trading-dashboard.service, + trading-paper.timer, trading-auswertung.timer "active"; ein + anderer Zustand wird nach DIENSTE_NOCHMAL_S einmal neu gelesen + (eine eben geweckte WSL startet ihre Dienste noch). + 9. Wie steht der Killswitch? Quelle: Tabelle killswitch_zustand in data/registry.db + (trading/registry/registry.py:281). gesperrt -> Befund: ohne + "Freigeben" handelt der naechste Tag nicht. Ein Zustand + ausserhalb aktiv/angehalten/gesperrt -> Befund. +Jede Frage laeuft fuer sich: Scheitert eine (Sperre im Register, Journal nicht lesbar), ist das ein +Befund, und die anderen werden trotzdem beantwortet. +Meldung: bei mindestens einem Befund eine Meldung mit hoher Prioritaet ueber den Melder des Projekts. +Ob sie ankam, sagt der Rueckgabewert von Melder.melde (None oder der Grund, trading/paper/melder.py); +er steht in fakten.meldung. Kein Alarm entsteht, wenn WSL selbst nicht laeuft -- dann steht nur die +Zeile in der Tagesnotiz (benannte Luecke; der Totmann in Home Assistant, Paket Z2-7, deckt sie ab). +Nicht gesehen: die Guete der Kurse (steht im Bericht), Teilausfuehrungen einzeln, alles vor dem +Handelstag. Betraege gibt das Werkzeug nie aus; fremde Zeilen laufen durch maskiere(). + +Ein Fangnetz, kein Nachweis ueber den Handelstag. +Aufruf (WSL, Venv): python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung] +Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "" "" +Fuer Angriffsfaelle ohne Netz und ohne systemd: --projekt, --kalender, --journal, --dienste, +--ohne-broker, --ohne-kerzen. +Ausgabe: genau eine JSON-Zeile auf stdout. Exit 0 gelaufen (Befunde stehen in der Ausgabe), +2 abgebrochen (die bis dahin gesammelten Befunde stehen trotzdem in der Ausgabe). +""" + +import argparse +import json +import os +import re +import sqlite3 +import subprocess +import sys +import time +from datetime import date +from datetime import timedelta +from pathlib import Path + +PROJEKT = "/mnt/d/Claude/Code Projekte/active - Trading Plattform" +GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml" +AUSWERTUNG = Path(__file__).resolve().parent / "handelstag_auswertung.py" +# Nur fuer die Angriffsfaelle: ein Ersatz, der vorgegebene Zeilen druckt, statt ans Netz zu gehen. +AUSWERTUNG_ERSATZ = os.environ.get("ABENDKONTROLLE_AUSWERTUNG") +HANDEL_URL = "https://paper-api.alpaca.markets" +DIENSTE = ("trading-dashboard.service", "trading-paper.timer", "trading-auswertung.timer") +DIENSTE_NOCHMAL_S = 30 +KILLSWITCH_ZUSTAENDE = ("aktiv", "angehalten", "gesperrt") +AUSWERTUNG_TIMEOUT_S = 900 + +ENDE = re.compile( + r"Ende: (?P[^,]+), Position (?P[^,]+), Orders (?P[^,]+), " + r"Killswitch (?P[^,]+), Exit (?P-?\d+)" +) +BROKER_OFFEN = re.compile(r"offene positionen beim broker jetzt: (\d+)") +# Maske fuer alles, was aus fremder Ausgabe in Notiz und Meldung geht, in dieser Reihenfolge: +# - jede Zahl hinter einem Geldwort, +# - jede Zahl vor einer Waehrung, +# - jede Zahl mit mehr als zwei Nachkommastellen, +# - jede Zahl mit mindestens vier Stellen vor dem Komma und Nachkommastellen. +MASKEN = ( + (re.compile(r"(?i)\b(kontowert|konto|startkapital|equity|cash|preis|kurs|usd|pnl)([^0-9-]{0,3})-?[0-9][0-9.,]*"), + lambda m: f"{m.group(1)}{m.group(2)}[x]"), + (re.compile(r"-?\d[\d.,]*\s?(?:USD|EUR|\$|€)"), lambda m: "[x]"), + (re.compile(r"-?\d[\d,]*\.\d{3,}"), lambda m: "[x]"), + (re.compile(r"-?\d{4,}[.,]\d{1,2}\b"), lambda m: "[x]"), +) + + +def maskiere(text: str) -> str: + for muster, ersatz in MASKEN: + text = muster.sub(ersatz, text) + return text + + +class Kontrolle: + def __init__(self) -> None: + self.befunde: list[str] = [] + self.hinweise: list[str] = [] + self.fakten: dict = {} + + def befund(self, text: str) -> None: + self.befunde.append(maskiere(text)) + + def hinweis(self, text: str) -> None: + self.hinweise.append(maskiere(text)) + + def schritt(self, name: str, aufruf, *argumente): + """Eine Frage fuer sich: Scheitert sie, ist das ein Befund, und der Rest laeuft weiter.""" + try: + return aufruf(*argumente) + except Exception as ausnahme: + self.befund(f"{name} nicht geprueft ({type(ausnahme).__name__}: {str(ausnahme)[:160]})") + return None + + +# --- 1 Handelstag --------------------------------------------------------------------------------- + +def lies_kalender(tag: str, kalender_datei: str | None, projekt: Path): + """Die Liste aus /v2/calendar oder aus einer Datei (Angriffsfaelle).""" + if kalender_datei is not None: + return json.loads(Path(kalender_datei).read_text(encoding="utf-8")) + import requests # erst hier: Die Angriffsfaelle laufen ohne Netz + + sys.path.insert(0, str(projekt)) + sys.path.insert(0, str(AUSWERTUNG.parent)) + from handelstag_auswertung import kopfzeilen # liest die Schluessel, gibt sie nie aus + + kopf = kopfzeilen() + if kopf is None: + raise RuntimeError("Schluessel fuer Alpaca nicht lesbar") + antwort = requests.get( + f"{HANDEL_URL}/v2/calendar", headers=kopf, params={"start": tag, "end": tag}, timeout=30 + ) + antwort.raise_for_status() + return antwort.json() + + +def ist_handelstag(tag: str, liste) -> bool | None: + """True, False, oder None, wenn die Antwort nicht die gemessene Form hat.""" + if not isinstance(liste, list): + return None + for eintrag in liste: + if not isinstance(eintrag, dict) or not isinstance(eintrag.get("date"), str): + return None + return any(eintrag["date"] == tag for eintrag in liste) + + +# --- 2 Protokolle, 3 Ende je Lauf, 6 Handel -------------------------------------------------------- + +def protokolle(projekt: Path, tag: str) -> list[Path]: + return sorted((projekt / "artifacts" / "paper").glob(f"{tag}T*-paper.jsonl")) + + +def lies_protokoll(pfad: Path, k: Kontrolle) -> dict: + takte = aktiv = pausiert_danach = unlesbar = 0 + auto = [] + ende = None + # Dateiiteration statt splitlines: U+2028 in einem Text zerlegte sonst eine JSON-Zeile. + with pfad.open(encoding="utf-8", errors="replace") as datei: + for zeile in datei: + if not zeile.strip(): + continue + try: + satz = json.loads(zeile) + except json.JSONDecodeError: + unlesbar += 1 + continue + if not isinstance(satz, dict): + unlesbar += 1 + continue + art = satz.get("art") + if art == "takt": + takte += 1 + if satz.get("killswitch") == "aktiv": + aktiv += 1 + elif aktiv: + pausiert_danach += 1 + elif art == "ereignis" and satz.get("was") == "auto_fortsetzen": + auto.append(str(satz.get("text"))[:200]) + elif art == "ende": + ende = { + schluessel: satz.get(schluessel) + for schluessel in ("grund", "fehler", "position", "positionen", "orders", "killswitch") + } + if unlesbar: + k.befund(f"{pfad.name}: {unlesbar} Zeile(n) nicht lesbar") + return {"datei": pfad.name, "takte": takte, "takte_aktiv": aktiv, "takte_pausiert_danach": pausiert_danach, + "auto_fortsetzen": auto, "ende": ende} + + +def ausserhalb_der_handelszeit(lauf: dict) -> bool: + """Ein Lauf, der ohne einen einzigen Takt mit wartezeit endete (Start nach Boersenschluss).""" + return bool(lauf["ende"]) and lauf["ende"].get("grund") == "wartezeit" and lauf["takte"] == 0 + + +def pruefe_laeufe(laeufe: list[dict], k: Kontrolle) -> None: + for lauf in laeufe: + ende = lauf["ende"] + if ende is None: + continue # die Auswertung meldet "keine Zeile 'ende'" + if ausserhalb_der_handelszeit(lauf): + k.hinweis(f"{lauf['datei']}: Lauf ausserhalb der Handelszeit (Ende wartezeit), nicht bewertet") + continue + if ende.get("grund") != "boersenschluss": + k.befund(f"{lauf['datei']}: Lauf endete mit {ende.get('grund')} nach {lauf['takte']} Takten") + if ende.get("fehler"): + k.befund(f"{lauf['datei']}: Lauf endete mit Fehler {str(ende.get('fehler'))[:120]}") + if lauf["takte_pausiert_danach"]: + k.hinweis(f"{lauf['datei']}: {lauf['takte_pausiert_danach']} Takte nach dem Fortsetzen nicht aktiv") + for text in lauf["auto_fortsetzen"]: + k.hinweis(f"auto_fortsetzen: {text}") + bewertet = [lauf for lauf in laeufe if not ausserhalb_der_handelszeit(lauf)] + if laeufe and not bewertet: + k.befund("kein Lauf des scharfen Runners in der Handelszeit, nur Laeufe nach Boersenschluss") + elif bewertet and sum(lauf["takte_aktiv"] for lauf in bewertet) == 0: + k.befund("Tag ohne Handel: in keinem Takt stand der Killswitch auf aktiv") + + +# --- 3 Journal ------------------------------------------------------------------------------------- + +def lies_journal(tag: str, journal_datei: str | None) -> str: + if journal_datei is not None: + return Path(journal_datei).read_text(encoding="utf-8", errors="replace") + morgen = (date.fromisoformat(tag) + timedelta(days=1)).isoformat() + lauf = subprocess.run( + ["journalctl", "-u", "trading-paper.service", "--since", f"{tag} 00:00", "--until", f"{morgen} 06:00", + "-o", "cat", "--no-pager"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, + ) + if lauf.returncode != 0: + raise RuntimeError(f"journalctl Exit {lauf.returncode}") + return lauf.stdout + + +def pruefe_journal(text: str, k: Kontrolle) -> dict | None: + alle = [{schluessel: wert.strip() for schluessel, wert in m.groupdict().items()} for m in ENDE.finditer(text)] + # Ein Lauf ausserhalb der Handelszeit (Neustart nach Boersenschluss) endet mit wartezeit und Exit 3. + enden = [satz for satz in alle if satz["grund"] != "wartezeit"] + k.fakten["enden_im_journal"] = len(enden) + if len(alle) > len(enden): + k.hinweis(f"{len(alle) - len(enden)} Lauf/Laeufe ausserhalb der Handelszeit im Journal (Ende wartezeit)") + if "Failed with result" in text: + k.befund("Journal: trading-paper.service endete mit 'Failed with result'") + if not enden: + k.befund("Journal: keine Zeile 'Ende: …, Exit n' eines Handelslaufs von trading-paper.service an diesem Tag") + return None + # Jeder Lauf des Tages zaehlt: Ein Runner, der mittags mit einer Stoerung endete und neu gestartet + # wurde, ist auch dann ein Befund, wenn der zweite sauber schliesst. + for nummer, satz in enumerate(enden, start=1): + if satz["exit"] != "0": + k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete mit Exit {satz['exit']} (Grund {satz['grund']})") + if satz["grund"] != "boersenschluss": + k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete nicht mit boersenschluss, sondern mit {satz['grund']}") + letzte = enden[-1] + if letzte["position"] != "0": + k.befund(f"Runner endete mit Position {letzte['position']}") + return letzte + + +# --- 4 und 5 Auswertung ---------------------------------------------------------------------------- + +def fahre_auswertung(pfad: Path, projekt: Path, broker: bool, kerzen: bool, k: Kontrolle) -> None: + befehl = [sys.executable, AUSWERTUNG_ERSATZ or str(AUSWERTUNG), str(pfad)] + if broker: + befehl.append("--broker") + if kerzen: + befehl.append("--kerzen") + lauf = subprocess.run( + befehl, cwd=projekt, capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=AUSWERTUNG_TIMEOUT_S, env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) + zeilen = lauf.stdout.splitlines() + lauf.stderr.splitlines() + for zeile in zeilen: + if zeile.strip().startswith("BEFUND") or zeile.startswith("VORAUSSETZUNG FEHLT"): + k.befund(f"{pfad.name}: {zeile.strip()}") + if lauf.returncode != 0: + k.befund(f"{pfad.name}: handelstag_auswertung.py endete mit Exit {lauf.returncode}") + if broker: + offen = [int(m.group(1)) for m in map(BROKER_OFFEN.search, zeilen) if m] + if not offen: + k.befund(f"{pfad.name}: keine Zeile zu offenen Positionen beim Broker -- Konto nicht geprueft") + else: + k.fakten["broker_offen"] = offen[-1] + if offen[-1] != 0: + k.befund(f"Beim Broker stehen {offen[-1]} offene Position(en)") + + +# --- 8 Dienste und 9 Killswitch ------------------------------------------------------------------- + +def lies_dienste(dienste_datei: str | None) -> dict[str, str]: + if dienste_datei is not None: + return json.loads(Path(dienste_datei).read_text(encoding="utf-8")) + + def lesen(): + ergebnis = {} + for dienst in DIENSTE: + lauf = subprocess.run(["systemctl", "is-active", dienst], capture_output=True, text=True, timeout=30) + ergebnis[dienst] = lauf.stdout.strip() or f"exit {lauf.returncode}" + return ergebnis + + zustaende = lesen() + if any(wert != "active" for wert in zustaende.values()): + time.sleep(DIENSTE_NOCHMAL_S) + zustaende = lesen() + return zustaende + + +def pruefe_dienste(zustaende, k: Kontrolle) -> None: + if not isinstance(zustaende, dict): + k.befund("Dienste: Zustand nicht lesbar") + return + k.fakten["dienste"] = zustaende + for dienst in DIENSTE: + if zustaende.get(dienst) != "active": + k.befund(f"{dienst} ist nicht aktiv ({zustaende.get(dienst)})") + + +def pruefe_killswitch(projekt: Path, k: Kontrolle) -> None: + register = projekt / "data" / "registry.db" + if not register.is_file(): + k.befund("Register data/registry.db fehlt") + return + with sqlite3.connect(f"file:{register}?mode=ro", uri=True, timeout=10) as verbindung: + zeile = verbindung.execute( + "SELECT zustand, seit, ausloeser FROM killswitch_zustand WHERE id = 1" + ).fetchone() + if zeile is None: + k.befund("Register: kein Killswitch-Zustand") + return + k.fakten["killswitch"] = {"zustand": zeile[0], "seit": zeile[1], "ausloeser": zeile[2]} + if zeile[0] == "gesperrt": + k.befund("Killswitch steht auf gesperrt: Ohne 'Freigeben' handelt der naechste Tag nicht") + elif zeile[0] not in KILLSWITCH_ZUSTAENDE: + k.befund(f"Killswitch in unbekanntem Zustand {zeile[0]!r}") + + +# --- Meldung --------------------------------------------------------------------------------------- + +def melde(titel: str, text: str, projekt: Path) -> str: + """Eine Meldung mit hoher Prioritaet ueber den Melder des Projekts. Scheitert nie nach aussen. + + Melder.melde wirft nicht: Es gibt None bei Erfolg und sonst den Grund zurueck (Typ der Ausnahme + oder "HTTP "). Genau das steht im Ergebnis. + """ + try: + sys.path.insert(0, str(projekt)) + os.chdir(projekt) + from trading.paper.melder import baue_melder + from trading.paper.meldungen import HOCH + from trading.paper.meldungen import Meldung + + melder = baue_melder(config_pfad="config/paper.toml") + if not melder.aktiv: + return "nicht gesendet: Melder nicht aktiv" + fehler = melder.melde(Meldung(titel=titel, text=maskiere(text), prioritaet=HOCH, tags="warning")) + return "gesendet" if fehler is None else f"nicht gesendet: {fehler}" + except Exception as ausnahme: # die Kontrolle haengt nicht an der Meldung + return f"nicht gesendet: {type(ausnahme).__name__}" + + +# --- Ablauf ---------------------------------------------------------------------------------------- + +def kontrolliere(argumente: argparse.Namespace, k: Kontrolle) -> None: + projekt = Path(argumente.projekt) + tag = argumente.tag + date.fromisoformat(tag) # ein falsches Datum ist ein Bedienfehler und bricht ab + # handelstag_auswertung.py und der Melder lesen aus dem Arbeitsverzeichnis (Projektordner). + os.chdir(projekt) + + zustaende = k.schritt("Dienste", lies_dienste, argumente.dienste) + if zustaende is not None: + pruefe_dienste(zustaende, k) + k.schritt("Killswitch", pruefe_killswitch, projekt, k) + + liste = k.schritt("Kalender, und damit Lauf, Ende und Bericht,", lies_kalender, tag, argumente.kalender, projekt) + handelstag = None if liste is None else ist_handelstag(tag, liste) + k.fakten["handelstag"] = handelstag + if handelstag is None: + if liste is not None: + k.befund("Kalender in unerwarteter Form; Lauf, Ende und Bericht ungeprueft") + return + dateien = protokolle(projekt, tag) + if not handelstag: + if dateien: + k.hinweis(f"kein Handelstag, aber {len(dateien)} Protokoll(e) des scharfen Runners") + return + + if not dateien: + k.befund("kein Protokoll des scharfen Runners an einem Handelstag -- der Runner lief nicht") + laeufe = [lauf for lauf in (k.schritt(f"Protokoll {pfad.name}", lies_protokoll, pfad, k) for pfad in dateien) if lauf] + k.fakten["laeufe"] = laeufe + pruefe_laeufe(laeufe, k) + + text = k.schritt("Journal", lies_journal, tag, argumente.journal) + if text is not None: + k.fakten["ende"] = pruefe_journal(text, k) + + ausserhalb = {lauf["datei"] for lauf in laeufe if ausserhalb_der_handelszeit(lauf)} + for pfad in dateien: + if pfad.name in ausserhalb: + continue + k.schritt(f"Auswertung {pfad.name}", fahre_auswertung, pfad, projekt, + not argumente.ohne_broker, not argumente.ohne_kerzen, k) + + bericht = projekt / "data" / "paper" / "auswertungen" / f"{tag}.md" + k.fakten["bericht"] = bericht.is_file() + if not bericht.is_file(): + k.befund(f"Tagesbericht data/paper/auswertungen/{tag}.md fehlt") + + +def parse_args(argv=None) -> argparse.Namespace: + zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) + zerleger.add_argument("--tag") + zerleger.add_argument("--meldung", nargs=2, metavar=("TITEL", "TEXT")) + zerleger.add_argument("--projekt", default=PROJEKT) + zerleger.add_argument("--kalender") + zerleger.add_argument("--journal") + zerleger.add_argument("--dienste") + zerleger.add_argument("--ohne-broker", action="store_true") + zerleger.add_argument("--ohne-kerzen", action="store_true") + zerleger.add_argument("--ohne-meldung", action="store_true") + argumente = zerleger.parse_args(argv) + if bool(argumente.tag) == bool(argumente.meldung): + zerleger.error("genau eines von --tag und --meldung") + return argumente + + +def main(argv=None) -> int: + os.environ.setdefault("TRADING_GEHEIMNISSE", GEHEIMNISSE) + argumente = parse_args(argv) + if argumente.meldung: + print(json.dumps({"meldung": melde(*argumente.meldung, Path(argumente.projekt))}, ensure_ascii=False)) + return 0 + + k = Kontrolle() + code = 0 + try: + kontrolliere(argumente, k) + except Exception as ausnahme: + k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}") + code = 2 + if k.befunde and not argumente.ohne_meldung: + text = "\n".join(f"- {befund}" for befund in k.befunde[:12]) + k.fakten["meldung"] = melde( + f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)", text, Path(argumente.projekt) + ) + print(json.dumps( + {"tag": argumente.tag, "befunde": k.befunde, "hinweise": k.hinweise, "fakten": k.fakten, "exit": code}, + ensure_ascii=False, default=str, + )) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/af_gegenfall.py b/.vault-tools/trading-plattform-pruefung/af_gegenfall.py new file mode 100644 index 0000000..3a4b045 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/af_gegenfall.py @@ -0,0 +1,204 @@ +"""Paket AF ohne Netz: Gegenfall und Gegenprobe der Fortsetzung zum Tagesstart. + +Koordinator-Werkzeug, nicht Teil des Repos (Paket AF, 2026-09-24). Faehrt die echte +PaperSitzung des Arbeitsbaums mit der ausgelieferten config/paper.toml und ihrem Schalter +[auto_fortsetzen] gegen einen ProbeBroker, mit echten IEX-Kerzen aus einer JSON-Datei von +hole_kerzen.py (fuer jedes Instrument dieselben), einer ProbeUhr, einem Wegwerf-Register +mit der Neustartregel und einem Melder, der nur sammelt. Kein Netz, keine Order an Alpaca, +das echte Register bleibt unberuehrt. + +Drei Faelle: + restposition vor der Oeffnung gestartet, Restposition beim Start -> nicht fortgesetzt + mitten 60 Marktminuten nach der Oeffnung gestartet, flach -> nicht fortgesetzt + flach vor der Oeffnung gestartet, flach -> fortgesetzt (die Gegenprobe) +Exit 0 heisst: Der Fall lief wie erwartet. Jede Abweichung ist eine Zeile BEFUND und Exit 2, +eine fehlende Voraussetzung Exit 3. +Aufruf (WSL, aus dem Arbeitsbaum): python af_gegenfall.py +""" + +import json +import sys +import tempfile +from datetime import datetime +from datetime import timedelta +from pathlib import Path + +sys.path.insert(0, str(Path.cwd())) + +from trading.killswitch.killswitch import Killswitch # noqa: E402 +from trading.killswitch.killswitch import lade_killswitch_config # noqa: E402 +from trading.paper.agenten import agenten_je_instrument # noqa: E402 +from trading.paper.agenten import beschreibung_je_instrument # noqa: E402 +from trading.paper.agenten import lade_agent # noqa: E402 +from trading.paper.config import enge_limits # noqa: E402 +from trading.paper.config import lade_paper_config # noqa: E402 +from trading.paper.kerzen import FesteKerzenquelle # noqa: E402 +from trading.paper.kerzen import Kerze # noqa: E402 +from trading.paper.probe import ProbeBroker # noqa: E402 +from trading.paper.sitzung import PaperSitzung # noqa: E402 +from trading.paper.uhr import ProbeUhr # noqa: E402 +from trading.registry.registry import Registry # noqa: E402 +from trading.risk.limits import lade_limits # noqa: E402 + +FAELLE = ("restposition", "mitten", "flach") +RESTPOSITION = 10 + + +class SammelMelder: + """Nimmt jede Meldung an und schickt nichts.""" + + aktiv = True + + def __init__(self) -> None: + self.gesehen = [] + + def melde(self, meldung): + self.gesehen.append(meldung) + return None + + +class Protokoll: + """Haelt die Zeilen im Speicher, durch json.dumps wie das echte Protokoll.""" + + def __init__(self) -> None: + self.zeilen = [] + + def schreibe(self, eintrag) -> None: + self.zeilen.append(json.loads(json.dumps(eintrag, ensure_ascii=False, default=str))) + + +def lies_kerzen(pfad: Path) -> list: + roh = json.loads(pfad.read_text(encoding="utf-8")) + return [ + Kerze( + zeit=datetime.fromisoformat(k["zeit"]), + open=float(k["open"]), + high=float(k["high"]), + low=float(k["low"]), + close=float(k["close"]), + volume=float(k["volume"]), + ) + for k in roh + ] + + +def main() -> int: + if len(sys.argv) != 3 or sys.argv[2] not in FAELLE: + print(f"Aufruf: python af_gegenfall.py <{'|'.join(FAELLE)}>") + return 3 + pfad, fall = Path(sys.argv[1]), sys.argv[2] + if not pfad.is_file(): + print(f"VORAUSSETZUNG FEHLT: {pfad}") + return 3 + kerzen = sorted(lies_kerzen(pfad), key=lambda kerze: kerze.zeit) + if not kerzen: + print(f"VORAUSSETZUNG FEHLT: keine Kerze in {pfad}") + return 3 + config = lade_paper_config("config/paper.toml") + if not getattr(config, "auto_fortsetzen", False): + print("VORAUSSETZUNG FEHLT: config/paper.toml traegt [auto_fortsetzen] aktiv = true nicht") + return 3 + limits = enge_limits(lade_limits("config/risk.toml"), config) + ks_config = lade_killswitch_config("config/killswitch.toml") + quelle = FesteKerzenquelle(kerzen) + if fall == "mitten": + beginn = kerzen[0].zeit + timedelta(minutes=60) + else: + beginn = kerzen[0].zeit - timedelta(minutes=30) + uhr = ProbeUhr(beginn) + rest = config.alle_instrumente[-1] + broker = ProbeBroker( + kerzenquelle=quelle, + uhr=uhr, + instrument_id=config.instrument_id, + instrumente=config.alle_instrumente, + positionen={rest: RESTPOSITION} if fall == "restposition" else None, + ) + protokoll = Protokoll() + melder = SammelMelder() + with tempfile.TemporaryDirectory(prefix="af-gegenfall-") as wurzel: + ordner = Path(wurzel) + registry = Registry(db_pfad=ordner / "registry.db", artefakt_wurzel=ordner / "artifacts") + Killswitch.laden(broker=broker, registry=registry, config=ks_config) + agent, beschreibung = lade_agent(config, registry) + sitzung = PaperSitzung( + broker=broker, + kerzenquelle=quelle, + agent=agent, + agent_beschreibung=beschreibung_je_instrument(config, beschreibung), + agenten=agenten_je_instrument(config, agent), + registry=registry, + limits=limits, + killswitch_config=ks_config, + config=config, + uhr=uhr, + protokoll=protokoll, + protokoll_pfad="af-gegenfall.jsonl", + status_pfad=ordner / "status.json", + modus="probe", + soll_abbrechen=lambda: False, + melder=melder, + ) + code = sitzung.laufe() + gespeichert = registry.killswitch_zustand() + ereignisse = registry.killswitch_ereignisse() + + auto = [z for z in protokoll.zeilen if z.get("was") == "auto_fortsetzen"] + orders = [z for z in protokoll.zeilen if z.get("art") == "order"] + takte = [z for z in protokoll.zeilen if z.get("art") == "takt"] + print(f"Fall {fall}, {len(kerzen)} Kerzen ab {kerzen[0].zeit.isoformat()}, Beginn {beginn.isoformat()}") + print(f"Instrumente: {', '.join(config.alle_instrumente)}") + for zeile in auto: + print(f"auto_fortsetzen {zeile.get('ergebnis')} | {zeile.get('text')} | phase {zeile.get('phase')}") + for meldung in melder.gesehen: + print(f"Meldung [{meldung.prioritaet}] {meldung.titel}") + for zeile in meldung.text.split("\n"): + if zeile.startswith(("Killswitch", "Nicht selbst fortgesetzt", "Ausloeser", "Position")): + print(f" {zeile}") + print(f"Killswitch am Ende: {gespeichert.zustand}, Ausloeser {gespeichert.ausloeser}") + print(f"Killswitch-Ereignisse: {[(e.stufe, e.ausloeser) for e in ereignisse]}") + print(f"Takte {len(takte)}, Orders {len(orders)}, Exit {code}") + + befunde = [] + titel = [m.titel for m in melder.gesehen] + automatik = [e for e in ereignisse if e.ausloeser == "automatik"] + if fall == "flach": + if [z.get("ergebnis") for z in auto] != ["fortgesetzt"]: + befunde.append(f"auto_fortsetzen {[z.get('ergebnis') for z in auto]} statt ['fortgesetzt']") + if "Paper-Runner laeuft" not in titel or "Paper-Runner wartet" in titel: + befunde.append(f"Meldungen {titel}") + if len(automatik) != 1 or automatik[0].stufe != "fortsetzen": + befunde.append(f"Ereignisse mit Ausloeser automatik: {[(e.stufe, e.grund[:60]) for e in automatik]}") + if not orders: + befunde.append("keine Order -- fortgesetzt, aber nicht gehandelt") + if code != 0: + befunde.append(f"Exit {code} statt 0") + else: + if fall == "restposition": + erwartet = f"Konto nicht flach: {rest} {RESTPOSITION}" + else: + erwartet = "dieser Prozess ist nicht vor der Oeffnung gestartet" + if [z.get("text") for z in auto] != [f"nicht_fortgesetzt: {erwartet}"]: + befunde.append(f"auto_fortsetzen {[z.get('text') for z in auto]}") + wartet = [m for m in melder.gesehen if m.titel == "Paper-Runner wartet"] + if len(wartet) != 1 or f"Nicht selbst fortgesetzt: {erwartet}" not in wartet[0].text.split("\n"): + befunde.append(f"Meldungen {titel}") + if automatik: + befunde.append(f"{len(automatik)} Ereignis(se) mit Ausloeser automatik") + if orders: + befunde.append(f"{len(orders)} Order(s) ohne Fortsetzung") + if any(t.get("killswitch") != "angehalten" for t in takte): + befunde.append("ein Takt nicht angehalten") + erwarteter_code = 4 if fall == "restposition" else 0 + if code != erwarteter_code: + befunde.append(f"Exit {code} statt {erwarteter_code}") + for befund in befunde: + print(f"BEFUND: {befund}") + if befunde: + return 2 + print("ERGEBNIS: Fall wie erwartet") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/af_registerprobe.py b/.vault-tools/trading-plattform-pruefung/af_registerprobe.py new file mode 100644 index 0000000..125729c --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/af_registerprobe.py @@ -0,0 +1,54 @@ +"""Fragt pruefe_register (Paket AF) gegen ein Killswitch-Register, nur lesend. + +Koordinator-Werkzeug, nicht fuers Repo (2026-09-24, Befund der AF-Pruefung: das Drehbuch hatte fuer +die Registerpruefung nur eine Erwartung in Prosa). Oeffnet data/registry.db mit mode=ro -- die Klasse +Registry fuehrt beim Oeffnen das Schema aus und schriebe damit in die Datei --, baut Zustand und +Ereignisse als einfache Objekte und ruft die echte Funktion trading.paper.auto_fortsetzen.pruefe_register +des Projekts. Druckt den Befund und die juengsten Ereignisse (Stufe, Ausloeser, Zeit), nie einen Betrag. + +Was es nicht sieht: Konto, Spielgeld, Uhr und Schalter -- das pruefen Sitzung und vorstart_pruefung.py. +Vor der Oeffnung am Handelstag steht der Killswitch nach dem Dashboard-Neustart auf angehalten/neustart; +davor (etwa nach Pascals Klick am Vortag) ist "steht auf aktiv" der erwartete Befund, kein Fehler. + +Aufruf (WSL, im Projektordner): ~/venvs/trading/bin/python af_registerprobe.py +Exit 0: erfuellt, 1: nicht erfuellt, 3: Register fehlt. +""" + +import sqlite3 +import sys +from pathlib import Path +from types import SimpleNamespace + + +def main(argv=None) -> int: + argumente = sys.argv[1:] if argv is None else argv + projekt = Path(argumente[0] if argumente else ".").resolve() + db = projekt / "data" / "registry.db" + if not db.is_file(): + print(f"ABBRUCH: {db} fehlt") + return 3 + sys.path.insert(0, str(projekt)) + from trading.paper.auto_fortsetzen import pruefe_register + + verbindung = sqlite3.connect(f"file:{db}?mode=ro", uri=True) + try: + zeile = verbindung.execute("select zustand, ausloeser from killswitch_zustand").fetchone() + ereignisse = [ + SimpleNamespace(id=nummer, stufe=stufe, ausloeser=ausloeser, zeit=zeit) + for nummer, stufe, ausloeser, zeit in verbindung.execute( + "select id, stufe, ausloeser, zeit from killswitch_ereignisse order by id" + ) + ] + finally: + verbindung.close() + gespeichert = None if zeile is None else SimpleNamespace(zustand=zeile[0], ausloeser=zeile[1]) + befund = pruefe_register(gespeichert, ereignisse) + print(f"Zustand: {zeile}") + for ereignis in ereignisse[-4:]: + print(f" Nr. {ereignis.id}: {ereignis.stufe} von {ereignis.ausloeser}, {ereignis.zeit}") + print(f"pruefe_register: {'erfuellt' if befund.erfuellt else 'NICHT erfuellt'} -- {befund.grund}") + return 0 if befund.erfuellt else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh b/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh new file mode 100644 index 0000000..07d5194 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Dashboard-Neustart nach einem Einspielen, mit Tor 6b (liest das Dashboard die Geheimnisdatei unter PrivateUsers=yes?). +# Koordinator-Werkzeug, nicht fuers Repo. Pascals dauerhafte Freigabe gilt fuer genau diesen Neustart, nur ausserhalb der +# Handelszeit; das Skript verweigert, solange der Runner laeuft. Aufruf aus der PowerShell mit WSL_PW per WSLENV: +# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh +set -u +[ -n "${WSL_PW:-}" ] || { echo "WSL_PW fehlt"; exit 20; } +S() { printf '%s\n' "$WSL_PW" | sudo -S -p '' "$@"; } +if systemctl is-active --quiet trading-paper.service; then echo "Runner laeuft - kein Neustart"; exit 21; fi +date +S systemctl restart trading-dashboard.service || exit 22 +sleep 8 +systemctl is-active trading-dashboard.service +systemctl show trading-dashboard.service --property=NRestarts,ActiveEnterTimestamp +journalctl -u trading-dashboard.service --since "-1min" --no-pager | tail -10 | sed -E 's/[0-9]{6,}/######/g' +if journalctl -u trading-dashboard.service --since "-1min" --no-pager | grep -E "kann sie aber nicht lesen|PermissionError|secrets.toml|Traceback"; then + echo "TOR 6b ZU"; exit 27 +fi +echo "Tor 6b offen" +code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: 127.0.0.1:8144" http://127.0.0.1:8144/api/gesund) +echo "api/gesund: $code" +code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: 127.0.0.1:8144" http://127.0.0.1:8144/) +echo "Oberflaeche /: $code" diff --git a/.vault-tools/trading-plattform-pruefung/geheimnis_scan.py b/.vault-tools/trading-plattform-pruefung/geheimnis_scan.py new file mode 100644 index 0000000..77874d7 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/geheimnis_scan.py @@ -0,0 +1,86 @@ +"""Vor dem Push: kommt ein Wert aus der Geheimnis- oder Zugangsdatei in den neuen Commits vor? + +Koordinator-Werkzeug, nicht fuers Repo (Pascal 2026-09-24: Push jederzeit, vorher dieser Scan). +Regressionsnachweis vor jedem Scan: SCAN_PROBE= muss einen Treffer melden. + +Liest die Werte, gibt sie nie aus, nur Schluesselname und Trefferzahl. Laeuft in WSL als claude. +Aufruf: python geheimnis_scan.py [ ...] +""" +import subprocess +import sys +import tomllib +from pathlib import Path + +GEHEIM = Path("/home/claude/.config/trading-plattform/secrets.toml") +repo = Path(sys.argv[1]) +von = sys.argv[2] +ziele = sys.argv[3:] + + +def blaetter(tabelle, pfad=""): + for schluessel, wert in tabelle.items(): + name = f"{pfad}.{schluessel}" if pfad else schluessel + if isinstance(wert, dict): + yield from blaetter(wert, name) + elif isinstance(wert, str): + yield name, wert + + +werte = [] +for datei in (GEHEIM, repo / "config" / "zugang.toml"): + if not datei.exists(): + print(f"BEFUND: {datei.name} fehlt, Scan ohne diese Werte") + continue + with datei.open("rb") as f: + daten = tomllib.load(f) + for name, wert in blaetter(daten): + # Kurze Werte (true/false, Hostnamen wie localhost, Modellnamen) treffen Prosa. + if len(wert) >= 12: + werte.append((f"{datei.name}:{name}", wert)) +schluessel_json = repo / "config" / "zugang-schluessel.json" +if schluessel_json.exists(): + import json + for i, eintrag in enumerate(json.loads(schluessel_json.read_text(encoding="utf-8")) or []): + if isinstance(eintrag, dict): + for k, v in eintrag.items(): + if isinstance(v, str) and len(v) >= 12: + werte.append((f"zugang-schluessel.json:{i}.{k}", v)) +# Seit 2026-09-24 (Paket IB): die IBKR-Zugangsdateien. Die Kontonummer hat 9 Zeichen, deshalb +# hier ab 6 statt ab 12; diese drei Werte stehen in keiner Prosa. +IBKR = Path("/home/claude/.config/trading-plattform/ibkr") +for name in ("paper_benutzer", "paper_passwort", "paper_konto"): + datei = IBKR / name + if datei.exists(): + wert = datei.read_text(encoding="utf-8").strip() + if len(wert) >= 6: + werte.append((f"ibkr:{name}", wert)) + else: + print(f"BEFUND: ibkr/{name} fehlt, Scan ohne diesen Wert") +import os +if os.environ.get("SCAN_PROBE"): + # Regressionsnachweis: ein bekannter Text aus dem Verlauf muss als Treffer erscheinen. + werte.append(("probe", os.environ["SCAN_PROBE"])) +print(f"geprueft: {len(werte)} Werte ab 12 Zeichen") + +befunde = 0 +for ziel in ziele: + text = subprocess.run( + ["git", "-c", "safe.directory=*", "-C", str(repo), "log", "-p", "--all-match", f"{von}..{ziel}"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ).stdout + print(f"{von}..{ziel}: {len(text)} Zeichen Verlauf") + for name, wert in werte: + n = text.count(wert) + if n: + befunde += 1 + print(f" TREFFER {name}: {n}") + verfolgt = subprocess.run( + ["git", "-c", "safe.directory=*", "-C", str(repo), "ls-tree", "-r", "--name-only", ziel], + capture_output=True, text=True, + ).stdout.splitlines() + for gesperrt in ("config/secrets.toml", "config/zugang.toml", "config/zugang-schluessel.json"): + if gesperrt in verfolgt: + befunde += 1 + print(f" TREFFER verfolgt: {gesperrt}") +print(f"ERGEBNIS: {befunde} Treffer") +sys.exit(1 if befunde else 0) diff --git a/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh b/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh new file mode 100644 index 0000000..6a4957c --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Erster Anmeldetest am IBKR-Paper-Konto: IB Gateway als Container in WSL, nur lesend. +# Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: Container in WSL, beide Downloads frei +# (ghcr.io/gnzsnz/ib-gateway, festgelegt auf die Pruefsumme unten; nautilus-ibapi im eigenen Venv +# ~/venvs/ibkr-probe), Test nach 22:00, zweiter Faktor bei ihm nur ein Passkey. +# Das Abbild ist dasselbe, das NautilusTrader 1.231.0 mit DockerizedIBGatewayConfig startet +# (config.py: container_image "ghcr.io/gnzsnz/ib-gateway:stable"; gateway.py: Paper aussen 4004). +# Die Zugangsdaten liest der Container aus /home/claude/.config/trading-plattform/ibkr/ (Pascal traegt sie +# mit ibkr_zugang_eintragen.sh ein); dieses Skript gibt sie nie aus und maskiert den Benutzernamen im Log. +# Aufruf aus der PowerShell (sudo per WSLENV, wie bei den anderen Betriebsskripten): +# $env:WSL_PW=[Environment]::GetEnvironmentVariable('DEBIAN_WSL_PASSWORD','User'); $env:WSLENV='WSL_PW' +# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh +# Schritte: laden | pruefen | starten | log | probe | stoppen +set -u +BILD="ghcr.io/gnzsnz/ib-gateway@sha256:5fb04cb93cd705eff8a5a58b015503003864ffd9ea890be4d07273389149aa9f" +ZUGANG=/home/claude/.config/trading-plattform/ibkr +NAME=ibkr-gateway-probe +PROBE_PY=/home/claude/venvs/ibkr-probe/bin/python +PROBE=/mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_probe.py + +[ -n "${WSL_PW:-}" ] || { echo "WSL_PW fehlt (sudo per WSLENV)"; exit 20; } +S() { printf '%s\n' "$WSL_PW" | sudo -S -p '' "$@"; } + +stunde=$(TZ=Europe/Berlin date +%-H) +# RUHE_AUSNAHME: nur auf Pascals ausdrueckliche Anweisung fuer einen einzelnen Lauf (so am 2026-09-24, +# 17:0x: "Bitte schon einmal testen ohne andere Workflows zu gefaehrden"); der Wert nennt Datum und Anlass. +if [ "$stunde" -ge 15 ] && [ "$stunde" -lt 22 ] && [ -z "${RUHE_AUSNAHME:-}" ]; then + echo "Ruhe-Regel: zwischen 15 und 22 Uhr Berlin laeuft hier nichts (jetzt $(TZ=Europe/Berlin date +%H:%M))." + exit 21 +fi + +maske() { + # Den Benutzernamen und lange Ziffernfolgen aus fremder Ausgabe nehmen, ohne ihn selbst zu zeigen. + python3 -c ' +import re, sys +from pathlib import Path +benutzer = "" +p = Path(sys.argv[1]) +if p.is_file(): + benutzer = p.read_text(encoding="utf-8").strip() +for zeile in sys.stdin: + if benutzer: + zeile = zeile.replace(benutzer, "[benutzer]") + # Kontonummern: ein bis vier Grossbuchstaben und mindestens fuenf Ziffern. Die erste Fassung kannte + # nur DU plus Ziffern; das Paper-Konto hat einen Buchstaben mehr und stand am 2026-09-24 unmaskiert im Log. + zeile = re.sub(r"\b[A-Z]{1,4}\d{5,}\b", "[konto]", zeile) + sys.stdout.write(zeile) +' "$ZUGANG/paper_benutzer" +} + +case "${1:-}" in + laden) + S docker pull "$BILD" 2>&1 | tail -n 3 + S docker image inspect "$BILD" --format 'Id {{.Id}} Groesse {{.Size}} Erstellt {{.Created}}' + ;; + pruefen) + # Welche Variablen liest das Abbild aus Dateien, und unter welcher UID laeuft es? + S docker run --rm --entrypoint sh "$BILD" -c ' + echo "== id"; id + echo "== _FILE-Variablen in den Startskripten"; grep -rhoE "[A-Z_]+_FILE" /home /usr/local/bin /opt 2>/dev/null | sort -u | head -20 + echo "== Ports"; grep -rhoE "4003|4004|4001|4002|5900" /home /usr/local/bin 2>/dev/null | sort | uniq -c | head + ' 2>&1 | head -40 + ;; + starten) + for datei in paper_benutzer paper_passwort; do + [ -s "$ZUGANG/$datei" ] || { echo "fehlt: $ZUGANG/$datei (erst ibkr_zugang_eintragen.sh)"; exit 22; } + done + S docker rm -f "$NAME" >/dev/null 2>&1 + # Benutzername und Passwort aus Dateien im Container; TWS_USERID_FILE nur, wenn "pruefen" es fand, + # sonst setzt BENUTZER_ALS_VARIABLE=1 den Namen als Variable (er ist kein Geheimnis wie das Passwort). + if [ "${BENUTZER_ALS_VARIABLE:-0}" = "1" ]; then + BENUTZER_ARG=(-e "TWS_USERID=$(cat "$ZUGANG/paper_benutzer")") + else + BENUTZER_ARG=(-e "TWS_USERID_FILE=/run/secrets/ibkr/paper_benutzer") + fi + S docker run -d --name "$NAME" \ + -e TRADING_MODE=paper -e READ_ONLY_API=yes -e TWOFA_TIMEOUT_ACTION=exit \ + -e TWS_PASSWORD_FILE=/run/secrets/ibkr/paper_passwort "${BENUTZER_ARG[@]}" \ + -e TIME_ZONE=America/New_York \ + -v "$ZUGANG:/run/secrets/ibkr:ro" \ + -p 127.0.0.1:4004:4004 \ + "$BILD" >/dev/null && echo "gestartet: $NAME (Paper, nur lesend, Port 127.0.0.1:4004)" + ;; + log) + S docker logs --tail "${2:-60}" "$NAME" 2>&1 | maske + ;; + probe) + cd /tmp && "$PROBE_PY" "$PROBE" --port 4004 + ;; + stoppen) + S docker rm -f "$NAME" 2>&1 | tail -n 1 + ;; + *) + echo "Schritt fehlt: laden | pruefen | starten | log | probe | stoppen" + exit 2 + ;; +esac diff --git a/.vault-tools/trading-plattform-pruefung/ibkr_probe.py b/.vault-tools/trading-plattform-pruefung/ibkr_probe.py new file mode 100644 index 0000000..9ea097b --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/ibkr_probe.py @@ -0,0 +1,157 @@ +"""Lesende Probe gegen das IBKR-Paper-Konto ueber ein laufendes IB Gateway: verbindet, liest, trennt. + +Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: Zugang zum IBKR-Paper-Konto vorbereiten, +Gateway als Container in WSL (ghcr.io/gnzsnz/ib-gateway, wie DockerizedIBGatewayConfig in NautilusTrader +1.231.0), erster Test heute nach 22:00. Diese Probe handelt nie: Das Gateway laeuft mit READ_ONLY_API=yes, +und sie ruft keine Order-Methode. + +Entscheidungsliste (Frage -> Herkunft der Antwort): + 1. Erreicht die Probe das Gateway? Quelle: EClient.connect/isConnected (nautilus-ibapi 10.45.1), + Port 4004 = Paper aussen (gateway.py:43-45 in Nautilus 1.231.0). + 2. Welches Konto ist angemeldet? Quelle: managedAccounts-Rueckruf. Ausgegeben: die ersten zwei + Zeichen und die Laenge (Paper-Konten beginnen im Doku-Beispiel + mit "DU"; das ist ein Beispiel, keine Zusage). + 3. Welche Kontoart, welche Waehrung? Quelle: reqAccountSummary mit den Tags AccountType, Currency; + Betraege (NetLiquidation, TotalCashValue) nur als "vorhanden". + 4. Wie viele Positionen? Quelle: reqPositions -> positionEnd; nur die Zahl. + 5. Kommt ein verzoegerter Kurs? Quelle: reqMarketDataType(4, DELAYED_FROZEN; ohne Abo laut + Nautilus-Doku) und reqMktData(AAPL, SMART, USD, Snapshot). + Ausgegeben: welche Kursart kam (Tick-Typ), nie der Kurs. + 6. Welche Fehler meldet das Gateway? Quelle: error-Rueckruf; Code und gekuerzter Text (ohne Zahlen + ueber vier Stellen). +Eine fehlende Antwort nach ZEITLIMIT_S ist ein Befund, kein Schweigen. Exit 0 alles beantwortet, +1 mindestens ein Befund, 3 keine Verbindung, 2 abgebrochen. +Aufruf (WSL, eigenes Venv): ~/venvs/ibkr-probe/bin/python ibkr_probe.py [--port 4004] [--client-id 991] +""" + +import argparse +import re +import sys +import threading +import time + +ZEITLIMIT_S = 30 +NUR_LESEN_TAGS = "AccountType,Currency,NetLiquidation,TotalCashValue" +BETRAGS_TAGS = {"NetLiquidation", "TotalCashValue"} + + +def maskiere(text: str) -> str: + return re.sub(r"\d{5,}|\d+\.\d+", "[x]", str(text)) + + +def main(argv=None) -> int: + zerleger = argparse.ArgumentParser(allow_abbrev=False) + zerleger.add_argument("--host", default="127.0.0.1") + zerleger.add_argument("--port", type=int, default=4004) + zerleger.add_argument("--client-id", type=int, default=991) + argumente = zerleger.parse_args(argv) + + from ibapi.client import EClient + from ibapi.contract import Contract + from ibapi.wrapper import EWrapper + + class Probe(EWrapper, EClient): + def __init__(self): + EWrapper.__init__(self) + EClient.__init__(self, self) + self.ereignisse = {name: threading.Event() for name in ("konto", "zusammenfassung", "positionen", "kurs", "zeit")} + self.konten = "" + self.zusammenfassung = {} + self.positionen = 0 + self.kurs_arten = set() + self.fehler = [] + self.zeit = None + + def managedAccounts(self, accountsList): + self.konten = accountsList + self.ereignisse["konto"].set() + + def currentTime(self, zeit): + self.zeit = zeit + self.ereignisse["zeit"].set() + + def accountSummary(self, reqId, account, tag, value, currency): + self.zusammenfassung[tag] = "vorhanden" if tag in BETRAGS_TAGS else value + + def accountSummaryEnd(self, reqId): + self.ereignisse["zusammenfassung"].set() + + def position(self, account, contract, position, avgCost): + self.positionen += 1 + + def positionEnd(self): + self.ereignisse["positionen"].set() + + def tickPrice(self, reqId, tickType, price, attrib): + if price and price > 0: + self.kurs_arten.add(tickType) + + def tickSnapshotEnd(self, reqId): + self.ereignisse["kurs"].set() + + def error(self, reqId, *rest): + # Die Signatur wechselt zwischen ibapi-Versionen: nautilus-ibapi 10.45.1 schickt vor dem + # Code einen Zeitstempel in Millisekunden (gemessen am 2026-09-24: 1790261211141 vor 502). + # Fehlercodes sind klein; der Zeitstempel ist es nicht. + werte = [r for r in rest if isinstance(r, (int, str))] + code = next((w for w in werte if isinstance(w, int) and 0 < w < 100000), None) + text = next((w for w in werte if isinstance(w, str) and len(w) > 3), "") + self.fehler.append((reqId, code, maskiere(text)[:160])) + + probe = Probe() + befunde = [] + try: + probe.connect(argumente.host, argumente.port, argumente.client_id) + faden = threading.Thread(target=probe.run, daemon=True) + faden.start() + beginn = time.time() + while not probe.isConnected() and time.time() - beginn < 10: + time.sleep(0.2) + if not probe.isConnected(): + print(f"BEFUND: keine Verbindung zu {argumente.host}:{argumente.port}") + for f in probe.fehler[:5]: + print(f" fehler {f}") + return 3 + print(f"verbunden mit {argumente.host}:{argumente.port}, Serverversion {probe.serverVersion()}") + + probe.reqCurrentTime() + probe.reqAccountSummary(9001, "All", NUR_LESEN_TAGS) + probe.reqPositions() + probe.reqMarketDataType(4) + aapl = Contract() + aapl.symbol, aapl.secType, aapl.exchange, aapl.currency = "AAPL", "STK", "SMART", "USD" + probe.reqMktData(9002, aapl, "", True, False, []) + + for name, ereignis in probe.ereignisse.items(): + if not ereignis.wait(ZEITLIMIT_S): + befunde.append(f"keine Antwort auf {name} in {ZEITLIMIT_S} s") + + konten = [k for k in probe.konten.split(",") if k] + print(f"konten: {len(konten)}; " + ", ".join(f"{k[:2]}… ({len(k)} Zeichen)" for k in konten)) + print(f"serverzeit: {'da' if probe.zeit else 'fehlt'}") + print(f"kontoart: {probe.zusammenfassung.get('AccountType')}, waehrung: {probe.zusammenfassung.get('Currency')}, " + f"nettoliquidation: {probe.zusammenfassung.get('NetLiquidation', 'fehlt')}, bargeld: {probe.zusammenfassung.get('TotalCashValue', 'fehlt')}") + print(f"positionen: {probe.positionen}") + print(f"kursarten AAPL (Tick-Typen mit Preis): {sorted(probe.kurs_arten) or 'keine'}") + for f in probe.fehler: + print(f"gateway meldet: reqId {f[0]}, code {f[1]}: {f[2]}") + if not konten: + befunde.append("kein Konto gemeldet") + if not probe.kurs_arten: + befunde.append("kein verzoegerter Kurs fuer AAPL") + probe.cancelAccountSummary(9001) + probe.cancelPositions() + except Exception as ausnahme: + print(f"BEFUND: Probe abgebrochen: {type(ausnahme).__name__}: {maskiere(ausnahme)[:200]}") + return 2 + finally: + if probe.isConnected(): + probe.disconnect() + for befund in befunde: + print(f"BEFUND: {befund}") + print(f"ERGEBNIS: {len(befunde)} Befund(e)") + return 1 if befunde else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh b/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh new file mode 100644 index 0000000..4e45db9 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Traegt die Zugangsdaten des IBKR-Paper-Kontos ein. Pascal fuehrt es selbst in seinem Terminal aus; +# kein Agent sieht die Werte. Passwort ohne Anzeige, zweimal zur Kontrolle. +# Ablage: /home/claude/.config/trading-plattform/ibkr/ (Ordner 0700, Dateien 0600, Besitzer claude), +# neben der Geheimnisdatei aus Paket E2. Ausgegeben werden nur Dateinamen und Laengen. +# Koordinator-Werkzeug, nicht fuers Repo. Aufruf aus der PowerShell: +# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh +set -eu +umask 077 +# IBKR_ZIEL nur fuer die Probe des Koordinators mit erfundenen Werten in einem Wegwerf-Ordner. +ZIEL=${IBKR_ZIEL:-/home/claude/.config/trading-plattform/ibkr} +if [ "$(id -un)" != "claude" ]; then + echo "Bitte als Benutzer claude ausfuehren (wsl -d Debian -u claude -- bash ...)." + exit 2 +fi +mkdir -p "$ZIEL" +chmod 700 "$ZIEL" +echo "IBKR-Paper-Konto: Zugangsdaten eintragen (nichts davon verlaesst diesen Rechner)." +read -r -p "Paper-Benutzername: " BENUTZER +read -r -s -p "Paper-Passwort (wird nicht angezeigt): " PASSWORT +echo +read -r -s -p "Passwort noch einmal: " PASSWORT2 +echo +if [ -z "$BENUTZER" ] || [ -z "$PASSWORT" ]; then + echo "Benutzername oder Passwort leer, nichts geschrieben." + exit 2 +fi +if [ "$PASSWORT" != "$PASSWORT2" ]; then + echo "Die beiden Passwoerter stimmen nicht ueberein, nichts geschrieben." + exit 2 +fi +read -r -p "Paper-Kontonummer (beginnt meist mit DU; leer lassen, wenn du sie nicht zur Hand hast): " KONTO +printf '%s' "$BENUTZER" > "$ZIEL/paper_benutzer" +printf '%s' "$PASSWORT" > "$ZIEL/paper_passwort" +if [ -n "$KONTO" ]; then + printf '%s' "$KONTO" > "$ZIEL/paper_konto" +fi +unset PASSWORT PASSWORT2 +chmod 600 "$ZIEL"/* +echo "Gespeichert unter $ZIEL, nur fuer den Benutzer claude lesbar:" +for datei in "$ZIEL"/*; do + printf ' %s: %s Zeichen\n' "$(basename "$datei")" "$(wc -c < "$datei" | tr -d ' ')" +done +echo "Fertig. Aendern: dieses Skript noch einmal ausfuehren. Loeschen: rm -r $ZIEL" diff --git a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py new file mode 100644 index 0000000..13366bb --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py @@ -0,0 +1,236 @@ +"""Rueckweg des Paper-Runners von zehn Aktien auf AAPL, einmal, nach Pascals Vorgabe. + +Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: zehn Aktien "bis Mittwoch" -- gelesen als +bis einschliesslich Mittwoch 2026-09-30; ab Donnerstag 2026-10-01 handelt der Runner wieder AAPL mit der +Registerversion. Der Rueckweg ist der aus Paket M: config/paper-aapl-register.toml wird config/paper.toml. +Geplante Aufgabe "Trading Plattform - Rueckweg AAPL" (rueckweg_aapl_einrichten.ps1): 2026-09-30 22:50, +zweiter Versuch 2026-10-01 22:10, verpasste Termine beim naechsten Start (StartWhenAvailable). + +Schritte, jeder mit seinem Grund: + 1. Nicht vor FRUEHESTENS (nach Boersenschluss des letzten Tages mit zehn Aktien). + 2. Ergebnisdatei ABLAGE/rueckweg.json da -> schon erledigt, nichts tun. + 3. Laeuft der Runner (systemctl is-active trading-paper.service in WSL), wird nichts getauscht: Er hat + seine Konfiguration beim Start gelesen, und ein Tausch mitten am Tag wirkte erst beim naechsten Start. + Befund, der zweite Termin versucht es erneut. + Stand er, wird nach WARTEN_S noch einmal gefragt: Hat erst dieser Aufruf WSL geweckt, startet der + Zeitgeber den Runner 60 s danach (OnStartupSec=60) und liest dabei die Konfiguration. + 4. Im Projektordner muss ZWEIG ausgecheckt sein, sonst Befund. + 5. config/paper.toml gleich der Rueckweg-Datei: sauberer Ordner -> schon getauscht (Ergebnisdatei, + Ende); nur config/paper.toml geaendert -> halb erledigt (ein gescheiterter Commit), weiter mit 8; + sonst Befund. + 6. Sonst muss der Ordner sauber sein (git status --porcelain leer), sonst Befund: Ein Commit ueber + fremde Aenderungen waere schlimmer als ein Tag mehr mit zehn Aktien. Und config/paper.toml muss die + Konfiguration mit mehreren Aktien sein (Zeile 'quelle = "folge"'), sonst Befund. + 7. Jeder Schluessel aus config/paper.toml ausser den erwarteten Unterschieden (ERWARTET_ANDERS) muss + in der Rueckweg-Datei mit demselben Wert stehen, sonst Befund: Der Tausch verloere ihn still oder + schaltete ihn still um (etwa [auto_fortsetzen] aktiv = false, von Pascal gesetzt). + 8. Bytes kopieren, git add, git commit (lokal; gepusht wird von der naechsten Sitzung -- ein Push aus + einer Aufgabe ohne Fenster koennte an einer Anmeldeabfrage haengen). + 9. Eine Zeile in Inbox/.md unter "## Agentenprotokoll"; bei einem Befund zusaetzlich eine Meldung + mit hoher Prioritaet ueber abendkontrolle_wsl.py --meldung. +Aufruf von Hand: python rueckweg_aapl.py [--jetzt ISO] [--projekt PFAD] [--vault PFAD] [--ohne-wsl] +""" + +import argparse +import json +import subprocess +import sys +import time +import tomllib +import traceback +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from abendkontrolle import KEIN_FENSTER # noqa: E402 +from abendkontrolle import WSL # noqa: E402 +from abendkontrolle import WSL_PY # noqa: E402 +from abendkontrolle import WSL_SKRIPT # noqa: E402 +from abendkontrolle import schreibe_notiz # noqa: E402 + +VAULT = Path(r"D:\Claude") +PROJEKT = VAULT / "Code Projekte" / "active - Trading Plattform" +GIT = r"C:\Program Files\Git\cmd\git.exe" +FRUEHESTENS = datetime(2026, 9, 30, 22, 5) +ZIEL = Path("config") / "paper.toml" +RUECKWEG = Path("config") / "paper-aapl-register.toml" +MEHRERE = 'quelle = "folge"' +ZWEIG = "master" +ERWARTET_ANDERS = ("agent.", "umgebung.instrumente", "umgebung.instrument", "grenzen.max_tagesverlust_anteil") +WARTEN_S = 90 +NOCHMAL = "Steht noch ein Termin der Aufgabe aus, versucht er es erneut; sonst entscheidet die naechste Sitzung." +NACHRICHT = ( + "Rueckweg auf AAPL: config/paper.toml aus config/paper-aapl-register.toml\n\n" + "Pascal am 2026-09-24: zehn Aktien bis Mittwoch 2026-09-30. Ab 2026-10-01 handelt der Runner wieder\n" + "AAPL mit der Registerversion (Rueckweg aus Paket M). Automatisch durch die geplante Aufgabe\n" + "'Trading Plattform - Rueckweg AAPL' (.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py).\n\n" + "Co-Authored-By: Claude Opus 5.5 \n" +) + + +def git(projekt: Path, *argumente: str) -> subprocess.CompletedProcess: + return subprocess.run( + [GIT, "-C", str(projekt), *argumente], capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=120, creationflags=KEIN_FENSTER, + env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}, + ) + + +def runner_aktiv(vorgabe: str | None) -> bool: + if vorgabe is not None: + zustand = vorgabe # Angriffsfaelle: die Ausgabe von systemctl is-active, ohne WSL + else: + lauf = subprocess.run( + [WSL, "-d", "Debian", "-u", "claude", "--", "systemctl", "is-active", "trading-paper.service"], + capture_output=True, timeout=60, creationflags=KEIN_FENSTER, + ) + zustand = lauf.stdout.decode("utf-8", errors="replace").strip() + if zustand in ("active", "activating", "deactivating", "reloading"): + return True + if zustand in ("inactive", "failed"): + return False + raise RuntimeError(f"Zustand des Runners nicht lesbar: {zustand!r}") + + +def melde(titel: str, text: str, ohne_wsl: bool) -> str: + if ohne_wsl: + return "ohne WSL, nicht gesendet" + lauf = subprocess.run( + [WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--meldung", titel, text], + capture_output=True, timeout=120, creationflags=KEIN_FENSTER, + ) + return lauf.stdout.decode("utf-8", errors="replace").strip()[-200:] + + +def blaetter(datei: Path) -> dict: + """Alle Blattschluessel einer TOML-Datei als Punktpfad mit ihrem Wert.""" + def ablaufen(tabelle: dict, pfad: str): + for name, wert in tabelle.items(): + voll = f"{pfad}.{name}" if pfad else name + if isinstance(wert, dict): + yield from ablaufen(wert, voll) + else: + yield voll, wert + with datei.open("rb") as f: + return dict(ablaufen(tomllib.load(f), "")) + + +def fehlende_schluessel(ziel: Path, quelle: Path) -> list[str]: + """Was der Tausch ausser den erwarteten Unterschieden aendern wuerde. + + Projektvorgabe (Paket M, Rueckweg): Die beiden Dateien unterscheiden sich nur in [agent], + umgebung.instrumente (zehn Aktien) gegen umgebung.instrument (AAPL) und im Wert von + grenzen.max_tagesverlust_anteil (1 % gegen 0,2 %); verglichen am 2026-09-24 im Arbeitsbaum M. + Jeder andere Schluessel, der nur in config/paper.toml steht, ginge beim Tausch still verloren + (etwa [auto_fortsetzen] aus Paket AF oder etwas aus Z2), und jeder andere Wert, der sich + unterscheidet, sprang still um -- etwa auto_fortsetzen.aktiv, das Pascal in config/paper.toml + abgeschaltet hat (Befund der Plankritik AF, 2026-09-24). + """ + alt, neu = blaetter(ziel), blaetter(quelle) + befunde = [] + for name in sorted(alt.keys() | neu.keys()): + if name.startswith(ERWARTET_ANDERS): + continue + if name not in neu: + befunde.append(f"{name} fehlt in der Rueckweg-Datei") + elif name not in alt: + # Die Gegenrichtung (Nachkritik AF, 2026-09-24): Hat Pascal [auto_fortsetzen] in + # config/paper.toml geloescht -- laut Task 421 ein gueltiges Abschalten --, brachte der + # Tausch aktiv = true aus der Rueckweg-Datei still zurueck. + befunde.append(f"{name} steht nur in der Rueckweg-Datei") + elif neu[name] != alt[name]: + befunde.append(f"{name} anderer Wert") + return befunde + + +def rueckweg(jetzt: datetime, projekt: Path, ablage: Path, runner_vorgabe: str | None) -> tuple[str, str | None]: + """(Ergebnis fuer die Notiz, Befund oder None).""" + if jetzt < FRUEHESTENS: + return f"zu frueh ({jetzt:%Y-%m-%d %H:%M}), nichts getan", None + if (ablage / "rueckweg.json").exists(): + return "schon erledigt", None + if runner_aktiv(runner_vorgabe): + return "", f"Der Runner laeuft; config/paper.toml nicht getauscht. {NOCHMAL}" + if runner_vorgabe is None: + # Hat erst dieser Aufruf WSL geweckt, startet der Zeitgeber den Runner 60 s danach + # (OnStartupSec=60) mit der Konfiguration, die er dann liest. Also warten und noch einmal fragen. + time.sleep(WARTEN_S) + if runner_aktiv(None): + return "", f"Der Runner ist eben gestartet; config/paper.toml nicht getauscht. {NOCHMAL}" + zweig = git(projekt, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() + if zweig != ZWEIG: + return "", f"Im Projektordner ist {zweig!r} ausgecheckt, nicht {ZWEIG}; nicht getauscht" + ziel, quelle = projekt / ZIEL, projekt / RUECKWEG + if not quelle.is_file(): + return "", f"{RUECKWEG.as_posix()} fehlt; nicht getauscht" + status = git(projekt, "status", "--porcelain") + if status.returncode != 0: + return "", f"git status scheiterte ({status.returncode}); nicht getauscht" + geaendert = [z for z in status.stdout.splitlines() if z.strip()] + if ziel.is_file() and ziel.read_bytes() == quelle.read_bytes(): + if not geaendert: + return "config/paper.toml ist schon die AAPL-Konfiguration", None + if geaendert != [f" M {ZIEL.as_posix()}"] and geaendert != [f"M {ZIEL.as_posix()}"]: + return "", f"Projektordner nicht sauber (git status: {' | '.join(geaendert)[:200]}); nicht getauscht" + # Halb erledigt: getauscht, aber nicht committet (etwa ein gescheiterter Commit beim ersten Termin). + else: + if geaendert: + return "", f"Projektordner nicht sauber (git status: {' | '.join(geaendert)[:200]}); nicht getauscht" + if MEHRERE not in ziel.read_text(encoding="utf-8"): + return "", "config/paper.toml ist nicht die Konfiguration mit mehreren Aktien; nicht getauscht, Pascal entscheidet" + fehlend = fehlende_schluessel(ziel, quelle) + if fehlend: + return "", ( + f"config/paper-aapl-register.toml weicht ausserhalb der erwarteten Stellen von config/paper.toml ab " + f"({', '.join(fehlend)[:200]}); nicht getauscht, Pascal entscheidet" + ) + ziel.write_bytes(quelle.read_bytes()) + for schritt in (("add", ZIEL.as_posix()), ("commit", "-m", NACHRICHT)): + lauf = git(projekt, *schritt) + if lauf.returncode != 0: + return "", ( + f"git {schritt[0]} scheiterte ({lauf.returncode}: {(lauf.stderr or lauf.stdout).strip()[:200]}); " + f"Datei ist getauscht, aber nicht committet. {NOCHMAL}" + ) + kopf = git(projekt, "rev-parse", "--short", "HEAD").stdout.strip() + return f"config/paper.toml auf AAPL (Register) zurueckgestellt, Commit {kopf} (nicht gepusht)", None + + +def main(argv=None) -> int: + zerleger = argparse.ArgumentParser(allow_abbrev=False) + zerleger.add_argument("--jetzt") + zerleger.add_argument("--projekt", default=str(PROJEKT)) + zerleger.add_argument("--vault", default=str(VAULT)) + zerleger.add_argument("--ohne-wsl", action="store_true", help="Angriffsfaelle: keine Meldung, Runner laut --runner-zustand") + zerleger.add_argument("--runner-zustand", help="Angriffsfaelle: Ausgabe von systemctl is-active statt WSL") + argumente = zerleger.parse_args(argv) + if argumente.ohne_wsl and argumente.runner_zustand is None: + argumente.runner_zustand = "inactive" + jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now() + projekt, vault = Path(argumente.projekt), Path(argumente.vault) + ablage = projekt / "data" / "paper" / "abendkontrollen" + ablage.mkdir(parents=True, exist_ok=True) + try: + ergebnis, befund = rueckweg(jetzt, projekt, ablage, argumente.runner_zustand) + except Exception as ausnahme: + ergebnis, befund = "", f"abgebrochen: {type(ausnahme).__name__}: {str(ausnahme)[:200]}" + with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as log: + log.write(traceback.format_exc()) + with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as log: + log.write(f"{datetime.now().isoformat(timespec='seconds')} rueckweg: {ergebnis or befund}\n") + if ergebnis in ("schon erledigt",) or ergebnis.startswith("zu frueh"): + return 0 + kopf = f"- {jetzt:%H:%M} Uhr — Rückweg auf AAPL (automatisch, Pascals Vorgabe: zehn Aktien bis 30.09.)" + if befund is None: + (ablage / "rueckweg.json").write_text( + json.dumps({"zeit": jetzt.isoformat(), "ergebnis": ergebnis}, ensure_ascii=False), encoding="utf-8" + ) + schreibe_notiz(vault, jetzt.date().isoformat(), f"{kopf}: {ergebnis}.") + return 0 + gemeldet = melde("Rueckweg AAPL nicht ausgefuehrt", befund, argumente.ohne_wsl) + schreibe_notiz(vault, jetzt.date().isoformat(), f"{kopf}: **nicht ausgeführt** — {befund} #needs-review (Meldung: {gemeldet})") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_angriffe.py b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_angriffe.py new file mode 100644 index 0000000..1577c8b --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_angriffe.py @@ -0,0 +1,113 @@ +"""Angriffsfaelle fuer rueckweg_aapl.py auf einem Wegwerf-Git-Repo. Koordinator-Werkzeug, nicht fuers Repo. + +Jeder Fall einzeln, zuerst die Grundlinie; ohne WSL (--ohne-wsl, Runnerzustand vorgegeben), ohne Meldung. +Aufruf unter Windows: python rueckweg_aapl_angriffe.py Exit 0, wenn jeder Fall wie erwartet ausgeht. +""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent +sys.path.insert(0, str(HIER)) +import rueckweg_aapl as rw # noqa: E402 + +ZEHN = '[agent]\nquelle = "folge"\ninstrumente = ["AAPL.XNAS", "MSFT.XNAS"]\n' +AAPL = '[agent]\nquelle = "register"\ninstrument = "AAPL.XNAS"\n' +abweichungen: list[str] = [] + + +def repo(tmp: Path, paper: str | None = ZEHN, aapl: str | None = AAPL) -> Path: + projekt = tmp / "projekt" + (projekt / "config").mkdir(parents=True) + if paper is not None: + (projekt / "config" / "paper.toml").write_bytes(paper.encode("utf-8")) + if aapl is not None: + (projekt / "config" / "paper-aapl-register.toml").write_bytes(aapl.encode("utf-8")) + (projekt / ".gitignore").write_text("data/\n", encoding="utf-8") + for befehl in (["init", "-q", "-b", "master"], ["config", "user.email", "t@example.invalid"], ["config", "user.name", "Test"], + ["config", "core.autocrlf", "false"], ["add", "-A"], ["commit", "-q", "-m", "start"]): + subprocess.run([rw.GIT, "-C", str(projekt), *befehl], check=True, capture_output=True) + (tmp / "Inbox").mkdir() + return projekt + + +def fall(name, *, jetzt="2026-09-30T22:50:00", vorbereiten=None, runner="inactive", paper=ZEHN, aapl=AAPL, + erwartet_code, getauscht, notiz, marke, commits, grund=None): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + projekt = repo(tmp, paper, aapl) + if vorbereiten: + vorbereiten(projekt) + vorher = subprocess.run([rw.GIT, "-C", str(projekt), "rev-list", "--count", "HEAD"], capture_output=True, text=True).stdout.strip() + code = rw.main(["--jetzt", jetzt, "--projekt", str(projekt), "--vault", str(tmp), "--ohne-wsl", "--runner-zustand", runner]) + paper_jetzt = (projekt / "config" / "paper.toml").read_text(encoding="utf-8") if (projekt / "config" / "paper.toml").exists() else None + notizen = list((tmp / "Inbox").glob("*.md")) + text = notizen[0].read_text(encoding="utf-8") if notizen else "" + hat_marke = (projekt / "data" / "paper" / "abendkontrollen" / "rueckweg.json").exists() + nachher = subprocess.run([rw.GIT, "-C", str(projekt), "rev-list", "--count", "HEAD"], capture_output=True, text=True).stdout.strip() + ist = { + "code": code, + "getauscht": paper_jetzt == AAPL, + "notiz": ("nicht ausgeführt" if "nicht ausgeführt" in text else ("ja" if "Rückweg auf AAPL" in text else "nein")), + "marke": hat_marke, + "commits": int(nachher) - int(vorher), + } + soll = {"code": erwartet_code, "getauscht": getauscht, "notiz": notiz, "marke": marke, "commits": commits} + ok = ist == soll and (grund is None or grund in text) + zeile = f"{'OK ' if ok else 'FEHL'} {name}: {ist}" + ("" if ok else f" SOLL {soll}") + print(zeile) + if not ok: + abweichungen.append(zeile) + + +fall("Grundlinie", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) +fall("zu frueh", jetzt="2026-09-30T21:59:00", erwartet_code=0, getauscht=False, notiz="nein", marke=False, commits=0) +fall("Ergebnisdatei schon da", + vorbereiten=lambda p: ((p / "data" / "paper" / "abendkontrollen").mkdir(parents=True), (p / "data" / "paper" / "abendkontrollen" / "rueckweg.json").write_text("{}")), + erwartet_code=0, getauscht=False, notiz="nein", marke=True, commits=0) +fall("Runner aktiv", runner="active", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Runner deactivating", runner="deactivating", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Runnerzustand unlesbar", runner="", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Runner failed (steht)", runner="failed", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) +fall("schon AAPL", paper=AAPL, erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=0) +fall("Ordner nicht sauber (fremde Aenderung)", + vorbereiten=lambda p: (p / "config" / "risk.toml").write_text("x\n"), + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("paper.toml geaendert, nicht committet", + vorbereiten=lambda p: (p / "config" / "paper.toml").write_text(ZEHN + "# neu\n"), + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("paper.toml ohne folge", paper='[agent]\nquelle = "halte"\n', + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Rueckweg-Datei fehlt", aapl=None, erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("zweiter Termin Donnerstag", jetzt="2026-10-01T22:10:00", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) +# Befunde des Pruefers: halb erledigter Tausch, verlorener Abschnitt, falscher Zweig +fall("halb getauscht (Datei getauscht, nicht committet)", + vorbereiten=lambda p: (p / "config" / "paper.toml").write_bytes(AAPL.encode("utf-8")), + erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) +AUTO = "\n[auto_fortsetzen]\naktiv = true\n" +fall("halb getauscht und fremde Aenderung", + vorbereiten=lambda p: ((p / "config" / "paper.toml").write_bytes(AAPL.encode("utf-8")), (p / "config" / "risk.toml").write_text("x\n")), + erwartet_code=1, getauscht=True, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Abschnitt nur in paper.toml", paper=ZEHN + AUTO, + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Abschnitt in beiden", paper=ZEHN + AUTO, aapl=AAPL + AUTO, + erwartet_code=0, getauscht=False, notiz="ja", marke=True, commits=1) +AUS = "\n[auto_fortsetzen]\naktiv = false\n" +fall("Automatik nur in paper.toml abgeschaltet", paper=ZEHN + AUS, aapl=AAPL + AUTO, + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Abschnitt in paper.toml geloescht, in der Rueckweg-Datei noch da", paper=ZEHN, aapl=AAPL + AUTO, + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0, + grund="auto_fortsetzen.aktiv steht nur in der Rueckweg-Datei") +fall("anderer Wert in [grenzen]", paper=ZEHN + "\n[grenzen]\nmax_orders = 5\n", aapl=AAPL + "\n[grenzen]\nmax_orders = 10\n", + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) +fall("Tagesverlust darf sich unterscheiden", + paper=ZEHN + "\n[grenzen]\nmax_tagesverlust_anteil = 0.01\n", aapl=AAPL + "\n[grenzen]\nmax_tagesverlust_anteil = 0.002\n", + erwartet_code=0, getauscht=False, notiz="ja", marke=True, commits=1) +fall("anderer Zweig ausgecheckt", + vorbereiten=lambda p: subprocess.run([rw.GIT, "-C", str(p), "checkout", "-q", "-b", "z2"], check=True), + erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) + +print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)") +sys.exit(1 if abweichungen else 0) diff --git a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_einrichten.ps1 b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_einrichten.ps1 new file mode 100644 index 0000000..37fec5a --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_einrichten.ps1 @@ -0,0 +1,37 @@ +# Richtet die einmalige Windows-Aufgabe "Trading Plattform - Rueckweg AAPL" ein oder entfernt sie. +# Koordinator-Werkzeug, nicht fuers Repo. Pascal 2026-09-24: zehn Aktien bis Mittwoch 2026-09-30. +# Zwei Termine: Mi 2026-09-30 22:50 und als zweiter Versuch Do 2026-10-01 22:10; StartWhenAvailable holt +# einen verpassten Termin beim naechsten Start nach. rueckweg_aapl.py tut nach dem ersten Erfolg nichts mehr. +# +# Aufruf: powershell -NoProfile -File rueckweg_aapl_einrichten.ps1 [-Aktion einrichten|entfernen] +param( + [ValidateSet("einrichten", "entfernen")] + [string]$Aktion = "einrichten" +) + +$Name = "Trading Plattform - Rueckweg AAPL" +if ($Aktion -eq "entfernen") { + Unregister-ScheduledTask -TaskName $Name -Confirm:$false + return +} + +$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe" +$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\rueckweg_aapl.py" +if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" } +if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" } + +$Benutzer = "$env:USERDOMAIN\$env:USERNAME" +$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '"') -WorkingDirectory "D:\Claude" +$Erster = New-ScheduledTaskTrigger -Once -At ([datetime]"2026-09-30T22:50:00") +$Zweiter = New-ScheduledTaskTrigger -Once -At ([datetime]"2026-10-01T22:10:00") +$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited +$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 15) -MultipleInstances IgnoreNew +Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger @($Erster, $Zweiter) -Principal $Wer ` + -Settings $Regeln -Force ` + -Description "Rueckweg des Paper-Runners auf AAPL nach Pascals Vorgabe (zehn Aktien bis 2026-09-30). Siehe rueckweg_aapl.py." | Out-Null + +$Aufgabe = Get-ScheduledTask -TaskName $Name +"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State +$Aufgabe.Triggers | ForEach-Object { "Ausloeser: {0} {1}" -f $_.CimClass.CimClassName, $_.StartBoundary } +"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime diff --git a/.vault-tools/workflow-pruefung/LIESMICH.md b/.vault-tools/workflow-pruefung/LIESMICH.md new file mode 100644 index 0000000..b86a532 --- /dev/null +++ b/.vault-tools/workflow-pruefung/LIESMICH.md @@ -0,0 +1,46 @@ +--- +type: resource +status: active +title: "Workflow-Prüfung: Attrappe, Fälle und Messreihe zum Verbrauch" +summary: "Node-Attrappe für die gespeicherten Workflows des Vaults mit Fällen je Workflow, dazu verbrauch_reihe.py, das aus den Workflow-Protokollen rechnet, wie viel vom 5-Stunden-Fenster und von der Woche zwischen zwei Verbrauchsablesungen verbraucht wurde. Entstanden 2026-09-24." +tags: [vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +--- + +# Workflow-Prüfung + +Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]], Abschnitt „Verbrauch und Opus-Plätze". Hier steht nur, wie man die Werkzeuge benutzt und was belegt ist. Der Plan, mit dem sie entstanden sind: `docs/plans/2026-09-24-parallelitaet/00-uebersicht.md` in diesem Ordner. + +## Ablauf + +1. **Nach jeder Änderung an einem Workflow unter `.claude/workflows/`:** `node .vault-tools/workflow-pruefung/faelle_workflows.mjs` im Vault-Wurzelordner. Mit einem Ordner als Argument prüft das Skript die Workflows dort, etwa eine Kopie mit angewendetem Plan. Jeder Workflow hat zuerst eine Grundlinie; scheitert sie, messen seine übrigen Fälle nichts. +2. **Ein neuer Fall braucht einen Rotlauf.** Die Fälle gegen den Stand vor der Änderung fahren, oder den Mutanten nachstellen, den der Fall fangen soll. Grün allein belegt nichts. +3. **Mutanten:** `python .vault-tools/workflow-pruefung/mutanten_workflows.py` (einige Minuten) und `python .vault-tools/workflow-pruefung/mutanten_reihe.py`. Ein veraltetes Muster bricht mit Exit 2 ab und muss an den neuen Code angepasst werden; ein wirkungsloser Mutant steht mit Grund in `WIRKUNGSLOS`. Wer einen Workflow ändert, ergänzt hier den Mutanten, den sein neuer Fall fangen soll. +4. **Messreihe:** `python .vault-tools/workflow-pruefung/verbrauch_reihe.py [--seit JJJJ-MM-TT]`. Die Ausgabe ist eine Tabelle je Paar aufeinanderfolgender Ablesungen eines Laufs: Minuten, Agentenminuten je Modellfamilie, Differenz im 5-Stunden-Fenster und in der Woche. Darunter steht eine Näherung in Prozent des 5-Stunden-Fensters je Opus-Agentenstunde. Ein `Reset` zwischen zwei Ablesungen zählt nicht mit. + +## Was die Attrappe nachbildet + +`attrappe.mjs` führt ein Workflow-Skript mit nachgebauten Globals aus, genauso streng wie das Laufzeitsystem: `workflow()` im Kind wirft, im Kind ist `phase()` wirkungslos und jeder Agent trägt die Phase `▸ ` (ab dem zweiten Aufruf desselben Kindes `▸ #`), ein unbekannter Workflow wirft, `Date.now()`, `new Date()` ohne Argument und `Math.random()` werfen. Ein Schema ohne `properties` wirft, ein Ergebnis ohne die `required`-Felder wird zu `null`, und ein `agent()` ohne `model` ist ein Verstoß. Aufrufe über `D:/Claude/.claude/workflows/...` leitet sie in den Ordner unter Test um. Sie zählt, wie viele Agenten je Phase und Modell gleichzeitig laufen. + +**Was sie nicht sieht:** die Obergrenze gleichzeitiger Agenten je Lauf, Budget, Resume und das Verhalten echter Modelle. Wie lange echte Linsen bei drei gleichzeitigen Opus-Agenten laufen und wie viel Verbrauch sie erzeugen, zeigt nur ein echter Lauf. + +## Was belegt ist + +Stand 2026-09-24 nach Plankritik, Prüfung und Nachbesserung. Die Zahlen gelten für diesen Stand; wer Fälle oder Mutanten ergänzt, fährt die Läufe neu, statt die Zahlen hier nachzuziehen. + +- **Rotlauf:** Gegen die vier alten Workflows scheiterten 122 von 134 Fällen (Stand vor der letzten Nachbesserung). Grün blieben die elf Selbsttests der Attrappe und „kritisch hebt auf xhigh“, ein Verhalten, das sich nicht geändert hat. +- **Mutanten an den Workflows:** 52 Mutanten, alle gefangen. Ausgenommen ist „harte Obergrenze fehlt“ in `verbrauch.js`: Er ist wirkungslos, solange keine Stufe mehr als vier Plätze gibt. Wer die Stufen anhebt, braucht dafür einen Fall. +- **Selbsttests der Attrappe:** Ein Mutant, bei dem `Date.now()` nicht mehr wirft, fällt auf. +- **`verbrauch_reihe.py`:** 27 Fälle, 18 Mutanten, alle gefangen. Die Plankritik fand den wichtigsten Fehler, den kein Fall gesehen hatte: `resetsAt` schwankt bei jeder Ablesung um Millisekunden und springt über Sekunden- und Datumsgrenzen. Das Skript hielt deshalb jedes echte Paar für einen Reset, weil die Testdaten denselben festen Zeitstempel annahmen wie der Code. Seitdem gilt eine Toleranz von fünf Minuten, und die Grundlinie trägt echte Zitterwerte. +- **`verbrauch.js` im echten Laufzeitsystem** (wf_8746f9c7-f1f): Die Ablesung ergab 43 % und 12 %, genau den Wert, den der Koordinator zur selben Zeit selbst über `get_usage` las, und damit 3 Plätze. Mit `probe` bei 95 % kamen 0 Plätze und `gebremst` heraus; ein Lauf mit `probe` startet keinen Agenten und steht deshalb in keinem Journal. +- **Neue `plankritik.js` im echten Laufzeitsystem** (wf_32edc4e9-501): Nach der Ablesung von 44 % starteten die drei Linsen innerhalb einer Sekunde; die Widerlegung lief zu zweit. Die Ablesungen stehen im Journal unter den Phasen `▸ verbrauch`, `▸ verbrauch #2` und `▸ verbrauch #3`. `verbrauch_reihe.py` wertet sie aus: erstes Paar `Reset` (das Fenster wurde um 18:30 UTC zurückgesetzt), zweites Paar 3 Punkte bei 19 Opus-Minuten. +- **Neue `pruefung.js` im echten Laufzeitsystem** (wf_e0294c82-76c): Ablesungen 13 %/16 % vor und 18 %/18 % nach einem `pruefer` von 24 Minuten. +- **Neue `planung.js` im echten Laufzeitsystem** (wf_5df09faa-a4d, Probe mit drei Tasks im Scratchpad): Task 1 lief allein, Task 2 und 3 starteten erst nach seinem Ende und liefen gleichzeitig; beide übernahmen die Zieldatei-Zeile aus Task 1, haben seine Taskdatei also gelesen. +- **Erster Eichwert** am Abend des 2026-09-24, Plan laut App „Pro“: `verbrauch_reihe.py --seit 2026-09-24` ergab 8,1 % des 5-Stunden-Fensters je Opus-Agentenstunde (5 Paare, 1,7 Opus-Stunden, darunter ein Plankritik-Lauf der Trading-Sitzung). Die Woche stieg in denselben Paaren um 5 Punkte. Der Verbrauch anderer Sitzungen und der Hauptsitzung steckt mit drin; die Zahl ist also eher zu hoch. Für das Nachstellen der Stufen reicht ein Abend nicht. + +**Offen:** Die Trading-Sitzung hat `plankritik.js` am 2026-09-24 nach einem echten Lauf nachgebessert (Ausgabe der Planwerkzeuge nur bei `EXIT 0`). Für diese Stelle gibt es noch keinen Fall. + +## Was die Skripte nicht sehen + +Den Verbrauch anderer Sitzungen und der Hauptsitzung: Er steckt in jeder Differenz mit drin. Ablesungen über `probe` starten keinen Agenten und fehlen in der Messreihe. `get_usage` gibt es nur in der Desktop-App; anderswo gilt über den Rückfall die Zwei, und die Messreihe bleibt leer. Die Stufen in `verbrauch.js` sind vorläufig. Sie sind ein Fangnetz und kein Nachweis dafür, dass ein Lauf das Fenster nicht reißt. diff --git a/.vault-tools/workflow-pruefung/attrappe.mjs b/.vault-tools/workflow-pruefung/attrappe.mjs new file mode 100644 index 0000000..cc64e87 --- /dev/null +++ b/.vault-tools/workflow-pruefung/attrappe.mjs @@ -0,0 +1,150 @@ +// Attrappe für die Workflow-Skripte unter .claude/workflows/: führt ein Skript mit nachgebauten +// Globals aus und protokolliert jeden agent()-Aufruf mit Modell, Phase, Start und Ende. +// +// Grenzen wie im Ziel (Workflow-Referenz der Claude-Code-Sitzung vom 2026-09-24, dazu die +// Plankritik vom selben Tag am Laufzeitsystem 2.1.280): +// - workflow() in einem Kind wirft; Verschachtelung geht nur eine Ebene tief. Das Ziel meldet +// "workflow() cannot be called from within a child workflow", dann einen Gedankenstrich und +// "nesting is limited to one level"; die Attrappe setzt einen Doppelpunkt statt des Strichs. +// - Im Kind ist phase() wirkungslos, und jeder agent() bekommt die Phase "▸ ", ab dem +// zweiten Aufruf desselben Kindes "▸ #" (Bundle 2.1.280 und meta.json eines +// echten Laufs mit drei Ablesungen: "▸ verbrauch", "▸ verbrauch #2", "▸ verbrauch #3"). +// - workflow() mit unbekanntem Namen oder unlesbarem scriptPath wirft. +// - Date.now(), new Date() ohne Argument und Math.random() werfen. +// - agent() mit schema braucht type object und properties, required muss in properties stehen. +// Ein Ergebnis ohne die required-Felder wird wie ein gescheiterter Agent zu null. +// - parallel() ist eine Barriere; ein werfender Thunk wird zu null. +// Zusätzlich als Verstoß protokolliert (Stamm, "Agenten & Modelle"): agent() ohne model. +// Was die Attrappe nicht nachbildet: die Obergrenze gleichzeitiger Agenten je Lauf, Budget, +// Resume und das Verhalten echter Modelle. Sie ist ein Fangnetz für die Steuerlogik der +// Skripte, kein Nachweis für einen echten Lauf. +import { readFileSync, existsSync } from 'node:fs' +import path from 'node:path' + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor +// Aufrufe über den echten Pfad werden in den Ordner unter Test umgeleitet. +const ECHTER_ORDNER = 'D:/Claude/.claude/workflows/' + +class DatumAttrappe extends Date { + constructor(...a) { + if (a.length === 0) throw new Error('new Date() ohne Argument ist im Workflow nicht erlaubt') + super(...a) + } + static now() { throw new Error('Date.now() ist im Workflow nicht erlaubt') } +} +const MatheAttrappe = Object.create(Math, { + random: { value: () => { throw new Error('Math.random() ist im Workflow nicht erlaubt') } }, +}) + +function lade(datei) { + if (!existsSync(datei)) throw new Error('Workflow-Datei nicht lesbar: ' + datei) + const text = readFileSync(datei, 'utf8') + if (!/^export const meta = /m.test(text)) throw new Error(datei + ': export const meta fehlt') + const name = /^export const meta = \{[\s\S]*?\bname:\s*'([^']+)'/m.exec(text) + const quelle = text.replace(/^export const meta = /m, 'const meta = ') + return { name: name ? name[1] : path.basename(datei, '.js'), fn: new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'workflow', 'args', 'budget', 'Date', 'Math', quelle) } +} + +const warte = ms => new Promise(r => setTimeout(r, ms)) + +// antworte(aufruf) liefert { wert, dauer } oder undefined (Agent ohne Ergebnis) oder wirft. +// aufruf trägt label, phase, modell, effort, agentType, prompt, tiefe und schema. +export async function fuehreAus(datei, args, antworte, optionen = {}) { + const ordner = optionen.ordner || path.dirname(datei) + const protokoll = { agents: [], logs: [], phasen: [], verstoesse: [], spitze: {}, spitzeJePhase: {}, workflows: [] } + const laufend = {} + const laufendJePhase = {} + const kindAufrufe = {} + let uhr = 0 + + function aufloesen(ref) { + if (typeof ref === 'string') return path.join(ordner, ref + '.js') + if (ref && typeof ref.scriptPath === 'string') { + const s = ref.scriptPath.replace(/\\/g, '/') + return s.startsWith(ECHTER_ORDNER) ? path.join(ordner, s.slice(ECHTER_ORDNER.length)) : s + } + throw new Error('workflow(): Name oder { scriptPath } erwartet') + } + + async function lauf(datei, a, tiefe) { + const { name, fn } = lade(datei) + let kindPhase = null + if (tiefe >= 1) { + kindAufrufe[name] = (kindAufrufe[name] || 0) + 1 + kindPhase = '▸ ' + name + (kindAufrufe[name] > 1 ? ' #' + kindAufrufe[name] : '') + } + let aktuellePhase = null + + async function agent(prompt, opts = {}) { + if (typeof prompt !== 'string' || !prompt.trim()) protokoll.verstoesse.push('agent() ohne Auftragstext: ' + (opts.label || '?')) + if (!opts.model) protokoll.verstoesse.push('agent() ohne model: ' + (opts.label || '?')) + if (opts.schema !== undefined) { + if (!opts.schema || opts.schema.type !== 'object' || !opts.schema.properties) throw new Error('Schema braucht type object und properties: ' + opts.label) + if ((opts.schema.required || []).some(k => !(k in opts.schema.properties))) throw new Error('required nicht in properties: ' + opts.label) + } + const modell = opts.model || '(erbt)' + const phase = kindPhase || opts.phase || aktuellePhase || '(ohne)' + const eintrag = { label: opts.label || null, phase, modell, effort: opts.effort, agentType: opts.agentType, prompt, tiefe, start: ++uhr, ende: null, ergebnis: undefined } + protokoll.agents.push(eintrag) + const k = phase + '|' + modell + laufend[modell] = (laufend[modell] || 0) + 1 + laufendJePhase[k] = (laufendJePhase[k] || 0) + 1 + protokoll.spitze[modell] = Math.max(protokoll.spitze[modell] || 0, laufend[modell]) + protokoll.spitzeJePhase[k] = Math.max(protokoll.spitzeJePhase[k] || 0, laufendJePhase[k]) + let wert = null + try { + const antwort = await antworte({ ...eintrag, schema: opts.schema }) + await warte(antwort && typeof antwort.dauer === 'number' ? antwort.dauer : 5) + wert = antwort && typeof antwort === 'object' && 'wert' in antwort ? antwort.wert : null + if (wert !== null && opts.schema) { + const fehlt = (opts.schema.required || []).filter(f => !wert || typeof wert !== 'object' || !(f in wert)) + if (fehlt.length) { + protokoll.verstoesse.push(`${opts.label}: Ergebnis ohne ${fehlt.join(', ')}`) + wert = null + } + } + } catch (e) { + protokoll.logs.push(`agent ${opts.label} geworfen: ${e && e.message}`) + wert = null + } finally { + laufend[modell]-- + laufendJePhase[k]-- + eintrag.ende = ++uhr + } + eintrag.ergebnis = wert + return wert + } + + const parallel = thunks => Promise.all(thunks.map(t => Promise.resolve().then(t).catch(e => { + protokoll.logs.push('parallel: ' + (e && e.message)) + return null + }))) + const pipeline = (items, ...stufen) => Promise.all(items.map(async (item, i) => { + try { + let r = item + for (const stufe of stufen) r = await stufe(r, item, i) + return r + } catch (e) { + protokoll.logs.push('pipeline: ' + (e && e.message)) + return null + } + })) + const phase = titel => { if (!kindPhase) { aktuellePhase = titel; protokoll.phasen.push(titel) } } + const log = text => { protokoll.logs.push(String(text)) } + const workflow = async (ref, kindArgs) => { + if (tiefe >= 1) throw new Error('workflow() cannot be called from within a child workflow: nesting is limited to one level') + const ziel = aufloesen(ref) + protokoll.workflows.push({ ref: typeof ref === 'string' ? ref : ref.scriptPath, args: kindArgs, tiefe: tiefe + 1 }) + return lauf(ziel, kindArgs, tiefe + 1) + } + const budget = { total: null, spent: () => 0, remaining: () => Infinity } + return fn(agent, parallel, pipeline, phase, log, workflow, a, budget, DatumAttrappe, MatheAttrappe) + } + + try { + const ergebnis = await lauf(datei, args, optionen.tiefe || 0) + return { ergebnis, fehler: null, protokoll } + } catch (e) { + return { ergebnis: null, fehler: (e && e.message) || String(e), protokoll } + } +} diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/00-uebersicht.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/00-uebersicht.md new file mode 100644 index 0000000..b4252ae --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/00-uebersicht.md @@ -0,0 +1,143 @@ +--- +type: plan +title: "Parallelität im Codeworkflow: Verbrauchsbremse, Linsen gleichzeitig, Planer nach Voraussetzungen" +status: active +summary: "Nach dem Plan liest ein neuer Workflow verbrauch den Planverbrauch ab und rechnet die erlaubten Opus-Plätze, die Plankritik fährt ihre Linsen danach gleichzeitig, ein neuer Workflow planung startet Planer nach ihren Voraussetzungen, pruefung und welle-mit-pruefung schreiben den Verbrauch mit, und Attrappe, Fälle, Mutantenläufe und ein Auswerteskript für die Messreihe liegen unter .vault-tools/workflow-pruefung/." +tags: [plan, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +--- + +# Parallelität im Codeworkflow + +## Ziel + +Pascal hat am 2026-09-24 im Chat alle drei Stufen der Vorschläge freigegeben („Setze alle Stufen so um.“); die Vorschläge stehen in [[AI/secondbrain/regelwerk-memory/2026-09-24-parallelitaet-codeworkflow]]. „So“ heißt wie vorgeschlagen: Stufe 3, drei bis vier Opus-Plätze für Reviews und Erstplanung, kommt erst nach der Messreihe und der Eichung der Stufen. Nach diesem Plan gilt: + +- **`verbrauch.js`** liest über `get_usage` das 5-Stunden-Fenster und die Woche ab und rechnet, wie viele Opus-Agenten gleichzeitig laufen dürfen. Die Mechanik reicht bis vier, bei steigendem Verbrauch sinkt die Zahl, null heißt gebremst. Ohne lesbare Ablesung gilt die alte Zwei. +- **`plankritik.js`** fährt ihre Linsen mit so vielen Plätzen, wie `verbrauch.js` erlaubt; bei drei Linsen sind das bis zu drei (Stufe 2). Die Widerlegung bleibt bei höchstens zwei. Die Aufträge sagen, wo Tests laufen dürfen. +- **`planung.js`** (neu) startet je Task einen `planer`, sobald die Taskdateien seiner Voraussetzungen geschrieben sind, statt paarweise nach Wellen. Vorgegeben sind zwei Plätze (Stufe 1); mehr erst nach der Eichung. +- **`pruefung.js`** und **`welle-mit-pruefung.js`** schreiben den Verbrauch um die Prüfung mit. `welle-mit-pruefung` kann mit `bis: "vergleich"` vor der Prüfung enden, damit der Koordinator seine Gegenprüfung parallel zum `pruefer` fährt. +- **`.vault-tools/workflow-pruefung/`** enthält die Node-Attrappe mit Fällen für alle geänderten Workflows, die beiden Mutantenläufe und `verbrauch_reihe.py` für die Messreihe. + +## Nicht im Umfang + +- Die Regeltexte: `CLAUDE.md` („Agenten & Modelle"), `AI/Regelwerk/codeworkflow.md`, `.claude/agents/planer.md` (Delta-Nachbesserung) und der Skill `plan-executer-pruefung`. Sie kommen vom Koordinator. +- `.claude/workflows/executer-welle.js` bleibt unverändert: Opus läuft dort nur bei der Hochstufung nach zwei Fehlschlägen, und dort bleibt die Zwei. +- Die Prüfstände für die offenen Hebel (vierte Linse „Nebenläufigkeit", Widerlegung auf Sonnet, eigene Kopie je Agent, Rot-Nachweis in der Vorbereitung). Sie waren in den Vorschlägen ein späterer Schritt, keine der drei Stufen. +- `LIESMICH.md` des Werkzeugordners schreibt der Koordinator mit den Belegen dieses Laufs. +- Kein Commit. Im Vault-Repo committet nur Pascal oder die Aufgabe um 04:00. + +## Globale Vorgaben + +- Arbeitsverzeichnis ist `D:/Claude`, alle Pfade in den Taskdateien gelten von dort. +- Code, der im Plan steht, wird byte-genau übernommen, auch Kommentare, Umlaute und die Zeichen `·` und `▸`. Jede Datei endet mit genau einem Zeilenumbruch, Zeilenenden LF. +- Ein Widerspruch zwischen Plan und Baum wird gemeldet und nicht still aufgelöst. +- Jeder Executer fasst nur die Dateien unter „Zieldateien" seiner Taskdatei an. +- `.claude/workflows/` und `.vault-tools/` gehen um 04:00 ins öffentliche Repo. Die Dateien dieses Plans enthalten keine Kundennamen, Domains oder Zugangsdaten. + +## Architektur + +```text +plankritik / planung / pruefung / welle-mit-pruefung + └─ workflow({ scriptPath: "D:/Claude/.claude/workflows/verbrauch.js" }, { anlass, wunsch }) + └─ agent (haiku): ToolSearch -> mcp__ccd_session_mgmt__get_usage -> fenster + └─ Skript: Fenster einordnen, Stufen anwenden -> { status, plaetze, gebremst, grund } + Rückfall, wenn verbrauch.js nicht läuft (etwa als Kind): plaetze = min(wunsch, 2) + +welle-mit-pruefung ── workflow('executer-welle') ── workflow('pruefung', { verbrauch: false }) + └─ liest selbst vor und nach der Prüfung ab + +.vault-tools/workflow-pruefung/ + attrappe.mjs Globals des Laufzeitsystems nachgebaut, streng wie das Ziel + faelle_workflows.mjs Grundlinie und Angriffsliste je Workflow + mutanten_workflows.py Mutantenliste gegen die Workflows + verbrauch_reihe.py Differenzen zwischen Ablesungen aus journal.jsonl + faelle_verbrauch_reihe.py, mutanten_reihe.py +``` + +## Entscheidungsliste (pruefwerkzeuge.md, Abschnitt 1) + +| Frage | Herkunft | Antwort | +|---|---|---| +| Welche Fenster zählen? | Quelle | `get_usage` in der Desktop-App lieferte am 2026-09-24 `5-hour limit`, `Weekly · all models`, `Weekly · Fable`. Von 19 typisierten Limit-Abbrüchen seit 22.08. waren 16 `five_hour`, 3 `seven_day`, keiner `seven_day_opus`. Gezählt werden 5 Stunden und Woche aller Modelle, dazu `Weekly · Opus`, falls es auftaucht. | +| Woran erkennt das Skript sie? | Quelle, Annahme aufgelöst | An den Labels (Regex, Groß- und Kleinschreibung egal). Ein unbekanntes oder fehlendes Fenster ist der Status `unbekannte_fenster` mit Rückfall auf zwei, nie ein stilles Grün. | +| Was ist ein gültiger Wert? | Projektvorgabe | Eine endliche Zahl ab 0. Über 100 % heißt überschritten und bremst. | +| Welche Stufen? | Projektvorgabe, vorläufig | Stehen nur in `verbrauch.js`: 5 Stunden unter 50 % vier, unter 70 % zwei, unter 85 % einer, sonst gebremst; Woche mit 70, 85 und 95 %. Nachgestellt wird nach der Messreihe. | +| Harte Obergrenze? | Projektvorgabe | Vier (Mechanik für Stufe 3). | +| Was gilt ohne Ablesung? | Projektvorgabe | Zwei, die bisherige Stammregel. | +| Wer wünscht wie viele? | Projektvorgabe | Plankritik: so viele wie Linsen (Stufe 2). Planung: zwei (Stufe 1), drei bis vier über `plaetze` erst nach der Eichung (Stufe 3). Widerlegung: höchstens zwei. | +| Wer entscheidet? | Projektvorgabe | Das Skript. Der Haiku-Agent überträgt nur Zahlen (Harness-Gedächtnis: `lokales-modell-urteilt-nicht`). | +| Wann ist zwischen zwei Ablesungen ein Reset? | Quelle | `resetsAt` schwankt je Ablesung um Millisekunden und springt über Sekunden- und Datumsgrenzen (gemessen 2026-09-24). Reset ist eine Verschiebung um mehr als fünf Minuten oder ein fallender Wert. | +| Wie oft wird abgelesen? | Projektvorgabe | Plankritik vor den Linsen, vor der Widerlegung, am Ende. Planung am Anfang, nach je vier fertigen Planern, solange Tasks offen sind, und am Ende. Prüfung vor und nach dem `pruefer`. Eine Ablesung kostete 12 bis 13 s. | + +## Dateien und Tasks + +| Task | Zieldatei | Art | Modell | Welle | +|---|---|---|---|---| +| 01 | `.claude/workflows/verbrauch.js` | neu | sonnet, low | 0 (ausgeführt) | +| 02 | `.claude/workflows/planung.js` | neu | haiku | 1 | +| 03 | `.claude/workflows/plankritik.js` | ersetzt | haiku | 1 | +| 04 | `.claude/workflows/pruefung.js` | ersetzt | haiku | 1 | +| 05 | `.claude/workflows/welle-mit-pruefung.js` | ersetzt | haiku | 1 | +| 06 | `.vault-tools/workflow-pruefung/attrappe.mjs`, `faelle_workflows.mjs`, `mutanten_workflows.py` | neu | sonnet, low | 2 | +| 07 | `.vault-tools/workflow-pruefung/verbrauch_reihe.py`, `faelle_verbrauch_reihe.py`, `mutanten_reihe.py` | neu | sonnet, low | 1 | +| 08 | `.claude/workflows/verbrauch.js` | Delta zu 01 | haiku | 1 | +| 09 | acht Dateien aus 02 bis 07 | Delta nach der Prüfung | sonnet, low | 3 | + +Die Zieldateien einer Welle sind disjunkt. **Welle 0** zog Task 01 vor die Plankritik, damit sie schon mit echter Ablesung lief. **Task 08** bessert Task 01 nach der Plankritik als Delta nach; Task 01 bleibt byte-gleich. **Welle 2** folgt auf Welle 1, weil Task 06 die Fälle und die Mutanten gegen die Dateien aus Welle 1 fährt. + +## Plankritik + +Gelaufen am 2026-09-24 mit der neuen `plankritik.js` über `scriptPath` (wf_32edc4e9-501, 26 Minuten, drei Linsen gleichzeitig nach einer Ablesung von 44 %). 32 Befunde nach dem Zusammenfassen, 12 bestätigt, keiner widerlegt, 20 kleine ungeprüft. Eingearbeitet: + +- **Blockierend, alle drei Linsen:** `verbrauch_reihe.py` hielt jedes echte Paar für einen Reset, weil `resetsAt` je Ablesung um Millisekunden schwankt. Die Fälle hatten denselben festen Zeitstempel wie der Produktivcode angenommen. Jetzt gilt die Toleranz aus der Entscheidungsliste; die Grundlinie trägt echte Zitterwerte. +- **Fehlende Fälle:** gesenkte Plätze vor der Widerlegung, gesenkte und gehobene Plätze beim Nachlesen der Planung, Bremse am Anfang der Planung, Startreihenfolge, kein Nachlesen ohne offene Tasks, Testort in allen Aufträgen, Wochenschwellen knapp darunter, Opus-Woche unter der Woche aller Modelle, Kappen am Ende eines Paars, höchstes statt erstes Fenster, Ablesezeit. +- **Mutantenliste nicht im Plan:** Die Mutantenläufe liegen jetzt als Zieldateien in Task 06 und 07. +- **Stufe 3 zu früh:** `planung.js` gibt ohne `plaetze` zwei Plätze vor. +- **Über 100 %:** bremst jetzt (Task 08). +- **Klein, mit erledigt:** `zaehlung` bleibt im gebremsten Fall ein Objekt, ein Planer-Ergebnis zählt nur mit der erwarteten Taskdatei, die Attrappe führt Agenten im Kind unter `▸ ` und wirft die Meldung des Ziels, `ascii` heißt `nur_ascii`, die Kommentare zur Hilfsfunktion stimmen. +- **Offen gelassen:** die Laufkopie unter `_lauf/` löscht der Koordinator nach dem letzten Lauf; ob gleichzeitige Linsen sich in einer gemeinsamen Kopie stören, ist ungemessen (siehe Risiken). + +## Prüfung + +Gelaufen am 2026-09-24 mit der neuen `pruefung.js` über `scriptPath` (wf_e0294c82-76c, `pruefer` auf `high`, 24 Minuten, Ablesungen 13 %/16 % vorher und 18 %/18 % nachher). Kein blockierender Befund, 2 wichtige, 12 kleine. Der Prüfer riet von einer vollen weiteren Runde ab und nannte die Nacharbeit mit Substanz. Eingearbeitet als **Task 09** (Delta, 17 Blöcke): + +- **Wichtig:** Die Reset-Toleranz hatte keinen Fall für ihren Wert; eine Toleranz von 1 s bis 6 h blieb grün. Jetzt vier Fälle (4:59, 5:01 vor und zurück, fünf Stunden bei steigendem Wert) und drei Mutanten am Wert. +- **Wichtig:** `planung.js` war nie im echten Laufzeitsystem gelaufen. Nachgeholt mit einer Probe aus drei Tasks, siehe Check 9. +- **Klein, erledigt:** doppelte Linsen werden abgewiesen; `welle-mit-pruefung` meldet `status: ohne_pruefung`, wenn keine Prüfung lief; der Dateiname des Planer-Ergebnisses wird mit Pfadgrenze verglichen; die Attrappe nummeriert wiederholte Kinder wie das Ziel (`▸ name #2`); Fälle für den Nachlesetakt, den Lesehinweis, ein fehlendes `verbrauch.js` und das verankerte 5-Stunden-Label. +- **Klein, bewusst nicht geändert:** Fehlt das 5-Stunden-Fenster, gilt der Rückfall auf zwei, auch wenn die Woche hoch steht (so in der Entscheidungsliste). Die Platzregel bis zur Eichung steht an drei Stellen (Stamm, `codeworkflow.md`, `planung.js`) und muss bei der Freigabe von Stufe 3 gemeinsam nachgezogen werden. + +**Fremde Änderung im Baum:** Die Trading-Sitzung hat `plankritik.js` am 2026-09-24 um 21:47 Uhr nach einem echten Lauf nachgebessert. Die Ausgabe der Planwerkzeuge gilt nur noch bei `EXIT 0` als gelaufen. Dabei hat sie die Datei im Arbeitsbaum auf CRLF umgestellt; Git normalisiert das beim Commit. Diese Stelle steht nicht in diesem Plan und hat noch keinen eigenen Fall in `faelle_workflows.mjs`. Task 09 liegt sauber darüber. + +## Rot-Nachweis + +Erbracht an den Entwürfen; die Executer brauchen keine Produktivdatei beiseitezulegen. + +- `faelle_workflows.mjs` gegen die vier alten Workflows ohne `verbrauch.js` und `planung.js` (Stand vor Task 09): 122 von 134 Fällen rot. Grün blieben die elf Selbsttests der Attrappe und „kritisch hebt auf xhigh", ein unverändertes Verhalten. +- `mutanten_workflows.py` nach Task 09: 52 Mutanten, alle gefangen bis auf den in `WIRKUNGSLOS` begründeten („harte Obergrenze fehlt": keine Stufe gibt mehr als vier Plätze). +- `mutanten_reihe.py` nach Task 09: 18 Mutanten, alle gefangen. + +## Abnahme — Fertig heißt + +1. `node .vault-tools/workflow-pruefung/faelle_workflows.mjs`: Die letzte Zeile enthält `0 gescheitert`, Exit 0. +2. `python .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py`: Die letzte Zeile enthält `0 gescheitert`, Exit 0. +3. Jede Zieldatei ist byte-gleich mit der Kopie, auf die der ganze Plan angewendet ist (Task 01, 08, 02 bis 07, 06), belegt mit `cmp` und `welle_vergleich.py`. +4. `python .vault-tools/workflow-pruefung/mutanten_workflows.py` und `mutanten_reihe.py` im echten Baum: jeweils `0 ueberlebt, 0 veraltet`, Exit 0. +5. `verbrauch.js` liest im echten Laufzeitsystem ab: Status `ok`, 5-Stunden-Wert gleich der eigenen `get_usage`-Ablesung des Koordinators (erfüllt am 2026-09-24, wf_8746f9c7-f1f: 43 % und 12 % auf beiden Seiten). +6. `verbrauch.js` mit `wunsch` 3 und `probe` bei 95 % im echten Laufzeitsystem: `plaetze` 0, `gebremst` true (erfüllt, wf_8746f9c7-f1f). +7. `verbrauch_reihe.py` wertet die drei Ablesungen der Plankritik wf_32edc4e9-501 aus: Das Paar „vor Linsen → vor Widerlegung" steht im 5-Stunden-Fenster als `Reset` (das Fenster wurde um 18:30 UTC zurückgesetzt), das Paar „vor Widerlegung → Ende" mit einer Zahl. +8. `pruefung.js` läuft im echten Laufzeitsystem mit zwei Ablesungen im Ergebnis (erfüllt, wf_e0294c82-76c). +9. `planung.js` läuft im echten Laufzeitsystem mit einer Probe aus drei Tasks (2 und 3 hängen an 1): Task 1 läuft allein, 2 und 3 starten erst nach seinem Ende und laufen gleichzeitig, jede Taskdatei liegt im Planordner (erfüllt, wf_5df09faa-a4d: Task 1 20:06:59 bis 20:07:10 UTC, Task 2 und 3 ab 20:07:12; beide übernahmen die Zieldatei-Zeile aus Task 1). + +## Lauf mit echten Daten + +Die Checks 5 bis 8 sind der Lauf mit echten Daten. Die Plankritik dieses Plans war der erste echte Lauf mit drei gleichzeitigen Opus-Linsen: Sie starteten innerhalb einer Sekunde, die Linsenphase dauerte bis zur letzten Linse, und die Plankritik brauchte insgesamt 26 Minuten. + +## Risiken + +- **Andere Sitzungen nutzen die gespeicherten Workflows.** Die Trading-Sitzung fährt heute ab 22:04 Wellen und Prüfungen. Die neuen Fassungen sind rückwärtsverträglich: Alle neuen Argumente sind freiwillig, alle bisherigen Rückgabefelder bleiben. Ein altes `welle-mit-pruefung` mit neuem Kind `pruefung` ergibt Ablesungen mit `nicht_gelesen`, die Prüfung läuft trotzdem. +- **Verzögertes Nachladen.** Ein Aufruf über den Namen kann noch eine Weile die alte Fassung treffen (am 2026-09-23 gut eine Stunde, am 2026-09-24 gut 20 Minuten). Aufrufe über `scriptPath` lesen die Datei direkt. +- **Gemeinsame Kopie.** Bis zu drei Linsen und zwei Widerleger fahren Tests in derselben Kopie. Schon bei zwei Widerlegern liefen in 68 % ihrer Laufzeit zwei gleichzeitig. Ob sie sich dabei stören, ist nicht gemessen; es hängt davon ab, ob die Suite des Projekts gemeinsamen Zustand in die Kopie schreibt. Mutanten entstehen nach dem Auftrag in einer eigenen Kopie der Kopie. +- **`get_usage` gibt es nur in der Desktop-App.** In anderen Sitzungen gilt über den Rückfall die Zwei. +- **Die Stufen sind ungeeicht.** Ein erster Messpunkt: Ein Workflow mit 21 Opus-Agenten auf `high` hob das 5-Stunden-Fenster mit dem übrigen Sitzungsverbrauch um rund 36 Punkte und die Woche um rund 10 Punkte (Plan laut App „Pro"). diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-01-verbrauch.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-01-verbrauch.md new file mode 100644 index 0000000..1daad72 --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-01-verbrauch.md @@ -0,0 +1,170 @@ +--- +type: plan-task +title: "Task 01 — verbrauch.js: Planverbrauch lesen und Opus-Plätze rechnen" +status: draft +summary: "Neuer gespeicherter Workflow, der über get_usage das 5-Stunden-Fenster und die Woche abliest und daraus die erlaubten Opus-Plätze rechnet; die Stufen stehen nur hier." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: sonnet +effort: low +modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt. Sonnet low statt Haiku, weil der Code das Zeichen · (U+00B7) trägt und Haiku in der Messung vom 2026-09-23 typografische Zeichen still ersetzt hat." +--- + +# Task 01 — verbrauch.js: Planverbrauch lesen und Opus-Plätze rechnen + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. + +Was danach anders ist: + +- **Es gibt `verbrauch.js`.** Ein Haiku-Agent lädt `mcp__ccd_session_mgmt__get_usage` und überträgt die Fenster; das Skript ordnet sie ein (`5-hour ...`, `Weekly ... all models`, falls vorhanden `Weekly ... Opus`) und rechnet `plaetze = min(wunsch, Stufe 5 Stunden, Stufe Woche, 4)`. +- **Ohne lesbare Ablesung gilt zwei**, wie bisher im Stamm. `nicht_gelesen` und `unbekannte_fenster` sind eigene Stati, nie ein stilles Grün. +- **`wunsch: 0` misst nur** und bremst nie; `probe` ersetzt den Agenten für den Regressionslauf im echten Laufzeitsystem. + +## Zieldateien + +- `.claude/workflows/verbrauch.js` (neu) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.claude/workflows/verbrauch.js` + +Die ganze Datei ist neu: + +```javascript +export const meta = { + name: 'verbrauch', + description: 'Liest den Planverbrauch (5-Stunden-Fenster, Woche) über get_usage und rechnet daraus, wie viele Opus-Agenten gleichzeitig laufen dürfen', + whenToUse: 'Vor jeder Opus-Staffel eines Workflows, als Kind aufgerufen: workflow({ scriptPath: "D:/Claude/.claude/workflows/verbrauch.js" }, { anlass, wunsch }). Args: {anlass, wunsch: ganze Zahl ab 0, probe?}. wunsch 0 heißt nur messen. Rückgabe {status: ok|nicht_gelesen|unbekannte_fenster, quelle, fuenf_stunden, woche, woche_opus, plaetze, gebremst, grund, fenster}. Ohne lesbare Ablesung gilt die alte Obergrenze zwei. Regel: AI/Regelwerk/codeworkflow.md, "Verbrauch und Opus-Plätze".', + phases: [{ title: 'Verbrauch', detail: 'ein Haiku-Agent liest get_usage ab, das Skript entscheidet' }], +} + +// Geltungsbereich (AI/Regelwerk/pruefwerkzeuge.md): Das Skript liest den Planverbrauch und +// rechnet daraus, wie viele Opus-Agenten gleichzeitig laufen dürfen. Es ist eine Bremse, kein +// Nachweis. Woher die Antworten kommen: +// - Welche Fenster zählen und wie sie heißen: Quelle. get_usage in der Desktop-App lieferte am +// 2026-09-24 "5-hour limit", "Weekly · all models" und "Weekly · Fable", je mit percentUsed +// und resetsAt. Von 19 typisierten Limit-Abbrüchen im Session-Speicher (22.08. bis 20.09.) +// waren 16 five_hour und 3 seven_day, keiner seven_day_opus. Ein Fenster "Weekly · Opus" zählt +// trotzdem wie die Woche, falls es auftaucht, weil die Agenten, um die es geht, auf Opus laufen. +// - Die Stufen: Projektvorgabe, vorläufig (Pascal, 2026-09-24). Sie stehen nur hier und werden +// nach der Messreihe mit .vault-tools/workflow-pruefung/verbrauch_reihe.py nachgestellt. +// - Ohne lesbare Ablesung: Projektvorgabe, die bisherige Stammregel von zwei Opus-Agenten. +// Was das Skript nicht sieht: Agenten, die nach der Ablesung in anderen Sitzungen starten; +// laufende Agenten, die nach der Ablesung weiter verbrauchen; Sitzungen ohne get_usage +// (außerhalb der Desktop-App). Das Urteil rechnet das Skript, der Haiku-Agent liest nur ab. + +const HARTE_OBERGRENZE = 4 +const OHNE_ABLESUNG = 2 +// Je Stufe [Schwelle in Prozent, Plätze unterhalb der Schwelle]; ab der letzten Schwelle 0. +const STUFEN_FUENF_STUNDEN = [[50, 4], [70, 2], [85, 1]] +const STUFEN_WOCHE = [[70, 4], [85, 2], [95, 1]] + +const FUENF_STUNDEN = /^\s*5[- ]?hour/i +const WOCHE_ALLE = /^\s*weekly\b.*\ball models\b/i +const WOCHE_OPUS = /^\s*weekly\b.*\bopus\b/i + +const ABLESUNG = { + type: 'object', + properties: { + verfuegbar: { type: 'boolean' }, + status: { type: 'string' }, + plan: { type: 'string' }, + fenster: { + type: 'array', + items: { + type: 'object', + properties: { label: { type: 'string' }, percentUsed: { type: 'number' }, resetsAt: { type: 'string' } }, + required: ['label', 'percentUsed', 'resetsAt'], + }, + }, + fehler: { type: 'string' }, + }, + required: ['verfuegbar', 'status', 'plan', 'fenster', 'fehler'], +} + +const AUFTRAG = `Lade mit dem Werkzeug ToolSearch das Werkzeug mcp__ccd_session_mgmt__get_usage (Abfrage "select:mcp__ccd_session_mgmt__get_usage") und rufe es einmal ohne Parameter auf. +Übertrage aus dem Ergebnis plan.status nach status und plan.plan nach plan. Übertrage jeden Eintrag aus plan.windows mit label, percentUsed und resetsAt unverändert nach fenster, auch Einträge, die dir unwichtig erscheinen. Setze verfuegbar auf true und fehler auf "". +Ist das Werkzeug nicht verfügbar oder scheitert der Aufruf, setze verfuegbar auf false, status auf "fehler", plan auf "", fenster auf [] und schreibe die Fehlermeldung wörtlich nach fehler. +Tu sonst nichts. Übertrage das Ergebnis in die Felder des StructuredOutput-Werkzeugs.` + +function pruefeArgs(a) { + if (!a || typeof a !== 'object') throw new Error('verbrauch: args fehlen') + if (typeof a.anlass !== 'string' || !a.anlass.trim()) throw new Error('verbrauch: anlass fehlt') + if (!Number.isInteger(a.wunsch) || a.wunsch < 0) throw new Error('verbrauch: wunsch muss eine ganze Zahl ab 0 sein, ist ' + JSON.stringify(a.wunsch)) + if (a.probe !== undefined && (a.probe === null || typeof a.probe !== 'object' || Array.isArray(a.probe))) throw new Error('verbrauch: probe muss ein Objekt sein') +} + +function stufe(prozent, stufen) { + for (const [schwelle, plaetze] of stufen) if (prozent < schwelle) return plaetze + return 0 +} + +// Ordnet die Fenster ein. Ein Fenster, das als 5 Stunden oder Woche erkannt wird, aber keine +// Prozentzahl von 0 bis 100 trägt, ist ein Befund und wird nicht still übergangen. +function einordnen(fenster) { + if (!Array.isArray(fenster)) return { fehler: 'fenster ist keine Liste' } + const e = { fuenf: null, woche: null, opus: null, andere: [] } + for (const f of fenster) { + if (!f || typeof f !== 'object' || typeof f.label !== 'string') return { fehler: 'Fenster ohne label: ' + JSON.stringify(f) } + const art = FUENF_STUNDEN.test(f.label) ? 'fuenf' : WOCHE_ALLE.test(f.label) ? 'woche' : WOCHE_OPUS.test(f.label) ? 'opus' : null + if (!art) { e.andere.push(f.label); continue } + const p = f.percentUsed + if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 100) return { fehler: `${f.label}: percentUsed ${JSON.stringify(p)} ist keine Zahl von 0 bis 100` } + const neu = { prozent: p, zuruecksetzung: typeof f.resetsAt === 'string' ? f.resetsAt : null } + if (!e[art] || e[art].prozent < p) e[art] = neu + } + const fehlt = [!e.fuenf && '5-hour limit', !e.woche && 'Weekly all models'].filter(Boolean) + if (fehlt.length) return { fehler: 'Fenster fehlt: ' + fehlt.join(', ') + (e.andere.length ? '; gelesen: ' + e.andere.join(', ') : '') } + return e +} + +pruefeArgs(args) +phase('Verbrauch') +const wunsch = args.wunsch +const deckel = wunsch > HARTE_OBERGRENZE ? `; wunsch ${wunsch} auf die harte Obergrenze ${HARTE_OBERGRENZE} gesenkt` : '' +let quelle = 'probe' +let ablesung = args.probe +if (ablesung === undefined) { + quelle = 'agent' + ablesung = await agent(AUFTRAG, { label: 'verbrauch:' + args.anlass, phase: 'Verbrauch', schema: ABLESUNG, model: 'haiku' }) +} + +function ohneAblesung(status, grund) { + const plaetze = Math.min(wunsch, OHNE_ABLESUNG) + log(`Verbrauch ${args.anlass}: ${status} (${grund}); ${plaetze} Plätze nach der alten Obergrenze`) + return { status, quelle, fuenf_stunden: null, woche: null, woche_opus: null, plaetze, gebremst: false, grund: grund + deckel, fenster: ablesung && Array.isArray(ablesung.fenster) ? ablesung.fenster : null } +} + +if (!ablesung || typeof ablesung !== 'object') return ohneAblesung('nicht_gelesen', 'kein Ergebnis vom Ableseagenten') +if (ablesung.verfuegbar !== true || ablesung.status !== 'ok') return ohneAblesung('nicht_gelesen', `get_usage: verfuegbar ${ablesung.verfuegbar}, status ${ablesung.status}${ablesung.fehler ? ', ' + ablesung.fehler : ''}`) +const e = einordnen(ablesung.fenster) +if (e.fehler) return ohneAblesung('unbekannte_fenster', e.fehler) + +const wocheProzent = Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0) +const grenzeFuenf = stufe(e.fuenf.prozent, STUFEN_FUENF_STUNDEN) +const grenzeWoche = stufe(wocheProzent, STUFEN_WOCHE) +const plaetze = Math.min(wunsch, grenzeFuenf, grenzeWoche, HARTE_OBERGRENZE) +const gebremst = wunsch > 0 && plaetze === 0 +const grund = `5 Stunden ${e.fuenf.prozent} % (bis ${grenzeFuenf} Plätze), Woche ${wocheProzent} % (bis ${grenzeWoche} Plätze)${e.opus ? ', Opus-Woche ' + e.opus.prozent + ' %' : ''}${deckel}` +log(`Verbrauch ${args.anlass}: ${grund}; ${plaetze} von ${wunsch} Plätzen${gebremst ? ', gebremst' : ''}`) +return { status: 'ok', quelle, fuenf_stunden: e.fuenf, woche: e.woche, woche_opus: e.opus, plaetze, gebremst, grund, fenster: ablesung.fenster } +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/verbrauch.js +``` + +Erwartet: `PARSE OK`, Exit 0. + +Danach `git status --porcelain -- .claude/workflows/verbrauch.js` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-02-planung.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-02-planung.md new file mode 100644 index 0000000..bdaf898 --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-02-planung.md @@ -0,0 +1,263 @@ +--- +type: plan-task +title: "Task 02 — planung.js: Planer starten, sobald ihre Voraussetzungen stehen" +status: draft +summary: "Neuer gespeicherter Workflow für die Erstplanung: je Task ein planer, gestartet nach den Voraussetzungen statt paarweise, Plätze nach verbrauch.js bis vier." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: haiku +modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·." +--- + +# Task 02 — planung.js: Planer starten, sobald ihre Voraussetzungen stehen + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. + +Was danach anders ist: + +- **Kein Warten auf Paare mehr.** Ein Task startet, sobald die Planer aller seiner Voraussetzungen ein Ergebnis mit der erwarteten Taskdatei geliefert haben; Tasks hinter einer gescheiterten Voraussetzung gelten als blockiert. +- **Plätze nach Verbrauch, vorgegeben zwei.** Am Anfang und nach je `nachlesen_alle` fertigen Planern liest `verbrauch.js` ab und senkt oder hebt die Plätze; gebremst heißt kein weiterer Start, die laufenden enden. Drei oder vier Plätze (Stufe 3) gibt es erst nach der Eichung über `plaetze`. +- **Fehler im Auftrag fallen vor dem ersten Agenten auf:** Verweis ins Leere, Zyklus, doppelte Nummer oder Datei, unbekanntes Modell. + +## Zieldateien + +- `.claude/workflows/planung.js` (neu) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.claude/workflows/planung.js` + +Die ganze Datei ist neu: + +```javascript +export const meta = { + name: 'planung', + description: 'Erstplanung: je Task ein planer, gestartet, sobald die Taskdateien seiner Voraussetzungen geschrieben sind; Opus-Plätze nach Verbrauch, vorgegeben zwei', + whenToUse: 'Schritt 2 der Standardschleife, wenn 00-uebersicht.md mit der Tasktabelle steht und viele Taskdateien zu schreiben sind (AI/Regelwerk/codeworkflow.md, "Verbrauch und Opus-Plätze"). Nicht für Nachbesserungen. Args: {projekt, planordner, gemeinsam, taskauftrag, tasks: [{nr, thema, datei, modell, voraussetzungen: [nr], welle?}], erledigt?: [nr], plaetze?: 1..4, nachlesen_alle?: 1..20, zusatz?}. plaetze ist der Wunsch, verbrauch.js senkt ihn; ohne plaetze gilt zwei, drei oder vier erst nach der Eichung der Stufen. taskauftrag darf {NR}, {THEMA}, {DATEI}, {MODELL}, {WELLE} und {VORAUSSETZUNGEN} enthalten. Rückgabe mit status fertig, unvollstaendig, gebremst oder abgebrochen; offene Tasks mit erledigt in einem neuen Lauf nachholen, nicht mit resumeFromRunId. Die Plankritik läuft danach als eigener Workflow.', + phases: [{ title: 'Taskdateien', detail: 'planer je Task, Start nach den Voraussetzungen, Plätze nach Verbrauch' }], +} + +// Rollenparameter: model wird immer gesetzt (Stamm), der Effort kommt aus dem Frontmatter von +// .claude/agents/planer.md. Ein Task startet erst, wenn die Planer aller seiner Voraussetzungen +// ein Ergebnis geliefert haben; sonst erfindet er Namen, die ein Vorgänger anders schreibt +// (codeworkflow.md, "Verbrauch und Opus-Plätze"). Die Zahl gleichzeitiger Planer liest +// verbrauch.js am Anfang und nach je nachlesen_alle fertigen Planern ab. Gebremst heißt: Kein +// Planer startet mehr, die laufenden enden, der Rest kommt als offen zurück. Nach drei Planern +// ohne Ergebnis startet keiner mehr, meist ist dann das Kontingent erschöpft. Ein Ergebnis zählt +// nur, wenn es die erwartete Taskdatei nennt; sonst gilt der Task als gescheitert. +// VORGABE_PLAETZE ist Stufe 1 der Freigabe vom 2026-09-24 (Pool mit zwei Plätzen). Stufe 3 mit +// drei bis vier Plätzen für die Erstplanung kommt erst nach der Eichung der Stufen in verbrauch.js. +const MODELLE = ['haiku', 'sonnet', 'opus'] +const HOECHSTENS = 4 +const VORGABE_PLAETZE = 2 +const OHNE_ABLESUNG = 2 +const ABBRUCH_NACH = 3 +const NACHLESEN_ALLE = 4 +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' + +const TASK_SCHEMA = { + type: 'object', + properties: { + datei: { type: 'string' }, + zieldateien: { type: 'array', items: { type: 'string' } }, + testdateien: { type: 'array', items: { type: 'string' } }, + zeilen_code: { type: 'integer' }, + abweichungen_von_uebersicht: { type: 'array', items: { type: 'string' } }, + offene_punkte: { type: 'array', items: { type: 'string' } }, + }, + required: ['datei', 'zieldateien', 'testdateien', 'abweichungen_von_uebersicht', 'offene_punkte'], +} + +function pruefeArgs(a) { + if (!a || typeof a !== 'object') throw new Error('planung: args fehlen') + const fehlt = ['projekt', 'planordner', 'gemeinsam', 'taskauftrag', 'tasks'].filter(k => !a[k]) + if (fehlt.length) throw new Error('planung: args fehlen: ' + fehlt.join(', ')) + if (!Array.isArray(a.tasks) || a.tasks.length === 0) throw new Error('planung: tasks muss eine nicht leere Liste sein') + const nummern = new Set() + const dateien = new Set() + a.tasks.forEach((t, i) => { + if (!t || typeof t !== 'object') throw new Error(`planung: tasks[${i}] ist kein Objekt`) + if (!Number.isInteger(t.nr)) throw new Error(`planung: tasks[${i}]: nr muss eine ganze Zahl sein`) + if (nummern.has(t.nr)) throw new Error(`planung: Task ${t.nr} steht doppelt in tasks`) + nummern.add(t.nr) + for (const k of ['thema', 'datei', 'modell']) if (typeof t[k] !== 'string' || !t[k]) throw new Error(`planung: Task ${t.nr}: ${k} fehlt`) + if (!MODELLE.includes(t.modell)) throw new Error(`planung: Task ${t.nr}: modell "${t.modell}" nicht in ${MODELLE.join('/')}`) + if (dateien.has(t.datei)) throw new Error(`planung: Datei ${t.datei} steht bei zwei Tasks`) + dateien.add(t.datei) + if (!Array.isArray(t.voraussetzungen) || !t.voraussetzungen.every(n => Number.isInteger(n))) throw new Error(`planung: Task ${t.nr}: voraussetzungen muss eine Liste ganzer Zahlen sein`) + if (new Set(t.voraussetzungen).size !== t.voraussetzungen.length) throw new Error(`planung: Task ${t.nr}: eine Voraussetzung steht doppelt`) + }) + a.tasks.forEach(t => t.voraussetzungen.forEach(n => { + if (!nummern.has(n)) throw new Error(`planung: Task ${t.nr}: Voraussetzung ${n} gibt es nicht`) + })) + if (a.erledigt !== undefined && !(Array.isArray(a.erledigt) && a.erledigt.every(n => nummern.has(n)))) throw new Error('planung: erledigt muss eine Liste bekannter Tasknummern sein') + if (a.plaetze !== undefined && !(Number.isInteger(a.plaetze) && a.plaetze >= 1 && a.plaetze <= HOECHSTENS)) throw new Error('planung: plaetze muss eine ganze Zahl von 1 bis ' + HOECHSTENS + ' sein') + if (a.nachlesen_alle !== undefined && !(Number.isInteger(a.nachlesen_alle) && a.nachlesen_alle >= 1 && a.nachlesen_alle <= 20)) throw new Error('planung: nachlesen_alle muss eine ganze Zahl von 1 bis 20 sein') + // Zyklen: Kahn-Verfahren über die Voraussetzungen; was übrig bleibt, hängt im Kreis. + const offen = new Map(a.tasks.map(t => [t.nr, t.voraussetzungen.length])) + const bereit = a.tasks.filter(t => t.voraussetzungen.length === 0).map(t => t.nr) + let geschafft = 0 + while (bereit.length) { + const nr = bereit.pop() + geschafft++ + for (const t of a.tasks) if (t.voraussetzungen.includes(nr)) { + offen.set(t.nr, offen.get(t.nr) - 1) + if (offen.get(t.nr) === 0) bereit.push(t.nr) + } + } + if (geschafft < a.tasks.length) throw new Error('planung: Zyklus in den Voraussetzungen bei Task ' + [...offen].filter(([, n]) => n > 0).map(([nr]) => nr).join(', ')) +} + +// Eine Hilfsfunktion ablesen() steht auch in plankritik.js (gleiche Form) sowie in pruefung.js +// und welle-mit-pruefung.js (dort nur messend, ohne Rückgabe). Die Regel (Fenster, Stufen, +// Rückfall) steht nur in verbrauch.js. +const ablesungen = [] +async function ablesen(anlass, wunsch) { + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'planung:' + anlass, wunsch }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + if (!r || typeof r.plaetze !== 'number') { + r = { status: 'nicht_gelesen', quelle: 'keine', fuenf_stunden: null, woche: null, plaetze: Math.min(wunsch, OHNE_ABLESUNG), gebremst: false, grund: (r && r.grund) || 'verbrauch.js ohne verwertbares Ergebnis' } + } + ablesungen.push({ anlass, ...r }) + log(`Verbrauch ${anlass}: ${r.status}, ${r.plaetze} Plätze${r.gebremst ? ', gebremst' : ''} (${r.grund})`) + return r +} + +function auftrag(a, t, jeNr) { + const vor = t.voraussetzungen.map(n => jeNr.get(n).datei) + const text = a.taskauftrag + .split('{NR}').join(String(t.nr).padStart(2, '0')) + .split('{THEMA}').join(t.thema) + .split('{DATEI}').join(t.datei) + .split('{WELLE}').join(t.welle === undefined ? '' : String(t.welle)) + .split('{MODELL}').join(t.modell) + .split('{VORAUSSETZUNGEN}').join(vor.length ? vor.join(', ') : '(keine)') + const lies = vor.length ? ` Die Taskdateien deiner Voraussetzungen liegen fertig im Planordner: ${vor.join(', ')}. Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst.` : '' + return `${a.gemeinsam} + +${text} + +Schreib genau eine Datei: ${a.planordner}/${t.datei}.${lies} +${a.zusatz ? a.zusatz + '\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.` +} + +pruefeArgs(args) +phase('Taskdateien') +const jeNr = new Map(args.tasks.map(t => [t.nr, t])) +const status = new Map(args.tasks.map(t => [t.nr, (args.erledigt || []).includes(t.nr) ? 'erledigt' : 'offen'])) +const reihenfolge = [...args.tasks].sort((x, y) => ((x.welle || 0) - (y.welle || 0)) || (x.nr - y.nr)) +const wunsch = args.plaetze || VORGABE_PLAETZE +const nachlesenAlle = args.nachlesen_alle || NACHLESEN_ALLE +const geschrieben = [] +const gescheitert = [] +const plaetzeVerlauf = [] +const laufend = new Map() +let gebremst = false +let abgebrochen = false +let plaetze = 0 +let fertigSeitAblesung = 0 + +const nochOffen = () => [...status.values()].some(s => s === 'offen') + +function starte(t) { + status.set(t.nr, 'laeuft') + const opts = { label: `planer:task-${String(t.nr).padStart(2, '0')}`, phase: 'Taskdateien', schema: TASK_SCHEMA, agentType: 'planer', model: 'opus' } + laufend.set(t.nr, agent(auftrag(args, t, jeNr), opts).then(r => ({ nr: t.nr, r }), e => ({ nr: t.nr, r: null, fehler: e && e.message }))) +} + +if (nochOffen()) { + const v = await ablesen('Anfang', wunsch) + plaetzeVerlauf.push(v.plaetze) + if (v.gebremst) gebremst = true + else plaetze = v.plaetze +} + +while (true) { + // Tasks hinter einer gescheiterten oder blockierten Voraussetzung starten nie. + let geaendert = true + while (geaendert) { + geaendert = false + for (const t of args.tasks) { + if (status.get(t.nr) === 'offen' && t.voraussetzungen.some(n => ['gescheitert', 'blockiert'].includes(status.get(n)))) { + status.set(t.nr, 'blockiert') + geaendert = true + } + } + } + if (!gebremst && !abgebrochen) { + for (const t of reihenfolge) { + if (laufend.size >= plaetze) break + if (status.get(t.nr) === 'offen' && t.voraussetzungen.every(n => ['fertig', 'erledigt'].includes(status.get(n)))) starte(t) + } + } + if (laufend.size === 0) break + const { nr, r: roh, fehler: grund } = await Promise.race(laufend.values()) + laufend.delete(nr) + const passt = roh && typeof roh.datei === 'string' && roh.datei.replace(/\\/g, '/').endsWith(jeNr.get(nr).datei) + const r = passt ? roh : null + const fehler = grund || (roh && !passt ? `Ergebnis nennt ${JSON.stringify(roh.datei)} statt ${jeNr.get(nr).datei}` : null) + if (r) { + status.set(nr, 'fertig') + geschrieben.push({ nr, ...r }) + } else { + status.set(nr, 'gescheitert') + gescheitert.push(nr) + log(`Task ${nr}: planer ohne Ergebnis${fehler ? ' (' + fehler + ')' : ''}`) + if (gescheitert.length >= ABBRUCH_NACH && !abgebrochen) { + abgebrochen = true + log(`${gescheitert.length} Planer ohne Ergebnis, vermutlich Kontingent: kein weiterer Start`) + } + } + fertigSeitAblesung++ + if (!gebremst && !abgebrochen && fertigSeitAblesung >= nachlesenAlle && nochOffen()) { + fertigSeitAblesung = 0 + const v = await ablesen(`nach ${geschrieben.length + gescheitert.length} Tasks`, wunsch) + plaetzeVerlauf.push(v.plaetze) + if (v.gebremst) { + gebremst = true + log('Planung gebremst: kein weiterer Start, die laufenden Planer enden') + } else plaetze = v.plaetze + } +} +if (geschrieben.length + gescheitert.length > 0) await ablesen('Ende', 0) + +const liste = s => args.tasks.filter(t => status.get(t.nr) === s).map(t => t.nr) +const blockiert = liste('blockiert') +const offen = liste('offen') +const zustand = gebremst ? 'gebremst' : abgebrochen ? 'abgebrochen' : gescheitert.length || blockiert.length || offen.length ? 'unvollstaendig' : 'fertig' +return { + status: zustand, + geschrieben, + gescheitert, + blockiert, + offen, + verbrauch: ablesungen, + plaetze_verlauf: plaetzeVerlauf, + naechster_schritt: zustand === 'fertig' + ? 'plankritik über den ganzen Planordner.' + : 'Gescheiterte, blockierte und offene Tasks in einem neuen Lauf nachholen, mit erledigt: [' + args.tasks.filter(t => ['fertig', 'erledigt'].includes(status.get(t.nr))).map(t => t.nr).join(', ') + ']; nicht mit resumeFromRunId fortsetzen.', +} +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/planung.js +``` + +Erwartet: `PARSE OK`, Exit 0. + +Danach `git status --porcelain -- .claude/workflows/planung.js` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-03-plankritik.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-03-plankritik.md new file mode 100644 index 0000000..6491fae --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-03-plankritik.md @@ -0,0 +1,365 @@ +--- +type: plan-task +title: "Task 03 — plankritik.js: Linsen gleichzeitig nach Verbrauch, Testort im Auftrag" +status: draft +summary: "Die Linsen laufen mit so vielen Opus-Plätzen, wie verbrauch.js erlaubt, die Widerlegung mit höchstens zwei; beide Phasen können bremsen; die Aufträge nennen, wo Tests laufen dürfen." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: haiku +modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·." +--- + +# Task 03 — plankritik.js: Linsen gleichzeitig nach Verbrauch, Testort im Auftrag + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. + +Was danach anders ist: + +- **Drei Linsen gleichzeitig**, sofern `verbrauch.js` drei Plätze gibt; bisher wartete die dritte Linse im Mittel 14,5 Minuten auf einen Opus-Platz (fünf echte Läufe). +- **Die Widerlegung bleibt bei höchstens zwei.** Vor Linsen und Widerlegung wird abgelesen; gebremst kommt als `status: gebremst` mit `gebremst_vor` zurück, ungeprüfte Befunde tragen `nicht geprüft (gebremst)`. +- **Testort im Auftrag:** Tests nur in der Kopie, nie im echten Projektbaum; Mutanten nur in einer eigenen Kopie der Kopie. Ohne Kopie keine Tests. +- **Die bisherigen Rückgabefelder bleiben**, auch `zaehlung` als Objekt im gebremsten Fall; neu sind `status`, `gebremst_vor`, `verbrauch`, `plaetze` und `naechster_schritt`. + +## Zieldateien + +- `.claude/workflows/plankritik.js` (geändert) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.claude/workflows/plankritik.js` + +Die ganze Datei ist neu (sie ersetzt die bisherige Fassung vollständig): + +```javascript +export const meta = { + name: 'plankritik', + description: 'Liest einen Planordner mit getrennten pruefer-Linsen, gleichzeitig nach Verbrauch bis vier, fasst doppelte Befunde zusammen und lässt jeden wichtigen oder blockierenden Befund von einem zweiten pruefer zu widerlegen versuchen, höchstens zwei gleichzeitig', + whenToUse: 'Schritt 2 der Standardschleife vor dem ersten Executer, Pflicht ab etwa drei Tasks (AI/Regelwerk/codeworkflow.md, "Plankritik vor dem ersten Executer"). Args: {projekt, planordner, spec?, kopie?, materialisieren?: {ohne: [relpfade], tasks?: [taskdateien]}, suite?, linsen?, plaetze?: 1..4, verifizieren?: true, verifizieren_ab?: "wichtig"|"blockierend", effort?, werkzeuge?, zusatz?}. Ohne kopie baut materialisieren sie selbst (kopie_bauen.py plus plan_materialisiere.py); ohne() muss Geheimnisdateien und große Datenordner nennen. plaetze begrenzt die gleichzeitigen Linsen; verbrauch.js senkt sie nach dem 5-Stunden-Fenster und der Woche. Rückgabe mit status fertig oder gebremst; ein gebremster Lauf wird neu gestartet, nicht fortgesetzt.', + phases: [ + { title: 'Vorbereitung', detail: 'Hilfsagent: Kopie bauen, Plan anwenden, Planwerkzeuge fahren' }, + { title: 'Plankritik', detail: 'je Linse ein pruefer, gleichzeitig nach Verbrauch' }, + { title: 'Widerlegung', detail: 'je wichtiger Befund ein zweiter pruefer, höchstens zwei gleichzeitig' }, + ], +} + +// Rollenparameter: model wird immer gesetzt (Stamm). Der Effort der pruefer kommt aus dem +// Frontmatter von .claude/agents/pruefer.md; args.effort überschreibt ihn nur bewusst. +// Der Hilfsagent hat keinen agentType und bekommt deshalb seinen Effort mit (low: gemessen +// gleich treu wie high bei mechanischer Arbeit, codeworkflow.md "Effort je Rolle"). +// Opus-Plätze: Vor den Linsen und vor der Widerlegung liest verbrauch.js den Planverbrauch ab. +// Die Linsen laufen mit so vielen Plätzen, wie verbrauch.js erlaubt (höchstens so viele wie +// Linsen, höchstens LINSEN_HOECHSTENS), die Widerlegung mit höchstens zwei. Gebremst heißt: Die +// Phase startet nicht, und der Lauf kommt mit status gebremst zurück. +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] +const LINSEN_HOECHSTENS = 4 +const WIDERLEGUNG_HOECHSTENS = 2 +const OHNE_ABLESUNG = 2 +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' +const RANG = { blockierend: 0, wichtig: 1, klein: 2 } +// Einfache Anführungszeichen: In Bash bleiben darin $, Backtick und Backslash wörtlich. +const q = s => "'" + String(s).replace(/'/g, "'\\''") + "'" + +const LINSEN = { + arithmetik: (a, ctx) => `Linse: Fixture-Arithmetik und Rot-Nachweise. Rechne jede Mindestzahl, jede Grenze und jeden Schwellenwert im Produktivcode des Plans gegen jede Fixture der Tests nach. Prüfe jede im Plan erwartete Fehlermeldung und jede vorhergesagte Testausgabe wörtlich: Würde der Test vor der Umsetzung genau so rot, und danach grün? pytest kürzt FAILED-Zeilen ohne Terminal auf 80 Zeichen; vergleiche Fehlerzeilen deshalb mit COLUMNS=300 vor dem Befehl.${ctx.kopie ? ` Der Plan ist auf die Kopie ${ctx.kopie} angewendet. Fahre dort die Suite (${a.suite || 'Testbefehl aus dem Plan'}) und vergleiche jede Vorhersage des Plans mit der echten Ausgabe. In der Kopie änderst du nichts außer dem, was die Testläufe selbst anlegen.` : ' Eine Kopie mit angewendetem Plan gibt es nicht; du rechnest am Text.'}`, + bezeichner: (a, ctx) => `Linse: Bezeichner, Signaturen und Byte-Treue. Prüfe jeden Namen, jede Signatur und jeden Import über Taskgrenzen hinweg. Jeder Aus-Block muss genau einmal im echten Baum stehen, jeder Codeblock muss sich parsen lassen, und Codeblöcke dürfen keine Zeilen- und Absatztrenner, Format- oder Steuerzeichen enthalten. Prüfbefehle im Plan dürfen nur Code treffen, nie ein Wort, das auch in einem Kommentar steht.${ctx.werkzeuge ? `\n\nDie Planwerkzeuge sind schon gelaufen, hier ihre Rohausgabe. Bewerte sie, statt sie erneut zu fahren; nur bei Zweifeln selbst nachfahren.\n\n${ctx.werkzeuge}\n` : ` Fahre dazu plan_bloecke.py vor und plan_zeichen.py aus ${a.werkzeuge || 'D:/Claude/.vault-tools/plan-kopie'}.`}`, + abdeckung: (a) => `Linse: Spec-Abdeckung in beide Richtungen. Jede Anforderung${a.spec ? ` aus ${a.spec}` : ' der Übersicht'} braucht einen Task und einen Test, und jeder Task braucht eine Anforderung. Die "Fertig heißt"-Liste der Übersicht muss je Check einen Beleg außerhalb der eigenen Meinung haben. Das Frontmatter jeder Taskdatei braucht modell und modell_begruendung; Abschrift-Tasks mit Anführungszeichen, Gedanken- und Streckenstrichen oder Auslassungspunkten im Code tragen modell sonnet und effort low statt haiku.`, +} + +const BEFUNDE = { + type: 'object', + properties: { + befunde: { + type: 'array', + items: { + type: 'object', + properties: { + schweregrad: { type: 'string', enum: ['blockierend', 'wichtig', 'klein'] }, + datei: { type: 'string' }, + zeile: { type: 'integer' }, + beschreibung: { type: 'string' }, + reproduktion: { type: 'string' }, + }, + required: ['schweregrad', 'datei', 'zeile', 'beschreibung', 'reproduktion'], + }, + }, + selbst_ausgefuehrt: { type: 'array', items: { type: 'string' } }, + nicht_pruefbar: { type: 'array', items: { type: 'string' } }, + }, + required: ['befunde', 'selbst_ausgefuehrt', 'nicht_pruefbar'], +} + +const LAUF = { + type: 'object', + properties: { + laeufe: { type: 'array', items: { type: 'object', properties: { befehl: { type: 'string' }, exit: { type: 'integer' }, ausgabe: { type: 'string' } }, required: ['befehl', 'exit', 'ausgabe'] } }, + }, + required: ['laeufe'], +} + +const URTEIL = { + type: 'object', + properties: { + urteil: { type: 'string', enum: ['bestaetigt', 'widerlegt', 'offen'] }, + schweregrad: { type: 'string', enum: ['blockierend', 'wichtig', 'klein'], description: 'dein eigenes Urteil über die Schwere' }, + bedingung: { type: 'string', description: 'unter welcher Bedingung der Befund gilt; leer, wenn immer' }, + begruendung: { type: 'string' }, + beleg: { type: 'string', description: 'eigener Befehl mit Ausgabe oder wörtliches Zitat mit Fundstelle' }, + }, + required: ['urteil', 'schweregrad', 'bedingung', 'begruendung', 'beleg'], +} + +function pruefeArgs(a) { + if (!a || typeof a !== 'object') throw new Error('plankritik: args fehlen') + const fehlt = ['projekt', 'planordner'].filter(k => !a[k]) + if (fehlt.length) throw new Error('plankritik: args fehlen: ' + fehlt.join(', ')) + if (a.linsen !== undefined && !Array.isArray(a.linsen)) throw new Error('plankritik: linsen muss eine Liste sein, bekannt: ' + Object.keys(LINSEN).join(', ')) + if (a.materialisieren && a.materialisieren.tasks !== undefined && !Array.isArray(a.materialisieren.tasks)) throw new Error('plankritik: materialisieren.tasks muss eine Liste von Taskdateien sein') + const unbekannt = (a.linsen || []).filter(l => !LINSEN[l]) + if (unbekannt.length) throw new Error('plankritik: unbekannte Linse: ' + unbekannt.join(', ') + '; bekannt: ' + Object.keys(LINSEN).join(', ')) + if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('plankritik: effort "' + a.effort + '" ungültig') + if (a.verifizieren_ab !== undefined && !['wichtig', 'blockierend'].includes(a.verifizieren_ab)) throw new Error('plankritik: verifizieren_ab muss wichtig oder blockierend sein') + if (a.plaetze !== undefined && !(Number.isInteger(a.plaetze) && a.plaetze >= 1 && a.plaetze <= LINSEN_HOECHSTENS)) throw new Error('plankritik: plaetze muss eine ganze Zahl von 1 bis ' + LINSEN_HOECHSTENS + ' sein') + if (a.materialisieren && !(Array.isArray(a.materialisieren.ohne) && a.materialisieren.ohne.length > 0 && a.materialisieren.ohne.every(x => typeof x === 'string' && x))) throw new Error('plankritik: materialisieren.ohne muss eine nicht leere Liste sein, Ausschlüsse als Pfade vom Projektwurzelordner, mindestens .git (falls vorhanden) und Geheimnisdateien') +} + +// Höchstens n Aufgaben gleichzeitig, ohne Leerlauf zwischen Paaren. +async function hoechstens(n, items, fn) { + const ergebnisse = new Array(items.length).fill(null) + let naechster = 0 + async function arbeiter() { + while (naechster < items.length) { + const i = naechster++ + try { ergebnisse[i] = await fn(items[i], i) } catch (e) { log(`Aufgabe ${i + 1} abgebrochen: ${e && e.message}`) } + } + } + await Promise.all(Array.from({ length: Math.min(n, items.length) }, () => arbeiter())) + return ergebnisse +} + +// Eine Hilfsfunktion ablesen() steht auch in planung.js (gleiche Form) sowie in pruefung.js und +// welle-mit-pruefung.js (dort nur messend, ohne Rückgabe). Die Regel (Fenster, Stufen, Rückfall) +// steht nur in verbrauch.js; hier steht nur der Rückfall für den Fall, dass verbrauch.js selbst +// nicht läuft, etwa weil dieser Workflow als Kind läuft. +const ablesungen = [] +async function ablesen(anlass, wunsch) { + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'plankritik:' + anlass, wunsch }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + if (!r || typeof r.plaetze !== 'number') { + r = { status: 'nicht_gelesen', quelle: 'keine', fuenf_stunden: null, woche: null, plaetze: Math.min(wunsch, OHNE_ABLESUNG), gebremst: false, grund: (r && r.grund) || 'verbrauch.js ohne verwertbares Ergebnis' } + } + ablesungen.push({ anlass, ...r }) + log(`Verbrauch ${anlass}: ${r.status}, ${r.plaetze} Plätze${r.gebremst ? ', gebremst' : ''} (${r.grund})`) + return r +} + +function werkzeugAgent(label, phase, befehl, zweck) { + return agent(`Du führst einen Werkzeugbefehl aus und gibst seine Ausgabe roh zurück. Zweck: ${zweck} + +Führe genau diesen Befehl einmal im Bash-Werkzeug (Git-Bash) aus, unverändert: +${befehl} + +Ändere keine Datei außer denen, die der Befehl selbst anlegt. Deute nichts und fasse nichts zusammen. Gib Befehl, Exit-Code und die vollständige Ausgabe zurück; bei mehr als 200 Zeilen die ersten 60 und die letzten 120, mit einer Zeile "[... N Zeilen gekürzt ...]" dazwischen.`, + { label, phase, schema: LAUF, model: 'sonnet', effort: 'low' }) +} + +function testort(ctx) { + return ctx.kopie + ? `Tests fährst du nur in der Kopie ${ctx.kopie}, nie im echten Projektbaum: Dort kann Betrieb laufen, und andere Prüfer arbeiten gleichzeitig. Mutanten legst du nur in einer eigenen Kopie dieser Kopie an, in einem neuen Ordner unter dem System-Temp, nie in ${ctx.kopie} selbst.` + : 'Tests fährst du in dieser Plankritik nicht, auch nicht im echten Projektbaum: Dort kann Betrieb laufen, und andere Prüfer arbeiten gleichzeitig.' +} + +function auftrag(a, linse, ctx) { + return `Lies den Plan im Ordner ${a.planordner} für das Projekt ${a.projekt}, bevor ein Executer startet. Du änderst keine Datei im Projekt und keine im Plan. Eine Umsetzung gibt es noch nicht, also auch keinen Executer-Bericht, dessen Testlauf du wiederholen könntest. ${testort(ctx)} + +${LINSEN[linse](a, ctx)} + +Melde jeden Befund mit Schweregrad, auch unsichere; ein zweiter Prüfer versucht danach jeden wichtigen Befund zu widerlegen. Nenn je Befund Datei, Zeile und wie er sich reproduzieren lässt. +${a.zusatz ? a.zusatz + '\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.` +} + +function pfadnorm(p) { return String(p).replace(/\\/g, '/').replace(/\/+/g, '/').toLowerCase() } +function dateiname(p) { return pfadnorm(p).split('/').pop() } + +// Doppelte Befunde verschiedener Linsen zusammenfassen: gleicher Pfad und gleiche Zeile. +// Zeile 0 oder kleiner heißt "ganze Datei" und wird nie zusammengefasst, weil darunter +// verschiedene Befunde derselben Datei liegen. +function zusammenfassen(befunde) { + const gruppen = new Map() + befunde.forEach((b, i) => { + const k = b.zeile > 0 ? pfadnorm(b.datei) + ':' + b.zeile : 'einzeln:' + i + if (!gruppen.has(k)) gruppen.set(k, { ...b, linsen: [b.linse], quellen: [b] }) + else { + const g = gruppen.get(k) + g.quellen.push(b) + if (!g.linsen.includes(b.linse)) g.linsen.push(b.linse) + if (RANG[b.schweregrad] < RANG[g.schweregrad]) g.schweregrad = b.schweregrad + } + }) + return [...gruppen.values()] +} + +function widerlegungsauftrag(a, g, ctx) { + const texte = g.quellen.map((b, i) => `Fassung ${i + 1} (Linse ${b.linse}, ${b.schweregrad}, ${b.datei}:${b.zeile}): ${b.beschreibung}\nReproduktion laut Linse: ${b.reproduktion}`).join('\n\n') + return `Eine Plankritik hat zum Plan im Ordner ${a.planordner} (Projekt ${a.projekt}) diesen Befund gemeldet, Fundstelle ${g.datei}:${g.zeile}: + + +${texte} + + +Versuche ihn zu widerlegen. Lies die Stelle selbst und reproduziere den Befund mit einem eigenen Befehl. Du änderst keine Datei im Projekt und keine im Plan. ${testort(ctx)} +- "bestaetigt" nur mit eigener Reproduktion oder einem wörtlichen Beleg aus der Quelle. +- "widerlegt" nur mit einem Gegenbeleg: ein Befehl mit Ausgabe oder ein Zitat, das die Aussage falsch macht. +- Sonst "offen". +Gilt der Befund nur unter einer Bedingung, nenn sie. Gib deine eigene Einschätzung der Schwere an. +Übertrage dein Urteil in die Felder des StructuredOutput-Werkzeugs.` +} + +pruefeArgs(args) +const W = args.werkzeuge || 'D:/Claude/.vault-tools/plan-kopie' +const PY = 'PYTHONIOENCODING=utf-8 python -X utf8' +const ctx = { kopie: args.kopie || null, werkzeuge: null, temp: null } +const vorbereitung = [] + +// Eine Kopie gilt nur als verwendbar, wenn der Lauf sauber endet, den Pfad bis zum +// Zeilenende nennt (Pfade mit Leerzeichen) und das Planwerkzeug mindestens einen Block +// angewendet hat. Sonst rechnet Linse 1 am Text, und das steht im Log. +function kopieAusLauf(r, wozu) { + const lauf = r && r.laeufe && r.laeufe[0] + if (!lauf) { log(`${wozu}: Hilfsagent ohne Ergebnis, keine Kopie`); return null } + const m = /^KOPIE=(.+?)\s*$/m.exec(lauf.ausgabe) + const angewendet = /'(neu|ersetzt|angehaengt)': [1-9]/.test(lauf.ausgabe) + if (lauf.exit !== 0 || !m) { log(`${wozu}: Kopie nicht verwendbar (Exit ${lauf.exit}${m ? '' : ', kein KOPIE-Pfad'})`); return null } + ctx.temp = m[1].replace(/\/mat$/, '') + if (!angewendet) { log(`${wozu}: das Planwerkzeug hat keinen Block angewendet; die Kopie ist der unveränderte Stand und wird nicht verwendet`); return null } + return m[1] +} + +function tempText() { + return ctx.temp ? `${ctx.temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn nicht mehr gebraucht` : null +} + +phase('Vorbereitung') +if (ctx.kopie && args.materialisieren) log('kopie ist gesetzt, materialisieren wird übergangen') +if (!ctx.kopie && args.materialisieren) { + const liste = (args.materialisieren.tasks || []).map(q).join(' ') + const auswahl = liste + ? `TASKS=(${liste})` + : `shopt -s nullglob; pushd ${q(args.planordner)} >/dev/null && TASKS=(task-*.md) && popd >/dev/null; [ \${#TASKS[@]} -gt 0 ] || { echo "KEINE_TASKS: im Planordner liegt keine task-*.md; materialisieren.tasks angeben"; exit 3; }` + const befehl = `${auswahl}; T=$(mktemp -d) && echo "KOPIE=$(cygpath -m "$T/mat")" && ${PY} ${q(W + '/kopie_bauen.py')} ${q(args.projekt)} "$T/mat" --ohne ${args.materialisieren.ohne.map(q).join(' ')} && ${PY} ${q(W + '/plan_materialisiere.py')} ${q(args.planordner)} "$T/mat" "\${TASKS[@]}"` + const r = await werkzeugAgent('werkzeug:kopie', 'Vorbereitung', befehl, 'Kopie des Projekts bauen und den Plan darauf anwenden') + ctx.kopie = kopieAusLauf(r, 'Plankritik') + vorbereitung.push(r) +} +{ + const befehl = `${PY} ${q(W + '/plan_zeichen.py')} ${q(args.planordner)}; echo "EXIT plan_zeichen: $?"; ls ${q(args.planordner)}/task-*.md >/dev/null 2>&1 && { ${PY} ${q(W + '/plan_bloecke.py')} vor ${q(args.planordner)}/task-*.md --projekt ${q(args.projekt)}; echo "EXIT plan_bloecke: $?"; } || echo "plan_bloecke: keine task-*.md im Planordner, nicht gefahren"` + const r = await werkzeugAgent('werkzeug:planwerkzeuge', 'Vorbereitung', befehl, 'Codeblöcke auf unsichtbare Zeichen und Aus-Blöcke gegen den Baum prüfen') + if (r && r.laeufe && r.laeufe[0]) ctx.werkzeuge = r.laeufe[0].ausgabe + else log('Planwerkzeuge: Hilfsagent ohne Ergebnis; Linse bezeichner fährt sie selbst') + vorbereitung.push(r) +} + +phase('Plankritik') +const gewaehlt = args.linsen && args.linsen.length ? args.linsen : Object.keys(LINSEN) +const vorLinsen = await ablesen('vor Linsen', Math.min(args.plaetze || gewaehlt.length, gewaehlt.length, LINSEN_HOECHSTENS)) +if (vorLinsen.gebremst) { + log('Plankritik gebremst vor den Linsen: ' + vorLinsen.grund) + return { + status: 'gebremst', + gebremst_vor: 'Linsen', + zaehlung: { roh: 0, zusammengefasst: 0, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: 0 }, + kopie: ctx.kopie, + temp_ordner: tempText(), + befunde: [], + selbst_ausgefuehrt: [], + nicht_pruefbar: [], + vorbereitung, + ohne_ergebnis: gewaehlt, + verbrauch: ablesungen, + plaetze: { linsen: 0, widerlegung: 0 }, + naechster_schritt: 'Nach der Zurücksetzung des Fensters neu starten, nicht mit resumeFromRunId fortsetzen: Die Ablesung käme sonst aus dem Zwischenspeicher.', + } +} +const kritiken = await hoechstens(vorLinsen.plaetze, gewaehlt, linse => { + const opts = { label: 'plankritik:' + linse, phase: 'Plankritik', schema: BEFUNDE, agentType: 'pruefer', model: 'opus' } + if (args.effort) opts.effort = args.effort + return agent(auftrag(args, linse, ctx), opts).then(r => ({ linse, ergebnis: r })) +}) +const ergebnisse = kritiken.filter(k => k && k.ergebnis) +const ohneErgebnis = gewaehlt.filter(l => !ergebnisse.some(e => e.linse === l)) +if (ohneErgebnis.length) log('Linsen ohne Ergebnis: ' + ohneErgebnis.join(', ')) + +const roh = ergebnisse.flatMap(e => e.ergebnis.befunde.map(b => ({ linse: e.linse, ...b }))) +const gruppen = zusammenfassen(roh) +const ab = RANG[args.verifizieren_ab || 'wichtig'] +const zuPruefen = args.verifizieren === false ? [] : gruppen.filter(g => RANG[g.schweregrad] <= ab) +log(`${roh.length} Befunde, zusammengefasst ${gruppen.length}; ${zuPruefen.length} gehen in die Widerlegung, ${gruppen.length - zuPruefen.length} nicht (${args.verifizieren === false ? 'abgeschaltet' : 'unter der Schwelle ' + (args.verifizieren_ab || 'wichtig')})`) + +phase('Widerlegung') +let gebremstVor = null +let plaetzeWiderlegung = 0 +if (zuPruefen.length) { + const vorWiderlegung = await ablesen('vor Widerlegung', Math.min(WIDERLEGUNG_HOECHSTENS, zuPruefen.length)) + if (vorWiderlegung.gebremst) { + gebremstVor = 'Widerlegung' + log('Widerlegung gebremst, die Befunde bleiben ungeprüft: ' + vorWiderlegung.grund) + } else plaetzeWiderlegung = vorWiderlegung.plaetze +} +if (!gebremstVor) { + const urteile = await hoechstens(plaetzeWiderlegung, zuPruefen, (g, i) => { + const opts = { label: `widerlegung:${i + 1}:${dateiname(g.datei)}:${g.zeile}`, phase: 'Widerlegung', schema: URTEIL, agentType: 'pruefer', model: 'opus' } + if (args.effort) opts.effort = args.effort + return agent(widerlegungsauftrag(args, g, ctx), opts) + }) + zuPruefen.forEach((g, i) => { + if (!urteile[i]) log(`Widerlegung ${i + 1} (${g.datei}:${g.zeile}) ohne Ergebnis; Befund bleibt offen`) + g.widerlegung = urteile[i] || { urteil: 'offen', schweregrad: g.schweregrad, bedingung: '', begruendung: 'Widerlegungsagent ohne Ergebnis', beleg: '' } + }) +} +await ablesen('Ende', 0) + +const URTEILRANG = { bestaetigt: 0, offen: 1, undefined: 2, widerlegt: 3 } +const befunde = gruppen + .map(g => ({ urteil: g.widerlegung ? g.widerlegung.urteil : gebremstVor && zuPruefen.includes(g) ? 'nicht geprüft (gebremst)' : 'nicht geprüft', ...g })) + .sort((x, y) => (URTEILRANG[x.widerlegung && x.widerlegung.urteil] - URTEILRANG[y.widerlegung && y.widerlegung.urteil]) || (RANG[x.schweregrad] - RANG[y.schweregrad])) +const zaehlung = { roh: roh.length, zusammengefasst: gruppen.length, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: gruppen.filter(g => !g.widerlegung).length } +befunde.forEach(b => { if (b.widerlegung) zaehlung[b.widerlegung.urteil]++ }) + +return { + status: gebremstVor ? 'gebremst' : 'fertig', + gebremst_vor: gebremstVor, + zaehlung, + kopie: ctx.kopie, + temp_ordner: tempText(), + befunde, + selbst_ausgefuehrt: ergebnisse.map(e => ({ linse: e.linse, befehle: e.ergebnis.selbst_ausgefuehrt })), + nicht_pruefbar: ergebnisse.flatMap(e => e.ergebnis.nicht_pruefbar.map(x => e.linse + ': ' + x)), + vorbereitung, + ohne_ergebnis: ohneErgebnis, + verbrauch: ablesungen, + plaetze: { linsen: vorLinsen.plaetze, widerlegung: plaetzeWiderlegung }, + naechster_schritt: gebremstVor ? 'Die Widerlegung nach der Zurücksetzung des Fensters nachholen: plankritik neu starten, nicht mit resumeFromRunId fortsetzen.' : 'Bestätigte und offene Befunde am Plan korrigieren, danach die Kopie neu bauen.', +} +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/plankritik.js +``` + +Erwartet: `PARSE OK`, Exit 0. + +Danach `git status --porcelain -- .claude/workflows/plankritik.js` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-04-pruefung.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-04-pruefung.md new file mode 100644 index 0000000..ee56555 --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-04-pruefung.md @@ -0,0 +1,139 @@ +--- +type: plan-task +title: "Task 04 — pruefung.js: Verbrauch vor und nach dem pruefer mitschreiben" +status: draft +summary: "Die Prüfung liest vor und nach dem pruefer den Planverbrauch ab, nur zum Messen; verbrauch false schaltet das ab, wenn der Elternworkflow selbst liest." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: haiku +modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·." +--- + +# Task 04 — pruefung.js: Verbrauch vor und nach dem pruefer mitschreiben + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. + +Was danach anders ist: + +- **Zwei Ablesungen um den pruefer**, `wunsch: 0`, also Messung ohne Bremse. Das Ergebnis trägt `verbrauch`. +- **Als Kind** kann `pruefung` keinen weiteren Workflow aufrufen; ohne `verbrauch: false` stehen die Ablesungen dann als `nicht_gelesen` da, die Prüfung läuft trotzdem. + +## Zieldateien + +- `.claude/workflows/pruefung.js` (geändert) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.claude/workflows/pruefung.js` + +Die ganze Datei ist neu (sie ersetzt die bisherige Fassung vollständig): + +```javascript +export const meta = { + name: 'pruefung', + description: 'Lässt eine fertige Welle vom pruefer mit dem vollen Pflichtprogramm gegen Plan und Zielumgebung prüfen; kritische Änderungen mit xhigh; schreibt den Planverbrauch vor und nach der Prüfung mit', + whenToUse: 'Schritt 4 der Standardschleife nach einer Welle, zusätzlich zur eigenen Gegenprüfung des Koordinators (AI/Regelwerk/codeworkflow.md). Args: {projekt, plan, tasks?, suite?, version?, kritisch?, abschluss?, schwerpunkt?, effort?, verbrauch?}. kritisch für sicherheits- oder datenrelevante Änderungen; abschluss fragt, ob eine weitere Runde noch Substanz hat; verbrauch: false schaltet die Ablesung ab, welle-mit-pruefung liest dann selbst.', + phases: [{ title: 'Prüfung', detail: 'ein pruefer' }], +} + +// Rollenparameter: model wird immer gesetzt (Stamm). Der Effort kommt aus dem Frontmatter +// von .claude/agents/pruefer.md (high). kritisch hebt auf xhigh: In der Messung vom +// 2026-09-23 fuhr nur xhigh einen großen eigenen Datenlauf (codeworkflow.md, "Effort je Rolle"). +// Verbrauch: Vor und nach dem pruefer liest verbrauch.js den Planverbrauch ab, nur zum Messen +// (wunsch 0); gebremst wird hier nicht. Als Kind von welle-mit-pruefung kann dieser Workflow +// keinen weiteren aufrufen; dort liest der Elternworkflow und gibt verbrauch: false mit. +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' + +const BERICHT = { + type: 'object', + properties: { + selbst_ausgefuehrt: { type: 'array', items: { type: 'string' } }, + eigener_lauf: { type: 'array', items: { type: 'string' } }, + befunde: { + type: 'array', + items: { + type: 'object', + properties: { + schweregrad: { type: 'string', enum: ['blockierend', 'wichtig', 'klein'] }, + datei: { type: 'string' }, + zeile: { type: 'integer' }, + beschreibung: { type: 'string' }, + reproduktion: { type: 'string' }, + }, + required: ['schweregrad', 'datei', 'zeile', 'beschreibung', 'reproduktion'], + }, + }, + plan_vs_umsetzung: { type: 'array', items: { type: 'string' } }, + geprueft_und_in_ordnung: { type: 'array', items: { type: 'string' } }, + nicht_pruefbar: { type: 'array', items: { type: 'string' } }, + weitere_runde: { type: 'string', description: 'nur bei Abschlussprüfung: hat eine weitere Runde noch Substanz, und warum' }, + }, + required: ['selbst_ausgefuehrt', 'eigener_lauf', 'befunde', 'plan_vs_umsetzung', 'geprueft_und_in_ordnung', 'nicht_pruefbar'], +} + +function pruefeArgs(a) { + const fehlt = ['projekt', 'plan'].filter(k => !a || !a[k]) + if (fehlt.length) throw new Error('pruefung: args fehlen: ' + fehlt.join(', ')) + if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('pruefung: effort "' + a.effort + '" ungültig') + if (a.tasks !== undefined && !Array.isArray(a.tasks)) throw new Error('pruefung: tasks muss eine Liste sein') + if (a.verbrauch !== undefined && typeof a.verbrauch !== 'boolean') throw new Error('pruefung: verbrauch muss true oder false sein') +} + +function auftrag(a) { + const umfang = a.tasks && a.tasks.length ? `Geprüft wird die Umsetzung dieser Tasks: ${a.tasks.join(', ')}.` : 'Geprüft wird die gesamte Umsetzung des Plans.' + return `Prüfe die Umsetzung im Projekt ${a.projekt} gegen den Plan ${a.plan}. ${umfang} +Der Executer-Bericht ist eine Behauptung. Fahre das Pflichtprogramm deiner Agentendefinition: Tests selbst ausführen${a.suite ? ` (${a.suite})` : ''}, die zentralen Artefakte selbst lesen, einen eigenen Lauf mit realistischen Daten und eigenem Prüfcode. Eigenen Prüfcode führst du als python -c oder in einem Wegwerfordner unter dem System-Temp aus; im Projekt änderst du nichts. +${a.version ? `Aussagen über fremde Software belegst du am tatsächlich eingesetzten Versionsstand: ${a.version}.\n` : ''}${a.schwerpunkt ? `Schwerpunkt: ${a.schwerpunkt}\n` : ''}Melde jeden Befund mit Schweregrad, auch unsichere; der Koordinator filtert. +${a.abschluss ? 'Das ist die Abschlussprüfung dieser Runde. Sag im Feld weitere_runde, ob eine weitere Runde noch Substanz hat oder nur noch Text umschichtet, und warum.\n' : ''}Übertrage deinen Schlussbericht in die Felder des StructuredOutput-Werkzeugs.` +} + +// Eine messende Hilfsfunktion ablesen() steht auch in welle-mit-pruefung.js; plankritik.js und +// planung.js haben eine mit Wunsch und Rückgabe. Die Regel (Fenster, Stufen, Rückfall) steht nur +// in verbrauch.js. +const ablesungen = [] +async function ablesen(anlass) { + if (args.verbrauch === false) return + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'pruefung:' + anlass, wunsch: 0 }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + ablesungen.push({ anlass, ...(r || { status: 'nicht_gelesen', grund: 'verbrauch.js ohne Ergebnis' }) }) +} + +pruefeArgs(args) +phase('Prüfung') +const opts = { label: 'pruefung' + (args.kritisch ? ':kritisch' : ''), phase: 'Prüfung', schema: BERICHT, agentType: 'pruefer', model: 'opus' } +if (args.effort) opts.effort = args.effort +else if (args.kritisch) opts.effort = 'xhigh' +await ablesen('vor Prüfung') +const bericht = await agent(auftrag(args), opts) +await ablesen('nach Prüfung') +if (!bericht) throw new Error('pruefung: der pruefer hat keinen Bericht geliefert') + +const RANG = { blockierend: 0, wichtig: 1, klein: 2 } +const zaehlung = { blockierend: 0, wichtig: 0, klein: 0 } +bericht.befunde.forEach(b => { zaehlung[b.schweregrad]++ }) +return { effort: opts.effort || '(Frontmatter)', zaehlung, ...bericht, befunde: [...bericht.befunde].sort((x, y) => RANG[x.schweregrad] - RANG[y.schweregrad]), verbrauch: ablesungen } +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/pruefung.js +``` + +Erwartet: `PARSE OK`, Exit 0. + +Danach `git status --porcelain -- .claude/workflows/pruefung.js` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-05-welle-mit-pruefung.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-05-welle-mit-pruefung.md new file mode 100644 index 0000000..22a6f50 --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-05-welle-mit-pruefung.md @@ -0,0 +1,246 @@ +--- +type: plan-task +title: "Task 05 — welle-mit-pruefung.js: Rückkehrpunkt nach dem Vergleich und Ablesung um die Prüfung" +status: draft +summary: "Mit bis vergleich endet der Workflow vor der Prüfung und gibt pruefung_args zurück, damit der Koordinator seine Gegenprüfung parallel zur Prüfung fahren kann; sonst liest er vor und nach der Prüfung ab." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: haiku +modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·." +--- + +# Task 05 — welle-mit-pruefung.js: Rückkehrpunkt nach dem Vergleich und Ablesung um die Prüfung + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. + +Was danach anders ist: + +- **`bis: "vergleich"`** gibt `pruefung_args` mit den Rohbelegen im `schwerpunkt` zurück. Der Koordinator startet `pruefung` damit selbst und fährt währenddessen seine Gegenprüfung, nur lesend. +- **Ohne `bis`** liest der Workflow vor und nach dem Kind `pruefung` ab und gibt ihm `verbrauch: false` mit. + +## Zieldateien + +- `.claude/workflows/welle-mit-pruefung.js` (geändert) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.claude/workflows/welle-mit-pruefung.js` + +Die ganze Datei ist neu (sie ersetzt die bisherige Fassung vollständig): + +```javascript +export const meta = { + name: 'welle-mit-pruefung', + description: 'Schritt 3 und 4 in einem Aufruf: Kopie mit angewendetem Plan vor der Welle, executer-welle, Byte-Vergleich und Suite, danach pruefung mit den Rohbelegen; mit bis "vergleich" endet er vor der Prüfung', + whenToUse: 'Nach der Plankritik, sobald eine Welle mit disjunkten Zieldateien steht (AI/Regelwerk/codeworkflow.md, Schritte 3 und 4). Args: {projekt, planordner, tasks: [{datei, modell, effort?}], kopie? oder ohne: [relpfade, nicht leer], prosa?: [relpfade], suite?, kritisch?, abschluss?, effort?, version?, schwerpunkt?, rot_nachweis_erbracht?, zusatz?, werkzeuge?, bis?: "vergleich"}. Ohne kopie wird sie vor der Welle gebaut; ohne nennt dann .git und Geheimnisdateien. Mit bis "vergleich" kommt pruefung_args zurück: pruefung damit selbst starten und währenddessen die eigene Gegenprüfung fahren. Ersetzt die eigene Gegenprüfung des Koordinators nicht.', + phases: [ + { title: 'Kopie', detail: 'Hilfsagent: Stand vor der Welle kopieren und den Plan der Welle anwenden' }, + { title: 'Welle', detail: 'executer-welle' }, + { title: 'Vergleich', detail: 'Hilfsagent: welle_vergleich.py und Suite im echten Baum' }, + { title: 'Prüfung', detail: 'pruefung mit den Rohbelegen' }, + ], +} + +// Ruft die gespeicherten Workflows executer-welle und pruefung verschachtelt auf, damit ihre +// Aufträge und Rollenparameter an genau einer Stelle stehen. Der Hilfsagent hat keinen +// agentType und bekommt seinen Effort deshalb mit (low). Opus läuft nur in den Kindern, +// und die laufen nacheinander; die Obergrenze von zwei hält executer-welle selbst. +// Verbrauch: Vor und nach der Prüfung liest dieser Workflow den Planverbrauch über verbrauch.js +// ab (nur messen); das Kind pruefung bekommt verbrauch: false, weil ein Kind keinen weiteren +// Workflow aufrufen kann. +const MODELLE = ['haiku', 'sonnet', 'opus'] +const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] +const VERBRAUCH = 'D:/Claude/.claude/workflows/verbrauch.js' +// Einfache Anführungszeichen: In Bash bleiben darin $, Backtick und Backslash wörtlich. +const q = s => "'" + String(s).replace(/'/g, "'\\''") + "'" +const PY = 'PYTHONIOENCODING=utf-8 python -X utf8' + +const LAUF = { + type: 'object', + properties: { + laeufe: { type: 'array', items: { type: 'object', properties: { befehl: { type: 'string' }, exit: { type: 'integer' }, ausgabe: { type: 'string' } }, required: ['befehl', 'exit', 'ausgabe'] } }, + }, + required: ['laeufe'], +} + +// Dieselben Regeln wie in executer-welle, aber VOR dem ersten Agenten: Ein ungültiger Task +// soll keine Projektkopie im Temp hinterlassen. +function pruefeArgs(a) { + if (!a || typeof a !== 'object') throw new Error('welle-mit-pruefung: args fehlen') + const fehlt = ['projekt', 'planordner', 'tasks'].filter(k => !a[k]) + if (fehlt.length) throw new Error('welle-mit-pruefung: args fehlen: ' + fehlt.join(', ')) + if (!Array.isArray(a.tasks) || a.tasks.length === 0) throw new Error('welle-mit-pruefung: tasks muss eine nicht leere Liste von {datei, modell, effort?} sein') + a.tasks.forEach((t, i) => { + if (!t || typeof t !== 'object' || !t.datei || typeof t.datei !== 'string') throw new Error(`welle-mit-pruefung: tasks[${i}] ohne datei`) + if (!MODELLE.includes(t.modell)) throw new Error(`welle-mit-pruefung: ${t.datei}: modell "${t.modell}" nicht in ${MODELLE.join('/')}`) + if (t.effort !== undefined && !EFFORTS.includes(t.effort)) throw new Error(`welle-mit-pruefung: ${t.datei}: effort "${t.effort}" nicht in ${EFFORTS.join('/')}`) + }) + if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('welle-mit-pruefung: effort "' + a.effort + '" ungültig') + if (a.prosa !== undefined && !Array.isArray(a.prosa)) throw new Error('welle-mit-pruefung: prosa muss eine Liste sein') + if (a.bis !== undefined && a.bis !== 'vergleich') throw new Error('welle-mit-pruefung: bis kennt nur "vergleich"') + if (!a.kopie) { + const ok = Array.isArray(a.ohne) && a.ohne.length > 0 && a.ohne.every(x => typeof x === 'string' && x) + if (!ok) throw new Error('welle-mit-pruefung: kopie oder ohne angeben. ohne ist eine nicht leere Liste von Ausschlüssen für kopie_bauen.py, als Pfade vom Projektwurzelordner, mindestens .git (falls vorhanden) und Geheimnisdateien') + } +} + +function werkzeugAgent(label, phase, befehl, zweck) { + return agent(`Du führst einen Werkzeugbefehl aus und gibst seine Ausgabe roh zurück. Zweck: ${zweck} + +Führe genau diesen Befehl einmal im Bash-Werkzeug (Git-Bash) aus, unverändert: +${befehl} + +Ändere keine Datei außer denen, die der Befehl selbst anlegt. Deute nichts und fasse nichts zusammen. Gib Befehl, Exit-Code und die vollständige Ausgabe zurück; bei mehr als 200 Zeilen die ersten 60 und die letzten 120, mit einer Zeile "[... N Zeilen gekürzt ...]" dazwischen.`, + { label, phase, schema: LAUF, model: 'sonnet', effort: 'low' }) +} + +// Anfang und Ende behalten: Das Ergebnis der Suite steht am Schluss. +function kopfUndEnde(text, kopf, ende) { + if (!text || text.length <= kopf + ende) return text || '' + return text.slice(0, kopf) + '\n[... gekürzt ...]\n' + text.slice(-ende) +} + +// Eine messende Hilfsfunktion ablesen() steht auch in pruefung.js; plankritik.js und planung.js +// haben eine mit Wunsch und Rückgabe. Die Regel (Fenster, Stufen, Rückfall) steht nur in +// verbrauch.js. +const ablesungen = [] +async function ablesen(anlass) { + let r = null + try { + r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 }) + } catch (e) { + r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) } + } + ablesungen.push({ anlass, ...(r || { status: 'nicht_gelesen', grund: 'verbrauch.js ohne Ergebnis' }) }) +} + +pruefeArgs(args) +const W = args.werkzeuge || 'D:/Claude/.vault-tools/plan-kopie' +const taskdateien = args.tasks.map(t => t.datei) +let kopie = args.kopie || null +let temp = null +const belege = {} +const fehler = [] + +phase('Kopie') +if (!kopie) { + const befehl = `T=$(mktemp -d) && echo "KOPIE=$(cygpath -m "$T/mat")" && ${PY} ${q(W + '/kopie_bauen.py')} ${q(args.projekt)} "$T/mat" --ohne ${args.ohne.map(q).join(' ')} && ${PY} ${q(W + '/plan_materialisiere.py')} ${q(args.planordner)} "$T/mat" ${taskdateien.map(q).join(' ')}` + const r = await werkzeugAgent('werkzeug:kopie', 'Kopie', befehl, 'Stand vor der Welle kopieren und die Taskdateien der Welle darauf anwenden') + belege.kopie = r + const lauf = r && r.laeufe && r.laeufe[0] + const m = lauf && /^KOPIE=(.+?)\s*$/m.exec(lauf.ausgabe) + if (m) temp = m[1].replace(/\/mat$/, '') + const angewendet = lauf && /'(neu|ersetzt|angehaengt)': [1-9]/.test(lauf.ausgabe) + if (lauf && lauf.exit === 0 && m && angewendet) kopie = m[1] + else log('Kopie nicht verwendbar (' + (!lauf ? 'Hilfsagent ohne Ergebnis' : lauf.exit !== 0 ? 'Exit ' + lauf.exit : !m ? 'kein KOPIE-Pfad' : 'kein Block angewendet') + '); der Byte-Vergleich entfällt, die Welle läuft trotzdem') +} + +phase('Welle') +let welle = null +try { + welle = await workflow('executer-welle', { + projekt: args.projekt, planordner: args.planordner, tasks: args.tasks, + rot_nachweis_erbracht: args.rot_nachweis_erbracht, zusatz: args.zusatz, + }) +} catch (e) { + fehler.push('executer-welle: ' + (e && e.message)) + log('executer-welle gescheitert: ' + (e && e.message)) +} + +phase('Vergleich') +if (welle) { + const teile = [] + if (kopie) teile.push(`${PY} ${q(W + '/welle_vergleich.py')} --projekt ${q(args.projekt)} --mat ${q(kopie)}${args.prosa && args.prosa.length ? ' --prosa ' + args.prosa.map(q).join(' ') : ''} -- ${taskdateien.map(d => q(args.planordner + '/' + d)).join(' ')}; echo "EXIT welle_vergleich: $?"`) + if (args.suite) teile.push(`if cd ${q(args.projekt)}; then export COLUMNS=300; ${args.suite}; echo "EXIT suite: $?"; else echo "EXIT suite: cd ins Projekt gescheitert"; fi`) + if (teile.length) { + belege.vergleich = await werkzeugAgent('werkzeug:vergleich', 'Vergleich', teile.join('; '), 'Nach der Welle: geänderte Dateien und Bytes gegen die Kopie, dann die Suite im echten Baum') + if (!belege.vergleich) log('Vergleich: Hilfsagent ohne Ergebnis') + } else log('Vergleich entfällt: weder Kopie noch suite') +} else log('Vergleich entfällt: keine Welle') + +phase('Prüfung') +let pruefung = null +let pruefungGrund = null +if (!welle) pruefungGrund = 'keine Welle' +else if (welle.ohne_bericht && welle.ohne_bericht.length) pruefungGrund = 'Welle unvollständig, ohne Bericht: ' + welle.ohne_bericht.join(', ') +let pruefungArgs = null +if (!pruefungGrund) { + const vl = belege.vergleich && belege.vergleich.laeufe && belege.vergleich.laeufe[0] + const vergleichstext = vl ? kopfUndEnde(vl.ausgabe, 1500, 2500) : '' + const schwerpunkt = [ + args.schwerpunkt || '', + welle.mit_abweichung.length ? `Diese Tasks melden Abweichungen vom Plan: ${welle.mit_abweichung.join(', ')}.` : '', + vergleichstext ? `Rohausgabe von Byte-Vergleich und Suite nach der Welle (vom Hilfsagenten, nicht von dir gefahren):\n${vergleichstext}` : '', + ].filter(Boolean).join('\n') + pruefungArgs = { + projekt: args.projekt, plan: args.planordner, tasks: taskdateien, suite: args.suite, + kritisch: args.kritisch, abschluss: args.abschluss, version: args.version, effort: args.effort, schwerpunkt, + } +} + +const tempText = temp ? `${temp} bleibt liegen (Projektkopie ohne die Ausschlüsse); löschen, wenn der Diff nicht mehr gebraucht wird` : null + +if (args.bis === 'vergleich') { + if (pruefungGrund) log('Prüfung wäre übersprungen worden: ' + pruefungGrund) + return { + status: 'bis_vergleich', + kopie, + temp_ordner: tempText, + welle, + vergleich: belege.vergleich || null, + kopie_lauf: belege.kopie || null, + pruefung_args: pruefungArgs, + pruefung_grund: pruefungGrund, + fehler, + verbrauch: ablesungen, + naechster_schritt: pruefungArgs + ? 'pruefung mit pruefung_args als eigenen Workflow im Hintergrund starten. Währenddessen die eigene Gegenprüfung nach Schritt 4, nur lesend: nichts in Projektbaum oder Planordner schreiben, Rot- und Mutantentests nur auf einer Kopie, den pruefer nicht abbrechen. Plankorrektur und Commit erst nach seinem Bericht.' + : 'Keine Prüfung möglich (' + pruefungGrund + '). Welle nachziehen, dann erneut.', + } +} + +if (pruefungGrund) log('Prüfung übersprungen: ' + pruefungGrund) +else { + await ablesen('vor Prüfung') + try { + pruefung = await workflow('pruefung', { ...pruefungArgs, verbrauch: false }) + } catch (e) { + fehler.push('pruefung: ' + (e && e.message)) + pruefungGrund = 'pruefung gescheitert: ' + (e && e.message) + log(pruefungGrund) + } + await ablesen('nach Prüfung') +} + +return { + status: 'fertig', + kopie, + temp_ordner: tempText, + welle, + vergleich: belege.vergleich || null, + kopie_lauf: belege.kopie || null, + pruefung: pruefung || { uebersprungen: pruefungGrund }, + fehler, + verbrauch: ablesungen, + naechster_schritt: 'Eigene Gegenprüfung nach Schritt 4: Testlauf selbst wiederholen, zentrale Artefakte selbst lesen, eigener Lauf mit echten Daten; bei Abweichung im Byte-Vergleich diff auf die ganze Datei. Befunde am Plan korrigieren, nicht am Code.', +} +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/welle-mit-pruefung.js +``` + +Erwartet: `PARSE OK`, Exit 0. + +Danach `git status --porcelain -- .claude/workflows/welle-mit-pruefung.js` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-06-attrappe-und-faelle.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-06-attrappe-und-faelle.md new file mode 100644 index 0000000..0450ed6 --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-06-attrappe-und-faelle.md @@ -0,0 +1,1116 @@ +--- +type: plan-task +title: "Task 06 — Attrappe und Fälle für die Workflows" +status: draft +summary: "Node-Attrappe mit den Grenzen des Workflow-Laufzeitsystems und die Fälle für verbrauch, plankritik, pruefung, welle-mit-pruefung und planung samt Selbsttests der Attrappe." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: sonnet +effort: low +modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt. Sonnet low statt Haiku, weil der Code das Zeichen · (U+00B7) trägt und Haiku in der Messung vom 2026-09-23 typografische Zeichen still ersetzt hat." +--- + +# Task 06 — Attrappe und Fälle für die Workflows + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. + +Was danach anders ist: + +- **`attrappe.mjs`** führt ein Workflow-Skript mit nachgebauten Globals aus: `workflow()` im Kind wirft, im Kind tragen Agenten die Phase `▸ `, `Date.now()` und `Math.random()` werfen, ein Agent ohne `model` ist ein Verstoß. Sie zählt gleichzeitige Agenten je Phase und Modell. +- **`faelle_workflows.mjs`** fährt je Workflow zuerst die Grundlinie, dann die Angriffsliste nach `AI/Regelwerk/pruefwerkzeuge.md`, einschließlich gesenkter und gehobener Plätze beim Nachlesen. +- **`mutanten_workflows.py`** fährt die Mutantenliste gegen die Workflows; ein veraltetes Muster bricht mit Exit 2 ab, ein wirkungsloser Mutant steht mit Grund in `WIRKUNGSLOS`. + +## Zieldateien + +- `.vault-tools/workflow-pruefung/attrappe.mjs` (neu) +- `.vault-tools/workflow-pruefung/faelle_workflows.mjs` (neu) +- `.vault-tools/workflow-pruefung/mutanten_workflows.py` (neu) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.vault-tools/workflow-pruefung/attrappe.mjs` + +Die ganze Datei ist neu: + +```javascript +// Attrappe für die Workflow-Skripte unter .claude/workflows/: führt ein Skript mit nachgebauten +// Globals aus und protokolliert jeden agent()-Aufruf mit Modell, Phase, Start und Ende. +// +// Grenzen wie im Ziel (Workflow-Referenz der Claude-Code-Sitzung vom 2026-09-24, dazu die +// Plankritik vom selben Tag am Laufzeitsystem 2.1.280): +// - workflow() in einem Kind wirft; Verschachtelung geht nur eine Ebene tief. Das Ziel meldet +// "workflow() cannot be called from within a child workflow", dann einen Gedankenstrich und +// "nesting is limited to one level"; die Attrappe setzt einen Doppelpunkt statt des Strichs. +// - Im Kind ist phase() wirkungslos, und jeder agent() bekommt die Phase "▸ ". +// - workflow() mit unbekanntem Namen oder unlesbarem scriptPath wirft. +// - Date.now(), new Date() ohne Argument und Math.random() werfen. +// - agent() mit schema braucht type object und properties, required muss in properties stehen. +// Ein Ergebnis ohne die required-Felder wird wie ein gescheiterter Agent zu null. +// - parallel() ist eine Barriere; ein werfender Thunk wird zu null. +// Zusätzlich als Verstoß protokolliert (Stamm, "Agenten & Modelle"): agent() ohne model. +// Was die Attrappe nicht nachbildet: die Obergrenze gleichzeitiger Agenten je Lauf, Budget, +// Resume und das Verhalten echter Modelle. Sie ist ein Fangnetz für die Steuerlogik der +// Skripte, kein Nachweis für einen echten Lauf. +import { readFileSync, existsSync } from 'node:fs' +import path from 'node:path' + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor +// Aufrufe über den echten Pfad werden in den Ordner unter Test umgeleitet. +const ECHTER_ORDNER = 'D:/Claude/.claude/workflows/' + +class DatumAttrappe extends Date { + constructor(...a) { + if (a.length === 0) throw new Error('new Date() ohne Argument ist im Workflow nicht erlaubt') + super(...a) + } + static now() { throw new Error('Date.now() ist im Workflow nicht erlaubt') } +} +const MatheAttrappe = Object.create(Math, { + random: { value: () => { throw new Error('Math.random() ist im Workflow nicht erlaubt') } }, +}) + +function lade(datei) { + if (!existsSync(datei)) throw new Error('Workflow-Datei nicht lesbar: ' + datei) + const text = readFileSync(datei, 'utf8') + if (!/^export const meta = /m.test(text)) throw new Error(datei + ': export const meta fehlt') + const name = /^export const meta = \{[\s\S]*?\bname:\s*'([^']+)'/m.exec(text) + const quelle = text.replace(/^export const meta = /m, 'const meta = ') + return { name: name ? name[1] : path.basename(datei, '.js'), fn: new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'workflow', 'args', 'budget', 'Date', 'Math', quelle) } +} + +const warte = ms => new Promise(r => setTimeout(r, ms)) + +// antworte(aufruf) liefert { wert, dauer } oder undefined (Agent ohne Ergebnis) oder wirft. +// aufruf trägt label, phase, modell, effort, agentType, prompt, tiefe und schema. +export async function fuehreAus(datei, args, antworte, optionen = {}) { + const ordner = optionen.ordner || path.dirname(datei) + const protokoll = { agents: [], logs: [], phasen: [], verstoesse: [], spitze: {}, spitzeJePhase: {}, workflows: [] } + const laufend = {} + const laufendJePhase = {} + let uhr = 0 + + function aufloesen(ref) { + if (typeof ref === 'string') return path.join(ordner, ref + '.js') + if (ref && typeof ref.scriptPath === 'string') { + const s = ref.scriptPath.replace(/\\/g, '/') + return s.startsWith(ECHTER_ORDNER) ? path.join(ordner, s.slice(ECHTER_ORDNER.length)) : s + } + throw new Error('workflow(): Name oder { scriptPath } erwartet') + } + + async function lauf(datei, a, tiefe) { + const { name, fn } = lade(datei) + const kindPhase = tiefe >= 1 ? '▸ ' + name : null + let aktuellePhase = null + + async function agent(prompt, opts = {}) { + if (typeof prompt !== 'string' || !prompt.trim()) protokoll.verstoesse.push('agent() ohne Auftragstext: ' + (opts.label || '?')) + if (!opts.model) protokoll.verstoesse.push('agent() ohne model: ' + (opts.label || '?')) + if (opts.schema !== undefined) { + if (!opts.schema || opts.schema.type !== 'object' || !opts.schema.properties) throw new Error('Schema braucht type object und properties: ' + opts.label) + if ((opts.schema.required || []).some(k => !(k in opts.schema.properties))) throw new Error('required nicht in properties: ' + opts.label) + } + const modell = opts.model || '(erbt)' + const phase = kindPhase || opts.phase || aktuellePhase || '(ohne)' + const eintrag = { label: opts.label || null, phase, modell, effort: opts.effort, agentType: opts.agentType, prompt, tiefe, start: ++uhr, ende: null, ergebnis: undefined } + protokoll.agents.push(eintrag) + const k = phase + '|' + modell + laufend[modell] = (laufend[modell] || 0) + 1 + laufendJePhase[k] = (laufendJePhase[k] || 0) + 1 + protokoll.spitze[modell] = Math.max(protokoll.spitze[modell] || 0, laufend[modell]) + protokoll.spitzeJePhase[k] = Math.max(protokoll.spitzeJePhase[k] || 0, laufendJePhase[k]) + let wert = null + try { + const antwort = await antworte({ ...eintrag, schema: opts.schema }) + await warte(antwort && typeof antwort.dauer === 'number' ? antwort.dauer : 5) + wert = antwort && typeof antwort === 'object' && 'wert' in antwort ? antwort.wert : null + if (wert !== null && opts.schema) { + const fehlt = (opts.schema.required || []).filter(f => !wert || typeof wert !== 'object' || !(f in wert)) + if (fehlt.length) { + protokoll.verstoesse.push(`${opts.label}: Ergebnis ohne ${fehlt.join(', ')}`) + wert = null + } + } + } catch (e) { + protokoll.logs.push(`agent ${opts.label} geworfen: ${e && e.message}`) + wert = null + } finally { + laufend[modell]-- + laufendJePhase[k]-- + eintrag.ende = ++uhr + } + eintrag.ergebnis = wert + return wert + } + + const parallel = thunks => Promise.all(thunks.map(t => Promise.resolve().then(t).catch(e => { + protokoll.logs.push('parallel: ' + (e && e.message)) + return null + }))) + const pipeline = (items, ...stufen) => Promise.all(items.map(async (item, i) => { + try { + let r = item + for (const stufe of stufen) r = await stufe(r, item, i) + return r + } catch (e) { + protokoll.logs.push('pipeline: ' + (e && e.message)) + return null + } + })) + const phase = titel => { if (!kindPhase) { aktuellePhase = titel; protokoll.phasen.push(titel) } } + const log = text => { protokoll.logs.push(String(text)) } + const workflow = async (ref, kindArgs) => { + if (tiefe >= 1) throw new Error('workflow() cannot be called from within a child workflow: nesting is limited to one level') + const ziel = aufloesen(ref) + protokoll.workflows.push({ ref: typeof ref === 'string' ? ref : ref.scriptPath, args: kindArgs, tiefe: tiefe + 1 }) + return lauf(ziel, kindArgs, tiefe + 1) + } + const budget = { total: null, spent: () => 0, remaining: () => Infinity } + return fn(agent, parallel, pipeline, phase, log, workflow, a, budget, DatumAttrappe, MatheAttrappe) + } + + try { + const ergebnis = await lauf(datei, args, optionen.tiefe || 0) + return { ergebnis, fehler: null, protokoll } + } catch (e) { + return { ergebnis: null, fehler: (e && e.message) || String(e), protokoll } + } +} +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs` + +Die ganze Datei ist neu: + +```javascript +// Fälle für die Workflow-Skripte unter .claude/workflows/, gefahren mit attrappe.mjs. +// Aufruf: node faelle_workflows.mjs [] +// Ohne Argument gilt ../../.claude/workflows relativ zu dieser Datei. Je Workflow läuft zuerst +// die Grundlinie; scheitert sie, messen die übrigen Fälle dieses Workflows nichts. +// Exit 0, wenn alle Fälle bestehen, sonst 1. +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { fuehreAus } from './attrappe.mjs' + +const hier = path.dirname(fileURLToPath(import.meta.url)) +const ORDNER = path.resolve(process.argv[2] || path.join(hier, '..', '..', '.claude', 'workflows')) +const WF = name => path.join(ORDNER, name + '.js') + +const ok = (wert, dauer = 5) => ({ wert, dauer }) +const fenster = (fuenf, woche, extra = []) => [ + { label: '5-hour limit', percentUsed: fuenf, resetsAt: '2026-09-24T18:30:00.000Z' }, + { label: 'Weekly · all models', percentUsed: woche, resetsAt: '2026-09-28T00:00:00.000Z' }, + ...extra, +] +const ablesung = (fuenf, woche, extra) => ({ verfuegbar: true, status: 'ok', plan: 'Pro', fenster: fenster(fuenf, woche, extra), fehler: '' }) +const OHNE = 'ohne' + +// Antworten für alle Agenten, die die Workflows starten. Unbekannte Labels werfen, damit ein +// falsches Label im Protokoll auffällt. +function antworten(k = {}) { + let n = 0 + return async a => { + const label = a.label || '' + if (label.startsWith('verbrauch:')) { + const x = (k.ablesungen || [])[n++] + if (x === OHNE) return undefined + return ok(x === undefined ? ablesung(10, 10) : x) + } + if (label === 'werkzeug:planwerkzeuge') return ok({ laeufe: [{ befehl: 'planwerkzeuge', exit: 0, ausgabe: 'keine Befunde' }] }) + if (label === 'werkzeug:kopie') return ok({ laeufe: [{ befehl: 'kopie', exit: 0, ausgabe: "KOPIE=C:/tmp/k/mat\n{'neu': 2}" }] }) + if (label === 'werkzeug:vergleich') return ok({ laeufe: [{ befehl: 'vergleich', exit: 0, ausgabe: 'VERGLEICH-OK\nEXIT suite: 0' }] }) + if (label.startsWith('plankritik:')) { + const linse = label.slice('plankritik:'.length) + if (k.linse) { const x = k.linse(linse); if (x === OHNE) return undefined; if (x !== undefined) return ok(x, k.dauer || 30) } + const basis = { arithmetik: 10, bezeichner: 20, abdeckung: 30 }[linse] || 40 + return ok({ befunde: [0, 1].map(j => ({ schweregrad: 'wichtig', datei: 'task-01.md', zeile: basis + j, beschreibung: linse + ' ' + j, reproduktion: 'x' })), selbst_ausgefuehrt: [], nicht_pruefbar: [] }, k.dauer || 30) + } + if (label.startsWith('widerlegung:')) return ok({ urteil: 'bestaetigt', schweregrad: 'wichtig', bedingung: '', begruendung: 'nachgestellt', beleg: 'befehl' }, k.dauer || 30) + if (label.startsWith('pruefung')) { + if (k.pruefer === OHNE) return undefined + return ok({ selbst_ausgefuehrt: [], eigener_lauf: [], befunde: [{ schweregrad: 'klein', datei: 'a.py', zeile: 1, beschreibung: 'x', reproduktion: 'y' }], plan_vs_umsetzung: [], geprueft_und_in_ordnung: [], nicht_pruefbar: [] }, 20) + } + if (label.startsWith('executer:')) { + if (k.executer === OHNE) return undefined + return ok({ erledigt: ['alles'], geaenderte_dateien: [], verifikation: [], abweichungen: ['keine'], annahmen: [], beobachtet: [] }, 10) + } + if (label.startsWith('planer:task-')) { + const nr = Number(label.slice('planer:task-'.length)) + const x = k.planer ? k.planer(nr) : undefined + if (x === OHNE) return undefined + const ziel = /Schreib genau eine Datei: (\S+\.md)/.exec(a.prompt) + const datei = k.planerDatei ? k.planerDatei(nr, ziel && ziel[1]) : ziel && ziel[1] + return ok({ datei, zieldateien: [], testdateien: [], abweichungen_von_uebersicht: [], offene_punkte: [] }, k.planerDauer ? k.planerDauer(nr) : 20 + nr * 3) + } + throw new Error('unerwarteter Agent ' + label) + } +} + +function pruefe() { + const fehler = [] + return { fehler, soll: (bedingung, text) => { if (!bedingung) fehler.push(text) } } +} +const mitLabel = (p, anfang) => p.agents.filter(x => (x.label || '').startsWith(anfang)) +const spitze = (p, phase, modell) => p.spitzeJePhase[phase + '|' + modell] || 0 +// Wie viele andere Agenten mit diesem Labelanfang liefen, als a startete. +const laufendBeiStart = (p, a, anfang) => mitLabel(p, anfang).filter(b => b !== a && b.start < a.start && (b.ende === null || b.ende > a.start)).length +function ohneVerstoss(p, soll) { soll(p.verstoesse.length === 0, 'Verstöße: ' + p.verstoesse.join('; ')) } + +const faelle = [] +const fall = (workflow, name, lauf) => faelle.push({ workflow, name, lauf }) + +// ---------------------------------------------------------------- attrappe +// Die Attrappe muss so streng sein wie das Ziel, sonst misst jeder grüne Fall nur sie selbst. +const TMP = mkdtempSync(path.join(os.tmpdir(), 'attrappe-')) +function skript(name, rumpf) { + const datei = path.join(TMP, name + '.js') + writeFileSync(datei, `export const meta = { name: '${name}', description: 'Selbsttest' }\n${rumpf}\n`, 'utf8') + return datei +} +const A = (datei, k) => fuehreAus(datei, {}, antworten(k), { ordner: TMP }) + +for (const [i, [name, rumpf, muster]] of [ + ['Date.now() wirft', 'return Date.now()', /Date\.now\(\)/], + ['new Date() ohne Argument wirft', 'return new Date()', /new Date\(\)/], + ['Math.random() wirft', 'return Math.random()', /Math\.random\(\)/], + ['unbekannter Workflow wirft', "return await workflow('gibt-es-nicht', {})", /nicht lesbar/], + ['Schema ohne properties wirft', "return await agent('x', { label: 'verbrauch:t', model: 'haiku', schema: { type: 'object' } })", /Schema braucht/], +].entries()) { + fall('attrappe', name, async () => { + const { fehler, soll } = pruefe() + const { fehler: f } = await A(skript('wirft-' + i, rumpf)) + soll(f && muster.test(f), 'Fehler: ' + f) + return fehler + }) +} + +fall('attrappe', 'Grundlinie: Math.min und new Date(0) gehen, Globals sind da', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f } = await A(skript('grund', "phase('P'); log('x'); return [Math.min(1, 2), new Date(0).getTime(), typeof parallel, typeof pipeline, args !== undefined]")) + soll(f === null && JSON.stringify(e) === '[1,0,"function","function",true]', 'Fehler/Ergebnis: ' + f + ' ' + JSON.stringify(e)) + return fehler +}) + +fall('attrappe', 'workflow() im Kind wirft', async () => { + const { fehler, soll } = pruefe() + const enkel = skript('enkel', 'return 1') + const kind = skript('kind', `return await workflow({ scriptPath: ${JSON.stringify(enkel)} }, {})`) + const { fehler: f } = await A(skript('eltern', `return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`)) + soll(f && /nesting is limited to one level/.test(f), 'Fehler: ' + f) + return fehler +}) + +fall('attrappe', 'im Kind tragen Agenten die Phase ▸ , phase() wirkt dort nicht', async () => { + const { fehler, soll } = pruefe() + const kind = skript('kindphase', "phase('Eigen'); return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet', phase: 'Eigen' })") + const { protokoll: p } = await A(skript('elternphase', `phase('Oben'); return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`)) + soll(p.agents.length === 1 && p.agents[0].phase === '▸ kindphase', 'Phase: ' + (p.agents[0] && p.agents[0].phase)) + soll(JSON.stringify(p.phasen) === '["Oben"]', 'phase() im Kind hat gewirkt: ' + JSON.stringify(p.phasen)) + return fehler +}) + +fall('attrappe', 'agent() ohne model ist ein Verstoß', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await A(skript('ohne-modell', "return await agent('x', { label: 'verbrauch:t' })")) + soll(p.verstoesse.some(v => /ohne model/.test(v)), 'Verstöße: ' + JSON.stringify(p.verstoesse)) + return fehler +}) + +fall('attrappe', 'Ergebnis ohne required-Feld wird null und ist ein Verstoß', async () => { + const { fehler, soll } = pruefe() + const schema = "{ type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a', 'b'] }" + const { ergebnis: e, protokoll: p } = await A(skript('required', `return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet', schema: ${schema} })`)) + soll(e === null && p.verstoesse.some(v => /Ergebnis ohne/.test(v)), 'Ergebnis/Verstöße: ' + JSON.stringify([e, p.verstoesse])) + return fehler +}) + +fall('attrappe', 'Spitze zählt gleichzeitige Agenten je Phase und Modell', async () => { + const { fehler, soll } = pruefe() + const rumpf = "phase('P'); await parallel([1, 2, 3].map(() => () => agent('x', { label: 'widerlegung:t', model: 'opus', phase: 'P' }))); await agent('x', { label: 'widerlegung:t', model: 'opus', phase: 'P' }); return 1" + const { protokoll: p } = await A(skript('spitze', rumpf)) + soll(spitze(p, 'P', 'opus') === 3, 'Spitze ' + spitze(p, 'P', 'opus')) + return fehler +}) + +// ---------------------------------------------------------------- verbrauch +const V =(a, k) => fuehreAus(WF('verbrauch'), a, antworten(k), { ordner: ORDNER }) + +fall('verbrauch', 'Grundlinie: Agent liest 10/10, wunsch 3 ergibt 3 Plätze', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await V({ anlass: 'test', wunsch: 3 }, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'ok' && e.plaetze === 3 && e.gebremst === false && e.quelle === 'agent', 'Ergebnis: ' + JSON.stringify(e)) + soll(p.agents.length === 1 && p.agents[0].label === 'verbrauch:test' && p.agents[0].modell === 'haiku', 'Agent: ' + JSON.stringify(p.agents.map(x => [x.label, x.modell]))) + ohneVerstoss(p, soll) + return fehler +}) + +fall('verbrauch', 'probe ersetzt den Agenten', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await V({ anlass: 't', wunsch: 2, probe: ablesung(10, 10) }) + soll(p.agents.length === 0, 'Agent gestartet trotz probe') + soll(e && e.quelle === 'probe' && e.plaetze === 2, JSON.stringify(e)) + return fehler +}) + +for (const [fuenf, woche, wunsch, plaetze] of [ + [49.9, 10, 4, 4], [50, 10, 4, 2], [69.9, 10, 4, 2], [70, 10, 4, 1], [84.9, 10, 4, 1], [85, 10, 4, 0], [100, 10, 4, 0], [0, 0, 4, 4], + [10, 69.9, 4, 4], [10, 70, 4, 2], [10, 84.9, 4, 2], [10, 85, 4, 1], [10, 94.9, 4, 1], [10, 95, 4, 0], [10, 100, 4, 0], + [101, 10, 4, 0], [10, 150, 4, 0], +]) { + fall('verbrauch', `Stufe 5 h ${fuenf} %, Woche ${woche} % ergibt ${plaetze}`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch, probe: ablesung(fuenf, woche) }) + soll(e && e.plaetze === plaetze, `plaetze ${e && e.plaetze}, erwartet ${plaetze}`) + soll(e && e.gebremst === (plaetze === 0), 'gebremst ' + (e && e.gebremst)) + return fehler + }) +} + +fall('verbrauch', 'wunsch 0 bei 95 % misst nur und bremst nicht', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 0, probe: ablesung(95, 10) }) + soll(e && e.plaetze === 0 && e.gebremst === false && e.status === 'ok', JSON.stringify(e)) + return fehler +}) + +fall('verbrauch', 'wunsch 7 wird auf die harte Obergrenze 4 gesenkt', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 7, probe: ablesung(10, 10) }) + soll(e && e.plaetze === 4 && /harte Obergrenze/.test(e.grund), JSON.stringify(e)) + return fehler +}) + +for (const wunsch of [-1, 1.5, '3', null, undefined]) { + fall('verbrauch', `wunsch ${JSON.stringify(wunsch)} wirft vor dem Agenten`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await V({ anlass: 't', wunsch }) + soll(f && /wunsch muss/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +for (const anlass of [undefined, '', ' ', 3]) { + fall('verbrauch', `anlass ${JSON.stringify(anlass)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f } = await V({ anlass, wunsch: 1 }) + soll(f && /anlass fehlt/.test(f), 'Fehler: ' + f) + return fehler + }) +} + +for (const probe of [null, [], 'x']) { + fall('verbrauch', `probe ${JSON.stringify(probe)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f } = await V({ anlass: 't', wunsch: 1, probe }) + soll(f && /probe muss/.test(f), 'Fehler: ' + f) + return fehler + }) +} + +fall('verbrauch', 'Agent ohne Ergebnis: nicht_gelesen, zwei Plätze, bei wunsch 1 einer', async () => { + const { fehler, soll } = pruefe() + const a = await V({ anlass: 't', wunsch: 3 }, { ablesungen: [OHNE] }) + soll(a.ergebnis && a.ergebnis.status === 'nicht_gelesen' && a.ergebnis.plaetze === 2 && a.ergebnis.gebremst === false, JSON.stringify(a.ergebnis)) + const b = await V({ anlass: 't', wunsch: 1 }, { ablesungen: [OHNE] }) + soll(b.ergebnis && b.ergebnis.plaetze === 1, JSON.stringify(b.ergebnis)) + return fehler +}) + +for (const [name, probe] of [ + ['status unavailable', { verfuegbar: true, status: 'unavailable', plan: '', fenster: [], fehler: '' }], + ['verfuegbar false', { verfuegbar: false, status: 'fehler', plan: '', fenster: [], fehler: 'Werkzeug fehlt' }], + ['verfuegbar als Text', { verfuegbar: 'true', status: 'ok', plan: 'Pro', fenster: fenster(10, 10), fehler: '' }], +]) { + fall('verbrauch', `${name}: nicht_gelesen`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 3, probe }) + soll(e && e.status === 'nicht_gelesen' && e.plaetze === 2, JSON.stringify(e)) + return fehler + }) +} + +const ok5 = { label: '5-hour limit', percentUsed: 10, resetsAt: 'x' } +const okW = { label: 'Weekly · all models', percentUsed: 10, resetsAt: 'y' } +for (const [name, liste] of [ + ['fenster leer', []], + ['fenster kein Array', { a: 1 }], + ['nur Woche', [okW]], + ['nur 5 Stunden', [ok5]], + ['Eintrag null an erster Stelle', [null, ok5, okW]], + ['Eintrag null an letzter Stelle', [ok5, okW, null]], + ['label fehlt', [ok5, { percentUsed: 1, resetsAt: 'z' }, okW]], + ['percentUsed null vorn', [{ ...ok5, percentUsed: null }, okW]], + ['percentUsed null hinten', [ok5, { ...okW, percentUsed: null }]], + ['percentUsed als Text', [{ ...ok5, percentUsed: '12' }, okW]], + ['percentUsed NaN', [{ ...ok5, percentUsed: NaN }, okW]], + ['percentUsed -1', [ok5, { ...okW, percentUsed: -1 }]], + ['percentUsed Infinity', [{ ...ok5, percentUsed: Infinity }, okW]], + ['Opus-Woche ohne Zahl', [ok5, okW, { label: 'Weekly · Opus', percentUsed: null, resetsAt: 'z' }]], +]) { + fall('verbrauch', `${name}: unbekannte_fenster, alte Obergrenze`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: liste, fehler: '' } }) + soll(e && e.status === 'unbekannte_fenster' && e.plaetze === 2 && e.gebremst === false, JSON.stringify(e)) + return fehler + }) +} + +for (const [name, liste] of [ + ['doppeltes 5-Stunden-Fenster, hohes zuerst', [{ ...ok5, percentUsed: 90 }, ok5, okW]], + ['doppeltes 5-Stunden-Fenster, hohes zuletzt', [ok5, okW, { ...ok5, percentUsed: 90 }]], +]) { + fall('verbrauch', `${name}: das höhere zählt`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: liste, fehler: '' } }) + soll(e && e.plaetze === 0 && e.gebremst === true && e.fuenf_stunden.prozent === 90, JSON.stringify(e)) + return fehler + }) +} + +fall('verbrauch', 'Opus-Woche 90 % zählt wie die Woche', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Opus', percentUsed: 90, resetsAt: 'z' }]) }) + soll(e && e.plaetze === 1 && e.woche_opus && e.woche_opus.prozent === 90, JSON.stringify(e)) + return fehler +}) + +fall('verbrauch', 'Woche aller Modelle 90 % zählt auch neben einer niedrigen Opus-Woche', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 90, [{ label: 'Weekly · Opus', percentUsed: 10, resetsAt: 'z' }]) }) + soll(e && e.plaetze === 1, JSON.stringify(e)) + return fehler +}) + +for (const [name, fremd] of [['Fable 99 %', 99], ['Fable ohne Zahl', null], ['Fable als Text', 'viel']]) { + fall('verbrauch', `fremdes Fenster (${name}) wird übergangen`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Fable', percentUsed: fremd, resetsAt: 'z' }]) }) + soll(e && e.status === 'ok' && e.plaetze === 4, JSON.stringify(e)) + return fehler + }) +} + +for (const [a, b] of [['5-Hour Limit', 'Weekly · All Models'], ['5 hour limit', 'weekly all models'], ['5hour', 'WEEKLY - ALL MODELS']]) { + fall('verbrauch', `Schreibweise "${a}" / "${b}" wird erkannt`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: [{ label: a, percentUsed: 60, resetsAt: 'x' }, { label: b, percentUsed: 10, resetsAt: 'y' }], fehler: '' } }) + soll(e && e.status === 'ok' && e.plaetze === 2, JSON.stringify(e)) + return fehler + }) +} + +// ---------------------------------------------------------------- plankritik +const PK_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', kopie: 'C:/tmp/k/mat' } +const PK = (a, k, o = {}) => fuehreAus(WF('plankritik'), a, antworten(k), { ordner: ORDNER, ...o }) + +fall('plankritik', 'Grundlinie: drei Linsen gleichzeitig, Widerlegung zu zweit, drei Ablesungen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'fertig', 'status ' + (e && e.status)) + soll(spitze(p, 'Plankritik', 'opus') === 3, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + soll(spitze(p, 'Widerlegung', 'opus') === 2, 'Widerlegung gleichzeitig: ' + spitze(p, 'Widerlegung', 'opus')) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(JSON.stringify(ab) === JSON.stringify(['verbrauch:plankritik:vor Linsen', 'verbrauch:plankritik:vor Widerlegung', 'verbrauch:plankritik:Ende']), 'Ablesungen: ' + JSON.stringify(ab)) + soll(mitLabel(p, 'verbrauch:').every(x => x.modell === 'haiku'), 'Ablesung nicht auf haiku') + soll(e && e.verbrauch.length === 3 && e.plaetze.linsen === 3 && e.plaetze.widerlegung === 2, 'verbrauch/plaetze: ' + JSON.stringify(e && e.plaetze)) + soll(e && e.zaehlung.bestaetigt === 6 && e.zaehlung.nicht_geprueft === 0, 'zaehlung ' + JSON.stringify(e && e.zaehlung)) + for (const k of ['zaehlung', 'kopie', 'temp_ordner', 'befunde', 'selbst_ausgefuehrt', 'nicht_pruefbar', 'vorbereitung', 'ohne_ergebnis']) soll(e && k in e, 'Feld fehlt: ' + k) + ohneVerstoss(p, soll) + return fehler +}) + +fall('plankritik', '5 Stunden bei 60 %: Linsen zu zweit', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(60, 10)] }) + soll(spitze(p, 'Plankritik', 'opus') === 2 && e.plaetze.linsen === 2, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + soll(e.status === 'fertig', 'status ' + e.status) + return fehler +}) + +fall('plankritik', 'gebremst vor den Linsen: keine Linse, keine Widerlegung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(90, 10)] }) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'gebremst' && e.gebremst_vor === 'Linsen', JSON.stringify(e && [e.status, e.gebremst_vor])) + soll(mitLabel(p, 'plankritik:').length === 0 && mitLabel(p, 'widerlegung:').length === 0, 'Opus gestartet trotz Bremse') + soll(e && e.verbrauch.length === 1 && e.ohne_ergebnis.length === 3, 'verbrauch/ohne_ergebnis ' + JSON.stringify(e && [e.verbrauch.length, e.ohne_ergebnis])) + soll(e && e.zaehlung && e.zaehlung.bestaetigt === 0 && e.zaehlung.nicht_geprueft === 0, 'zaehlung ' + JSON.stringify(e && e.zaehlung)) + soll(e && /neu starten/.test(e.naechster_schritt), 'naechster_schritt ' + (e && e.naechster_schritt)) + return fehler +}) + +fall('plankritik', 'gebremst vor der Widerlegung: nur die Befunde über der Schwelle heißen gebremst', async () => { + const { fehler, soll } = pruefe() + const zeile = { arithmetik: 11, bezeichner: 12, abdeckung: 13 } + const mitKlein = linse => ({ befunde: [ + { schweregrad: 'wichtig', datei: 'task-01.md', zeile: zeile[linse], beschreibung: 'w', reproduktion: 'x' }, + { schweregrad: 'klein', datei: 'task-02.md', zeile: zeile[linse], beschreibung: 'k', reproduktion: 'x' }, + ], selbst_ausgefuehrt: [], nicht_pruefbar: [] }) + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(10, 10), ablesung(90, 10)], linse: mitKlein }) + soll(mitLabel(p, 'plankritik:').length === 3, 'Linsen: ' + mitLabel(p, 'plankritik:').length) + soll(mitLabel(p, 'widerlegung:').length === 0, 'Widerlegung trotz Bremse') + soll(e.status === 'gebremst' && e.gebremst_vor === 'Widerlegung', JSON.stringify([e.status, e.gebremst_vor])) + const urteile = e.befunde.map(b => b.schweregrad + ':' + b.urteil).sort() + soll(urteile.filter(u => u === 'wichtig:nicht geprüft (gebremst)').length === 3 && urteile.filter(u => u === 'klein:nicht geprüft').length === 3, 'Urteile: ' + JSON.stringify(urteile)) + soll(e.zaehlung.nicht_geprueft === 6 && e.zaehlung.bestaetigt === 0, JSON.stringify(e.zaehlung)) + soll(mitLabel(p, 'verbrauch:').length === 3, 'Ablesung am Ende fehlt') + soll(/nachholen/.test(e.naechster_schritt), 'naechster_schritt ' + e.naechster_schritt) + return fehler +}) + +fall('plankritik', 'vor der Widerlegung 75 %: Widerlegung einzeln', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(10, 10), ablesung(75, 10)] }) + soll(spitze(p, 'Widerlegung', 'opus') === 1 && e.plaetze.widerlegung === 1, 'Widerlegung gleichzeitig: ' + spitze(p, 'Widerlegung', 'opus')) + soll(mitLabel(p, 'widerlegung:').length === 6 && e.status === 'fertig', JSON.stringify([mitLabel(p, 'widerlegung:').length, e.status])) + return fehler +}) + +fall('plankritik', 'Ablesung ohne Ergebnis: Linsen zu zweit (alte Obergrenze)', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [OHNE] }) + soll(spitze(p, 'Plankritik', 'opus') === 2, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + soll(e.status === 'fertig' && e.verbrauch[0].status === 'nicht_gelesen', JSON.stringify(e.verbrauch[0])) + return fehler +}) + +fall('plankritik', 'als Kind: verbrauch.js wirft, Rückfall auf zwei', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, {}, { tiefe: 1 }) + soll(f === null, 'Fehler: ' + f) + soll(spitze(p, '▸ plankritik', 'opus') === 2, 'Linsen gleichzeitig: ' + spitze(p, '▸ plankritik', 'opus')) + soll(mitLabel(p, 'verbrauch:').length === 0, 'Ableseagent trotz Kind') + soll(e && e.verbrauch.every(v => v.status === 'nicht_gelesen' && /nicht ausführbar/.test(v.grund)), JSON.stringify(e && e.verbrauch)) + soll(e && e.status === 'fertig', 'status ' + (e && e.status)) + return fehler +}) + +fall('plankritik', 'Aufträge nennen den Testort', async () => { + const { fehler, soll } = pruefe() + const mit = await PK(PK_ARGS, {}) + const satz = 'Tests fährst du nur in der Kopie C:/tmp/k/mat, nie im echten Projektbaum' + for (const a of [...mitLabel(mit.protokoll, 'plankritik:'), ...mitLabel(mit.protokoll, 'widerlegung:')]) { + soll(a.prompt.includes(satz) && /eigenen Kopie dieser Kopie/.test(a.prompt), a.label + ' ohne Testort') + } + const ohne = await PK({ projekt: 'D:/x', planordner: 'D:/x/plan' }, {}) + for (const a of mitLabel(ohne.protokoll, 'plankritik:')) { + soll(/Tests fährst du in dieser Plankritik nicht/.test(a.prompt), a.label + ': ohne Kopie kein Testverbot') + } + return fehler +}) + +fall('plankritik', 'plaetze 1 begrenzt die Linsen auf eine', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PK({ ...PK_ARGS, plaetze: 1 }, {}) + soll(spitze(p, 'Plankritik', 'opus') === 1, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + return fehler +}) + +for (const plaetze of [0, 5, 2.5, '3']) { + fall('plankritik', `plaetze ${JSON.stringify(plaetze)} wirft vor dem ersten Agenten`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PK({ ...PK_ARGS, plaetze }, {}) + soll(f && /plaetze muss/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +fall('plankritik', 'Linse ohne Ergebnis steht in ohne_ergebnis', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await PK(PK_ARGS, { linse: l => (l === 'arithmetik' ? OHNE : undefined) }) + soll(e.status === 'fertig' && JSON.stringify(e.ohne_ergebnis) === '["arithmetik"]', JSON.stringify(e.ohne_ergebnis)) + return fehler +}) + +fall('plankritik', 'verifizieren false: keine Ablesung vor der Widerlegung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK({ ...PK_ARGS, verifizieren: false }, {}) + soll(mitLabel(p, 'widerlegung:').length === 0, 'Widerlegung trotz verifizieren false') + soll(mitLabel(p, 'verbrauch:').length === 2, 'Ablesungen: ' + mitLabel(p, 'verbrauch:').length) + soll(e.status === 'fertig' && e.zaehlung.nicht_geprueft === 6, JSON.stringify(e.zaehlung)) + return fehler +}) + +// ---------------------------------------------------------------- pruefung +const PR_ARGS = { projekt: 'D:/x', plan: 'D:/x/plan' } +const PR = (a, k, o = {}) => fuehreAus(WF('pruefung'), a, antworten(k), { ordner: ORDNER, ...o }) + +fall('pruefung', 'Grundlinie: Ablesung vor und nach dem pruefer', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PR(PR_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + const folge = p.agents.slice().sort((x, y) => x.start - y.start).map(x => x.label) + soll(JSON.stringify(folge) === JSON.stringify(['verbrauch:pruefung:vor Prüfung', 'pruefung', 'verbrauch:pruefung:nach Prüfung']), 'Folge: ' + JSON.stringify(folge)) + soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'ok' && v.plaetze === 0 && v.gebremst === false), JSON.stringify(e && e.verbrauch)) + soll(e && e.effort === '(Frontmatter)', 'effort ' + (e && e.effort)) + soll(mitLabel(p, 'pruefung')[0].modell === 'opus', 'pruefer nicht auf opus') + ohneVerstoss(p, soll) + return fehler +}) + +fall('pruefung', 'kritisch hebt auf xhigh', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PR({ ...PR_ARGS, kritisch: true }, {}) + soll(e.effort === 'xhigh' && mitLabel(p, 'pruefung:kritisch')[0].effort === 'xhigh', 'effort ' + e.effort) + return fehler +}) + +fall('pruefung', 'verbrauch false: keine Ablesung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PR({ ...PR_ARGS, verbrauch: false }, {}) + soll(mitLabel(p, 'verbrauch:').length === 0 && e.verbrauch.length === 0, 'Ablesung trotz verbrauch false') + return fehler +}) + +fall('pruefung', 'als Kind ohne verbrauch false: Ablesung scheitert, Prüfung läuft', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PR(PR_ARGS, {}, { tiefe: 1 }) + soll(f === null, 'Fehler: ' + f) + soll(mitLabel(p, 'pruefung').length === 1, 'pruefer nicht gelaufen') + soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'nicht_gelesen'), JSON.stringify(e && e.verbrauch)) + return fehler +}) + +fall('pruefung', 'pruefer ohne Bericht: Fehler, beide Ablesungen gelaufen', async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PR(PR_ARGS, { pruefer: OHNE }) + soll(f === 'pruefung: der pruefer hat keinen Bericht geliefert', 'Fehler: ' + f) + soll(mitLabel(p, 'verbrauch:').length === 2, 'Ablesungen: ' + mitLabel(p, 'verbrauch:').length) + return fehler +}) + +for (const verbrauch of ['nein', 0, null]) { + fall('pruefung', `verbrauch ${JSON.stringify(verbrauch)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PR({ ...PR_ARGS, verbrauch }, {}) + soll(f && /verbrauch muss/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +// ---------------------------------------------------------------- welle-mit-pruefung +const WM_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', tasks: [{ datei: 'task-01.md', modell: 'haiku' }], kopie: 'C:/tmp/k/mat', suite: 'pytest -q' } +const WM = (a, k) => fuehreAus(WF('welle-mit-pruefung'), a, antworten(k), { ordner: ORDNER }) + +fall('welle-mit-pruefung', 'Grundlinie: Welle, Vergleich, Ablesung um die Prüfung, Kind liest nicht selbst', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await WM(WM_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'fertig', 'status ' + (e && e.status)) + soll(mitLabel(p, 'executer:').length === 1 && mitLabel(p, 'werkzeug:vergleich').length === 1 && mitLabel(p, 'pruefung').length === 1, 'Agenten: ' + JSON.stringify(p.agents.map(x => x.label))) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(JSON.stringify(ab) === JSON.stringify(['verbrauch:welle-mit-pruefung:vor Prüfung', 'verbrauch:welle-mit-pruefung:nach Prüfung']), 'Ablesungen: ' + JSON.stringify(ab)) + const kind = p.workflows.find(w => w.ref === 'pruefung') + soll(kind && kind.args.verbrauch === false, 'Kind pruefung ohne verbrauch false') + soll(e.pruefung.verbrauch.length === 0 && mitLabel(p, 'verbrauch:pruefung:').length === 0, 'Kind hat selbst abgelesen') + soll(e.verbrauch.length === 2, 'verbrauch ' + e.verbrauch.length) + ohneVerstoss(p, soll) + return fehler +}) + +fall('welle-mit-pruefung', 'bis vergleich: keine Prüfung, pruefung_args mit Rohbelegen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await WM({ ...WM_ARGS, bis: 'vergleich' }, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'bis_vergleich', 'status ' + (e && e.status)) + soll(mitLabel(p, 'pruefung').length === 0 && mitLabel(p, 'verbrauch:').length === 0, 'Prüfung oder Ablesung trotz bis') + const pa = e && e.pruefung_args + soll(pa && /VERGLEICH-OK/.test(pa.schwerpunkt) && pa.plan === 'D:/x/plan' && JSON.stringify(pa.tasks) === '["task-01.md"]', 'pruefung_args ' + JSON.stringify(pa)) + soll(pa && !('verbrauch' in pa), 'pruefung_args trägt verbrauch') + soll(e && /nur lesend/.test(e.naechster_schritt), 'naechster_schritt ohne Lesevorgabe') + return fehler +}) + +fall('welle-mit-pruefung', 'bis vergleich bei unvollständiger Welle: keine pruefung_args', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await WM({ ...WM_ARGS, bis: 'vergleich' }, { executer: OHNE }) + soll(e && e.status === 'bis_vergleich' && e.pruefung_args === null && /unvollständig/.test(e.pruefung_grund), JSON.stringify(e && [e.status, e.pruefung_args, e.pruefung_grund])) + return fehler +}) + +fall('welle-mit-pruefung', 'unvollständige Welle: Prüfung übersprungen, keine Ablesung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await WM(WM_ARGS, { executer: OHNE }) + soll(e.status === 'fertig' && /unvollständig/.test(e.pruefung.uebersprungen), JSON.stringify(e.pruefung)) + soll(mitLabel(p, 'verbrauch:').length === 0 && mitLabel(p, 'pruefung').length === 0, 'Ablesung oder Prüfung trotz Lücke') + return fehler +}) + +for (const bis of ['alles', '', 1]) { + fall('welle-mit-pruefung', `bis ${JSON.stringify(bis)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await WM({ ...WM_ARGS, bis }, {}) + soll(f && /bis kennt nur/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +// ---------------------------------------------------------------- planung +const T = (nr, voraussetzungen, welle) => ({ nr, thema: 'Thema ' + nr, datei: `task-${String(nr).padStart(2, '0')}-t.md`, modell: 'haiku', voraussetzungen, welle }) +const DAG = [T(1, [], 1), T(2, [1], 2), T(3, [1], 2), T(4, [1], 2), T(5, [2, 3], 3), T(6, [4], 3), T(7, [], 1)] +const PL_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', gemeinsam: 'GEMEINSAM', taskauftrag: 'Task {NR} {THEMA} in {DATEI}, Modell {MODELL}, Welle {WELLE}, vor: {VORAUSSETZUNGEN}', tasks: DAG } +const PL = (a, k, o = {}) => fuehreAus(WF('planung'), a, antworten(k), { ordner: ORDNER, ...o }) +const planerVon = (p, nr) => p.agents.find(x => x.label === `planer:task-${String(nr).padStart(2, '0')}`) + +function voraussetzungenEingehalten(p, tasks, soll) { + for (const t of tasks) { + const a = planerVon(p, t.nr) + if (!a) continue + for (const n of t.voraussetzungen) { + const v = planerVon(p, n) + soll(v && v.ende !== null && v.ende < a.start, `Task ${t.nr} startete vor dem Ende von Task ${n}`) + } + } +} + +fall('planung', 'Grundlinie: Start nach den Voraussetzungen, ohne Wunsch zwei gleichzeitig', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PL(PL_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'fertig' && e.geschrieben.length === 7, 'status ' + (e && e.status)) + voraussetzungenEingehalten(p, DAG, soll) + soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus')) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(ab[0] === 'verbrauch:planung:Anfang' && ab[ab.length - 1] === 'verbrauch:planung:Ende' && ab.includes('verbrauch:planung:nach 4 Tasks'), 'Ablesungen: ' + JSON.stringify(ab)) + soll(e && e.plaetze_verlauf[0] === 2, 'plaetze_verlauf ' + JSON.stringify(e && e.plaetze_verlauf)) + soll(p.agents.filter(x => x.label.startsWith('planer:')).every(x => x.modell === 'opus' && x.agentType === 'planer'), 'planer nicht auf opus/planer') + ohneVerstoss(p, soll) + return fehler +}) + +fall('planung', 'Wunsch vier: drei bis vier gleichzeitig, Voraussetzungen halten', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, {}) + const s = spitze(p, 'Taskdateien', 'opus') + soll(s >= 3 && s <= 4, 'gleichzeitig: ' + s) + soll(e.status === 'fertig' && e.plaetze_verlauf[0] === 4, JSON.stringify([e.status, e.plaetze_verlauf])) + voraussetzungenEingehalten(p, DAG, soll) + return fehler +}) + +const FREI = [1, 2, 3, 4, 5, 6, 7, 8].map(n => T(n, [], 1)) + +fall('planung', 'Nachlesen senkt die Plätze: danach startet keiner neben zwei anderen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 4, nachlesen_alle: 2 }, { ablesungen: [ablesung(10, 10), ablesung(60, 10), ablesung(60, 10), ablesung(60, 10), ablesung(60, 10)] }) + const zweite = mitLabel(p, 'verbrauch:')[1] + const danach = mitLabel(p, 'planer:').filter(a => a.start > zweite.start) + soll(danach.length > 0, 'nach der zweiten Ablesung startete kein Planer') + soll(danach.every(a => laufendBeiStart(p, a, 'planer:') <= 1), 'Start neben zwei oder mehr: ' + JSON.stringify(danach.map(a => [a.label, laufendBeiStart(p, a, 'planer:')]))) + soll(e.status === 'fertig' && JSON.stringify(e.plaetze_verlauf.slice(0, 2)) === '[4,2]', JSON.stringify([e.status, e.plaetze_verlauf])) + return fehler +}) + +fall('planung', 'Nachlesen hebt die Plätze: danach laufen mehr als zwei', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 4, nachlesen_alle: 2 }, { ablesungen: [ablesung(60, 10), ablesung(10, 10), ablesung(10, 10), ablesung(10, 10)] }) + const zweite = mitLabel(p, 'verbrauch:')[1] + const danach = mitLabel(p, 'planer:').filter(a => a.start > zweite.start) + soll(danach.some(a => laufendBeiStart(p, a, 'planer:') >= 2), 'nach dem Heben nie mehr als zwei gleichzeitig') + soll(e.status === 'fertig' && JSON.stringify(e.plaetze_verlauf.slice(0, 2)) === '[2,4]', JSON.stringify([e.status, e.plaetze_verlauf])) + return fehler +}) + +fall('planung', 'gebremst schon am Anfang: kein Planer, Status gebremst', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { ablesungen: [ablesung(90, 10)] }) + soll(mitLabel(p, 'planer:').length === 0, 'Planer trotz Bremse') + soll(e.status === 'gebremst' && e.offen.length === 7, JSON.stringify([e.status, e.offen])) + soll(/nicht mit resumeFromRunId/.test(e.naechster_schritt), 'naechster_schritt ' + e.naechster_schritt) + return fehler +}) + +fall('planung', 'Startreihenfolge folgt Welle und Nummer, nicht der Liste', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, tasks: [T(3, [], 2), T(2, [], 1), T(1, [], 1)], plaetze: 1 }, {}) + const folge = mitLabel(p, 'planer:').sort((x, y) => x.start - y.start).map(a => a.label) + soll(JSON.stringify(folge) === '["planer:task-01","planer:task-02","planer:task-03"]', 'Folge ' + JSON.stringify(folge)) + return fehler +}) + +fall('planung', 'kein Nachlesen, wenn kein Task mehr offen ist', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI.slice(0, 3), plaetze: 4, nachlesen_alle: 1 }, {}) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(JSON.stringify(ab) === '["verbrauch:planung:Anfang","verbrauch:planung:Ende"]', 'Ablesungen: ' + JSON.stringify(ab)) + return fehler +}) + +fall('planung', 'Ergebnis mit fremder Datei gilt als gescheitert', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { planerDatei: (nr, richtig) => (nr === 2 ? 'D:/x/plan/task-99-t.md' : richtig) }) + soll(e.gescheitert.includes(2) && e.blockiert.includes(5) && !planerVon(p, 5), JSON.stringify([e.gescheitert, e.blockiert])) + soll(p.logs.some(l => /statt task-02-t\.md/.test(l)), 'kein Hinweis auf die fremde Datei') + return fehler +}) + +fall('planung', '5 Stunden bei 60 %: höchstens zwei gleichzeitig', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, { ablesungen: [ablesung(60, 10), ablesung(60, 10)] }) + soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus')) + soll(e.status === 'fertig', 'status ' + e.status) + voraussetzungenEingehalten(p, DAG, soll) + return fehler +}) + +fall('planung', 'Bremse beim Nachlesen: kein Start mehr, laufende enden', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, nachlesen_alle: 2 }, { ablesungen: [ablesung(10, 10), ablesung(90, 10)] }) + soll(e.status === 'gebremst' && e.offen.length > 0, JSON.stringify([e.status, e.offen])) + const zweite = mitLabel(p, 'verbrauch:')[1] + soll(zweite && p.agents.filter(x => x.label.startsWith('planer:') && x.start > zweite.start).length === 0, 'Planer nach der Bremse gestartet') + soll(p.agents.filter(x => x.label.startsWith('planer:')).every(x => x.ende !== null), 'laufender Planer nicht beendet') + return fehler +}) + +fall('planung', 'erste Voraussetzung scheitert: abhängige blockiert, unabhängige laufen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { planer: nr => (nr === 1 ? OHNE : undefined) }) + soll(e.status === 'unvollstaendig', 'status ' + e.status) + soll(JSON.stringify(e.gescheitert) === '[1]' && JSON.stringify(e.blockiert) === '[2,3,4,5,6]', JSON.stringify([e.gescheitert, e.blockiert])) + soll(e.geschrieben.map(g => g.nr).join() === '7', 'geschrieben ' + e.geschrieben.map(g => g.nr)) + soll([2, 3, 4, 5, 6].every(n => !planerVon(p, n)), 'blockierter Task gestartet') + return fehler +}) + +fall('planung', 'letzte Tasks scheitern: nichts blockiert', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await PL(PL_ARGS, { planer: nr => (nr === 6 || nr === 7 ? OHNE : undefined) }) + soll(e.status === 'unvollstaendig' && JSON.stringify(e.gescheitert.slice().sort()) === '[6,7]' && e.blockiert.length === 0, JSON.stringify([e.status, e.gescheitert, e.blockiert])) + soll(e.geschrieben.length === 5, 'geschrieben ' + e.geschrieben.length) + return fehler +}) + +fall('planung', 'erledigt: Task 1 startet nicht, die abhängigen schon', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, erledigt: [1] }, {}) + soll(!planerVon(p, 1), 'Task 1 gestartet') + soll(e.status === 'fertig' && e.geschrieben.length === 6, JSON.stringify([e.status, e.geschrieben.length])) + return fehler +}) + +fall('planung', 'drei Planer ohne Ergebnis: Abbruch, kein weiterer Start', async () => { + const { fehler, soll } = pruefe() + const frei = [1, 2, 3, 4, 5, 6].map(n => T(n, [], 1)) + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: frei, plaetze: 2 }, { planer: nr => (nr <= 3 ? OHNE : undefined), planerDauer: nr => (nr <= 3 ? 5 : 60) }) + soll(e.status === 'abgebrochen', 'status ' + e.status) + soll(!planerVon(p, 5) && !planerVon(p, 6) && JSON.stringify(e.offen) === '[5,6]', 'offen ' + JSON.stringify(e.offen)) + return fehler +}) + +for (const [name, aendern, text] of [ + ['Voraussetzung ins Leere', a => ({ ...a, tasks: [T(1, [], 1), T(2, [99], 2)] }), /Voraussetzung 99 gibt es nicht/], + ['Zyklus', a => ({ ...a, tasks: [T(1, [2], 1), T(2, [1], 2)] }), /Zyklus/], + ['Selbstbezug', a => ({ ...a, tasks: [T(1, [1], 1)] }), /Zyklus/], + ['doppelte Nummer', a => ({ ...a, tasks: [T(1, [], 1), T(1, [], 1)] }), /doppelt in tasks/], + ['doppelte Datei', a => ({ ...a, tasks: [T(1, [], 1), { ...T(2, [], 1), datei: T(1, [], 1).datei }] }), /bei zwei Tasks/], + ['doppelte Voraussetzung', a => ({ ...a, tasks: [T(1, [], 1), T(2, [1, 1], 2)] }), /Voraussetzung steht doppelt/], + ['modell unbekannt', a => ({ ...a, tasks: [{ ...T(1, [], 1), modell: 'gpt' }] }), /modell "gpt"/], + ['erledigt ins Leere', a => ({ ...a, erledigt: [99] }), /erledigt muss/], + ['plaetze 5', a => ({ ...a, plaetze: 5 }), /plaetze muss/], + ['nachlesen_alle 0', a => ({ ...a, nachlesen_alle: 0 }), /nachlesen_alle muss/], + ['tasks leer', a => ({ ...a, tasks: [] }), /nicht leere Liste/], + ['nr als Text', a => ({ ...a, tasks: [{ ...T(1, [], 1), nr: '1' }] }), /nr muss/], + ['voraussetzungen fehlen', a => ({ ...a, tasks: [{ ...T(1, [], 1), voraussetzungen: undefined }] }), /voraussetzungen muss/], + ['gemeinsam fehlt', a => ({ ...a, gemeinsam: '' }), /args fehlen: gemeinsam/], +]) { + fall('planung', `${name} wirft vor dem ersten Agenten`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PL(aendern(PL_ARGS), {}) + soll(f && text.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +fall('planung', 'Auftrag nennt die Voraussetzungen und ersetzt alle Platzhalter', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL(PL_ARGS, {}) + const t5 = planerVon(p, 5).prompt + soll(t5.includes('GEMEINSAM') && t5.includes('task-02-t.md') && t5.includes('task-03-t.md') && t5.includes('D:/x/plan/task-05-t.md'), 'Auftrag Task 5 unvollständig') + soll(!/\{(NR|THEMA|DATEI|MODELL|WELLE|VORAUSSETZUNGEN)\}/.test(t5), 'Platzhalter übrig') + soll(/vor: \(keine\)/.test(planerVon(p, 1).prompt), 'Task 1 ohne "(keine)"') + return fehler +}) + +fall('planung', 'alles erledigt: kein Agent, keine Ablesung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, erledigt: DAG.map(t => t.nr) }, {}) + soll(p.agents.length === 0 && e.status === 'fertig' && e.verbrauch.length === 0, JSON.stringify([p.agents.length, e.status])) + return fehler +}) + +fall('planung', 'Ablesung ohne Ergebnis: höchstens zwei gleichzeitig', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, { ablesungen: [OHNE, OHNE] }) + soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus')) + return fehler +}) + +fall('planung', 'als Kind: Rückfall auf zwei, Planung läuft durch', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, {}, { tiefe: 1 }) + soll(f === null && e.status === 'fertig', 'Fehler/status: ' + f + ' ' + (e && e.status)) + soll(spitze(p, '▸ planung', 'opus') === 2, 'gleichzeitig: ' + spitze(p, '▸ planung', 'opus')) + return fehler +}) + +// ---------------------------------------------------------------- Lauf +let gescheitert = 0 +const grundlinieGescheitert = new Set() +for (const f of faelle) { + let fehler + try { fehler = await f.lauf() } catch (e) { fehler = ['Absturz im Fall: ' + (e && e.stack || e)] } + if (f.name.startsWith('Grundlinie') && fehler.length) grundlinieGescheitert.add(f.workflow) + if (fehler.length) { + gescheitert++ + console.log(`FEHLER ${f.workflow}: ${f.name}\n ${fehler.join('\n ')}`) + } else console.log(`OK ${f.workflow}: ${f.name}`) +} +for (const w of grundlinieGescheitert) console.log(`HINWEIS Grundlinie von ${w} gescheitert: Die übrigen Fälle dieses Workflows messen nichts.`) +rmSync(TMP, { recursive: true, force: true }) +console.log(`${faelle.length} Fälle, ${gescheitert} gescheitert (Ordner ${ORDNER})`) +process.exit(gescheitert ? 1 : 0) +``` + +### `.vault-tools/workflow-pruefung/mutanten_workflows.py` + +Die ganze Datei ist neu: + +```python +"""Mutantenlauf gegen die Workflows unter .claude/workflows/: je Mutant genau eine Ersetzung, dann faelle_workflows.mjs. + +Jede Ersetzung muss genau einmal vorkommen, sonst bricht der Lauf mit Exit 2 ab: Ein Muster, das +nicht mehr passt, ist ein veralteter Mutant und kein ueberlebter. Ein ueberlebter Mutant ist ein +Befund, ausser er steht in WIRKUNGSLOS mit Begruendung. +Aufruf: python mutanten_workflows.py [] +Exit 0, wenn jeder wirksame Mutant gefangen wird, 1 bei einem Ueberlebenden, 2 bei einem veralteten Muster. +""" +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent +FAELLE = HIER / "faelle_workflows.mjs" + +# Mutanten, die bei den heutigen Stufen kein Verhalten aendern, mit Grund. +WIRKUNGSLOS = { + "harte Obergrenze fehlt": "keine Stufe gibt mehr als vier Plaetze; wer die Stufen anhebt, braucht einen Fall", +} + +MUTANTEN = [ + ("verbrauch", "Stufe 50 -> 60", "[[50, 4], [70, 2], [85, 1]]", "[[60, 4], [70, 2], [85, 1]]"), + ("verbrauch", "Wochenstufe 85 -> 80", "[[70, 4], [85, 2], [95, 1]]", "[[70, 4], [80, 2], [95, 1]]"), + ("verbrauch", "ueber 100 wieder Fehler", "|| p < 0) return", "|| p < 0 || p > 100) return"), + ("verbrauch", "doppeltes Fenster: erstes gewinnt", "if (!e[art] || e[art].prozent < p) e[art] = neu", "if (!e[art]) e[art] = neu"), + ("verbrauch", "Opus-Woche ignoriert", "Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0)", "e.woche.prozent"), + ("verbrauch", "Opus-Woche ersetzt die Woche", "Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0)", "(e.opus ? e.opus.prozent : e.woche.prozent)"), + ("verbrauch", "harte Obergrenze fehlt", "Math.min(wunsch, grenzeFuenf, grenzeWoche, HARTE_OBERGRENZE)", "Math.min(wunsch, grenzeFuenf, grenzeWoche)"), + ("verbrauch", "gebremst auch bei wunsch 0", "const gebremst = wunsch > 0 && plaetze === 0", "const gebremst = plaetze === 0"), + ("verbrauch", "Status ok nicht verlangt", " || ablesung.status !== 'ok'", ""), + ("verbrauch", "Rueckfall 3 statt 2", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), + ("verbrauch", "wunsch-Pruefung fehlt", "if (!Number.isInteger(a.wunsch) || a.wunsch < 0) throw", "if (false) throw"), + ("verbrauch", "fremdes Fenster mit Zahl geprueft", "if (!art) { e.andere.push(f.label); continue }\n const p = f.percentUsed", "const p = f.percentUsed\n if (!art) { if (typeof p !== 'number') return { fehler: 'x' }; e.andere.push(f.label); continue }"), + ("plankritik", "Bremse vor Linsen fehlt", "if (vorLinsen.gebremst) {", "if (false) {"), + ("plankritik", "Bremse vor Widerlegung fehlt", "if (vorWiderlegung.gebremst) {", "if (false) {"), + ("plankritik", "Widerlegung bis vier", "const WIDERLEGUNG_HOECHSTENS = 2", "const WIDERLEGUNG_HOECHSTENS = 4"), + ("plankritik", "Widerlegung ignoriert die Ablesung", "} else plaetzeWiderlegung = vorWiderlegung.plaetze", "} else plaetzeWiderlegung = WIDERLEGUNG_HOECHSTENS"), + ("plankritik", "Linsen fest zwei", "await hoechstens(vorLinsen.plaetze, gewaehlt,", "await hoechstens(2, gewaehlt,"), + ("plankritik", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), + ("plankritik", "Testort fehlt in der Widerlegung", "Du änderst keine Datei im Projekt und keine im Plan. ${testort(ctx)}\n- ", "Du änderst keine Datei im Projekt und keine im Plan.\n- "), + ("plankritik", "Testort fehlt bei den Linsen", "Executer-Bericht, dessen Testlauf du wiederholen könntest. ${testort(ctx)}", "Executer-Bericht, dessen Testlauf du wiederholen könntest. ${linse === 'arithmetik' ? testort(ctx) : ''}"), + ("plankritik", "keine Ablesung am Ende", "await ablesen('Ende', 0)\n", "\n"), + ("plankritik", "gebremste Befunde ohne Hinweis", "'nicht geprüft (gebremst)'", "'nicht geprüft'"), + ("plankritik", "gebremst auch unter der Schwelle", "gebremstVor && zuPruefen.includes(g) ?", "gebremstVor ?"), + ("plankritik", "naechster_schritt uebergeht die Bremse", "naechster_schritt: gebremstVor ? ", "naechster_schritt: false ? "), + ("plankritik", "zaehlung null bei Bremse vor Linsen", "zaehlung: { roh: 0, zusammengefasst: 0, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: 0 },", "zaehlung: null,"), + ("pruefung", "verbrauch false wird uebergangen", "if (args.verbrauch === false) return\n", "\n"), + ("pruefung", "Ablesung nach der Pruefung fehlt", "await ablesen('nach Prüfung')\n", "\n"), + ("welle-mit-pruefung", "Kind liest selbst", "{ ...pruefungArgs, verbrauch: false }", "{ ...pruefungArgs }"), + ("welle-mit-pruefung", "bis vergleich wirkt nicht", "if (args.bis === 'vergleich') {", "if (false) {"), + ("welle-mit-pruefung", "Ablesung vor der Pruefung fehlt", " await ablesen('vor Prüfung')\n", "\n"), + ("planung", "Voraussetzung laufend genuegt", "['fertig', 'erledigt'].includes(status.get(n)))) starte(t)", "['fertig', 'erledigt', 'laeuft'].includes(status.get(n)))) starte(t)"), + ("planung", "keine Blockade", "status.set(t.nr, 'blockiert')\n geaendert = true", "geaendert = false"), + ("planung", "kein Abbruch nach drei", "gescheitert.length >= ABBRUCH_NACH && !abgebrochen", "false"), + ("planung", "Bremse beim Nachlesen fehlt", "if (v.gebremst) {\n gebremst = true\n log('Planung gebremst", "if (false) {\n gebremst = true\n log('Planung gebremst"), + ("planung", "Nachlesen uebernimmt die Plaetze nicht", "log('Planung gebremst: kein weiterer Start, die laufenden Planer enden')\n } else plaetze = v.plaetze", "log('Planung gebremst: kein weiterer Start, die laufenden Planer enden')\n }"), + ("planung", "Bremse am Anfang nicht gemeldet", " if (v.gebremst) gebremst = true\n else plaetze = v.plaetze", " plaetze = v.plaetze"), + ("planung", "Plaetze nicht begrenzt", "if (laufend.size >= plaetze) break", "if (laufend.size >= 99) break"), + ("planung", "Reihenfolge unsortiert", "const reihenfolge = [...args.tasks].sort((x, y) => ((x.welle || 0) - (y.welle || 0)) || (x.nr - y.nr))", "const reihenfolge = [...args.tasks]"), + ("planung", "Nachlesen ohne offene Tasks", "fertigSeitAblesung >= nachlesenAlle && nochOffen()", "fertigSeitAblesung >= nachlesenAlle"), + ("planung", "fremde Datei zaehlt als fertig", "const r = passt ? roh : null", "const r = roh"), + ("planung", "Vorgabe vier statt zwei", "const VORGABE_PLAETZE = 2", "const VORGABE_PLAETZE = 4"), + ("planung", "Zyklenpruefung fehlt", "if (geschafft < a.tasks.length) throw", "if (false) throw"), + ("planung", "erledigt ignoriert", "(args.erledigt || []).includes(t.nr) ? 'erledigt' : 'offen'", "'offen'"), + ("planung", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), +] + + +def main() -> int: + ordner = Path(sys.argv[1]) if len(sys.argv) > 1 else HIER.parent.parent / ".claude" / "workflows" + ueberlebt, veraltet = [], [] + for datei, name, alt, neu in MUTANTEN: + with tempfile.TemporaryDirectory() as tmp: + ziel = Path(tmp) / "wf" + shutil.copytree(ordner, ziel) + pfad = ziel / (datei + ".js") + text = pfad.read_text(encoding="utf-8") + if text.count(alt) != 1: + veraltet.append(name) + print(f"VERALTET {datei}: {name} (Muster {text.count(alt)}-mal)") + continue + pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n") + lauf = subprocess.run(["node", str(FAELLE), str(ziel)], capture_output=True, text=True, encoding="utf-8", errors="replace") + rot = [z for z in lauf.stdout.splitlines() if z.startswith("FEHLER")] + if lauf.returncode == 0: + grund = WIRKUNGSLOS.get(name) + if grund: + print(f"WIRKUNGSLOS {datei}: {name} ({grund})") + else: + ueberlebt.append(name) + print(f"UEBERLEBT {datei}: {name}") + else: + print(f"GEFANGEN {datei}: {name} ({len(rot)} rot, z. B. {rot[0][7:90] if rot else 'Absturz'})") + print(f"{len(MUTANTEN)} Mutanten, {len(ueberlebt)} ueberlebt, {len(veraltet)} veraltet") + return 2 if veraltet else 1 if ueberlebt else 0 + + +if __name__ == "__main__": + sys.stdout.reconfigure(errors="replace") + sys.exit(main()) +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node .vault-tools/workflow-pruefung/faelle_workflows.mjs +``` + +Erwartet: Alle übrigen Zeilen beginnen mit `OK`, die letzte enthält `0 gescheitert`, Exit 0. + +```bash +python .vault-tools/workflow-pruefung/mutanten_workflows.py +``` + +Erwartet: Die letzte Zeile enthält `0 ueberlebt, 0 veraltet`, Exit 0. Der Lauf dauert einige Minuten. + +Danach `git status --porcelain -- .vault-tools/workflow-pruefung/attrappe.mjs .vault-tools/workflow-pruefung/faelle_workflows.mjs .vault-tools/workflow-pruefung/mutanten_workflows.py` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-07-verbrauch-reihe.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-07-verbrauch-reihe.md new file mode 100644 index 0000000..881757d --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-07-verbrauch-reihe.md @@ -0,0 +1,661 @@ +--- +type: plan-task +title: "Task 07 — verbrauch_reihe.py: Messreihe aus den Workflow-Protokollen" +status: draft +summary: "Auswertung der Ablesungen: Differenz im 5-Stunden-Fenster und in der Woche zwischen zwei Ablesungen und die Agentenminuten dazwischen, dazu die Fälle." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: sonnet +effort: low +modell_begruendung: "Abschrift: jede Zeile steht wörtlich im Plan, Zieldateien und Verbote sind genannt. Sonnet low statt Haiku, weil der Code das Zeichen · (U+00B7) trägt und Haiku in der Messung vom 2026-09-23 typografische Zeichen still ersetzt hat." +--- + +# Task 07 — verbrauch_reihe.py: Messreihe aus den Workflow-Protokollen + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. + +Was danach anders ist: + +- **`verbrauch_reihe.py`** liest aus `~/.claude/projects/D--Claude/*/subagents/workflows/wf_*/` die Agenten mit Label `verbrauch:` und rechnet je Paar die Differenz. Ein Reset ist eine Verschiebung von `resetsAt` um mehr als fünf Minuten oder ein fallender Wert; das Zittern um Millisekunden zählt nicht. +- **Die Ausgabe ist reines ASCII**, kaputte Zeilen und Abstürze einzelner Läufe sind Hinweise und kein Abbruch. +- **`mutanten_reihe.py`** fährt die Mutantenliste gegen das Skript. + +## Zieldateien + +- `.vault-tools/workflow-pruefung/verbrauch_reihe.py` (neu) +- `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py` (neu) +- `.vault-tools/workflow-pruefung/mutanten_reihe.py` (neu) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/` und nichts unter `AI/Regelwerk/`, `.claude/agents/` oder `CLAUDE.md`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren, auch nicht von Kommentaren oder Sonderzeichen; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.vault-tools/workflow-pruefung/verbrauch_reihe.py` + +Die ganze Datei ist neu: + +```python +"""Wertet die Verbrauchsablesungen der Workflows aus: wie viel Prozent des 5-Stunden-Fensters und der Woche zwischen zwei Ablesungen verbraucht wurden und welche Agenten dazwischen liefen. + +Koordinator-Werkzeug (entstanden 2026-09-24). Es misst und beurteilt nichts; die Stufen in +.claude/workflows/verbrauch.js stellt der Mensch nach dieser Messreihe ein. +Quelle der Daten ist der Session-Speicher von Claude Code. Je Workflow-Lauf liegen unter +//subagents/workflows// die Datei journal.jsonl (Ereignisse "started" +mit agentId und label, "result" mit agentId und result), je Agent agent-.jsonl (Zeilen mit +timestamp) und agent-.meta.json (model). Ablesungen sind Agenten, deren label mit +"verbrauch:" beginnt; ihr result traegt fenster mit label, percentUsed und resetsAt. +Die Fenster werden wie in verbrauch.js erkannt: "5-hour ..." und "Weekly ... all models". +Was das Skript nicht sieht: den Verbrauch anderer Sitzungen und der Hauptsitzung im selben +Zeitraum (er steckt in der Differenz mit drin), Ablesungen ueber probe (sie starten keinen +Agenten) und Laeufe, deren Protokolle geloescht sind. Die Kennzahl je Opus-Agentenstunde ist eine +Naeherung, kein Nachweis. +Aufruf: python verbrauch_reihe.py [--speicher ] [--seit JJJJ-MM-TT] +Exit 0 mit Auswertung (auch bei null Ablesungen), 2 wenn der Speicher fehlt oder --seit kein +Datum ist. Die Ausgabe ist reines ASCII, damit sie auch auf einer cp1252-Konsole durchlaeuft. +""" + +import argparse +import json +import math +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +STANDARD_SPEICHER = Path.home() / ".claude" / "projects" / "D--Claude" +RESET_TOLERANZ = timedelta(minutes=5) +FUENF = re.compile(r"^\s*5[- ]?hour", re.I) +WOCHE = re.compile(r"^\s*weekly\b.*\ball models\b", re.I) +FAMILIEN = ("opus", "sonnet", "haiku", "fable") + + +def zeit(text): + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def lies_jsonl(pfad, befunde): + """Liest eine JSONL-Datei zeilenweise; kaputte Zeilen sind ein Befund, kein Absturz.""" + zeilen = [] + try: + with pfad.open(encoding="utf-8") as datei: + for nr, zeile in enumerate(datei, 1): + if not zeile.strip(): + continue + try: + zeilen.append(json.loads(zeile)) + except ValueError: + befunde.append(f"{pfad.parent.name}/{pfad.name}: Zeile {nr} ist kein JSON, uebergangen") + except OSError as e: + befunde.append(f"{pfad.parent.name}/{pfad.name}: nicht lesbar ({type(e).__name__})") + return zeilen + + +def einordnen(fenster): + """(prozent, resetsAt) fuer 5 Stunden und Woche; None, wo das Fenster fehlt oder kaputt ist.""" + fuenf = woche = None + if not isinstance(fenster, list): + return None, None + for f in fenster: + if not isinstance(f, dict) or not isinstance(f.get("label"), str): + return None, None + art = "fuenf" if FUENF.match(f["label"]) else "woche" if WOCHE.match(f["label"]) else None + if art is None: + continue + p = f.get("percentUsed") + if isinstance(p, bool) or not isinstance(p, (int, float)) or not math.isfinite(p) or p < 0: + return None, None + eintrag = (float(p), f.get("resetsAt")) + if art == "fuenf" and (fuenf is None or p > fuenf[0]): + fuenf = eintrag + elif art == "woche" and (woche is None or p > woche[0]): + woche = eintrag + return fuenf, woche + + +def zeitspanne(ordner, agent_id, befunde): + stempel = [] + for o in lies_jsonl(ordner / f"agent-{agent_id}.jsonl", befunde): + t = o.get("timestamp") if isinstance(o, dict) else None + if isinstance(t, str): + try: + stempel.append(zeit(t)) + except ValueError: + pass + return (min(stempel), max(stempel)) if stempel else None + + +def familie(ordner, agent_id): + try: + modell = json.loads((ordner / f"agent-{agent_id}.meta.json").read_text(encoding="utf-8")).get("model") + except (OSError, ValueError, AttributeError): + return "?" + if not isinstance(modell, str): + return "?" + for f in FAMILIEN: + if f in modell.lower(): + return f + return modell + + +def lauf_auswerten(ordner, befunde): + labels, ergebnisse = {}, {} + for o in lies_jsonl(ordner / "journal.jsonl", befunde): + if not isinstance(o, dict) or not isinstance(o.get("agentId"), str): + continue + if o.get("type") == "started": + labels[o["agentId"]] = o.get("label") if isinstance(o.get("label"), str) else "" + elif o.get("type") == "result": + ergebnisse[o["agentId"]] = o.get("result") + ablesungen, agenten = [], [] + for agent_id, label in labels.items(): + spanne = zeitspanne(ordner, agent_id, befunde) + if label.startswith("verbrauch:"): + if spanne is None: + befunde.append(f"{ordner.name}: Ablesung {label} ohne Zeitstempel, uebergangen") + continue + ergebnis = ergebnisse.get(agent_id) + fuenf, woche = einordnen(ergebnis.get("fenster") if isinstance(ergebnis, dict) else None) + # Ablesezeit ist das Ende des Ableseagenten: get_usage laeuft kurz vor seinem Schluss. + ablesungen.append({"anlass": label[len("verbrauch:"):], "zeit": spanne[1], "fuenf": fuenf, "woche": woche}) + elif spanne is not None: + agenten.append({"familie": familie(ordner, agent_id), "start": spanne[0], "ende": spanne[1]}) + ablesungen.sort(key=lambda a: a["zeit"]) + return ablesungen, agenten + + +def zeitpunkt(text): + if not isinstance(text, str): + return None + try: + return zeit(text) + except ValueError: + return None + + +def differenz(a, b): + """Prozentpunkte von a nach b im selben Fenster. + + get_usage rechnet resetsAt bei jeder Ablesung neu aus; der Wert schwankt um Millisekunden und + springt dabei auch ueber eine Sekunden- oder Datumsgrenze (gemessen 2026-09-24: 18:29:59.664Z, + 18:30:00.267Z, 18:30:00.459Z fuer dasselbe Fenster). Als Reset gilt deshalb erst eine + Verschiebung um mehr als RESET_TOLERANZ oder ein fallender Wert. + """ + if a is None or b is None: + return "unbekannt" + ra, rb = zeitpunkt(a[1]), zeitpunkt(b[1]) + if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None): + return "unbekannt" + if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]: + return "Reset" + return round(b[0] - a[0], 1) + + +def paare(ordner, ablesungen, agenten): + for a, b in zip(ablesungen, ablesungen[1:]): + minuten = {} + for g in agenten: + anteil = (min(g["ende"], b["zeit"]) - max(g["start"], a["zeit"])).total_seconds() + if anteil > 0: + minuten[g["familie"]] = minuten.get(g["familie"], 0.0) + anteil / 60 + yield { + "lauf": ordner.name, + "von": a["anlass"], + "bis": b["anlass"], + "minuten": round((b["zeit"] - a["zeit"]).total_seconds() / 60, 1), + "agenten": {k: round(v, 1) for k, v in sorted(minuten.items())}, + "d5": differenz(a["fuenf"], b["fuenf"]), + "dw": differenz(a["woche"], b["woche"]), + } + + +UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"}) + + +def nur_ascii(text): + return str(text).translate(UMLAUTE).encode("ascii", "replace").decode("ascii") + + +def main(argv=None): + p = argparse.ArgumentParser(description="Messreihe der Verbrauchsablesungen aus den Workflow-Protokollen") + p.add_argument("--speicher", type=Path, default=STANDARD_SPEICHER) + p.add_argument("--seit", default=None, help="nur Ablesungen ab diesem Tag (JJJJ-MM-TT, UTC)") + a = p.parse_args(argv) + if not a.speicher.is_dir(): + print(nur_ascii(f"Speicher fehlt: {a.speicher}")) + return 2 + seit = None + if a.seit: + try: + seit = datetime.fromisoformat(a.seit).replace(tzinfo=timezone.utc) + except ValueError: + print(nur_ascii(f"--seit {a.seit!r} ist kein Datum JJJJ-MM-TT")) + return 2 + befunde, zeilen, anzahl = [], [], 0 + for ordner in sorted(a.speicher.glob("*/subagents/workflows/wf_*")): + if not ordner.is_dir(): + continue + try: + ablesungen, agenten = lauf_auswerten(ordner, befunde) + if seit is not None: + ablesungen = [x for x in ablesungen if x["zeit"] >= seit] + anzahl += len(ablesungen) + zeilen.extend(paare(ordner, ablesungen, agenten)) + except Exception as e: # Ein Absturz ist ein Befund (pruefwerkzeuge.md, Abschnitt 4). + befunde.append(f"{ordner.name}: Auswertung abgebrochen ({type(e).__name__}: {e})") + print(f"{anzahl} Ablesungen, {len(zeilen)} Paare") + if zeilen: + print("| Lauf | von -> bis | Minuten | Agentenminuten | d 5h % | d Woche % |") + print("|---|---|---|---|---|---|") + for z in zeilen: + agenten = ", ".join(f"{k} {v}" for k, v in z["agenten"].items()) or "-" + print(nur_ascii(f"| {z['lauf']} | {z['von']} -> {z['bis']} | {z['minuten']} | {agenten} | {z['d5']} | {z['dw']} |")) + werte = [z for z in zeilen if isinstance(z["d5"], float) and z["agenten"].get("opus", 0) > 0] + if werte: + prozent = sum(z["d5"] for z in werte) + stunden = sum(z["agenten"]["opus"] for z in werte) / 60 + print(f"Naeherung: {prozent / stunden:.1f} % des 5-Stunden-Fensters je Opus-Agentenstunde " + f"({len(werte)} Paare, {stunden:.1f} Opus-Stunden; andere Sitzungen und Nicht-Opus-Agenten stecken in der Differenz)") + else: + print("Keine auswertbaren Paare mit Opus-Agenten.") + if befunde: + print("Hinweise:") + for b in befunde: + print(nur_ascii("- " + b)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +### `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py` + +Die ganze Datei ist neu: + +```python +"""Faelle fuer verbrauch_reihe.py: baut je Fall einen kuenstlichen Session-Speicher und faehrt das Skript als eigenen Prozess. + +Die Grundlinie laeuft zuerst; scheitert sie, messen die uebrigen Faelle nichts. Der Prozess laeuft +ohne PYTHONIOENCODING, damit eine Ausgabe ausserhalb von ASCII auf cp1252 auffaellt. +Aufruf: python faelle_verbrauch_reihe.py +Exit 0, wenn alle Faelle bestehen, sonst 1. +""" + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +SKRIPT = Path(__file__).with_name("verbrauch_reihe.py") + + +def fenster(fuenf, woche, reset5="2026-09-24T18:30:00Z", resetw="2026-09-28T00:00:00Z"): + return [ + {"label": "5-hour limit", "percentUsed": fuenf, "resetsAt": reset5}, + {"label": "Weekly · all models", "percentUsed": woche, "resetsAt": resetw}, + ] + + +def baue(speicher, laeufe): + """laeufe: {lauf: [agent]}; agent: {id, label, start, ende, modell, ergebnis, ohne_zeit, kaputt}.""" + for lauf, agenten in laeufe.items(): + ordner = speicher / "sitzung-1" / "subagents" / "workflows" / lauf + ordner.mkdir(parents=True) + with (ordner / "journal.jsonl").open("w", encoding="utf-8") as journal: + journal.write(json.dumps({"type": "launched"}) + "\n") + for a in agenten: + journal.write(json.dumps({"type": "started", "agentId": a["id"], "label": a["label"]}) + "\n") + if a.get("kaputt"): + journal.write("{kein json\n") + if "ergebnis" in a: + journal.write(json.dumps({"type": "result", "agentId": a["id"], "result": a["ergebnis"]}) + "\n") + for a in agenten: + (ordner / f"agent-{a['id']}.meta.json").write_text(json.dumps({"model": a.get("modell", "haiku")}), encoding="utf-8") + if a.get("ohne_zeit"): + continue + with (ordner / f"agent-{a['id']}.jsonl").open("w", encoding="utf-8") as d: + d.write(json.dumps({"timestamp": a["start"], "type": "user"}) + "\n") + d.write(json.dumps({"timestamp": a["ende"], "type": "assistant"}) + "\n") + + +def ablesung(nr, anlass, zeitpunkt, fuenf, woche, **extra): + ende = extra.pop("bis", None) or zeitpunkt + return {"id": f"v{nr}", "label": "verbrauch:" + anlass, "start": zeitpunkt, "ende": ende, "modell": "haiku", + "ergebnis": {"verfuegbar": True, "status": "ok", "plan": "Pro", "fenster": extra.pop("liste", None) or fenster(fuenf, woche, **extra), "fehler": ""}} + + +# resetsAt schwankt bei echten Ablesungen um Millisekunden und springt ueber Sekunden- und +# Datumsgrenzen (gemessen 2026-09-24); die Grundlinie traegt deshalb solche Werte. +ZITTER = {"reset5": "2026-09-24T18:29:59.664Z", "resetw": "2026-09-27T23:59:59.664Z"} + + +def opus(nr, start, ende, modell="opus"): + return {"id": f"o{nr}", "label": f"plankritik:linse{nr}", "start": start, "ende": ende, "modell": modell, "ergebnis": {}} + + +def fahre(speicher, *zusatz): + umgebung = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")} + lauf = subprocess.run([sys.executable, str(SKRIPT), "--speicher", str(speicher), *zusatz], + capture_output=True, env=umgebung) + return lauf.returncode, lauf.stdout.decode("ascii", "replace"), lauf.stderr.decode("utf-8", "replace") + + +GRUND = { + "wf_a": [ + ablesung(1, "plankritik:vor Linsen", "2026-09-24T10:00:00Z", 10, 5), + opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z"), + ablesung(2, "plankritik:Ende", "2026-09-24T10:30:10Z", 14, 6, **ZITTER), + ] +} + + +def f_grundlinie(tmp): + baue(tmp, GRUND) + code, aus, err = fahre(tmp) + fehler = [] + if code != 0: + fehler.append(f"Exit {code}: {err[-300:]}") + for erwartet in ["2 Ablesungen, 1 Paare", "| wf_a | plankritik:vor Linsen -> plankritik:Ende | 30.2 | opus 30.0 | 4.0 | 1.0 |", + "Naeherung: 8.0 % des 5-Stunden-Fensters je Opus-Agentenstunde (1 Paare, 0.5 Opus-Stunden"]: + if erwartet not in aus: + fehler.append("fehlt: " + erwartet) + if "Hinweise" in aus: + fehler.append("unerwartete Hinweise") + return fehler + + +def f_reset(tmp): + baue(tmp, {"wf_r": [ablesung(1, "a", "2026-09-24T10:00:00Z", 80, 5), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"), + ablesung(2, "b", "2026-09-24T10:30:00Z", 3, 6, reset5="2026-09-24T15:30:00Z")]}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "| Reset | 1.0 |" not in aus: + fehler.append("Reset nicht erkannt: " + aus[-300:]) + if "Keine auswertbaren Paare mit Opus-Agenten." not in aus: + fehler.append("Reset-Paar wurde mitgerechnet") + return fehler + + +def f_speicher_fehlt(tmp): + code, aus, _ = fahre(tmp / "gibt-es-nicht") + return [] if code == 2 and "Speicher fehlt" in aus else [f"Exit {code}, Ausgabe {aus!r}"] + + +def f_null_ablesungen(tmp): + baue(tmp, {"wf_n": [opus(1, "2026-09-24T10:00:00Z", "2026-09-24T10:10:00Z")]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "0 Ablesungen, 0 Paare" in aus else [f"Exit {code}, Ausgabe {aus!r}"] + + +def f_kaputte_zeile(tmp): + agenten = [dict(GRUND["wf_a"][0], kaputt=True), GRUND["wf_a"][1], GRUND["wf_a"][2]] + baue(tmp, {"wf_k": agenten}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "1 Paare" not in aus: + fehler.append("Auswertung trotz kaputter Zeile gescheitert: " + aus[-300:]) + if "ist kein JSON" not in aus: + fehler.append("kaputte Zeile nicht als Hinweis gemeldet") + return fehler + + +def f_ohne_zeitstempel(tmp, position): + agenten = [dict(a) for a in GRUND["wf_a"]] + agenten[position]["ohne_zeit"] = True + baue(tmp, {"wf_z": agenten}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "1 Ablesungen, 0 Paare" not in aus: + fehler.append(f"Exit {code}, Ausgabe {aus[-300:]!r}") + if "ohne Zeitstempel" not in aus: + fehler.append("fehlender Zeitstempel nicht gemeldet") + return fehler + + +def f_fenster_kaputt(tmp, liste, erwartet="| unbekannt | unbekannt |"): + baue(tmp, {"wf_f": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, liste=liste), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"), + ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 6)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_umlaut_im_anlass(tmp): + baue(tmp, {"wf_u": [ablesung(1, "pruefung:vor Prüfung", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "pruefung:nach Prüfung", "2026-09-24T10:30:00Z", 12, 5)]}) + code, aus, err = fahre(tmp) + return [] if code == 0 and "pruefung:vor Pruefung -> pruefung:nach Pruefung" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}, stderr {err[-200:]!r}"] + + +def f_seit(tmp): + baue(tmp, {"wf_s": [ablesung(1, "alt", "2026-09-20T10:00:00Z", 10, 5), ablesung(2, "a", "2026-09-24T10:00:00Z", 10, 5), + ablesung(3, "b", "2026-09-24T11:00:00Z", 12, 5)]}) + code, aus, _ = fahre(tmp, "--seit", "2026-09-24") + return [] if code == 0 and "2 Ablesungen, 1 Paare" in aus and "alt ->" not in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_seit_ungueltig(tmp): + baue(tmp, GRUND) + code, aus, _ = fahre(tmp, "--seit", "gestern") + return [] if code == 2 and "kein Datum" in aus else [f"Exit {code}, Ausgabe {aus!r}"] + + +def f_voller_modellname(tmp): + baue(tmp, {"wf_m": [GRUND["wf_a"][0], opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z", modell="claude-opus-5-5"), GRUND["wf_a"][2]]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "opus 30.0" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_agent_ausserhalb(tmp): + # Ein Agent, der vor der ersten Ablesung endet, zaehlt nicht mit; einer, der halb hineinreicht, anteilig. + baue(tmp, {"wf_x": [opus(1, "2026-09-24T09:00:00Z", "2026-09-24T09:50:00Z"), GRUND["wf_a"][0], + opus(2, "2026-09-24T09:55:00Z", "2026-09-24T10:10:00Z"), GRUND["wf_a"][2]]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| opus 10.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-400:]!r}"] + + +def f_absturz_ist_befund(tmp): + # Ein Zeitstempel ohne Zeitzone neben einem mit ihr laesst den Vergleich mit TypeError scheitern. + # Der Lauf muss das als Hinweis melden und die anderen Laeufe trotzdem auswerten. + kaputt = [dict(GRUND["wf_a"][0]), dict(GRUND["wf_a"][1], start="2026-09-24T10:00:10"), dict(GRUND["wf_a"][2])] + baue(tmp, {"wf_a": GRUND["wf_a"], "wf_b": kaputt}) + code, aus, err = fahre(tmp) + fehler = [] + if code != 0: + fehler.append(f"Exit {code}: {err[-300:]}") + if "wf_b: Auswertung abgebrochen (TypeError" not in aus: + fehler.append("Absturz nicht als Hinweis gemeldet") + if "| wf_a |" not in aus: + fehler.append("der gesunde Lauf fehlt") + return fehler + + +def f_fallender_wert(tmp): + baue(tmp, {"wf_d": [ablesung(1, "a", "2026-09-24T10:00:00Z", 30, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 5, 5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| Reset | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_reset_unlesbar(tmp): + baue(tmp, {"wf_u": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, reset5="bald"), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 6)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| unbekannt | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_hoechstes_fenster(tmp): + liste = [{"label": "5-hour limit", "percentUsed": 10, "resetsAt": "2026-09-24T18:30:00Z"}, + {"label": "5-hour limit", "percentUsed": 30, "resetsAt": "2026-09-24T18:30:00Z"}, + {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}] + baue(tmp, {"wf_h": [ablesung(1, "a", "2026-09-24T10:00:00Z", 0, 0, liste=liste), ablesung(2, "b", "2026-09-24T10:20:00Z", 35, 6)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| 5.0 | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_ablesezeit_ist_ende(tmp): + # Der Opus-Agent laeuft ganz innerhalb des ersten Ableseagenten, also vor der Ablesung. + baue(tmp, {"wf_t": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, bis="2026-09-24T10:00:10Z"), + opus(1, "2026-09-24T10:00:02Z", "2026-09-24T10:00:08Z"), + ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| 29.8 | - | 2.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_agent_ueber_das_paar_hinaus(tmp): + baue(tmp, {"wf_b": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5), + opus(1, "2026-09-24T10:20:00Z", "2026-09-24T10:50:00Z"), ablesung(3, "c", "2026-09-24T11:00:00Z", 15, 5)]}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "| a -> b | 30.0 | opus 10.0 |" not in aus: + fehler.append("erstes Paar nicht am Ende gekappt: " + aus[-400:]) + if "| b -> c | 30.0 | opus 20.0 |" not in aus: + fehler.append("zweites Paar falsch: " + aus[-400:]) + return fehler + + +def f_ueber_hundert(tmp): + baue(tmp, {"wf_g": [ablesung(1, "a", "2026-09-24T10:00:00Z", 98, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 103, 5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| 5.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +FAELLE = [ + ("Grundlinie: zwei Ablesungen, ein Opus-Agent dazwischen", f_grundlinie), + ("Reset zwischen zwei Ablesungen", f_reset), + ("Speicher fehlt: Exit 2", f_speicher_fehlt), + ("null Ablesungen: Exit 0 mit Zaehlung", f_null_ablesungen), + ("kaputte Journalzeile: Hinweis, kein Absturz", f_kaputte_zeile), + ("erste Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 0)), + ("letzte Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 2)), + ("5-Stunden-Fenster fehlt", lambda t: f_fenster_kaputt(t, [{"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}], "| unbekannt | 1.0 |")), + ("Umlaut im Anlass auf cp1252", f_umlaut_im_anlass), + ("Prozent als Text", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": "10", "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])), + ("Prozent als Wahrheitswert", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": True, "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])), + ("fenster kein Array", lambda t: f_fenster_kaputt(t, {"a": 1})), + ("--seit filtert aeltere Ablesungen", f_seit), + ("--seit ungueltig: Exit 2", f_seit_ungueltig), + ("voller Modellname zaehlt als opus", f_voller_modellname), + ("Agent ausserhalb und halb innerhalb des Paars", f_agent_ausserhalb), + ("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund), + ("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert), + ("resetsAt ohne Datum: unbekannt", f_reset_unlesbar), + ("doppeltes 5-Stunden-Fenster: das hoechste zaehlt", f_hoechstes_fenster), + ("Ablesezeit ist das Ende des Ableseagenten", f_ablesezeit_ist_ende), + ("Agent ueber das Paar hinaus wird am Ende gekappt", f_agent_ueber_das_paar_hinaus), + ("ueber 100 Prozent ist ein Wert, kein Fehler", f_ueber_hundert), +] + + +def main(): + # Fehlertexte koennen Zeichen ausserhalb der Konsolenkodierung tragen; der Treiber darf daran nicht sterben. + sys.stdout.reconfigure(errors="replace") + gescheitert = 0 + for name, fall in FAELLE: + with tempfile.TemporaryDirectory() as tmp: + try: + fehler = fall(Path(tmp)) + except Exception as e: # ein Absturz im Fall ist selbst ein Befund + fehler = [f"Absturz im Fall: {type(e).__name__}: {e}"] + if fehler: + gescheitert += 1 + print(f"FEHLER {name}\n " + "\n ".join(fehler)) + else: + print(f"OK {name}") + print(f"{len(FAELLE)} Faelle, {gescheitert} gescheitert") + return 1 if gescheitert else 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +### `.vault-tools/workflow-pruefung/mutanten_reihe.py` + +Die ganze Datei ist neu: + +```python +"""Mutantenlauf gegen verbrauch_reihe.py: je Mutant genau eine Ersetzung in einer Kopie des Ordners, dann faelle_verbrauch_reihe.py. + +Jede Ersetzung muss genau einmal vorkommen, sonst bricht der Lauf mit Exit 2 ab (veralteter Mutant). +Aufruf: python mutanten_reihe.py +Exit 0, wenn jeder Mutant gefangen wird, 1 bei einem Ueberlebenden, 2 bei einem veralteten Muster. +""" +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent + +MUTANTEN = [ + ("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"), + ("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"), + ("unlesbares resetsAt als gleich", " if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None):\n return \"unbekannt\"\n", ""), + ("bool als Zahl", "isinstance(p, bool) or ", ""), + ("ueber 100 als Fehler", "not math.isfinite(p) or p < 0", "not math.isfinite(p) or not 0 <= p <= 100"), + ("erstes statt hoechstes 5-Stunden-Fenster", "if art == \"fuenf\" and (fuenf is None or p > fuenf[0]):", "if art == \"fuenf\" and fuenf is None:"), + ("Ablesezeit Start statt Ende", "\"zeit\": spanne[1]", "\"zeit\": spanne[0]"), + ("nur_ascii fehlt", "print(nur_ascii(f\"| {z", "print((f\"| {z"), + ("Ende des Paars nicht gekappt", "min(g[\"ende\"], b[\"zeit\"])", "g[\"ende\"]"), + ("Anfang des Paars nicht gekappt", "max(g[\"start\"], a[\"zeit\"])", "g[\"start\"]"), + ("Absturz nicht gefangen", "except Exception as e: # Ein Absturz", "except ZeroDivisionError as e: # Ein Absturz"), + ("kaputte Zeile bricht ab", " except ValueError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile", " except KeyError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile"), + ("seit ignoriert", "ablesungen = [x for x in ablesungen if x[\"zeit\"] >= seit]", "pass"), + ("Speicher fehlt still", " return 2\n seit = None", " return 0\n seit = None"), + ("Familie aus vollem Namen fehlt", " if f in modell.lower():", " if f == modell.lower():"), +] + + +def main() -> int: + ueberlebt, veraltet = [], [] + for name, alt, neu in MUTANTEN: + with tempfile.TemporaryDirectory() as tmp: + ziel = Path(tmp) / "w" + ziel.mkdir() + for datei in ("verbrauch_reihe.py", "faelle_verbrauch_reihe.py"): + shutil.copy2(HIER / datei, ziel / datei) + pfad = ziel / "verbrauch_reihe.py" + text = pfad.read_text(encoding="utf-8") + if text.count(alt) != 1: + veraltet.append(name) + print(f"VERALTET {name} (Muster {text.count(alt)}-mal)") + continue + pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n") + lauf = subprocess.run([sys.executable, str(ziel / "faelle_verbrauch_reihe.py")], capture_output=True, text=True, encoding="utf-8", errors="replace") + rot = [z for z in lauf.stdout.splitlines() if z.startswith("FEHLER")] + if lauf.returncode == 0: + ueberlebt.append(name) + print(f"UEBERLEBT {name}") + else: + print(f"GEFANGEN {name} ({rot[0][7:80] if rot else 'Absturz'})") + print(f"{len(MUTANTEN)} Mutanten, {len(ueberlebt)} ueberlebt, {len(veraltet)} veraltet") + return 2 if veraltet else 1 if ueberlebt else 0 + + +if __name__ == "__main__": + sys.stdout.reconfigure(errors="replace") + sys.exit(main()) +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +python .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py +``` + +Erwartet: Alle übrigen Zeilen beginnen mit `OK`, die letzte enthält `0 gescheitert`, Exit 0. + +```bash +python .vault-tools/workflow-pruefung/mutanten_reihe.py +``` + +Erwartet: Die letzte Zeile enthält `0 ueberlebt, 0 veraltet`, Exit 0. + +Danach `git status --porcelain -- .vault-tools/workflow-pruefung/verbrauch_reihe.py .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py .vault-tools/workflow-pruefung/mutanten_reihe.py` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-08-nachbesserung-verbrauch.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-08-nachbesserung-verbrauch.md new file mode 100644 index 0000000..a04d6dc --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-08-nachbesserung-verbrauch.md @@ -0,0 +1,74 @@ +--- +type: plan-task +title: "Task 08 — Nachbesserung verbrauch.js: über 100 Prozent bremst" +status: draft +summary: "Delta nach Welle 0: Ein Fenster über 100 Prozent ist ein gültiger Wert und bremst, statt als unbekanntes Fenster auf zwei Plätze zurückzufallen. Befund der Plankritik vom 2026-09-24." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: haiku +modell_begruendung: "Abschrift: zwei Aus-wird-Blöcke stehen wörtlich im Plan, Zieldatei und Verbote sind genannt, kein Codeblock trägt typografische Anführungszeichen, Striche, Auslassungspunkte oder das Zeichen ·." +--- + +# Task 08 — Nachbesserung verbrauch.js: über 100 Prozent bremst + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. Task 01 ist in Welle 0 ausgeführt und bleibt byte-gleich; diese Nachbesserung ändert die Datei nur an zwei Stellen. + +Was danach anders ist: + +- **Über 100 % bremst.** Bisher war ein Fenster über 100 % ein unbekanntes Fenster mit Rückfall auf zwei Plätze, die Bremse lief also nicht monoton: bei 84 % ein Platz, bei 85 bis 100 % keiner, bei 101 % wieder zwei. Jetzt ist nur ein negativer oder nicht endlicher Wert ein Befund. + +## Zieldateien + +- `.claude/workflows/verbrauch.js` (geändert) + +## Verbote + +- Keine andere Datei, auch nicht die übrigen Workflows unter `.claude/workflows/`. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung + +### `.claude/workflows/verbrauch.js`, Stelle 1 + +Aus: + +```javascript +// Ordnet die Fenster ein. Ein Fenster, das als 5 Stunden oder Woche erkannt wird, aber keine +// Prozentzahl von 0 bis 100 trägt, ist ein Befund und wird nicht still übergangen. +``` + +wird: + +```javascript +// Ordnet die Fenster ein. Ein Fenster, das als 5 Stunden oder Woche erkannt wird, aber keine +// Prozentzahl ab 0 trägt, ist ein Befund und wird nicht still übergangen. Über 100 % heißt +// überschritten und bremst wie jeder Wert über der letzten Stufe. +``` + +### `.claude/workflows/verbrauch.js`, Stelle 2 + +Aus: + +```javascript + if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 100) return { fehler: `${f.label}: percentUsed ${JSON.stringify(p)} ist keine Zahl von 0 bis 100` } +``` + +wird: + +```javascript + if (typeof p !== 'number' || !Number.isFinite(p) || p < 0) return { fehler: `${f.label}: percentUsed ${JSON.stringify(p)} ist keine Zahl ab 0` } +``` + +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node -e "const t=require('fs').readFileSync(process.argv[1],'utf8').replace(/^export const meta = /m,'const meta = ');new (Object.getPrototypeOf(async function(){}).constructor)('agent','parallel','pipeline','phase','log','workflow','args','budget',t);console.log('PARSE OK')" .claude/workflows/verbrauch.js +``` + +Erwartet: `PARSE OK`, Exit 0. + +Danach `git status --porcelain -- .claude/workflows/verbrauch.js` zeigen. diff --git a/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-09-nachbesserung-pruefung.md b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-09-nachbesserung-pruefung.md new file mode 100644 index 0000000..5b694a5 --- /dev/null +++ b/.vault-tools/workflow-pruefung/docs/plans/2026-09-24-parallelitaet/task-09-nachbesserung-pruefung.md @@ -0,0 +1,438 @@ +--- +type: plan-task +title: "Task 09 — Nachbesserung nach der Prüfung: Fälle für die Reset-Toleranz und kleine Lücken" +status: draft +summary: "Delta nach Welle 1 und 2 aus der Prüfung wf_e0294c82-76c: Regressionsfälle und Mutanten für die Reset-Toleranz, doppelte Linsen abweisen, Status ohne_pruefung, Dateiname mit Pfadgrenze, Kindphase mit Nummer, Fälle für den Nachlesetakt, den Lesehinweis, fehlendes verbrauch.js und das verankerte 5-Stunden-Label." +tags: [plan-task, vault-tools, codeworkflow] +created: 2026-09-24 +updated: 2026-09-24 +modell: sonnet +effort: low +modell_begruendung: "Abschrift: alle Aus-wird-Blöcke stehen wörtlich im Plan, Zieldateien und Verbote sind genannt. Sonnet low statt Haiku, weil Blöcke die Zeichen · und ▸ tragen und Haiku in der Messung vom 2026-09-23 typografische Zeichen still ersetzt hat." +--- + +# Task 09 — Nachbesserung nach der Prüfung + +Die globalen Vorgaben stehen in `00-uebersicht.md` (Abschnitt „Globale Vorgaben“). Arbeitsverzeichnis ist der Vault-Wurzelordner `D:/Claude`; alle Pfade gelten von dort. Die Tasks 02 bis 07 sind ausgeführt und bleiben byte-gleich; diese Nachbesserung ändert die Dateien nur an den Stellen unten. Jeder Aus-Block steht genau einmal in seiner Datei. + +Was danach anders ist: + +- **Die Reset-Toleranz hat Fälle für ihren Wert:** Verschiebung um 4:59 (dasselbe Fenster), um 5:01 nach vorn und nach hinten (Reset), um fünf Stunden bei steigendem Wert (Reset). Die Mutanten 6 Stunden, 1 Sekunde und „nur nach vorn“ werden gefangen. +- **`plankritik.js`** weist doppelte Linsen ab. **`welle-mit-pruefung.js`** meldet `status: ohne_pruefung`, wenn die Prüfung übersprungen wurde oder scheiterte. **`planung.js`** vergleicht den Dateinamen des Planer-Ergebnisses mit Pfadgrenze. +- **Die Attrappe** nummeriert wiederholte Kinder wie das Ziel (`▸ name #2`). +- **Neue Fälle** für den Nachlesetakt, den Lesehinweis im Planerauftrag, ein fehlendes `verbrauch.js` in `welle-mit-pruefung` und das verankerte 5-Stunden-Label, dazu die Mutanten dafür. + +## Zieldateien + +- `.claude/workflows/plankritik.js` (geändert) +- `.claude/workflows/planung.js` (geändert) +- `.claude/workflows/welle-mit-pruefung.js` (geändert) +- `.vault-tools/workflow-pruefung/attrappe.mjs` (geändert) +- `.vault-tools/workflow-pruefung/faelle_workflows.mjs` (geändert) +- `.vault-tools/workflow-pruefung/mutanten_workflows.py` (geändert) +- `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py` (geändert) +- `.vault-tools/workflow-pruefung/mutanten_reihe.py` (geändert) + +## Verbote + +- Keine andere Datei. +- Kein Commit, kein Push, kein Lauf eines echten Workflows. +- Kein Umformulieren; ein Widerspruch zwischen Plan und Baum wird gemeldet. + +## Umsetzung +### `.claude/workflows/plankritik.js`, Stelle 1 + +Aus: + +```javascript + if (unbekannt.length) throw new Error('plankritik: unbekannte Linse: ' + unbekannt.join(', ') + '; bekannt: ' + Object.keys(LINSEN).join(', ')) + if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('plankritik: effort "' + a.effort + '" ungültig') +``` + +wird: + +```javascript + if (unbekannt.length) throw new Error('plankritik: unbekannte Linse: ' + unbekannt.join(', ') + '; bekannt: ' + Object.keys(LINSEN).join(', ')) + if (a.linsen && new Set(a.linsen).size !== a.linsen.length) throw new Error('plankritik: eine Linse steht doppelt in linsen: ' + a.linsen.join(', ')) + if (a.effort !== undefined && !EFFORTS.includes(a.effort)) throw new Error('plankritik: effort "' + a.effort + '" ungültig') +``` + +### `.claude/workflows/planung.js`, Stelle 1 + +Aus: + +```javascript + laufend.delete(nr) + const passt = roh && typeof roh.datei === 'string' && roh.datei.replace(/\\/g, '/').endsWith(jeNr.get(nr).datei) + const r = passt ? roh : null +``` + +wird: + +```javascript + laufend.delete(nr) + const passt = roh && typeof roh.datei === 'string' && ('/' + roh.datei.replace(/\\/g, '/')).endsWith('/' + jeNr.get(nr).datei) + const r = passt ? roh : null +``` + +### `.claude/workflows/welle-mit-pruefung.js`, Stelle 1 + +Aus: + +```javascript +return { + status: 'fertig', + kopie, +``` + +wird: + +```javascript +return { + status: pruefung ? 'fertig' : 'ohne_pruefung', + kopie, +``` + +### `.vault-tools/workflow-pruefung/attrappe.mjs`, Stelle 1 + +Aus: + +```javascript +// "nesting is limited to one level"; die Attrappe setzt einen Doppelpunkt statt des Strichs. +// - Im Kind ist phase() wirkungslos, und jeder agent() bekommt die Phase "▸ ". +// - workflow() mit unbekanntem Namen oder unlesbarem scriptPath wirft. +``` + +wird: + +```javascript +// "nesting is limited to one level"; die Attrappe setzt einen Doppelpunkt statt des Strichs. +// - Im Kind ist phase() wirkungslos, und jeder agent() bekommt die Phase "▸ ", ab dem +// zweiten Aufruf desselben Kindes "▸ #" (Bundle 2.1.280 und meta.json eines +// echten Laufs mit drei Ablesungen: "▸ verbrauch", "▸ verbrauch #2", "▸ verbrauch #3"). +// - workflow() mit unbekanntem Namen oder unlesbarem scriptPath wirft. +``` + +### `.vault-tools/workflow-pruefung/attrappe.mjs`, Stelle 2 + +Aus: + +```javascript + const laufendJePhase = {} + let uhr = 0 +``` + +wird: + +```javascript + const laufendJePhase = {} + const kindAufrufe = {} + let uhr = 0 +``` + +### `.vault-tools/workflow-pruefung/attrappe.mjs`, Stelle 3 + +Aus: + +```javascript + const { name, fn } = lade(datei) + const kindPhase = tiefe >= 1 ? '▸ ' + name : null + let aktuellePhase = null +``` + +wird: + +```javascript + const { name, fn } = lade(datei) + let kindPhase = null + if (tiefe >= 1) { + kindAufrufe[name] = (kindAufrufe[name] || 0) + 1 + kindPhase = '▸ ' + name + (kindAufrufe[name] > 1 ? ' #' + kindAufrufe[name] : '') + } + let aktuellePhase = null +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 1 + +Aus: + +```javascript +// Exit 0, wenn alle Fälle bestehen, sonst 1. +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import os from 'node:os' +``` + +wird: + +```javascript +// Exit 0, wenn alle Fälle bestehen, sonst 1. +import { mkdtempSync, writeFileSync, rmSync, cpSync } from 'node:fs' +import os from 'node:os' +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 2 + +Aus: + +```javascript + soll(JSON.stringify(p.phasen) === '["Oben"]', 'phase() im Kind hat gewirkt: ' + JSON.stringify(p.phasen)) + return fehler +``` + +wird: + +```javascript + soll(JSON.stringify(p.phasen) === '["Oben"]', 'phase() im Kind hat gewirkt: ' + JSON.stringify(p.phasen)) + return fehler +}) + +fall('attrappe', 'dasselbe Kind zweimal: ▸ name und ▸ name #2', async () => { + const { fehler, soll } = pruefe() + const kind = skript('kindzwei', "return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet' })") + const { protokoll: p } = await A(skript('elternzwei', `await workflow({ scriptPath: ${JSON.stringify(kind)} }, {}); return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`)) + soll(JSON.stringify(p.agents.map(a => a.phase)) === '["▸ kindzwei","▸ kindzwei #2"]', 'Phasen: ' + JSON.stringify(p.agents.map(a => a.phase))) + return fehler +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 3 + +Aus: + +```javascript + +fall('verbrauch', 'Woche aller Modelle 90 % zählt auch neben einer niedrigen Opus-Woche', async () => { +``` + +wird: + +```javascript + +fall('verbrauch', 'ein Label mit "hour" in der Mitte ist kein 5-Stunden-Fenster', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Opus hours', percentUsed: 90, resetsAt: 'z' }]) }) + soll(e && e.plaetze === 1 && e.fuenf_stunden.prozent === 10, JSON.stringify(e)) + return fehler +}) + +fall('verbrauch', 'Woche aller Modelle 90 % zählt auch neben einer niedrigen Opus-Woche', async () => { +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 4 + +Aus: + +```javascript + +fall('plankritik', 'Linse ohne Ergebnis steht in ohne_ergebnis', async () => { +``` + +wird: + +```javascript + +fall('plankritik', 'doppelte Linse wirft vor dem ersten Agenten', async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PK({ ...PK_ARGS, linsen: ['arithmetik', 'bezeichner', 'arithmetik'] }, {}) + soll(f && /doppelt in linsen/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler +}) + +fall('plankritik', 'Linse ohne Ergebnis steht in ohne_ergebnis', async () => { +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 5 + +Aus: + +```javascript + const { ergebnis: e, protokoll: p } = await WM(WM_ARGS, { executer: OHNE }) + soll(e.status === 'fertig' && /unvollständig/.test(e.pruefung.uebersprungen), JSON.stringify(e.pruefung)) + soll(mitLabel(p, 'verbrauch:').length === 0 && mitLabel(p, 'pruefung').length === 0, 'Ablesung oder Prüfung trotz Lücke') + return fehler +``` + +wird: + +```javascript + const { ergebnis: e, protokoll: p } = await WM(WM_ARGS, { executer: OHNE }) + soll(e.status === 'ohne_pruefung' && /unvollständig/.test(e.pruefung.uebersprungen), JSON.stringify([e.status, e.pruefung])) + soll(mitLabel(p, 'verbrauch:').length === 0 && mitLabel(p, 'pruefung').length === 0, 'Ablesung oder Prüfung trotz Lücke') + return fehler +}) + +fall('welle-mit-pruefung', 'verbrauch.js fehlt: Ablesungen nicht_gelesen, Welle und Prüfung bleiben', async () => { + const { fehler, soll } = pruefe() + const ohneVerbrauch = mkdtempSync(path.join(os.tmpdir(), 'ohne-verbrauch-')) + cpSync(ORDNER, ohneVerbrauch, { recursive: true }) + rmSync(path.join(ohneVerbrauch, 'verbrauch.js')) + const { ergebnis: e, fehler: f } = await fuehreAus(path.join(ohneVerbrauch, 'welle-mit-pruefung.js'), WM_ARGS, antworten({}), { ordner: ohneVerbrauch }) + rmSync(ohneVerbrauch, { recursive: true, force: true }) + soll(f === null && e.status === 'fertig' && e.welle && e.pruefung.zaehlung, 'Fehler/Ergebnis: ' + f + ' ' + JSON.stringify(e && e.status)) + soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'nicht_gelesen'), JSON.stringify(e && e.verbrauch)) + return fehler +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 6 + +Aus: + +```javascript + +fall('planung', '5 Stunden bei 60 %: höchstens zwei gleichzeitig', async () => { +``` + +wird: + +```javascript + +fall('planung', 'Ergebnis mit ähnlichem Dateinamen gilt als gescheitert', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await PL(PL_ARGS, { planerDatei: (nr, richtig) => (nr === 7 ? 'D:/x/plan/xtask-07-t.md' : richtig) }) + soll(JSON.stringify(e.gescheitert) === '[7]', JSON.stringify(e.gescheitert)) + return fehler +}) + +fall('planung', 'Nachlesen im Takt von nachlesen_alle, solange Tasks offen sind', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 2, nachlesen_alle: 2 }, { planerDauer: () => 100 }) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label.slice('verbrauch:planung:'.length)) + soll(JSON.stringify(ab) === '["Anfang","nach 2 Tasks","nach 4 Tasks","nach 6 Tasks","Ende"]', 'Ablesungen: ' + JSON.stringify(ab)) + return fehler +}) + +fall('planung', '5 Stunden bei 60 %: höchstens zwei gleichzeitig', async () => { +``` + +### `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, Stelle 7 + +Aus: + +```javascript + soll(!/\{(NR|THEMA|DATEI|MODELL|WELLE|VORAUSSETZUNGEN)\}/.test(t5), 'Platzhalter übrig') + soll(/vor: \(keine\)/.test(planerVon(p, 1).prompt), 'Task 1 ohne "(keine)"') +``` + +wird: + +```javascript + soll(!/\{(NR|THEMA|DATEI|MODELL|WELLE|VORAUSSETZUNGEN)\}/.test(t5), 'Platzhalter übrig') + soll(/Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst/.test(t5), 'Lesehinweis fehlt') + soll(!/Lies sie/.test(planerVon(p, 1).prompt), 'Lesehinweis ohne Voraussetzungen') + soll(/vor: \(keine\)/.test(planerVon(p, 1).prompt), 'Task 1 ohne "(keine)"') +``` + +### `.vault-tools/workflow-pruefung/mutanten_workflows.py`, Stelle 1 + +Aus: + +```python + ("planung", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), +] +``` + +wird: + +```python + ("planung", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), + ("planung", "Datei ohne Pfadgrenze", "('/' + roh.datei.replace(/\\\\/g, '/')).endsWith('/' + jeNr.get(nr).datei)", "roh.datei.replace(/\\\\/g, '/').endsWith(jeNr.get(nr).datei)"), + ("planung", "Nachlesezaehler nicht zurueckgesetzt", " fertigSeitAblesung = 0\n const v = await ablesen(", " const v = await ablesen("), + ("planung", "nachlesen_alle ignoriert", "const nachlesenAlle = args.nachlesen_alle || NACHLESEN_ALLE", "const nachlesenAlle = NACHLESEN_ALLE"), + ("planung", "Lesehinweis fehlt", " Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst.", ""), + ("plankritik", "doppelte Linse erlaubt", " if (a.linsen && new Set(a.linsen).size !== a.linsen.length) throw", " if (false) throw"), + ("welle-mit-pruefung", "status fertig ohne Pruefung", "status: pruefung ? 'fertig' : 'ohne_pruefung',", "status: 'fertig',"), + ("welle-mit-pruefung", "Ablesung ohne try", " try {\n r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })\n } catch (e) {\n r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }\n }", " r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })"), + ("verbrauch", "5-Stunden-Regex unverankert", "const FUENF_STUNDEN = /^\\s*5[- ]?hour/i", "const FUENF_STUNDEN = /hour/i"), +] +``` + +### `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py`, Stelle 1 + +Aus: + +```python + +def f_fallender_wert(tmp): +``` + +wird: + +```python + +def f_verschiebung(tmp, reset5, erwartet): + # Nur resetsAt verschiebt sich, der Wert steigt; entscheidend ist allein die Toleranz. + baue(tmp, {"wf_v": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 5, reset5=reset5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_fallender_wert(tmp): +``` + +### `.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py`, Stelle 2 + +Aus: + +```python + ("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund), + ("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert), +``` + +wird: + +```python + ("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund), + ("resetsAt 5 Stunden weiter, Wert steigt: Reset", lambda t: f_verschiebung(t, "2026-09-24T23:30:00Z", "| Reset | 0.0 |")), + ("resetsAt 4:59 verschoben: dasselbe Fenster", lambda t: f_verschiebung(t, "2026-09-24T18:34:59Z", "| 2.0 | 0.0 |")), + ("resetsAt 5:01 verschoben: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:35:01Z", "| Reset | 0.0 |")), + ("resetsAt 5:01 frueher: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:24:59Z", "| Reset | 0.0 |")), + ("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert), +``` + +### `.vault-tools/workflow-pruefung/mutanten_reihe.py`, Stelle 1 + +Aus: + +```python + ("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"), + ("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"), +``` + +wird: + +```python + ("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"), + ("Toleranz 6 Stunden", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(hours=6)"), + ("Toleranz 1 Sekunde", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(seconds=1)"), + ("Toleranz nur nach vorn", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if rb - ra > RESET_TOLERANZ or b[0] < a[0]:"), + ("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"), +``` +## Verifikation + +Im Vault-Wurzelordner: + +```bash +node .vault-tools/workflow-pruefung/faelle_workflows.mjs +``` + +Erwartet: Alle übrigen Zeilen beginnen mit `OK`, die letzte enthält `0 gescheitert`, Exit 0. + +```bash +python .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py +``` + +Erwartet: Die letzte Zeile enthält `0 gescheitert`, Exit 0. + +```bash +python .vault-tools/workflow-pruefung/mutanten_reihe.py +``` + +Erwartet: Die letzte Zeile enthält `0 ueberlebt, 0 veraltet`, Exit 0. + +```bash +python .vault-tools/workflow-pruefung/mutanten_workflows.py +``` + +Erwartet: Die letzte Zeile enthält `0 ueberlebt, 0 veraltet`, Exit 0. Der Lauf dauert einige Minuten. + +Danach `git status --porcelain -- .claude/workflows/plankritik.js .claude/workflows/planung.js .claude/workflows/welle-mit-pruefung.js .vault-tools/workflow-pruefung/attrappe.mjs .vault-tools/workflow-pruefung/faelle_workflows.mjs .vault-tools/workflow-pruefung/mutanten_workflows.py .vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py .vault-tools/workflow-pruefung/mutanten_reihe.py` zeigen. diff --git a/.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py b/.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py new file mode 100644 index 0000000..ed6f790 --- /dev/null +++ b/.vault-tools/workflow-pruefung/faelle_verbrauch_reihe.py @@ -0,0 +1,304 @@ +"""Faelle fuer verbrauch_reihe.py: baut je Fall einen kuenstlichen Session-Speicher und faehrt das Skript als eigenen Prozess. + +Die Grundlinie laeuft zuerst; scheitert sie, messen die uebrigen Faelle nichts. Der Prozess laeuft +ohne PYTHONIOENCODING, damit eine Ausgabe ausserhalb von ASCII auf cp1252 auffaellt. +Aufruf: python faelle_verbrauch_reihe.py +Exit 0, wenn alle Faelle bestehen, sonst 1. +""" + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +SKRIPT = Path(__file__).with_name("verbrauch_reihe.py") + + +def fenster(fuenf, woche, reset5="2026-09-24T18:30:00Z", resetw="2026-09-28T00:00:00Z"): + return [ + {"label": "5-hour limit", "percentUsed": fuenf, "resetsAt": reset5}, + {"label": "Weekly · all models", "percentUsed": woche, "resetsAt": resetw}, + ] + + +def baue(speicher, laeufe): + """laeufe: {lauf: [agent]}; agent: {id, label, start, ende, modell, ergebnis, ohne_zeit, kaputt}.""" + for lauf, agenten in laeufe.items(): + ordner = speicher / "sitzung-1" / "subagents" / "workflows" / lauf + ordner.mkdir(parents=True) + with (ordner / "journal.jsonl").open("w", encoding="utf-8") as journal: + journal.write(json.dumps({"type": "launched"}) + "\n") + for a in agenten: + journal.write(json.dumps({"type": "started", "agentId": a["id"], "label": a["label"]}) + "\n") + if a.get("kaputt"): + journal.write("{kein json\n") + if "ergebnis" in a: + journal.write(json.dumps({"type": "result", "agentId": a["id"], "result": a["ergebnis"]}) + "\n") + for a in agenten: + (ordner / f"agent-{a['id']}.meta.json").write_text(json.dumps({"model": a.get("modell", "haiku")}), encoding="utf-8") + if a.get("ohne_zeit"): + continue + with (ordner / f"agent-{a['id']}.jsonl").open("w", encoding="utf-8") as d: + d.write(json.dumps({"timestamp": a["start"], "type": "user"}) + "\n") + d.write(json.dumps({"timestamp": a["ende"], "type": "assistant"}) + "\n") + + +def ablesung(nr, anlass, zeitpunkt, fuenf, woche, **extra): + ende = extra.pop("bis", None) or zeitpunkt + return {"id": f"v{nr}", "label": "verbrauch:" + anlass, "start": zeitpunkt, "ende": ende, "modell": "haiku", + "ergebnis": {"verfuegbar": True, "status": "ok", "plan": "Pro", "fenster": extra.pop("liste", None) or fenster(fuenf, woche, **extra), "fehler": ""}} + + +# resetsAt schwankt bei echten Ablesungen um Millisekunden und springt ueber Sekunden- und +# Datumsgrenzen (gemessen 2026-09-24); die Grundlinie traegt deshalb solche Werte. +ZITTER = {"reset5": "2026-09-24T18:29:59.664Z", "resetw": "2026-09-27T23:59:59.664Z"} + + +def opus(nr, start, ende, modell="opus"): + return {"id": f"o{nr}", "label": f"plankritik:linse{nr}", "start": start, "ende": ende, "modell": modell, "ergebnis": {}} + + +def fahre(speicher, *zusatz): + umgebung = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")} + lauf = subprocess.run([sys.executable, str(SKRIPT), "--speicher", str(speicher), *zusatz], + capture_output=True, env=umgebung) + return lauf.returncode, lauf.stdout.decode("ascii", "replace"), lauf.stderr.decode("utf-8", "replace") + + +GRUND = { + "wf_a": [ + ablesung(1, "plankritik:vor Linsen", "2026-09-24T10:00:00Z", 10, 5), + opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z"), + ablesung(2, "plankritik:Ende", "2026-09-24T10:30:10Z", 14, 6, **ZITTER), + ] +} + + +def f_grundlinie(tmp): + baue(tmp, GRUND) + code, aus, err = fahre(tmp) + fehler = [] + if code != 0: + fehler.append(f"Exit {code}: {err[-300:]}") + for erwartet in ["2 Ablesungen, 1 Paare", "| wf_a | plankritik:vor Linsen -> plankritik:Ende | 30.2 | opus 30.0 | 4.0 | 1.0 |", + "Naeherung: 8.0 % des 5-Stunden-Fensters je Opus-Agentenstunde (1 Paare, 0.5 Opus-Stunden"]: + if erwartet not in aus: + fehler.append("fehlt: " + erwartet) + if "Hinweise" in aus: + fehler.append("unerwartete Hinweise") + return fehler + + +def f_reset(tmp): + baue(tmp, {"wf_r": [ablesung(1, "a", "2026-09-24T10:00:00Z", 80, 5), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"), + ablesung(2, "b", "2026-09-24T10:30:00Z", 3, 6, reset5="2026-09-24T15:30:00Z")]}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "| Reset | 1.0 |" not in aus: + fehler.append("Reset nicht erkannt: " + aus[-300:]) + if "Keine auswertbaren Paare mit Opus-Agenten." not in aus: + fehler.append("Reset-Paar wurde mitgerechnet") + return fehler + + +def f_speicher_fehlt(tmp): + code, aus, _ = fahre(tmp / "gibt-es-nicht") + return [] if code == 2 and "Speicher fehlt" in aus else [f"Exit {code}, Ausgabe {aus!r}"] + + +def f_null_ablesungen(tmp): + baue(tmp, {"wf_n": [opus(1, "2026-09-24T10:00:00Z", "2026-09-24T10:10:00Z")]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "0 Ablesungen, 0 Paare" in aus else [f"Exit {code}, Ausgabe {aus!r}"] + + +def f_kaputte_zeile(tmp): + agenten = [dict(GRUND["wf_a"][0], kaputt=True), GRUND["wf_a"][1], GRUND["wf_a"][2]] + baue(tmp, {"wf_k": agenten}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "1 Paare" not in aus: + fehler.append("Auswertung trotz kaputter Zeile gescheitert: " + aus[-300:]) + if "ist kein JSON" not in aus: + fehler.append("kaputte Zeile nicht als Hinweis gemeldet") + return fehler + + +def f_ohne_zeitstempel(tmp, position): + agenten = [dict(a) for a in GRUND["wf_a"]] + agenten[position]["ohne_zeit"] = True + baue(tmp, {"wf_z": agenten}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "1 Ablesungen, 0 Paare" not in aus: + fehler.append(f"Exit {code}, Ausgabe {aus[-300:]!r}") + if "ohne Zeitstempel" not in aus: + fehler.append("fehlender Zeitstempel nicht gemeldet") + return fehler + + +def f_fenster_kaputt(tmp, liste, erwartet="| unbekannt | unbekannt |"): + baue(tmp, {"wf_f": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, liste=liste), opus(1, "2026-09-24T10:01:00Z", "2026-09-24T10:20:00Z"), + ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 6)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_umlaut_im_anlass(tmp): + baue(tmp, {"wf_u": [ablesung(1, "pruefung:vor Prüfung", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "pruefung:nach Prüfung", "2026-09-24T10:30:00Z", 12, 5)]}) + code, aus, err = fahre(tmp) + return [] if code == 0 and "pruefung:vor Pruefung -> pruefung:nach Pruefung" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}, stderr {err[-200:]!r}"] + + +def f_seit(tmp): + baue(tmp, {"wf_s": [ablesung(1, "alt", "2026-09-20T10:00:00Z", 10, 5), ablesung(2, "a", "2026-09-24T10:00:00Z", 10, 5), + ablesung(3, "b", "2026-09-24T11:00:00Z", 12, 5)]}) + code, aus, _ = fahre(tmp, "--seit", "2026-09-24") + return [] if code == 0 and "2 Ablesungen, 1 Paare" in aus and "alt ->" not in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_seit_ungueltig(tmp): + baue(tmp, GRUND) + code, aus, _ = fahre(tmp, "--seit", "gestern") + return [] if code == 2 and "kein Datum" in aus else [f"Exit {code}, Ausgabe {aus!r}"] + + +def f_voller_modellname(tmp): + baue(tmp, {"wf_m": [GRUND["wf_a"][0], opus(1, "2026-09-24T10:00:10Z", "2026-09-24T10:30:10Z", modell="claude-opus-5-5"), GRUND["wf_a"][2]]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "opus 30.0" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_agent_ausserhalb(tmp): + # Ein Agent, der vor der ersten Ablesung endet, zaehlt nicht mit; einer, der halb hineinreicht, anteilig. + baue(tmp, {"wf_x": [opus(1, "2026-09-24T09:00:00Z", "2026-09-24T09:50:00Z"), GRUND["wf_a"][0], + opus(2, "2026-09-24T09:55:00Z", "2026-09-24T10:10:00Z"), GRUND["wf_a"][2]]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| opus 10.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-400:]!r}"] + + +def f_absturz_ist_befund(tmp): + # Ein Zeitstempel ohne Zeitzone neben einem mit ihr laesst den Vergleich mit TypeError scheitern. + # Der Lauf muss das als Hinweis melden und die anderen Laeufe trotzdem auswerten. + kaputt = [dict(GRUND["wf_a"][0]), dict(GRUND["wf_a"][1], start="2026-09-24T10:00:10"), dict(GRUND["wf_a"][2])] + baue(tmp, {"wf_a": GRUND["wf_a"], "wf_b": kaputt}) + code, aus, err = fahre(tmp) + fehler = [] + if code != 0: + fehler.append(f"Exit {code}: {err[-300:]}") + if "wf_b: Auswertung abgebrochen (TypeError" not in aus: + fehler.append("Absturz nicht als Hinweis gemeldet") + if "| wf_a |" not in aus: + fehler.append("der gesunde Lauf fehlt") + return fehler + + +def f_verschiebung(tmp, reset5, erwartet): + # Nur resetsAt verschiebt sich, der Wert steigt; entscheidend ist allein die Toleranz. + baue(tmp, {"wf_v": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 5, reset5=reset5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and erwartet in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_fallender_wert(tmp): + baue(tmp, {"wf_d": [ablesung(1, "a", "2026-09-24T10:00:00Z", 30, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 5, 5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| Reset | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_reset_unlesbar(tmp): + baue(tmp, {"wf_u": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, reset5="bald"), ablesung(2, "b", "2026-09-24T10:20:00Z", 12, 6)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| unbekannt | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_hoechstes_fenster(tmp): + liste = [{"label": "5-hour limit", "percentUsed": 10, "resetsAt": "2026-09-24T18:30:00Z"}, + {"label": "5-hour limit", "percentUsed": 30, "resetsAt": "2026-09-24T18:30:00Z"}, + {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}] + baue(tmp, {"wf_h": [ablesung(1, "a", "2026-09-24T10:00:00Z", 0, 0, liste=liste), ablesung(2, "b", "2026-09-24T10:20:00Z", 35, 6)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| 5.0 | 1.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_ablesezeit_ist_ende(tmp): + # Der Opus-Agent laeuft ganz innerhalb des ersten Ableseagenten, also vor der Ablesung. + baue(tmp, {"wf_t": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5, bis="2026-09-24T10:00:10Z"), + opus(1, "2026-09-24T10:00:02Z", "2026-09-24T10:00:08Z"), + ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| 29.8 | - | 2.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +def f_agent_ueber_das_paar_hinaus(tmp): + baue(tmp, {"wf_b": [ablesung(1, "a", "2026-09-24T10:00:00Z", 10, 5), ablesung(2, "b", "2026-09-24T10:30:00Z", 12, 5), + opus(1, "2026-09-24T10:20:00Z", "2026-09-24T10:50:00Z"), ablesung(3, "c", "2026-09-24T11:00:00Z", 15, 5)]}) + code, aus, _ = fahre(tmp) + fehler = [] + if code != 0 or "| a -> b | 30.0 | opus 10.0 |" not in aus: + fehler.append("erstes Paar nicht am Ende gekappt: " + aus[-400:]) + if "| b -> c | 30.0 | opus 20.0 |" not in aus: + fehler.append("zweites Paar falsch: " + aus[-400:]) + return fehler + + +def f_ueber_hundert(tmp): + baue(tmp, {"wf_g": [ablesung(1, "a", "2026-09-24T10:00:00Z", 98, 5), ablesung(2, "b", "2026-09-24T10:20:00Z", 103, 5)]}) + code, aus, _ = fahre(tmp) + return [] if code == 0 and "| 5.0 | 0.0 |" in aus else [f"Exit {code}, Ausgabe {aus[-300:]!r}"] + + +FAELLE = [ + ("Grundlinie: zwei Ablesungen, ein Opus-Agent dazwischen", f_grundlinie), + ("Reset zwischen zwei Ablesungen", f_reset), + ("Speicher fehlt: Exit 2", f_speicher_fehlt), + ("null Ablesungen: Exit 0 mit Zaehlung", f_null_ablesungen), + ("kaputte Journalzeile: Hinweis, kein Absturz", f_kaputte_zeile), + ("erste Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 0)), + ("letzte Ablesung ohne Zeitstempel", lambda t: f_ohne_zeitstempel(t, 2)), + ("5-Stunden-Fenster fehlt", lambda t: f_fenster_kaputt(t, [{"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}], "| unbekannt | 1.0 |")), + ("Umlaut im Anlass auf cp1252", f_umlaut_im_anlass), + ("Prozent als Text", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": "10", "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])), + ("Prozent als Wahrheitswert", lambda t: f_fenster_kaputt(t, [{"label": "5-hour limit", "percentUsed": True, "resetsAt": "2026-09-24T18:30:00Z"}, {"label": "Weekly · all models", "percentUsed": 5, "resetsAt": "2026-09-28T00:00:00Z"}])), + ("fenster kein Array", lambda t: f_fenster_kaputt(t, {"a": 1})), + ("--seit filtert aeltere Ablesungen", f_seit), + ("--seit ungueltig: Exit 2", f_seit_ungueltig), + ("voller Modellname zaehlt als opus", f_voller_modellname), + ("Agent ausserhalb und halb innerhalb des Paars", f_agent_ausserhalb), + ("Absturz in einem Lauf ist ein Hinweis", f_absturz_ist_befund), + ("resetsAt 5 Stunden weiter, Wert steigt: Reset", lambda t: f_verschiebung(t, "2026-09-24T23:30:00Z", "| Reset | 0.0 |")), + ("resetsAt 4:59 verschoben: dasselbe Fenster", lambda t: f_verschiebung(t, "2026-09-24T18:34:59Z", "| 2.0 | 0.0 |")), + ("resetsAt 5:01 verschoben: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:35:01Z", "| Reset | 0.0 |")), + ("resetsAt 5:01 frueher: Reset", lambda t: f_verschiebung(t, "2026-09-24T18:24:59Z", "| Reset | 0.0 |")), + ("fallender Wert im selben Fenster ist ein Reset", f_fallender_wert), + ("resetsAt ohne Datum: unbekannt", f_reset_unlesbar), + ("doppeltes 5-Stunden-Fenster: das hoechste zaehlt", f_hoechstes_fenster), + ("Ablesezeit ist das Ende des Ableseagenten", f_ablesezeit_ist_ende), + ("Agent ueber das Paar hinaus wird am Ende gekappt", f_agent_ueber_das_paar_hinaus), + ("ueber 100 Prozent ist ein Wert, kein Fehler", f_ueber_hundert), +] + + +def main(): + # Fehlertexte koennen Zeichen ausserhalb der Konsolenkodierung tragen; der Treiber darf daran nicht sterben. + sys.stdout.reconfigure(errors="replace") + gescheitert = 0 + for name, fall in FAELLE: + with tempfile.TemporaryDirectory() as tmp: + try: + fehler = fall(Path(tmp)) + except Exception as e: # ein Absturz im Fall ist selbst ein Befund + fehler = [f"Absturz im Fall: {type(e).__name__}: {e}"] + if fehler: + gescheitert += 1 + print(f"FEHLER {name}\n " + "\n ".join(fehler)) + else: + print(f"OK {name}") + print(f"{len(FAELLE)} Faelle, {gescheitert} gescheitert") + return 1 if gescheitert else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/workflow-pruefung/faelle_workflows.mjs b/.vault-tools/workflow-pruefung/faelle_workflows.mjs new file mode 100644 index 0000000..789fe47 --- /dev/null +++ b/.vault-tools/workflow-pruefung/faelle_workflows.mjs @@ -0,0 +1,848 @@ +// Fälle für die Workflow-Skripte unter .claude/workflows/, gefahren mit attrappe.mjs. +// Aufruf: node faelle_workflows.mjs [] +// Ohne Argument gilt ../../.claude/workflows relativ zu dieser Datei. Je Workflow läuft zuerst +// die Grundlinie; scheitert sie, messen die übrigen Fälle dieses Workflows nichts. +// Exit 0, wenn alle Fälle bestehen, sonst 1. +import { mkdtempSync, writeFileSync, rmSync, cpSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { fuehreAus } from './attrappe.mjs' + +const hier = path.dirname(fileURLToPath(import.meta.url)) +const ORDNER = path.resolve(process.argv[2] || path.join(hier, '..', '..', '.claude', 'workflows')) +const WF = name => path.join(ORDNER, name + '.js') + +const ok = (wert, dauer = 5) => ({ wert, dauer }) +const fenster = (fuenf, woche, extra = []) => [ + { label: '5-hour limit', percentUsed: fuenf, resetsAt: '2026-09-24T18:30:00.000Z' }, + { label: 'Weekly · all models', percentUsed: woche, resetsAt: '2026-09-28T00:00:00.000Z' }, + ...extra, +] +const ablesung = (fuenf, woche, extra) => ({ verfuegbar: true, status: 'ok', plan: 'Pro', fenster: fenster(fuenf, woche, extra), fehler: '' }) +const OHNE = 'ohne' + +// Antworten für alle Agenten, die die Workflows starten. Unbekannte Labels werfen, damit ein +// falsches Label im Protokoll auffällt. +function antworten(k = {}) { + let n = 0 + return async a => { + const label = a.label || '' + if (label.startsWith('verbrauch:')) { + const x = (k.ablesungen || [])[n++] + if (x === OHNE) return undefined + return ok(x === undefined ? ablesung(10, 10) : x) + } + if (label === 'werkzeug:planwerkzeuge') return ok({ laeufe: [{ befehl: 'planwerkzeuge', exit: 0, ausgabe: 'keine Befunde' }] }) + if (label === 'werkzeug:kopie') return ok({ laeufe: [{ befehl: 'kopie', exit: 0, ausgabe: "KOPIE=C:/tmp/k/mat\n{'neu': 2}" }] }) + if (label === 'werkzeug:vergleich') return ok({ laeufe: [{ befehl: 'vergleich', exit: 0, ausgabe: 'VERGLEICH-OK\nEXIT suite: 0' }] }) + if (label.startsWith('plankritik:')) { + const linse = label.slice('plankritik:'.length) + if (k.linse) { const x = k.linse(linse); if (x === OHNE) return undefined; if (x !== undefined) return ok(x, k.dauer || 30) } + const basis = { arithmetik: 10, bezeichner: 20, abdeckung: 30 }[linse] || 40 + return ok({ befunde: [0, 1].map(j => ({ schweregrad: 'wichtig', datei: 'task-01.md', zeile: basis + j, beschreibung: linse + ' ' + j, reproduktion: 'x' })), selbst_ausgefuehrt: [], nicht_pruefbar: [] }, k.dauer || 30) + } + if (label.startsWith('widerlegung:')) return ok({ urteil: 'bestaetigt', schweregrad: 'wichtig', bedingung: '', begruendung: 'nachgestellt', beleg: 'befehl' }, k.dauer || 30) + if (label.startsWith('pruefung')) { + if (k.pruefer === OHNE) return undefined + return ok({ selbst_ausgefuehrt: [], eigener_lauf: [], befunde: [{ schweregrad: 'klein', datei: 'a.py', zeile: 1, beschreibung: 'x', reproduktion: 'y' }], plan_vs_umsetzung: [], geprueft_und_in_ordnung: [], nicht_pruefbar: [] }, 20) + } + if (label.startsWith('executer:')) { + if (k.executer === OHNE) return undefined + return ok({ erledigt: ['alles'], geaenderte_dateien: [], verifikation: [], abweichungen: ['keine'], annahmen: [], beobachtet: [] }, 10) + } + if (label.startsWith('planer:task-')) { + const nr = Number(label.slice('planer:task-'.length)) + const x = k.planer ? k.planer(nr) : undefined + if (x === OHNE) return undefined + const ziel = /Schreib genau eine Datei: (\S+\.md)/.exec(a.prompt) + const datei = k.planerDatei ? k.planerDatei(nr, ziel && ziel[1]) : ziel && ziel[1] + return ok({ datei, zieldateien: [], testdateien: [], abweichungen_von_uebersicht: [], offene_punkte: [] }, k.planerDauer ? k.planerDauer(nr) : 20 + nr * 3) + } + throw new Error('unerwarteter Agent ' + label) + } +} + +function pruefe() { + const fehler = [] + return { fehler, soll: (bedingung, text) => { if (!bedingung) fehler.push(text) } } +} +const mitLabel = (p, anfang) => p.agents.filter(x => (x.label || '').startsWith(anfang)) +const spitze = (p, phase, modell) => p.spitzeJePhase[phase + '|' + modell] || 0 +// Wie viele andere Agenten mit diesem Labelanfang liefen, als a startete. +const laufendBeiStart = (p, a, anfang) => mitLabel(p, anfang).filter(b => b !== a && b.start < a.start && (b.ende === null || b.ende > a.start)).length +function ohneVerstoss(p, soll) { soll(p.verstoesse.length === 0, 'Verstöße: ' + p.verstoesse.join('; ')) } + +const faelle = [] +const fall = (workflow, name, lauf) => faelle.push({ workflow, name, lauf }) + +// ---------------------------------------------------------------- attrappe +// Die Attrappe muss so streng sein wie das Ziel, sonst misst jeder grüne Fall nur sie selbst. +const TMP = mkdtempSync(path.join(os.tmpdir(), 'attrappe-')) +function skript(name, rumpf) { + const datei = path.join(TMP, name + '.js') + writeFileSync(datei, `export const meta = { name: '${name}', description: 'Selbsttest' }\n${rumpf}\n`, 'utf8') + return datei +} +const A = (datei, k) => fuehreAus(datei, {}, antworten(k), { ordner: TMP }) + +for (const [i, [name, rumpf, muster]] of [ + ['Date.now() wirft', 'return Date.now()', /Date\.now\(\)/], + ['new Date() ohne Argument wirft', 'return new Date()', /new Date\(\)/], + ['Math.random() wirft', 'return Math.random()', /Math\.random\(\)/], + ['unbekannter Workflow wirft', "return await workflow('gibt-es-nicht', {})", /nicht lesbar/], + ['Schema ohne properties wirft', "return await agent('x', { label: 'verbrauch:t', model: 'haiku', schema: { type: 'object' } })", /Schema braucht/], +].entries()) { + fall('attrappe', name, async () => { + const { fehler, soll } = pruefe() + const { fehler: f } = await A(skript('wirft-' + i, rumpf)) + soll(f && muster.test(f), 'Fehler: ' + f) + return fehler + }) +} + +fall('attrappe', 'Grundlinie: Math.min und new Date(0) gehen, Globals sind da', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f } = await A(skript('grund', "phase('P'); log('x'); return [Math.min(1, 2), new Date(0).getTime(), typeof parallel, typeof pipeline, args !== undefined]")) + soll(f === null && JSON.stringify(e) === '[1,0,"function","function",true]', 'Fehler/Ergebnis: ' + f + ' ' + JSON.stringify(e)) + return fehler +}) + +fall('attrappe', 'workflow() im Kind wirft', async () => { + const { fehler, soll } = pruefe() + const enkel = skript('enkel', 'return 1') + const kind = skript('kind', `return await workflow({ scriptPath: ${JSON.stringify(enkel)} }, {})`) + const { fehler: f } = await A(skript('eltern', `return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`)) + soll(f && /nesting is limited to one level/.test(f), 'Fehler: ' + f) + return fehler +}) + +fall('attrappe', 'im Kind tragen Agenten die Phase ▸ , phase() wirkt dort nicht', async () => { + const { fehler, soll } = pruefe() + const kind = skript('kindphase', "phase('Eigen'); return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet', phase: 'Eigen' })") + const { protokoll: p } = await A(skript('elternphase', `phase('Oben'); return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`)) + soll(p.agents.length === 1 && p.agents[0].phase === '▸ kindphase', 'Phase: ' + (p.agents[0] && p.agents[0].phase)) + soll(JSON.stringify(p.phasen) === '["Oben"]', 'phase() im Kind hat gewirkt: ' + JSON.stringify(p.phasen)) + return fehler +}) + +fall('attrappe', 'dasselbe Kind zweimal: ▸ name und ▸ name #2', async () => { + const { fehler, soll } = pruefe() + const kind = skript('kindzwei', "return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet' })") + const { protokoll: p } = await A(skript('elternzwei', `await workflow({ scriptPath: ${JSON.stringify(kind)} }, {}); return await workflow({ scriptPath: ${JSON.stringify(kind)} }, {})`)) + soll(JSON.stringify(p.agents.map(a => a.phase)) === '["▸ kindzwei","▸ kindzwei #2"]', 'Phasen: ' + JSON.stringify(p.agents.map(a => a.phase))) + return fehler +}) + +fall('attrappe', 'agent() ohne model ist ein Verstoß', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await A(skript('ohne-modell', "return await agent('x', { label: 'verbrauch:t' })")) + soll(p.verstoesse.some(v => /ohne model/.test(v)), 'Verstöße: ' + JSON.stringify(p.verstoesse)) + return fehler +}) + +fall('attrappe', 'Ergebnis ohne required-Feld wird null und ist ein Verstoß', async () => { + const { fehler, soll } = pruefe() + const schema = "{ type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, required: ['a', 'b'] }" + const { ergebnis: e, protokoll: p } = await A(skript('required', `return await agent('x', { label: 'werkzeug:planwerkzeuge', model: 'sonnet', schema: ${schema} })`)) + soll(e === null && p.verstoesse.some(v => /Ergebnis ohne/.test(v)), 'Ergebnis/Verstöße: ' + JSON.stringify([e, p.verstoesse])) + return fehler +}) + +fall('attrappe', 'Spitze zählt gleichzeitige Agenten je Phase und Modell', async () => { + const { fehler, soll } = pruefe() + const rumpf = "phase('P'); await parallel([1, 2, 3].map(() => () => agent('x', { label: 'widerlegung:t', model: 'opus', phase: 'P' }))); await agent('x', { label: 'widerlegung:t', model: 'opus', phase: 'P' }); return 1" + const { protokoll: p } = await A(skript('spitze', rumpf)) + soll(spitze(p, 'P', 'opus') === 3, 'Spitze ' + spitze(p, 'P', 'opus')) + return fehler +}) + +// ---------------------------------------------------------------- verbrauch +const V =(a, k) => fuehreAus(WF('verbrauch'), a, antworten(k), { ordner: ORDNER }) + +fall('verbrauch', 'Grundlinie: Agent liest 10/10, wunsch 3 ergibt 3 Plätze', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await V({ anlass: 'test', wunsch: 3 }, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'ok' && e.plaetze === 3 && e.gebremst === false && e.quelle === 'agent', 'Ergebnis: ' + JSON.stringify(e)) + soll(p.agents.length === 1 && p.agents[0].label === 'verbrauch:test' && p.agents[0].modell === 'haiku', 'Agent: ' + JSON.stringify(p.agents.map(x => [x.label, x.modell]))) + ohneVerstoss(p, soll) + return fehler +}) + +fall('verbrauch', 'probe ersetzt den Agenten', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await V({ anlass: 't', wunsch: 2, probe: ablesung(10, 10) }) + soll(p.agents.length === 0, 'Agent gestartet trotz probe') + soll(e && e.quelle === 'probe' && e.plaetze === 2, JSON.stringify(e)) + return fehler +}) + +for (const [fuenf, woche, wunsch, plaetze] of [ + [49.9, 10, 4, 4], [50, 10, 4, 2], [69.9, 10, 4, 2], [70, 10, 4, 1], [84.9, 10, 4, 1], [85, 10, 4, 0], [100, 10, 4, 0], [0, 0, 4, 4], + [10, 69.9, 4, 4], [10, 70, 4, 2], [10, 84.9, 4, 2], [10, 85, 4, 1], [10, 94.9, 4, 1], [10, 95, 4, 0], [10, 100, 4, 0], + [101, 10, 4, 0], [10, 150, 4, 0], +]) { + fall('verbrauch', `Stufe 5 h ${fuenf} %, Woche ${woche} % ergibt ${plaetze}`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch, probe: ablesung(fuenf, woche) }) + soll(e && e.plaetze === plaetze, `plaetze ${e && e.plaetze}, erwartet ${plaetze}`) + soll(e && e.gebremst === (plaetze === 0), 'gebremst ' + (e && e.gebremst)) + return fehler + }) +} + +fall('verbrauch', 'wunsch 0 bei 95 % misst nur und bremst nicht', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 0, probe: ablesung(95, 10) }) + soll(e && e.plaetze === 0 && e.gebremst === false && e.status === 'ok', JSON.stringify(e)) + return fehler +}) + +fall('verbrauch', 'wunsch 7 wird auf die harte Obergrenze 4 gesenkt', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 7, probe: ablesung(10, 10) }) + soll(e && e.plaetze === 4 && /harte Obergrenze/.test(e.grund), JSON.stringify(e)) + return fehler +}) + +for (const wunsch of [-1, 1.5, '3', null, undefined]) { + fall('verbrauch', `wunsch ${JSON.stringify(wunsch)} wirft vor dem Agenten`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await V({ anlass: 't', wunsch }) + soll(f && /wunsch muss/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +for (const anlass of [undefined, '', ' ', 3]) { + fall('verbrauch', `anlass ${JSON.stringify(anlass)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f } = await V({ anlass, wunsch: 1 }) + soll(f && /anlass fehlt/.test(f), 'Fehler: ' + f) + return fehler + }) +} + +for (const probe of [null, [], 'x']) { + fall('verbrauch', `probe ${JSON.stringify(probe)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f } = await V({ anlass: 't', wunsch: 1, probe }) + soll(f && /probe muss/.test(f), 'Fehler: ' + f) + return fehler + }) +} + +fall('verbrauch', 'Agent ohne Ergebnis: nicht_gelesen, zwei Plätze, bei wunsch 1 einer', async () => { + const { fehler, soll } = pruefe() + const a = await V({ anlass: 't', wunsch: 3 }, { ablesungen: [OHNE] }) + soll(a.ergebnis && a.ergebnis.status === 'nicht_gelesen' && a.ergebnis.plaetze === 2 && a.ergebnis.gebremst === false, JSON.stringify(a.ergebnis)) + const b = await V({ anlass: 't', wunsch: 1 }, { ablesungen: [OHNE] }) + soll(b.ergebnis && b.ergebnis.plaetze === 1, JSON.stringify(b.ergebnis)) + return fehler +}) + +for (const [name, probe] of [ + ['status unavailable', { verfuegbar: true, status: 'unavailable', plan: '', fenster: [], fehler: '' }], + ['verfuegbar false', { verfuegbar: false, status: 'fehler', plan: '', fenster: [], fehler: 'Werkzeug fehlt' }], + ['verfuegbar als Text', { verfuegbar: 'true', status: 'ok', plan: 'Pro', fenster: fenster(10, 10), fehler: '' }], +]) { + fall('verbrauch', `${name}: nicht_gelesen`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 3, probe }) + soll(e && e.status === 'nicht_gelesen' && e.plaetze === 2, JSON.stringify(e)) + return fehler + }) +} + +const ok5 = { label: '5-hour limit', percentUsed: 10, resetsAt: 'x' } +const okW = { label: 'Weekly · all models', percentUsed: 10, resetsAt: 'y' } +for (const [name, liste] of [ + ['fenster leer', []], + ['fenster kein Array', { a: 1 }], + ['nur Woche', [okW]], + ['nur 5 Stunden', [ok5]], + ['Eintrag null an erster Stelle', [null, ok5, okW]], + ['Eintrag null an letzter Stelle', [ok5, okW, null]], + ['label fehlt', [ok5, { percentUsed: 1, resetsAt: 'z' }, okW]], + ['percentUsed null vorn', [{ ...ok5, percentUsed: null }, okW]], + ['percentUsed null hinten', [ok5, { ...okW, percentUsed: null }]], + ['percentUsed als Text', [{ ...ok5, percentUsed: '12' }, okW]], + ['percentUsed NaN', [{ ...ok5, percentUsed: NaN }, okW]], + ['percentUsed -1', [ok5, { ...okW, percentUsed: -1 }]], + ['percentUsed Infinity', [{ ...ok5, percentUsed: Infinity }, okW]], + ['Opus-Woche ohne Zahl', [ok5, okW, { label: 'Weekly · Opus', percentUsed: null, resetsAt: 'z' }]], +]) { + fall('verbrauch', `${name}: unbekannte_fenster, alte Obergrenze`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: liste, fehler: '' } }) + soll(e && e.status === 'unbekannte_fenster' && e.plaetze === 2 && e.gebremst === false, JSON.stringify(e)) + return fehler + }) +} + +for (const [name, liste] of [ + ['doppeltes 5-Stunden-Fenster, hohes zuerst', [{ ...ok5, percentUsed: 90 }, ok5, okW]], + ['doppeltes 5-Stunden-Fenster, hohes zuletzt', [ok5, okW, { ...ok5, percentUsed: 90 }]], +]) { + fall('verbrauch', `${name}: das höhere zählt`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: liste, fehler: '' } }) + soll(e && e.plaetze === 0 && e.gebremst === true && e.fuenf_stunden.prozent === 90, JSON.stringify(e)) + return fehler + }) +} + +fall('verbrauch', 'Opus-Woche 90 % zählt wie die Woche', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Opus', percentUsed: 90, resetsAt: 'z' }]) }) + soll(e && e.plaetze === 1 && e.woche_opus && e.woche_opus.prozent === 90, JSON.stringify(e)) + return fehler +}) + +fall('verbrauch', 'ein Label mit "hour" in der Mitte ist kein 5-Stunden-Fenster', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Opus hours', percentUsed: 90, resetsAt: 'z' }]) }) + soll(e && e.plaetze === 1 && e.fuenf_stunden.prozent === 10, JSON.stringify(e)) + return fehler +}) + +fall('verbrauch', 'Woche aller Modelle 90 % zählt auch neben einer niedrigen Opus-Woche', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 90, [{ label: 'Weekly · Opus', percentUsed: 10, resetsAt: 'z' }]) }) + soll(e && e.plaetze === 1, JSON.stringify(e)) + return fehler +}) + +for (const [name, fremd] of [['Fable 99 %', 99], ['Fable ohne Zahl', null], ['Fable als Text', 'viel']]) { + fall('verbrauch', `fremdes Fenster (${name}) wird übergangen`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: ablesung(10, 10, [{ label: 'Weekly · Fable', percentUsed: fremd, resetsAt: 'z' }]) }) + soll(e && e.status === 'ok' && e.plaetze === 4, JSON.stringify(e)) + return fehler + }) +} + +for (const [a, b] of [['5-Hour Limit', 'Weekly · All Models'], ['5 hour limit', 'weekly all models'], ['5hour', 'WEEKLY - ALL MODELS']]) { + fall('verbrauch', `Schreibweise "${a}" / "${b}" wird erkannt`, async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await V({ anlass: 't', wunsch: 4, probe: { verfuegbar: true, status: 'ok', plan: 'Pro', fenster: [{ label: a, percentUsed: 60, resetsAt: 'x' }, { label: b, percentUsed: 10, resetsAt: 'y' }], fehler: '' } }) + soll(e && e.status === 'ok' && e.plaetze === 2, JSON.stringify(e)) + return fehler + }) +} + +// ---------------------------------------------------------------- plankritik +const PK_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', kopie: 'C:/tmp/k/mat' } +const PK = (a, k, o = {}) => fuehreAus(WF('plankritik'), a, antworten(k), { ordner: ORDNER, ...o }) + +fall('plankritik', 'Grundlinie: drei Linsen gleichzeitig, Widerlegung zu zweit, drei Ablesungen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'fertig', 'status ' + (e && e.status)) + soll(spitze(p, 'Plankritik', 'opus') === 3, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + soll(spitze(p, 'Widerlegung', 'opus') === 2, 'Widerlegung gleichzeitig: ' + spitze(p, 'Widerlegung', 'opus')) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(JSON.stringify(ab) === JSON.stringify(['verbrauch:plankritik:vor Linsen', 'verbrauch:plankritik:vor Widerlegung', 'verbrauch:plankritik:Ende']), 'Ablesungen: ' + JSON.stringify(ab)) + soll(mitLabel(p, 'verbrauch:').every(x => x.modell === 'haiku'), 'Ablesung nicht auf haiku') + soll(e && e.verbrauch.length === 3 && e.plaetze.linsen === 3 && e.plaetze.widerlegung === 2, 'verbrauch/plaetze: ' + JSON.stringify(e && e.plaetze)) + soll(e && e.zaehlung.bestaetigt === 6 && e.zaehlung.nicht_geprueft === 0, 'zaehlung ' + JSON.stringify(e && e.zaehlung)) + for (const k of ['zaehlung', 'kopie', 'temp_ordner', 'befunde', 'selbst_ausgefuehrt', 'nicht_pruefbar', 'vorbereitung', 'ohne_ergebnis']) soll(e && k in e, 'Feld fehlt: ' + k) + ohneVerstoss(p, soll) + return fehler +}) + +fall('plankritik', '5 Stunden bei 60 %: Linsen zu zweit', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(60, 10)] }) + soll(spitze(p, 'Plankritik', 'opus') === 2 && e.plaetze.linsen === 2, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + soll(e.status === 'fertig', 'status ' + e.status) + return fehler +}) + +fall('plankritik', 'gebremst vor den Linsen: keine Linse, keine Widerlegung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(90, 10)] }) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'gebremst' && e.gebremst_vor === 'Linsen', JSON.stringify(e && [e.status, e.gebremst_vor])) + soll(mitLabel(p, 'plankritik:').length === 0 && mitLabel(p, 'widerlegung:').length === 0, 'Opus gestartet trotz Bremse') + soll(e && e.verbrauch.length === 1 && e.ohne_ergebnis.length === 3, 'verbrauch/ohne_ergebnis ' + JSON.stringify(e && [e.verbrauch.length, e.ohne_ergebnis])) + soll(e && e.zaehlung && e.zaehlung.bestaetigt === 0 && e.zaehlung.nicht_geprueft === 0, 'zaehlung ' + JSON.stringify(e && e.zaehlung)) + soll(e && /neu starten/.test(e.naechster_schritt), 'naechster_schritt ' + (e && e.naechster_schritt)) + return fehler +}) + +fall('plankritik', 'gebremst vor der Widerlegung: nur die Befunde über der Schwelle heißen gebremst', async () => { + const { fehler, soll } = pruefe() + const zeile = { arithmetik: 11, bezeichner: 12, abdeckung: 13 } + const mitKlein = linse => ({ befunde: [ + { schweregrad: 'wichtig', datei: 'task-01.md', zeile: zeile[linse], beschreibung: 'w', reproduktion: 'x' }, + { schweregrad: 'klein', datei: 'task-02.md', zeile: zeile[linse], beschreibung: 'k', reproduktion: 'x' }, + ], selbst_ausgefuehrt: [], nicht_pruefbar: [] }) + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(10, 10), ablesung(90, 10)], linse: mitKlein }) + soll(mitLabel(p, 'plankritik:').length === 3, 'Linsen: ' + mitLabel(p, 'plankritik:').length) + soll(mitLabel(p, 'widerlegung:').length === 0, 'Widerlegung trotz Bremse') + soll(e.status === 'gebremst' && e.gebremst_vor === 'Widerlegung', JSON.stringify([e.status, e.gebremst_vor])) + const urteile = e.befunde.map(b => b.schweregrad + ':' + b.urteil).sort() + soll(urteile.filter(u => u === 'wichtig:nicht geprüft (gebremst)').length === 3 && urteile.filter(u => u === 'klein:nicht geprüft').length === 3, 'Urteile: ' + JSON.stringify(urteile)) + soll(e.zaehlung.nicht_geprueft === 6 && e.zaehlung.bestaetigt === 0, JSON.stringify(e.zaehlung)) + soll(mitLabel(p, 'verbrauch:').length === 3, 'Ablesung am Ende fehlt') + soll(/nachholen/.test(e.naechster_schritt), 'naechster_schritt ' + e.naechster_schritt) + return fehler +}) + +fall('plankritik', 'vor der Widerlegung 75 %: Widerlegung einzeln', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [ablesung(10, 10), ablesung(75, 10)] }) + soll(spitze(p, 'Widerlegung', 'opus') === 1 && e.plaetze.widerlegung === 1, 'Widerlegung gleichzeitig: ' + spitze(p, 'Widerlegung', 'opus')) + soll(mitLabel(p, 'widerlegung:').length === 6 && e.status === 'fertig', JSON.stringify([mitLabel(p, 'widerlegung:').length, e.status])) + return fehler +}) + +fall('plankritik', 'Ablesung ohne Ergebnis: Linsen zu zweit (alte Obergrenze)', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK(PK_ARGS, { ablesungen: [OHNE] }) + soll(spitze(p, 'Plankritik', 'opus') === 2, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + soll(e.status === 'fertig' && e.verbrauch[0].status === 'nicht_gelesen', JSON.stringify(e.verbrauch[0])) + return fehler +}) + +fall('plankritik', 'als Kind: verbrauch.js wirft, Rückfall auf zwei', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PK(PK_ARGS, {}, { tiefe: 1 }) + soll(f === null, 'Fehler: ' + f) + soll(spitze(p, '▸ plankritik', 'opus') === 2, 'Linsen gleichzeitig: ' + spitze(p, '▸ plankritik', 'opus')) + soll(mitLabel(p, 'verbrauch:').length === 0, 'Ableseagent trotz Kind') + soll(e && e.verbrauch.every(v => v.status === 'nicht_gelesen' && /nicht ausführbar/.test(v.grund)), JSON.stringify(e && e.verbrauch)) + soll(e && e.status === 'fertig', 'status ' + (e && e.status)) + return fehler +}) + +fall('plankritik', 'Aufträge nennen den Testort', async () => { + const { fehler, soll } = pruefe() + const mit = await PK(PK_ARGS, {}) + const satz = 'Tests fährst du nur in der Kopie C:/tmp/k/mat, nie im echten Projektbaum' + for (const a of [...mitLabel(mit.protokoll, 'plankritik:'), ...mitLabel(mit.protokoll, 'widerlegung:')]) { + soll(a.prompt.includes(satz) && /eigenen Kopie dieser Kopie/.test(a.prompt), a.label + ' ohne Testort') + } + const ohne = await PK({ projekt: 'D:/x', planordner: 'D:/x/plan' }, {}) + for (const a of mitLabel(ohne.protokoll, 'plankritik:')) { + soll(/Tests fährst du in dieser Plankritik nicht/.test(a.prompt), a.label + ': ohne Kopie kein Testverbot') + } + return fehler +}) + +fall('plankritik', 'plaetze 1 begrenzt die Linsen auf eine', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PK({ ...PK_ARGS, plaetze: 1 }, {}) + soll(spitze(p, 'Plankritik', 'opus') === 1, 'Linsen gleichzeitig: ' + spitze(p, 'Plankritik', 'opus')) + return fehler +}) + +for (const plaetze of [0, 5, 2.5, '3']) { + fall('plankritik', `plaetze ${JSON.stringify(plaetze)} wirft vor dem ersten Agenten`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PK({ ...PK_ARGS, plaetze }, {}) + soll(f && /plaetze muss/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +fall('plankritik', 'doppelte Linse wirft vor dem ersten Agenten', async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PK({ ...PK_ARGS, linsen: ['arithmetik', 'bezeichner', 'arithmetik'] }, {}) + soll(f && /doppelt in linsen/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler +}) + +fall('plankritik', 'Linse ohne Ergebnis steht in ohne_ergebnis', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await PK(PK_ARGS, { linse: l => (l === 'arithmetik' ? OHNE : undefined) }) + soll(e.status === 'fertig' && JSON.stringify(e.ohne_ergebnis) === '["arithmetik"]', JSON.stringify(e.ohne_ergebnis)) + return fehler +}) + +fall('plankritik', 'verifizieren false: keine Ablesung vor der Widerlegung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PK({ ...PK_ARGS, verifizieren: false }, {}) + soll(mitLabel(p, 'widerlegung:').length === 0, 'Widerlegung trotz verifizieren false') + soll(mitLabel(p, 'verbrauch:').length === 2, 'Ablesungen: ' + mitLabel(p, 'verbrauch:').length) + soll(e.status === 'fertig' && e.zaehlung.nicht_geprueft === 6, JSON.stringify(e.zaehlung)) + return fehler +}) + +// ---------------------------------------------------------------- pruefung +const PR_ARGS = { projekt: 'D:/x', plan: 'D:/x/plan' } +const PR = (a, k, o = {}) => fuehreAus(WF('pruefung'), a, antworten(k), { ordner: ORDNER, ...o }) + +fall('pruefung', 'Grundlinie: Ablesung vor und nach dem pruefer', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PR(PR_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + const folge = p.agents.slice().sort((x, y) => x.start - y.start).map(x => x.label) + soll(JSON.stringify(folge) === JSON.stringify(['verbrauch:pruefung:vor Prüfung', 'pruefung', 'verbrauch:pruefung:nach Prüfung']), 'Folge: ' + JSON.stringify(folge)) + soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'ok' && v.plaetze === 0 && v.gebremst === false), JSON.stringify(e && e.verbrauch)) + soll(e && e.effort === '(Frontmatter)', 'effort ' + (e && e.effort)) + soll(mitLabel(p, 'pruefung')[0].modell === 'opus', 'pruefer nicht auf opus') + ohneVerstoss(p, soll) + return fehler +}) + +fall('pruefung', 'kritisch hebt auf xhigh', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PR({ ...PR_ARGS, kritisch: true }, {}) + soll(e.effort === 'xhigh' && mitLabel(p, 'pruefung:kritisch')[0].effort === 'xhigh', 'effort ' + e.effort) + return fehler +}) + +fall('pruefung', 'verbrauch false: keine Ablesung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PR({ ...PR_ARGS, verbrauch: false }, {}) + soll(mitLabel(p, 'verbrauch:').length === 0 && e.verbrauch.length === 0, 'Ablesung trotz verbrauch false') + return fehler +}) + +fall('pruefung', 'als Kind ohne verbrauch false: Ablesung scheitert, Prüfung läuft', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PR(PR_ARGS, {}, { tiefe: 1 }) + soll(f === null, 'Fehler: ' + f) + soll(mitLabel(p, 'pruefung').length === 1, 'pruefer nicht gelaufen') + soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'nicht_gelesen'), JSON.stringify(e && e.verbrauch)) + return fehler +}) + +fall('pruefung', 'pruefer ohne Bericht: Fehler, beide Ablesungen gelaufen', async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PR(PR_ARGS, { pruefer: OHNE }) + soll(f === 'pruefung: der pruefer hat keinen Bericht geliefert', 'Fehler: ' + f) + soll(mitLabel(p, 'verbrauch:').length === 2, 'Ablesungen: ' + mitLabel(p, 'verbrauch:').length) + return fehler +}) + +for (const verbrauch of ['nein', 0, null]) { + fall('pruefung', `verbrauch ${JSON.stringify(verbrauch)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PR({ ...PR_ARGS, verbrauch }, {}) + soll(f && /verbrauch muss/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +// ---------------------------------------------------------------- welle-mit-pruefung +const WM_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', tasks: [{ datei: 'task-01.md', modell: 'haiku' }], kopie: 'C:/tmp/k/mat', suite: 'pytest -q' } +const WM = (a, k) => fuehreAus(WF('welle-mit-pruefung'), a, antworten(k), { ordner: ORDNER }) + +fall('welle-mit-pruefung', 'Grundlinie: Welle, Vergleich, Ablesung um die Prüfung, Kind liest nicht selbst', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await WM(WM_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'fertig', 'status ' + (e && e.status)) + soll(mitLabel(p, 'executer:').length === 1 && mitLabel(p, 'werkzeug:vergleich').length === 1 && mitLabel(p, 'pruefung').length === 1, 'Agenten: ' + JSON.stringify(p.agents.map(x => x.label))) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(JSON.stringify(ab) === JSON.stringify(['verbrauch:welle-mit-pruefung:vor Prüfung', 'verbrauch:welle-mit-pruefung:nach Prüfung']), 'Ablesungen: ' + JSON.stringify(ab)) + const kind = p.workflows.find(w => w.ref === 'pruefung') + soll(kind && kind.args.verbrauch === false, 'Kind pruefung ohne verbrauch false') + soll(e.pruefung.verbrauch.length === 0 && mitLabel(p, 'verbrauch:pruefung:').length === 0, 'Kind hat selbst abgelesen') + soll(e.verbrauch.length === 2, 'verbrauch ' + e.verbrauch.length) + ohneVerstoss(p, soll) + return fehler +}) + +fall('welle-mit-pruefung', 'bis vergleich: keine Prüfung, pruefung_args mit Rohbelegen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await WM({ ...WM_ARGS, bis: 'vergleich' }, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'bis_vergleich', 'status ' + (e && e.status)) + soll(mitLabel(p, 'pruefung').length === 0 && mitLabel(p, 'verbrauch:').length === 0, 'Prüfung oder Ablesung trotz bis') + const pa = e && e.pruefung_args + soll(pa && /VERGLEICH-OK/.test(pa.schwerpunkt) && pa.plan === 'D:/x/plan' && JSON.stringify(pa.tasks) === '["task-01.md"]', 'pruefung_args ' + JSON.stringify(pa)) + soll(pa && !('verbrauch' in pa), 'pruefung_args trägt verbrauch') + soll(e && /nur lesend/.test(e.naechster_schritt), 'naechster_schritt ohne Lesevorgabe') + return fehler +}) + +fall('welle-mit-pruefung', 'bis vergleich bei unvollständiger Welle: keine pruefung_args', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await WM({ ...WM_ARGS, bis: 'vergleich' }, { executer: OHNE }) + soll(e && e.status === 'bis_vergleich' && e.pruefung_args === null && /unvollständig/.test(e.pruefung_grund), JSON.stringify(e && [e.status, e.pruefung_args, e.pruefung_grund])) + return fehler +}) + +fall('welle-mit-pruefung', 'unvollständige Welle: Prüfung übersprungen, keine Ablesung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await WM(WM_ARGS, { executer: OHNE }) + soll(e.status === 'ohne_pruefung' && /unvollständig/.test(e.pruefung.uebersprungen), JSON.stringify([e.status, e.pruefung])) + soll(mitLabel(p, 'verbrauch:').length === 0 && mitLabel(p, 'pruefung').length === 0, 'Ablesung oder Prüfung trotz Lücke') + return fehler +}) + +fall('welle-mit-pruefung', 'verbrauch.js fehlt: Ablesungen nicht_gelesen, Welle und Prüfung bleiben', async () => { + const { fehler, soll } = pruefe() + const ohneVerbrauch = mkdtempSync(path.join(os.tmpdir(), 'ohne-verbrauch-')) + cpSync(ORDNER, ohneVerbrauch, { recursive: true }) + rmSync(path.join(ohneVerbrauch, 'verbrauch.js')) + const { ergebnis: e, fehler: f } = await fuehreAus(path.join(ohneVerbrauch, 'welle-mit-pruefung.js'), WM_ARGS, antworten({}), { ordner: ohneVerbrauch }) + rmSync(ohneVerbrauch, { recursive: true, force: true }) + soll(f === null && e.status === 'fertig' && e.welle && e.pruefung.zaehlung, 'Fehler/Ergebnis: ' + f + ' ' + JSON.stringify(e && e.status)) + soll(e && e.verbrauch.length === 2 && e.verbrauch.every(v => v.status === 'nicht_gelesen'), JSON.stringify(e && e.verbrauch)) + return fehler +}) + +for (const bis of ['alles', '', 1]) { + fall('welle-mit-pruefung', `bis ${JSON.stringify(bis)} wirft`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await WM({ ...WM_ARGS, bis }, {}) + soll(f && /bis kennt nur/.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +// ---------------------------------------------------------------- planung +const T = (nr, voraussetzungen, welle) => ({ nr, thema: 'Thema ' + nr, datei: `task-${String(nr).padStart(2, '0')}-t.md`, modell: 'haiku', voraussetzungen, welle }) +const DAG = [T(1, [], 1), T(2, [1], 2), T(3, [1], 2), T(4, [1], 2), T(5, [2, 3], 3), T(6, [4], 3), T(7, [], 1)] +const PL_ARGS = { projekt: 'D:/x', planordner: 'D:/x/plan', gemeinsam: 'GEMEINSAM', taskauftrag: 'Task {NR} {THEMA} in {DATEI}, Modell {MODELL}, Welle {WELLE}, vor: {VORAUSSETZUNGEN}', tasks: DAG } +const PL = (a, k, o = {}) => fuehreAus(WF('planung'), a, antworten(k), { ordner: ORDNER, ...o }) +const planerVon = (p, nr) => p.agents.find(x => x.label === `planer:task-${String(nr).padStart(2, '0')}`) + +function voraussetzungenEingehalten(p, tasks, soll) { + for (const t of tasks) { + const a = planerVon(p, t.nr) + if (!a) continue + for (const n of t.voraussetzungen) { + const v = planerVon(p, n) + soll(v && v.ende !== null && v.ende < a.start, `Task ${t.nr} startete vor dem Ende von Task ${n}`) + } + } +} + +fall('planung', 'Grundlinie: Start nach den Voraussetzungen, ohne Wunsch zwei gleichzeitig', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PL(PL_ARGS, {}) + soll(f === null, 'Fehler: ' + f) + soll(e && e.status === 'fertig' && e.geschrieben.length === 7, 'status ' + (e && e.status)) + voraussetzungenEingehalten(p, DAG, soll) + soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus')) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(ab[0] === 'verbrauch:planung:Anfang' && ab[ab.length - 1] === 'verbrauch:planung:Ende' && ab.includes('verbrauch:planung:nach 4 Tasks'), 'Ablesungen: ' + JSON.stringify(ab)) + soll(e && e.plaetze_verlauf[0] === 2, 'plaetze_verlauf ' + JSON.stringify(e && e.plaetze_verlauf)) + soll(p.agents.filter(x => x.label.startsWith('planer:')).every(x => x.modell === 'opus' && x.agentType === 'planer'), 'planer nicht auf opus/planer') + ohneVerstoss(p, soll) + return fehler +}) + +fall('planung', 'Wunsch vier: drei bis vier gleichzeitig, Voraussetzungen halten', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, {}) + const s = spitze(p, 'Taskdateien', 'opus') + soll(s >= 3 && s <= 4, 'gleichzeitig: ' + s) + soll(e.status === 'fertig' && e.plaetze_verlauf[0] === 4, JSON.stringify([e.status, e.plaetze_verlauf])) + voraussetzungenEingehalten(p, DAG, soll) + return fehler +}) + +const FREI = [1, 2, 3, 4, 5, 6, 7, 8].map(n => T(n, [], 1)) + +fall('planung', 'Nachlesen senkt die Plätze: danach startet keiner neben zwei anderen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 4, nachlesen_alle: 2 }, { ablesungen: [ablesung(10, 10), ablesung(60, 10), ablesung(60, 10), ablesung(60, 10), ablesung(60, 10)] }) + const zweite = mitLabel(p, 'verbrauch:')[1] + const danach = mitLabel(p, 'planer:').filter(a => a.start > zweite.start) + soll(danach.length > 0, 'nach der zweiten Ablesung startete kein Planer') + soll(danach.every(a => laufendBeiStart(p, a, 'planer:') <= 1), 'Start neben zwei oder mehr: ' + JSON.stringify(danach.map(a => [a.label, laufendBeiStart(p, a, 'planer:')]))) + soll(e.status === 'fertig' && JSON.stringify(e.plaetze_verlauf.slice(0, 2)) === '[4,2]', JSON.stringify([e.status, e.plaetze_verlauf])) + return fehler +}) + +fall('planung', 'Nachlesen hebt die Plätze: danach laufen mehr als zwei', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 4, nachlesen_alle: 2 }, { ablesungen: [ablesung(60, 10), ablesung(10, 10), ablesung(10, 10), ablesung(10, 10)] }) + const zweite = mitLabel(p, 'verbrauch:')[1] + const danach = mitLabel(p, 'planer:').filter(a => a.start > zweite.start) + soll(danach.some(a => laufendBeiStart(p, a, 'planer:') >= 2), 'nach dem Heben nie mehr als zwei gleichzeitig') + soll(e.status === 'fertig' && JSON.stringify(e.plaetze_verlauf.slice(0, 2)) === '[2,4]', JSON.stringify([e.status, e.plaetze_verlauf])) + return fehler +}) + +fall('planung', 'gebremst schon am Anfang: kein Planer, Status gebremst', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { ablesungen: [ablesung(90, 10)] }) + soll(mitLabel(p, 'planer:').length === 0, 'Planer trotz Bremse') + soll(e.status === 'gebremst' && e.offen.length === 7, JSON.stringify([e.status, e.offen])) + soll(/nicht mit resumeFromRunId/.test(e.naechster_schritt), 'naechster_schritt ' + e.naechster_schritt) + return fehler +}) + +fall('planung', 'Startreihenfolge folgt Welle und Nummer, nicht der Liste', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, tasks: [T(3, [], 2), T(2, [], 1), T(1, [], 1)], plaetze: 1 }, {}) + const folge = mitLabel(p, 'planer:').sort((x, y) => x.start - y.start).map(a => a.label) + soll(JSON.stringify(folge) === '["planer:task-01","planer:task-02","planer:task-03"]', 'Folge ' + JSON.stringify(folge)) + return fehler +}) + +fall('planung', 'kein Nachlesen, wenn kein Task mehr offen ist', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI.slice(0, 3), plaetze: 4, nachlesen_alle: 1 }, {}) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label) + soll(JSON.stringify(ab) === '["verbrauch:planung:Anfang","verbrauch:planung:Ende"]', 'Ablesungen: ' + JSON.stringify(ab)) + return fehler +}) + +fall('planung', 'Ergebnis mit fremder Datei gilt als gescheitert', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { planerDatei: (nr, richtig) => (nr === 2 ? 'D:/x/plan/task-99-t.md' : richtig) }) + soll(e.gescheitert.includes(2) && e.blockiert.includes(5) && !planerVon(p, 5), JSON.stringify([e.gescheitert, e.blockiert])) + soll(p.logs.some(l => /statt task-02-t\.md/.test(l)), 'kein Hinweis auf die fremde Datei') + return fehler +}) + +fall('planung', 'Ergebnis mit ähnlichem Dateinamen gilt als gescheitert', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await PL(PL_ARGS, { planerDatei: (nr, richtig) => (nr === 7 ? 'D:/x/plan/xtask-07-t.md' : richtig) }) + soll(JSON.stringify(e.gescheitert) === '[7]', JSON.stringify(e.gescheitert)) + return fehler +}) + +fall('planung', 'Nachlesen im Takt von nachlesen_alle, solange Tasks offen sind', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, tasks: FREI, plaetze: 2, nachlesen_alle: 2 }, { planerDauer: () => 100 }) + const ab = mitLabel(p, 'verbrauch:').map(x => x.label.slice('verbrauch:planung:'.length)) + soll(JSON.stringify(ab) === '["Anfang","nach 2 Tasks","nach 4 Tasks","nach 6 Tasks","Ende"]', 'Ablesungen: ' + JSON.stringify(ab)) + return fehler +}) + +fall('planung', '5 Stunden bei 60 %: höchstens zwei gleichzeitig', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, { ablesungen: [ablesung(60, 10), ablesung(60, 10)] }) + soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus')) + soll(e.status === 'fertig', 'status ' + e.status) + voraussetzungenEingehalten(p, DAG, soll) + return fehler +}) + +fall('planung', 'Bremse beim Nachlesen: kein Start mehr, laufende enden', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, nachlesen_alle: 2 }, { ablesungen: [ablesung(10, 10), ablesung(90, 10)] }) + soll(e.status === 'gebremst' && e.offen.length > 0, JSON.stringify([e.status, e.offen])) + const zweite = mitLabel(p, 'verbrauch:')[1] + soll(zweite && p.agents.filter(x => x.label.startsWith('planer:') && x.start > zweite.start).length === 0, 'Planer nach der Bremse gestartet') + soll(p.agents.filter(x => x.label.startsWith('planer:')).every(x => x.ende !== null), 'laufender Planer nicht beendet') + return fehler +}) + +fall('planung', 'erste Voraussetzung scheitert: abhängige blockiert, unabhängige laufen', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL(PL_ARGS, { planer: nr => (nr === 1 ? OHNE : undefined) }) + soll(e.status === 'unvollstaendig', 'status ' + e.status) + soll(JSON.stringify(e.gescheitert) === '[1]' && JSON.stringify(e.blockiert) === '[2,3,4,5,6]', JSON.stringify([e.gescheitert, e.blockiert])) + soll(e.geschrieben.map(g => g.nr).join() === '7', 'geschrieben ' + e.geschrieben.map(g => g.nr)) + soll([2, 3, 4, 5, 6].every(n => !planerVon(p, n)), 'blockierter Task gestartet') + return fehler +}) + +fall('planung', 'letzte Tasks scheitern: nichts blockiert', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e } = await PL(PL_ARGS, { planer: nr => (nr === 6 || nr === 7 ? OHNE : undefined) }) + soll(e.status === 'unvollstaendig' && JSON.stringify(e.gescheitert.slice().sort()) === '[6,7]' && e.blockiert.length === 0, JSON.stringify([e.status, e.gescheitert, e.blockiert])) + soll(e.geschrieben.length === 5, 'geschrieben ' + e.geschrieben.length) + return fehler +}) + +fall('planung', 'erledigt: Task 1 startet nicht, die abhängigen schon', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, erledigt: [1] }, {}) + soll(!planerVon(p, 1), 'Task 1 gestartet') + soll(e.status === 'fertig' && e.geschrieben.length === 6, JSON.stringify([e.status, e.geschrieben.length])) + return fehler +}) + +fall('planung', 'drei Planer ohne Ergebnis: Abbruch, kein weiterer Start', async () => { + const { fehler, soll } = pruefe() + const frei = [1, 2, 3, 4, 5, 6].map(n => T(n, [], 1)) + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, tasks: frei, plaetze: 2 }, { planer: nr => (nr <= 3 ? OHNE : undefined), planerDauer: nr => (nr <= 3 ? 5 : 60) }) + soll(e.status === 'abgebrochen', 'status ' + e.status) + soll(!planerVon(p, 5) && !planerVon(p, 6) && JSON.stringify(e.offen) === '[5,6]', 'offen ' + JSON.stringify(e.offen)) + return fehler +}) + +for (const [name, aendern, text] of [ + ['Voraussetzung ins Leere', a => ({ ...a, tasks: [T(1, [], 1), T(2, [99], 2)] }), /Voraussetzung 99 gibt es nicht/], + ['Zyklus', a => ({ ...a, tasks: [T(1, [2], 1), T(2, [1], 2)] }), /Zyklus/], + ['Selbstbezug', a => ({ ...a, tasks: [T(1, [1], 1)] }), /Zyklus/], + ['doppelte Nummer', a => ({ ...a, tasks: [T(1, [], 1), T(1, [], 1)] }), /doppelt in tasks/], + ['doppelte Datei', a => ({ ...a, tasks: [T(1, [], 1), { ...T(2, [], 1), datei: T(1, [], 1).datei }] }), /bei zwei Tasks/], + ['doppelte Voraussetzung', a => ({ ...a, tasks: [T(1, [], 1), T(2, [1, 1], 2)] }), /Voraussetzung steht doppelt/], + ['modell unbekannt', a => ({ ...a, tasks: [{ ...T(1, [], 1), modell: 'gpt' }] }), /modell "gpt"/], + ['erledigt ins Leere', a => ({ ...a, erledigt: [99] }), /erledigt muss/], + ['plaetze 5', a => ({ ...a, plaetze: 5 }), /plaetze muss/], + ['nachlesen_alle 0', a => ({ ...a, nachlesen_alle: 0 }), /nachlesen_alle muss/], + ['tasks leer', a => ({ ...a, tasks: [] }), /nicht leere Liste/], + ['nr als Text', a => ({ ...a, tasks: [{ ...T(1, [], 1), nr: '1' }] }), /nr muss/], + ['voraussetzungen fehlen', a => ({ ...a, tasks: [{ ...T(1, [], 1), voraussetzungen: undefined }] }), /voraussetzungen muss/], + ['gemeinsam fehlt', a => ({ ...a, gemeinsam: '' }), /args fehlen: gemeinsam/], +]) { + fall('planung', `${name} wirft vor dem ersten Agenten`, async () => { + const { fehler, soll } = pruefe() + const { fehler: f, protokoll: p } = await PL(aendern(PL_ARGS), {}) + soll(f && text.test(f), 'Fehler: ' + f) + soll(p.agents.length === 0, 'Agent gestartet') + return fehler + }) +} + +fall('planung', 'Auftrag nennt die Voraussetzungen und ersetzt alle Platzhalter', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL(PL_ARGS, {}) + const t5 = planerVon(p, 5).prompt + soll(t5.includes('GEMEINSAM') && t5.includes('task-02-t.md') && t5.includes('task-03-t.md') && t5.includes('D:/x/plan/task-05-t.md'), 'Auftrag Task 5 unvollständig') + soll(!/\{(NR|THEMA|DATEI|MODELL|WELLE|VORAUSSETZUNGEN)\}/.test(t5), 'Platzhalter übrig') + soll(/Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst/.test(t5), 'Lesehinweis fehlt') + soll(!/Lies sie/.test(planerVon(p, 1).prompt), 'Lesehinweis ohne Voraussetzungen') + soll(/vor: \(keine\)/.test(planerVon(p, 1).prompt), 'Task 1 ohne "(keine)"') + return fehler +}) + +fall('planung', 'alles erledigt: kein Agent, keine Ablesung', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, protokoll: p } = await PL({ ...PL_ARGS, erledigt: DAG.map(t => t.nr) }, {}) + soll(p.agents.length === 0 && e.status === 'fertig' && e.verbrauch.length === 0, JSON.stringify([p.agents.length, e.status])) + return fehler +}) + +fall('planung', 'Ablesung ohne Ergebnis: höchstens zwei gleichzeitig', async () => { + const { fehler, soll } = pruefe() + const { protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, { ablesungen: [OHNE, OHNE] }) + soll(spitze(p, 'Taskdateien', 'opus') === 2, 'gleichzeitig: ' + spitze(p, 'Taskdateien', 'opus')) + return fehler +}) + +fall('planung', 'als Kind: Rückfall auf zwei, Planung läuft durch', async () => { + const { fehler, soll } = pruefe() + const { ergebnis: e, fehler: f, protokoll: p } = await PL({ ...PL_ARGS, plaetze: 4 }, {}, { tiefe: 1 }) + soll(f === null && e.status === 'fertig', 'Fehler/status: ' + f + ' ' + (e && e.status)) + soll(spitze(p, '▸ planung', 'opus') === 2, 'gleichzeitig: ' + spitze(p, '▸ planung', 'opus')) + return fehler +}) + +// ---------------------------------------------------------------- Lauf +let gescheitert = 0 +const grundlinieGescheitert = new Set() +for (const f of faelle) { + let fehler + try { fehler = await f.lauf() } catch (e) { fehler = ['Absturz im Fall: ' + (e && e.stack || e)] } + if (f.name.startsWith('Grundlinie') && fehler.length) grundlinieGescheitert.add(f.workflow) + if (fehler.length) { + gescheitert++ + console.log(`FEHLER ${f.workflow}: ${f.name}\n ${fehler.join('\n ')}`) + } else console.log(`OK ${f.workflow}: ${f.name}`) +} +for (const w of grundlinieGescheitert) console.log(`HINWEIS Grundlinie von ${w} gescheitert: Die übrigen Fälle dieses Workflows messen nichts.`) +rmSync(TMP, { recursive: true, force: true }) +console.log(`${faelle.length} Fälle, ${gescheitert} gescheitert (Ordner ${ORDNER})`) +process.exit(gescheitert ? 1 : 0) diff --git a/.vault-tools/workflow-pruefung/mutanten_reihe.py b/.vault-tools/workflow-pruefung/mutanten_reihe.py new file mode 100644 index 0000000..da4f2aa --- /dev/null +++ b/.vault-tools/workflow-pruefung/mutanten_reihe.py @@ -0,0 +1,65 @@ +"""Mutantenlauf gegen verbrauch_reihe.py: je Mutant genau eine Ersetzung in einer Kopie des Ordners, dann faelle_verbrauch_reihe.py. + +Jede Ersetzung muss genau einmal vorkommen, sonst bricht der Lauf mit Exit 2 ab (veralteter Mutant). +Aufruf: python mutanten_reihe.py +Exit 0, wenn jeder Mutant gefangen wird, 1 bei einem Ueberlebenden, 2 bei einem veralteten Muster. +""" +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent + +MUTANTEN = [ + ("Reset nur bei gleichem Text", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if a[1] != b[1] or b[0] < a[0]:"), + ("Toleranz 6 Stunden", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(hours=6)"), + ("Toleranz 1 Sekunde", "RESET_TOLERANZ = timedelta(minutes=5)", "RESET_TOLERANZ = timedelta(seconds=1)"), + ("Toleranz nur nach vorn", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if rb - ra > RESET_TOLERANZ or b[0] < a[0]:"), + ("fallender Wert kein Reset", "if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]:", "if abs(rb - ra) > RESET_TOLERANZ:"), + ("unlesbares resetsAt als gleich", " if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None):\n return \"unbekannt\"\n", ""), + ("bool als Zahl", "isinstance(p, bool) or ", ""), + ("ueber 100 als Fehler", "not math.isfinite(p) or p < 0", "not math.isfinite(p) or not 0 <= p <= 100"), + ("erstes statt hoechstes 5-Stunden-Fenster", "if art == \"fuenf\" and (fuenf is None or p > fuenf[0]):", "if art == \"fuenf\" and fuenf is None:"), + ("Ablesezeit Start statt Ende", "\"zeit\": spanne[1]", "\"zeit\": spanne[0]"), + ("nur_ascii fehlt", "print(nur_ascii(f\"| {z", "print((f\"| {z"), + ("Ende des Paars nicht gekappt", "min(g[\"ende\"], b[\"zeit\"])", "g[\"ende\"]"), + ("Anfang des Paars nicht gekappt", "max(g[\"start\"], a[\"zeit\"])", "g[\"start\"]"), + ("Absturz nicht gefangen", "except Exception as e: # Ein Absturz", "except ZeroDivisionError as e: # Ein Absturz"), + ("kaputte Zeile bricht ab", " except ValueError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile", " except KeyError:\n befunde.append(f\"{pfad.parent.name}/{pfad.name}: Zeile"), + ("seit ignoriert", "ablesungen = [x for x in ablesungen if x[\"zeit\"] >= seit]", "pass"), + ("Speicher fehlt still", " return 2\n seit = None", " return 0\n seit = None"), + ("Familie aus vollem Namen fehlt", " if f in modell.lower():", " if f == modell.lower():"), +] + + +def main() -> int: + ueberlebt, veraltet = [], [] + for name, alt, neu in MUTANTEN: + with tempfile.TemporaryDirectory() as tmp: + ziel = Path(tmp) / "w" + ziel.mkdir() + for datei in ("verbrauch_reihe.py", "faelle_verbrauch_reihe.py"): + shutil.copy2(HIER / datei, ziel / datei) + pfad = ziel / "verbrauch_reihe.py" + text = pfad.read_text(encoding="utf-8") + if text.count(alt) != 1: + veraltet.append(name) + print(f"VERALTET {name} (Muster {text.count(alt)}-mal)") + continue + pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n") + lauf = subprocess.run([sys.executable, str(ziel / "faelle_verbrauch_reihe.py")], capture_output=True, text=True, encoding="utf-8", errors="replace") + rot = [z for z in lauf.stdout.splitlines() if z.startswith("FEHLER")] + if lauf.returncode == 0: + ueberlebt.append(name) + print(f"UEBERLEBT {name}") + else: + print(f"GEFANGEN {name} ({rot[0][7:80] if rot else 'Absturz'})") + print(f"{len(MUTANTEN)} Mutanten, {len(ueberlebt)} ueberlebt, {len(veraltet)} veraltet") + return 2 if veraltet else 1 if ueberlebt else 0 + + +if __name__ == "__main__": + sys.stdout.reconfigure(errors="replace") + sys.exit(main()) diff --git a/.vault-tools/workflow-pruefung/mutanten_workflows.py b/.vault-tools/workflow-pruefung/mutanten_workflows.py new file mode 100644 index 0000000..0c195b6 --- /dev/null +++ b/.vault-tools/workflow-pruefung/mutanten_workflows.py @@ -0,0 +1,110 @@ +"""Mutantenlauf gegen die Workflows unter .claude/workflows/: je Mutant genau eine Ersetzung, dann faelle_workflows.mjs. + +Jede Ersetzung muss genau einmal vorkommen, sonst bricht der Lauf mit Exit 2 ab: Ein Muster, das +nicht mehr passt, ist ein veralteter Mutant und kein ueberlebter. Ein ueberlebter Mutant ist ein +Befund, ausser er steht in WIRKUNGSLOS mit Begruendung. +Aufruf: python mutanten_workflows.py [] +Exit 0, wenn jeder wirksame Mutant gefangen wird, 1 bei einem Ueberlebenden, 2 bei einem veralteten Muster. +""" +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HIER = Path(__file__).resolve().parent +FAELLE = HIER / "faelle_workflows.mjs" + +# Mutanten, die bei den heutigen Stufen kein Verhalten aendern, mit Grund. +WIRKUNGSLOS = { + "harte Obergrenze fehlt": "keine Stufe gibt mehr als vier Plaetze; wer die Stufen anhebt, braucht einen Fall", +} + +MUTANTEN = [ + ("verbrauch", "Stufe 50 -> 60", "[[50, 4], [70, 2], [85, 1]]", "[[60, 4], [70, 2], [85, 1]]"), + ("verbrauch", "Wochenstufe 85 -> 80", "[[70, 4], [85, 2], [95, 1]]", "[[70, 4], [80, 2], [95, 1]]"), + ("verbrauch", "ueber 100 wieder Fehler", "|| p < 0) return", "|| p < 0 || p > 100) return"), + ("verbrauch", "doppeltes Fenster: erstes gewinnt", "if (!e[art] || e[art].prozent < p) e[art] = neu", "if (!e[art]) e[art] = neu"), + ("verbrauch", "Opus-Woche ignoriert", "Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0)", "e.woche.prozent"), + ("verbrauch", "Opus-Woche ersetzt die Woche", "Math.max(e.woche.prozent, e.opus ? e.opus.prozent : 0)", "(e.opus ? e.opus.prozent : e.woche.prozent)"), + ("verbrauch", "harte Obergrenze fehlt", "Math.min(wunsch, grenzeFuenf, grenzeWoche, HARTE_OBERGRENZE)", "Math.min(wunsch, grenzeFuenf, grenzeWoche)"), + ("verbrauch", "gebremst auch bei wunsch 0", "const gebremst = wunsch > 0 && plaetze === 0", "const gebremst = plaetze === 0"), + ("verbrauch", "Status ok nicht verlangt", " || ablesung.status !== 'ok'", ""), + ("verbrauch", "Rueckfall 3 statt 2", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), + ("verbrauch", "wunsch-Pruefung fehlt", "if (!Number.isInteger(a.wunsch) || a.wunsch < 0) throw", "if (false) throw"), + ("verbrauch", "fremdes Fenster mit Zahl geprueft", "if (!art) { e.andere.push(f.label); continue }\n const p = f.percentUsed", "const p = f.percentUsed\n if (!art) { if (typeof p !== 'number') return { fehler: 'x' }; e.andere.push(f.label); continue }"), + ("plankritik", "Bremse vor Linsen fehlt", "if (vorLinsen.gebremst) {", "if (false) {"), + ("plankritik", "Bremse vor Widerlegung fehlt", "if (vorWiderlegung.gebremst) {", "if (false) {"), + ("plankritik", "Widerlegung bis vier", "const WIDERLEGUNG_HOECHSTENS = 2", "const WIDERLEGUNG_HOECHSTENS = 4"), + ("plankritik", "Widerlegung ignoriert die Ablesung", "} else plaetzeWiderlegung = vorWiderlegung.plaetze", "} else plaetzeWiderlegung = WIDERLEGUNG_HOECHSTENS"), + ("plankritik", "Linsen fest zwei", "await hoechstens(vorLinsen.plaetze, gewaehlt,", "await hoechstens(2, gewaehlt,"), + ("plankritik", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), + ("plankritik", "Testort fehlt in der Widerlegung", "Du änderst keine Datei im Projekt und keine im Plan. ${testort(ctx)}\n- ", "Du änderst keine Datei im Projekt und keine im Plan.\n- "), + ("plankritik", "Testort fehlt bei den Linsen", "Executer-Bericht, dessen Testlauf du wiederholen könntest. ${testort(ctx)}", "Executer-Bericht, dessen Testlauf du wiederholen könntest. ${linse === 'arithmetik' ? testort(ctx) : ''}"), + ("plankritik", "keine Ablesung am Ende", "await ablesen('Ende', 0)\n", "\n"), + ("plankritik", "gebremste Befunde ohne Hinweis", "'nicht geprüft (gebremst)'", "'nicht geprüft'"), + ("plankritik", "gebremst auch unter der Schwelle", "gebremstVor && zuPruefen.includes(g) ?", "gebremstVor ?"), + ("plankritik", "naechster_schritt uebergeht die Bremse", "naechster_schritt: gebremstVor ? ", "naechster_schritt: false ? "), + ("plankritik", "zaehlung null bei Bremse vor Linsen", "zaehlung: { roh: 0, zusammengefasst: 0, bestaetigt: 0, offen: 0, widerlegt: 0, nicht_geprueft: 0 },", "zaehlung: null,"), + ("pruefung", "verbrauch false wird uebergangen", "if (args.verbrauch === false) return\n", "\n"), + ("pruefung", "Ablesung nach der Pruefung fehlt", "await ablesen('nach Prüfung')\n", "\n"), + ("welle-mit-pruefung", "Kind liest selbst", "{ ...pruefungArgs, verbrauch: false }", "{ ...pruefungArgs }"), + ("welle-mit-pruefung", "bis vergleich wirkt nicht", "if (args.bis === 'vergleich') {", "if (false) {"), + ("welle-mit-pruefung", "Ablesung vor der Pruefung fehlt", " await ablesen('vor Prüfung')\n", "\n"), + ("planung", "Voraussetzung laufend genuegt", "['fertig', 'erledigt'].includes(status.get(n)))) starte(t)", "['fertig', 'erledigt', 'laeuft'].includes(status.get(n)))) starte(t)"), + ("planung", "keine Blockade", "status.set(t.nr, 'blockiert')\n geaendert = true", "geaendert = false"), + ("planung", "kein Abbruch nach drei", "gescheitert.length >= ABBRUCH_NACH && !abgebrochen", "false"), + ("planung", "Bremse beim Nachlesen fehlt", "if (v.gebremst) {\n gebremst = true\n log('Planung gebremst", "if (false) {\n gebremst = true\n log('Planung gebremst"), + ("planung", "Nachlesen uebernimmt die Plaetze nicht", "log('Planung gebremst: kein weiterer Start, die laufenden Planer enden')\n } else plaetze = v.plaetze", "log('Planung gebremst: kein weiterer Start, die laufenden Planer enden')\n }"), + ("planung", "Bremse am Anfang nicht gemeldet", " if (v.gebremst) gebremst = true\n else plaetze = v.plaetze", " plaetze = v.plaetze"), + ("planung", "Plaetze nicht begrenzt", "if (laufend.size >= plaetze) break", "if (laufend.size >= 99) break"), + ("planung", "Reihenfolge unsortiert", "const reihenfolge = [...args.tasks].sort((x, y) => ((x.welle || 0) - (y.welle || 0)) || (x.nr - y.nr))", "const reihenfolge = [...args.tasks]"), + ("planung", "Nachlesen ohne offene Tasks", "fertigSeitAblesung >= nachlesenAlle && nochOffen()", "fertigSeitAblesung >= nachlesenAlle"), + ("planung", "fremde Datei zaehlt als fertig", "const r = passt ? roh : null", "const r = roh"), + ("planung", "Vorgabe vier statt zwei", "const VORGABE_PLAETZE = 2", "const VORGABE_PLAETZE = 4"), + ("planung", "Zyklenpruefung fehlt", "if (geschafft < a.tasks.length) throw", "if (false) throw"), + ("planung", "erledigt ignoriert", "(args.erledigt || []).includes(t.nr) ? 'erledigt' : 'offen'", "'offen'"), + ("planung", "Rueckfall ohne Ablesung 3", "const OHNE_ABLESUNG = 2", "const OHNE_ABLESUNG = 3"), + ("planung", "Datei ohne Pfadgrenze", "('/' + roh.datei.replace(/\\\\/g, '/')).endsWith('/' + jeNr.get(nr).datei)", "roh.datei.replace(/\\\\/g, '/').endsWith(jeNr.get(nr).datei)"), + ("planung", "Nachlesezaehler nicht zurueckgesetzt", " fertigSeitAblesung = 0\n const v = await ablesen(", " const v = await ablesen("), + ("planung", "nachlesen_alle ignoriert", "const nachlesenAlle = args.nachlesen_alle || NACHLESEN_ALLE", "const nachlesenAlle = NACHLESEN_ALLE"), + ("planung", "Lesehinweis fehlt", " Lies sie, bevor du einen Namen, eine Signatur oder einen Import daraus übernimmst.", ""), + ("plankritik", "doppelte Linse erlaubt", " if (a.linsen && new Set(a.linsen).size !== a.linsen.length) throw", " if (false) throw"), + ("welle-mit-pruefung", "status fertig ohne Pruefung", "status: pruefung ? 'fertig' : 'ohne_pruefung',", "status: 'fertig',"), + ("welle-mit-pruefung", "Ablesung ohne try", " try {\n r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })\n } catch (e) {\n r = { status: 'nicht_gelesen', grund: 'verbrauch.js nicht ausführbar: ' + (e && e.message) }\n }", " r = await workflow({ scriptPath: VERBRAUCH }, { anlass: 'welle-mit-pruefung:' + anlass, wunsch: 0 })"), + ("verbrauch", "5-Stunden-Regex unverankert", "const FUENF_STUNDEN = /^\\s*5[- ]?hour/i", "const FUENF_STUNDEN = /hour/i"), +] + + +def main() -> int: + ordner = Path(sys.argv[1]) if len(sys.argv) > 1 else HIER.parent.parent / ".claude" / "workflows" + ueberlebt, veraltet = [], [] + for datei, name, alt, neu in MUTANTEN: + with tempfile.TemporaryDirectory() as tmp: + ziel = Path(tmp) / "wf" + shutil.copytree(ordner, ziel) + pfad = ziel / (datei + ".js") + text = pfad.read_text(encoding="utf-8") + if text.count(alt) != 1: + veraltet.append(name) + print(f"VERALTET {datei}: {name} (Muster {text.count(alt)}-mal)") + continue + pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n") + lauf = subprocess.run(["node", str(FAELLE), str(ziel)], capture_output=True, text=True, encoding="utf-8", errors="replace") + rot = [z for z in lauf.stdout.splitlines() if z.startswith("FEHLER")] + if lauf.returncode == 0: + grund = WIRKUNGSLOS.get(name) + if grund: + print(f"WIRKUNGSLOS {datei}: {name} ({grund})") + else: + ueberlebt.append(name) + print(f"UEBERLEBT {datei}: {name}") + else: + print(f"GEFANGEN {datei}: {name} ({len(rot)} rot, z. B. {rot[0][7:90] if rot else 'Absturz'})") + print(f"{len(MUTANTEN)} Mutanten, {len(ueberlebt)} ueberlebt, {len(veraltet)} veraltet") + return 2 if veraltet else 1 if ueberlebt else 0 + + +if __name__ == "__main__": + sys.stdout.reconfigure(errors="replace") + sys.exit(main()) diff --git a/.vault-tools/workflow-pruefung/verbrauch_reihe.py b/.vault-tools/workflow-pruefung/verbrauch_reihe.py new file mode 100644 index 0000000..8c61285 --- /dev/null +++ b/.vault-tools/workflow-pruefung/verbrauch_reihe.py @@ -0,0 +1,231 @@ +"""Wertet die Verbrauchsablesungen der Workflows aus: wie viel Prozent des 5-Stunden-Fensters und der Woche zwischen zwei Ablesungen verbraucht wurden und welche Agenten dazwischen liefen. + +Koordinator-Werkzeug (entstanden 2026-09-24). Es misst und beurteilt nichts; die Stufen in +.claude/workflows/verbrauch.js stellt der Mensch nach dieser Messreihe ein. +Quelle der Daten ist der Session-Speicher von Claude Code. Je Workflow-Lauf liegen unter +//subagents/workflows// die Datei journal.jsonl (Ereignisse "started" +mit agentId und label, "result" mit agentId und result), je Agent agent-.jsonl (Zeilen mit +timestamp) und agent-.meta.json (model). Ablesungen sind Agenten, deren label mit +"verbrauch:" beginnt; ihr result traegt fenster mit label, percentUsed und resetsAt. +Die Fenster werden wie in verbrauch.js erkannt: "5-hour ..." und "Weekly ... all models". +Was das Skript nicht sieht: den Verbrauch anderer Sitzungen und der Hauptsitzung im selben +Zeitraum (er steckt in der Differenz mit drin), Ablesungen ueber probe (sie starten keinen +Agenten) und Laeufe, deren Protokolle geloescht sind. Die Kennzahl je Opus-Agentenstunde ist eine +Naeherung, kein Nachweis. +Aufruf: python verbrauch_reihe.py [--speicher ] [--seit JJJJ-MM-TT] +Exit 0 mit Auswertung (auch bei null Ablesungen), 2 wenn der Speicher fehlt oder --seit kein +Datum ist. Die Ausgabe ist reines ASCII, damit sie auch auf einer cp1252-Konsole durchlaeuft. +""" + +import argparse +import json +import math +import re +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +STANDARD_SPEICHER = Path.home() / ".claude" / "projects" / "D--Claude" +RESET_TOLERANZ = timedelta(minutes=5) +FUENF = re.compile(r"^\s*5[- ]?hour", re.I) +WOCHE = re.compile(r"^\s*weekly\b.*\ball models\b", re.I) +FAMILIEN = ("opus", "sonnet", "haiku", "fable") + + +def zeit(text): + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def lies_jsonl(pfad, befunde): + """Liest eine JSONL-Datei zeilenweise; kaputte Zeilen sind ein Befund, kein Absturz.""" + zeilen = [] + try: + with pfad.open(encoding="utf-8") as datei: + for nr, zeile in enumerate(datei, 1): + if not zeile.strip(): + continue + try: + zeilen.append(json.loads(zeile)) + except ValueError: + befunde.append(f"{pfad.parent.name}/{pfad.name}: Zeile {nr} ist kein JSON, uebergangen") + except OSError as e: + befunde.append(f"{pfad.parent.name}/{pfad.name}: nicht lesbar ({type(e).__name__})") + return zeilen + + +def einordnen(fenster): + """(prozent, resetsAt) fuer 5 Stunden und Woche; None, wo das Fenster fehlt oder kaputt ist.""" + fuenf = woche = None + if not isinstance(fenster, list): + return None, None + for f in fenster: + if not isinstance(f, dict) or not isinstance(f.get("label"), str): + return None, None + art = "fuenf" if FUENF.match(f["label"]) else "woche" if WOCHE.match(f["label"]) else None + if art is None: + continue + p = f.get("percentUsed") + if isinstance(p, bool) or not isinstance(p, (int, float)) or not math.isfinite(p) or p < 0: + return None, None + eintrag = (float(p), f.get("resetsAt")) + if art == "fuenf" and (fuenf is None or p > fuenf[0]): + fuenf = eintrag + elif art == "woche" and (woche is None or p > woche[0]): + woche = eintrag + return fuenf, woche + + +def zeitspanne(ordner, agent_id, befunde): + stempel = [] + for o in lies_jsonl(ordner / f"agent-{agent_id}.jsonl", befunde): + t = o.get("timestamp") if isinstance(o, dict) else None + if isinstance(t, str): + try: + stempel.append(zeit(t)) + except ValueError: + pass + return (min(stempel), max(stempel)) if stempel else None + + +def familie(ordner, agent_id): + try: + modell = json.loads((ordner / f"agent-{agent_id}.meta.json").read_text(encoding="utf-8")).get("model") + except (OSError, ValueError, AttributeError): + return "?" + if not isinstance(modell, str): + return "?" + for f in FAMILIEN: + if f in modell.lower(): + return f + return modell + + +def lauf_auswerten(ordner, befunde): + labels, ergebnisse = {}, {} + for o in lies_jsonl(ordner / "journal.jsonl", befunde): + if not isinstance(o, dict) or not isinstance(o.get("agentId"), str): + continue + if o.get("type") == "started": + labels[o["agentId"]] = o.get("label") if isinstance(o.get("label"), str) else "" + elif o.get("type") == "result": + ergebnisse[o["agentId"]] = o.get("result") + ablesungen, agenten = [], [] + for agent_id, label in labels.items(): + spanne = zeitspanne(ordner, agent_id, befunde) + if label.startswith("verbrauch:"): + if spanne is None: + befunde.append(f"{ordner.name}: Ablesung {label} ohne Zeitstempel, uebergangen") + continue + ergebnis = ergebnisse.get(agent_id) + fuenf, woche = einordnen(ergebnis.get("fenster") if isinstance(ergebnis, dict) else None) + # Ablesezeit ist das Ende des Ableseagenten: get_usage laeuft kurz vor seinem Schluss. + ablesungen.append({"anlass": label[len("verbrauch:"):], "zeit": spanne[1], "fuenf": fuenf, "woche": woche}) + elif spanne is not None: + agenten.append({"familie": familie(ordner, agent_id), "start": spanne[0], "ende": spanne[1]}) + ablesungen.sort(key=lambda a: a["zeit"]) + return ablesungen, agenten + + +def zeitpunkt(text): + if not isinstance(text, str): + return None + try: + return zeit(text) + except ValueError: + return None + + +def differenz(a, b): + """Prozentpunkte von a nach b im selben Fenster. + + get_usage rechnet resetsAt bei jeder Ablesung neu aus; der Wert schwankt um Millisekunden und + springt dabei auch ueber eine Sekunden- oder Datumsgrenze (gemessen 2026-09-24: 18:29:59.664Z, + 18:30:00.267Z, 18:30:00.459Z fuer dasselbe Fenster). Als Reset gilt deshalb erst eine + Verschiebung um mehr als RESET_TOLERANZ oder ein fallender Wert. + """ + if a is None or b is None: + return "unbekannt" + ra, rb = zeitpunkt(a[1]), zeitpunkt(b[1]) + if ra is None or rb is None or (ra.tzinfo is None) != (rb.tzinfo is None): + return "unbekannt" + if abs(rb - ra) > RESET_TOLERANZ or b[0] < a[0]: + return "Reset" + return round(b[0] - a[0], 1) + + +def paare(ordner, ablesungen, agenten): + for a, b in zip(ablesungen, ablesungen[1:]): + minuten = {} + for g in agenten: + anteil = (min(g["ende"], b["zeit"]) - max(g["start"], a["zeit"])).total_seconds() + if anteil > 0: + minuten[g["familie"]] = minuten.get(g["familie"], 0.0) + anteil / 60 + yield { + "lauf": ordner.name, + "von": a["anlass"], + "bis": b["anlass"], + "minuten": round((b["zeit"] - a["zeit"]).total_seconds() / 60, 1), + "agenten": {k: round(v, 1) for k, v in sorted(minuten.items())}, + "d5": differenz(a["fuenf"], b["fuenf"]), + "dw": differenz(a["woche"], b["woche"]), + } + + +UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss"}) + + +def nur_ascii(text): + return str(text).translate(UMLAUTE).encode("ascii", "replace").decode("ascii") + + +def main(argv=None): + p = argparse.ArgumentParser(description="Messreihe der Verbrauchsablesungen aus den Workflow-Protokollen") + p.add_argument("--speicher", type=Path, default=STANDARD_SPEICHER) + p.add_argument("--seit", default=None, help="nur Ablesungen ab diesem Tag (JJJJ-MM-TT, UTC)") + a = p.parse_args(argv) + if not a.speicher.is_dir(): + print(nur_ascii(f"Speicher fehlt: {a.speicher}")) + return 2 + seit = None + if a.seit: + try: + seit = datetime.fromisoformat(a.seit).replace(tzinfo=timezone.utc) + except ValueError: + print(nur_ascii(f"--seit {a.seit!r} ist kein Datum JJJJ-MM-TT")) + return 2 + befunde, zeilen, anzahl = [], [], 0 + for ordner in sorted(a.speicher.glob("*/subagents/workflows/wf_*")): + if not ordner.is_dir(): + continue + try: + ablesungen, agenten = lauf_auswerten(ordner, befunde) + if seit is not None: + ablesungen = [x for x in ablesungen if x["zeit"] >= seit] + anzahl += len(ablesungen) + zeilen.extend(paare(ordner, ablesungen, agenten)) + except Exception as e: # Ein Absturz ist ein Befund (pruefwerkzeuge.md, Abschnitt 4). + befunde.append(f"{ordner.name}: Auswertung abgebrochen ({type(e).__name__}: {e})") + print(f"{anzahl} Ablesungen, {len(zeilen)} Paare") + if zeilen: + print("| Lauf | von -> bis | Minuten | Agentenminuten | d 5h % | d Woche % |") + print("|---|---|---|---|---|---|") + for z in zeilen: + agenten = ", ".join(f"{k} {v}" for k, v in z["agenten"].items()) or "-" + print(nur_ascii(f"| {z['lauf']} | {z['von']} -> {z['bis']} | {z['minuten']} | {agenten} | {z['d5']} | {z['dw']} |")) + werte = [z for z in zeilen if isinstance(z["d5"], float) and z["agenten"].get("opus", 0) > 0] + if werte: + prozent = sum(z["d5"] for z in werte) + stunden = sum(z["agenten"]["opus"] for z in werte) / 60 + print(f"Naeherung: {prozent / stunden:.1f} % des 5-Stunden-Fensters je Opus-Agentenstunde " + f"({len(werte)} Paare, {stunden:.1f} Opus-Stunden; andere Sitzungen und Nicht-Opus-Agenten stecken in der Differenz)") + else: + print("Keine auswertbaren Paare mit Opus-Agenten.") + if befunde: + print("Hinweise:") + for b in befunde: + print(nur_ascii("- " + b)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/AI/Regelwerk/codeworkflow.md b/AI/Regelwerk/codeworkflow.md index d5026ec..d454f75 100644 --- a/AI/Regelwerk/codeworkflow.md +++ b/AI/Regelwerk/codeworkflow.md @@ -1,11 +1,11 @@ --- type: resource -status: reference +status: pending-review title: Codeworkflow summary: Arbeitsweise für Code-Projekte — Standardschleife, Planaufteilung je Task, Subagenten und ihre Modellstufen und Effort, Gegenprüfung, Lauf mit echten Daten, Aufgabenmanagement. tags: [regelwerk, codeworkflow, code-projekte] created: 2026-08-03 -updated: 2026-09-23 +updated: 2026-09-24 --- # Codeworkflow @@ -69,6 +69,8 @@ Das Feld `effort:` im Task-Frontmatter ist freiwillig. Fehlt es, gilt das Frontm - Die zentralen Artefakte selbst lesen (SKILL.md, README, die Hauptmodule) statt sie aus dem Bericht zu glauben. - **Einen eigenen Lauf mit echten Daten fahren, mit eigenem Prüfcode.** Nicht die Tests des Agenten. Siehe „Lauf mit echten Daten" unten. +**Die eigene Gegenprüfung läuft, während der `pruefer` arbeitet, nicht danach.** `welle-mit-pruefung` mit `bis: "vergleich"` endet vor der Prüfung und gibt `pruefung_args` mit den Rohbelegen zurück; damit startest du `pruefung` im Hintergrund und prüfst in derselben Zeit selbst. Solange er läuft, schreibst du nichts in Projektbaum oder Planordner, fährst Rot- und Mutantentests nur auf einer Kopie und brichst ihn nicht ab, auch nicht bei einem eigenen Blocker. Ein abgebrochener Prüfer hinterlässt eine Runde ohne volle Abdeckung, und jede weitere Korrekturrunde bringt ihre eigene Fehlerschicht. Plankorrektur und Commit kommen nach seinem Bericht. Der Gewinn ist höchstens die Lücke nach der Prüfung: im Hub am 2026-09-24 drei bis elf Minuten je Prüfung, gerechnet aus sechs Prüfungen, nicht gemessen. + **Mechanische Prüfungen brauchen einen Regressionsnachweis.** Ein Prüfskript gilt erst als Gate, wenn du es mit absichtlich kaputter Eingabe hast anschlagen sehen — Exit-Code und Meldung selbst beobachtet, nicht aus einem Bericht übernommen. Ohne diesen Nachweis ist ein grüner Lauf kein Ergebnis, sondern eine unbelegte Zusicherung. Für jede Prüfung, die eine **offene Menge** einschränkt (verbotene Aktionen, verbotene Wörter, unerlaubte Muster), gilt zusätzlich: **Positivliste statt Negativliste.** Was nicht ausdrücklich erlaubt ist, ist ein Befund. Eine handgepflegte Negativliste verliert prinzipiell gegen eine offene Menge — 2026-08-18 im Projekt „Logging" fünfmal hintereinander belegt: jede Fassung des Rollenprüfskripts schloss die genannte Lücke und riss eine neue auf. Der Kopfkommentar eines solchen Skripts benennt deshalb seinen Geltungsbereich: was es prüft, was es nicht sieht, und dass es ein Fangnetz ist und kein Nachweis. Wo der belastbare Nachweis erst in der Zielumgebung möglich ist, gehört er dorthin und wird bis dahin als offen geführt. @@ -81,6 +83,8 @@ Für jede Prüfung, die eine **offene Menge** einschränkt (verbotene Aktionen, Faustregel: Lässt sich die Nachbesserung in einem Auftrag beschreiben, der für sich steht — geänderte Planstelle, Fehlerbild, erwartete Fehlermeldung —, dann ein frischer Subagent. Fortsetzen nur, wenn der Verlauf selbst gebraucht wird, etwa bei einer Fehlersuche über mehrere Runden. +**Nachbesserung nach einer ausgeführten Welle als Delta, nicht als Neufassung.** Der `planer` schreibt dann neue Taskdateien mit `Aus:`/`wird:`-Blöcken und für ältere Tasks eine Folgenliste. Die Regel für ihn steht in `.claude/agents/planer.md`, weil er das Regelwerk nicht lädt. Dein Teil: Die Plankritik liest nur die neuen Tasks und die Folgenliste (`materialisieren.tasks`). Nach dem Anwenden läuft `plan_bloecke.py nach` auf einer frisch gebauten Kopie. Die Folgenliste setzt ein Executer ein, danach prüfst du die Zeilenenden mit `git ls-files --eol` oder `file`, nicht mit `grep`. Und die `spec.json` des Rot-Nachweises bekommt die neuen Tasks. Gemessen im Hub am 2026-09-24: rund 20 Minuten je Runde schneller als die Neufassung, drei gegen drei Runden, nicht kontrolliert. Am 2026-09-23 waren beim Neuschreiben 10 von 17 Befunden der Nachprüfung durch das Umschreiben selbst entstanden. Für die Korrektur nach der Plankritik vor der ersten Ausführung ist das Delta nicht belegt. + **Die Nachbesserung folgt derselben Reihenfolge wie der Task selbst:** erst die neuen Tests, Fehlschlag im Wortlaut beobachten lassen, dann die Implementierung. Wer beides gleichzeitig schreiben lässt, bekommt einen grünen Lauf ohne Beleg, dass die neuen Tests je etwas gemessen haben. Dabei die **Ertragskurve mitschreiben**: was hat Runde 1 gefunden, was Runde 2, was Runde 3. Fällt der Ertrag auf Umformulierungen, ist Schluss — und der Prüfer wird im Abschlussauftrag ausdrücklich gefragt, ob eine weitere Runde noch Substanz hat. Er hat darauf brauchbar geantwortet und einmal von sich aus empfohlen, ohne weitere Runde zu committen. **Jede Nachbesserung ist selbst neuer, ungeprüfter Code** und bekommt in derselben Runde ihren eigenen Regressionsfall. @@ -122,6 +126,7 @@ Was echte Daten aufdecken und Fixtures nicht: verdrehte Vorzeichen, Skalierungsp - **Testdoubles, die nur zurückspiegeln.** Ein Fake, der beim Lesen zurückgibt, was er beim Schreiben bekam, bestätigt die Annahmen des Produktivcodes statt die echte Schnittstelle. Bei asymmetrischen APIs (Schreiben nimmt IDs, Lesen liefert Objekte) muss der Fake die Asymmetrie nachbilden. Im Zweifel in der API-Dokumentation nachschlagen statt Symmetrie anzunehmen. - **Schalterpaare.** Wo ein Schalter Schreibzugriffe freigibt und ein anderer sie unterdrückt, ist die Kombination der Testfall — nicht jeder für sich. Für jeden Trockenlauf-Modus einen Test führen, der über das Testdouble *jeden* schreibenden Aufruf zählt und auf null prüft, statt nur das erwartete Endergebnis zu betrachten. - **Der Test misst den Inhalt, nicht den Rahmen.** (2026-08-07, „Paketierung Intune") Die Layouttests prüften, ob der Text ins Fenster passt — grün. Ob das *Fenster* auf den *Bildschirm* passt, prüfte niemand: auf 1366×768 bei 125 % Skalierung lag die Fußzeile mit beiden Schaltflächen unter dem Bildschirmrand, der Benutzer hätte nichts mehr anklicken können. Wo etwas in einen Container gesetzt wird, immer beide Ebenen prüfen: passt der Inhalt hinein, und passt der Container dorthin, wo er landet. Die äußere Ebene gehört als reine Funktion mit den Maßen als Parameter getestet — der Entwicklungsrechner ist nie das kleinste Zielgerät. +- **Ein Thread im Test, viele im Betrieb.** (2026-09-24, „Nexhorizons Hub") FastAPI führt synchrone Abhängigkeiten und Endpunkte im Threadpool aus, und alle Anfragen teilten sich eine SQLite-Verbindung. 402 Tests, drei Plankritik-Runden und zwei Prüfungen waren grün, weil jeder Test einen Thread hat; erst ein Lastlauf gegen die echte Mittelschicht zeigte ein falsches Cookie mit Admin-Sitzung und eine gebrochene Audit-Kette. Hält ein Zustand eine Verbindung, einen Zähler oder einen Cache, steht im Plan, wer ihn aus welchem Thread benutzt, und der Lauf mit echten Daten fährt parallele Klienten. **Screenshots zur Fehlersuche.** Vor `CopyFromScreen` immer `SetProcessDPIAware()` aufrufen und das Fenster gezielt über `GetWindowRect` abfotografieren. Ohne das liefert `SystemInformation.VirtualScreen` bei aktiver Skalierung die *logische* Größe, während *physische* Pixel kopiert werden — das Ergebnis ist ein Ausschnitt der linken oberen Ecke, kein verkleinertes Gesamtbild. Sieht ein Fenster auf einem Screenshot abgeschnitten aus: zuerst das Aufnahmeverfahren prüfen, dann den Code. Sonst wird am Produktivcode ein Fehler „behoben", den es nicht gibt. @@ -231,14 +236,16 @@ Warum nicht überall die billigste Stufe: Beim Prüfer ist Abdeckung der Zweck. Was die Messung nicht belegt: Die Prüfstände waren klein, ein Modul mit 73 Zeilen. Bei Plänen und Reviews über viele Dateien kann der Abstand zwischen den Stufen anders aussehen. Die Recherche-Aufgabe (acht Fragen, Antworten verteilt auf fünf Regelwerk-Dateien) war zu leicht: Haiku und Sonnet beantworteten sie in jeder Stufe 8 von 8. Belegt ist dort nur, dass Nachschlagen keinen hohen Effort braucht. -**In Workflows** bekommt jeder `agent()`-Aufruf `model`. Den Effort liefert mit `agentType` (`planer`, `pruefer`, `executer`, `recherche`) dessen Frontmatter; ein `effort`-Parameter daneben schlägt das Frontmatter und steht nur für eine bewusste Abweichung. Ohne `agentType` erbt der Agent die Sitzung, dort wird der Effort immer mitgegeben. Plankritik, Executer-Welle und Prüfung liegen als gespeicherte Workflows unter `.claude/workflows/` (`plankritik`, `executer-welle`, `pruefung`); sie setzen genau das um und halten Opus bei höchstens zwei gleichzeitig. Was sie darüber hinaus tun: +**In Workflows** bekommt jeder `agent()`-Aufruf `model`. Den Effort liefert mit `agentType` (`planer`, `pruefer`, `executer`, `recherche`) dessen Frontmatter; ein `effort`-Parameter daneben schlägt das Frontmatter und steht nur für eine bewusste Abweichung. Ohne `agentType` erbt der Agent die Sitzung, dort wird der Effort immer mitgegeben. Gespeicherte Workflows unter `.claude/workflows/`: `plankritik`, `executer-welle`, `pruefung`, `welle-mit-pruefung`, `planung` für die Erstplanung und `verbrauch` für die Ablesung des Planverbrauchs. Sie setzen genau das um; wie viele Opus-Agenten sie gleichzeitig starten, regelt „Verbrauch und Opus-Plätze" unten. Was sie darüber hinaus tun: - `plankritik` baut auf Wunsch die Kopie mit angewendetem Plan selbst (`materialisieren` mit Ausschlüssen) und lässt einen billigen Hilfsagenten die Planwerkzeuge fahren; die Linsen bekommen die Rohausgabe. Danach fasst sie doppelte Befunde zusammen und lässt jeden wichtigen oder blockierenden von einem zweiten `pruefer` zu widerlegen versuchen: „bestätigt" nur mit eigener Reproduktion, „widerlegt" nur mit Gegenbeleg, sonst „offen". Das filtert Fehlalarme, bevor du den Plan korrigierst, und kostet je Befund einen Opus-Lauf. - `welle-mit-pruefung` fasst Schritt 3 und 4 zusammen: Kopie aus dem Stand **vor** der Welle, `executer-welle`, Byte-Vergleich und Suite durch den Hilfsagenten, dann `pruefung` mit diesen Rohbelegen. Ist die Welle unvollständig, prüft er nicht und sagt das. - Eine selbst gebaute Kopie verlangt eine nicht leere Liste von Ausschlüssen (`ohne`), gedacht für `.git` und Geheimnisdateien; fehlt sie oder ist sie leer, bricht der Workflow vor dem ersten Agenten ab. Ob die Liste die Geheimnisse wirklich trifft, sieht er nicht — das bleibt deine Sorgfalt. Die Kopie bleibt im Temp liegen, der Rückgabewert nennt den Ordner (`temp_ordner`). -- Scheitert in `welle-mit-pruefung` ein verschachtelter Schritt, kommen Welle, Kopie und Vergleich trotzdem zurück, der Fehler steht unter `fehler`. +- Scheitert in `welle-mit-pruefung` ein verschachtelter Schritt, kommen Welle, Kopie und Vergleich trotzdem zurück, der Fehler steht unter `fehler`. Mit `bis: "vergleich"` endet er vor der Prüfung (Schritt 4). +- `plankritik` fährt ihre Linsen mit so vielen Plätzen, wie `verbrauch` erlaubt, die Widerlegung mit höchstens zwei. Die Aufträge verbieten Tests im echten Projektbaum; Mutanten entstehen in einer eigenen Kopie der Kopie. +- `planung` startet je Task einen `planer`, sobald die Planer seiner Voraussetzungen fertig sind, statt paarweise nach Wellen. Das spart Wartezeit ohne einen Opus-Agenten mehr: gerechnet 19 Minuten bei der Hub-Planung mit 38 Tasks. Außerdem liest jeder Planer die Taskdateien seiner Voraussetzungen, bevor er einen Namen daraus übernimmt. Gebremst oder abgebrochen kommen die offenen Tasks zurück; nachgeholt wird in einem neuen Lauf mit `erledigt`, nicht über Resume. Die Plankritik läuft danach als eigener Workflow. -Deine eigene Gegenprüfung aus Schritt 4 ersetzt keiner davon; sie wird nur billiger, weil die Rohbelege schon da sind. Recherche und Sammeln `medium` — gemessen reichte fürs Nachschlagen sogar `low`, `medium` ist die Reserve für Recherche mit Synthese oder Webquellen, die der Prüfstand nicht hatte. Reine Abschrift `low` (gleich treu wie `high`, halbe Laufzeit), Urteil und Plan `high`. Höchstens zwei Opus-Agenten gleichzeitig: Am 2026-09-20 riss das Wochenkontingent bei neun parallelen Opus-Agenten, und alles stand zwei Stunden. Wo das richtige Ergebnis feststeht, wertet ein Muster im Skript aus statt eines Richter-Agenten. +Deine eigene Gegenprüfung aus Schritt 4 ersetzt keiner davon; sie wird nur billiger, weil die Rohbelege schon da sind. Recherche und Sammeln `medium` — gemessen reichte fürs Nachschlagen sogar `low`, `medium` ist die Reserve für Recherche mit Synthese oder Webquellen, die der Prüfstand nicht hatte. Reine Abschrift `low` (gleich treu wie `high`, halbe Laufzeit), Urteil und Plan `high`. Wie viele Opus-Agenten gleichzeitig laufen, regelt „Verbrauch und Opus-Plätze" unten. Wo das richtige Ergebnis feststeht, wertet ein Muster im Skript aus statt eines Richter-Agenten. **Bei Sonnet-Agenten treibt das Lesen die Kosten, nicht der Effort.** In den sechs Sonnet-Recherche-Läufen machte das Cache-Schreiben der gelesenen Dateien je Lauf ein Drittel bis gut 70 Prozent der Kosten aus, im Mittel gut die Hälfte; teurer oder billiger nach Effort sortierten sich die Läufe nicht. Gezielt lesen — `Grep` mit Zeilennummern, `Read` mit `offset` und `limit` — spart dort mehr als eine niedrigere Stufe. Bei Opus-Agenten dominiert die Ausgabe einschließlich Thinking, und genau die steuert der Effort. @@ -246,8 +253,27 @@ Deine eigene Gegenprüfung aus Schritt 4 ersetzt keiner davon; sie wird nur bill **Die Escape-Folge für U+2028 hat jedes Modell verloren.** Alle acht Abschrift-Läufe, Haiku wie Sonnet in jeder Stufe, schrieben sie als echtes Zeichen in die Datei. Alle acht Berichte nannten die Datei trotzdem „byte-genau" übernommen, die Tests blieben grün. Das liegt an der Schreibschicht, nicht am Modell. Der Byte-Vergleich nach jeder Welle bleibt Pflicht, egal mit welchen Parametern. +### Verbrauch und Opus-Plätze + +**Wie viele Opus-Agenten gleichzeitig laufen, richtet sich nach dem Verbrauch, nicht nach einer festen Zahl** (Pascals Freigabe vom 2026-09-24). Vor jeder Opus-Staffel liest der Workflow `verbrauch` über `get_usage` das 5-Stunden-Fenster und die Woche ab. Er senkt die gewünschten Plätze bei steigendem Verbrauch und gibt bei hohem keinen mehr frei; mehr als vier gibt es nie. Ohne Platz startet die Phase nicht, und der Workflow kommt mit `status: gebremst` zurück. Ein gebremster Lauf wird nach dem Zurücksetzen des Fensters neu gestartet, nicht über Resume, sonst käme die Ablesung aus dem Zwischenspeicher. Die Stufen stehen nur in `.claude/workflows/verbrauch.js`. + +Warum keine feste Zwei mehr: Der Vorfall vom 2026-09-20 war die Wochengrenze aller Modelle (`seven_day`), und alle 18 Opus-Agenten dieser Nacht liefen mit effort `max`. Häufiger reißt das 5-Stunden-Fenster, 16 von 19 typisierten Abbrüchen seit dem 22.08. Eine feste Menge Agenten verbraucht parallel dieselben Tokens wie nacheinander, nur schneller. Die Zwei hat also nur die Rate gebremst, und sie galt je Workflow, nicht über zwei gleichzeitige Sitzungen hinweg. Die Ablesung ist kontoweit. + +Wo das gilt: + +- **`plankritik`:** Die Linsen laufen nach Verbrauch. Die dritte Linse wartete vorher im Mittel 14,5 Minuten auf einen Platz (fünf echte Pläne). Die Widerlegung bleibt bei höchstens zwei. +- **`planung`:** Vorgegeben sind zwei Plätze, nach Verbrauch gesenkt und nach je vier fertigen Planern neu gelesen. +- **Ad-hoc-Workflows mit vielen Opus-Agenten** (Reviews, Erstplanungen): Vor jeder Staffel `workflow({ scriptPath: "D:/Claude/.claude/workflows/verbrauch.js" }, { anlass, wunsch })` aufrufen und dessen `plaetze` nehmen, bis zur Eichung mit `wunsch` zwei. Ein Kind eines Workflows kann das nicht, dort gilt zwei. +- **Opus-Agenten über das Agent-Werkzeug:** Vorher selbst `get_usage` lesen; bei hohem Verbrauch weniger starten, höchstens zwei. Die Stufen stehen in `verbrauch.js`. +- **`pruefung` und `welle-mit-pruefung`** lesen nur ab, für die Messreihe. Sie bremsen nicht. +- **Ohne `get_usage`**, also außerhalb der Desktop-App, gilt zwei. + +**Stufe 3 kommt erst nach der Eichung.** Drei oder vier Opus-Plätze für Reviews und Erstplanungen (`planung` mit `plaetze`, Ad-hoc-Fan-outs mit höherem `wunsch`) gibt es erst, wenn die Stufen in `verbrauch.js` an der Messreihe nachgestellt sind; bis dahin laufen drei gleichzeitig nur in den Linsen der Plankritik. **Die Stufen sind vorläufig** und stehen nur in `verbrauch.js`. Ein erster Messpunkt vom 2026-09-24, Plan laut App „Pro": Ein Workflow mit 21 Opus-Agenten auf `high` hob zusammen mit dem übrigen Verbrauch der Sitzung das 5-Stunden-Fenster von 7 auf 43 % und die Woche von 2 auf 12 %. Das sind grob 1,5 bis 2 % des Fensters je Opus-Lauf. Nach etwa einer Woche wertet `.vault-tools/workflow-pruefung/verbrauch_reihe.py` die Ablesungen aus, danach werden die Stufen nachgestellt. Werkzeuge und Belege: `.vault-tools/workflow-pruefung/LIESMICH.md`. Ein neuer oder geänderter Workflow unter `.claude/workflows/` wird dort mit `faelle_workflows.mjs` gegen die Attrappe gefahren, bevor er ins Verzeichnis kommt. + **Bei parallelem Einsatz zusätzlich:** +- **Tests nur in der Kopie.** Laufen mehrere Prüfer gleichzeitig, fährt keiner Tests im echten Projektbaum; Mutanten entstehen in einer eigenen Kopie der Kopie. Im echten Baum fährt die Suite immer nur einer. + - **Disjunkte Zieldateien.** Jeder Subagent bekommt die exakten Pfade genannt, die er anlegen darf, und die Anweisung, nichts anderes anzufassen. Gemeinsam benötigte Dateien (Paket-`__init__.py`, `.gitignore`) legt der Koordinator vorher an. - **Subagenten committen nicht.** Mehrere gleichzeitige `git commit` im selben Repository kollidieren am Index-Lock. Der Koordinator committet nach dem Rücklauf, je Task einzeln. - **`git status` in der Taskdatei nennt die eigenen Zieldateien.** „Genau diese Dateien im Baum und sonst nichts" scheitert in einer parallelen Welle an den Dateien der Nachbarn. In die Taskdatei gehört `git status --porcelain -- `; die Mengenprüfung über die ganze Welle macht der Koordinator nach dem Rücklauf. diff --git a/CLAUDE.md b/CLAUDE.md index f9070e6..1f89ad7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Rollen sind fest auf Modelle verteilt. **Beim Starten eines Subagenten immer das Die Aliasse `opus` und `sonnet` lösen in Claude Code 2.1.280 auf `claude-opus-5-5` bzw. `claude-sonnet-5` auf (Modellkatalog im Programm, nachgelesen 2026-09-23). Der eingebaute Agent `Explore` lädt diese Datei nicht und kennt die Vault-Regeln deshalb nicht — nur für reine Dateisuche; `general-purpose` erbt den Sitzungs-Effort und ist durch `recherche` ersetzt. Wann Haiku statt Sonnet, die Belege dazu und die Zuordnung der Superpowers-Skills: [[AI/Regelwerk/codeworkflow|codeworkflow.md]]. -**Ein Subagent ohne eigenen Effort erbt den der Hauptsitzung.** Läuft sie auf `xhigh` oder `max`, kostet ein Sonnet-Agent laut Katalog das 2,4- bzw. 5,6-Fache von `high`. Deshalb trägt jede Agentendatei `effort:`. In Workflows bekommt jeder `agent()`-Aufruf `model`; den Effort liefert mit `agentType` dessen Frontmatter, ohne `agentType` wird er mitgegeben, und ein `effort`-Parameter neben `agentType` steht nur für eine bewusste Abweichung. Höchstens zwei Opus-Agenten gleichzeitig. Für Plankritik, Executer-Welle und Prüfung gibt es gespeicherte Workflows unter `.claude/workflows/`, in denen das eingebaut ist, dazu `welle-mit-pruefung` für Schritt 3 und 4 in einem Aufruf. Eine geänderte Agentendatei oder ein neuer gespeicherter Workflow wirkt in der laufenden Sitzung nicht sofort, sondern erst nach einer Weile (am 2026-09-23 knapp eine Stunde) oder in der nächsten Sitzung. Ob ein Effort greift, zeigt nur das Feld `effort` im Transkript; ein noch unbekannter Workflow geht so lange über `scriptPath`. Messung und Begründung je Rolle: [[AI/Regelwerk/codeworkflow|codeworkflow.md]], „Effort je Rolle". +**Ein Subagent ohne eigenen Effort erbt den der Hauptsitzung.** Läuft sie auf `xhigh` oder `max`, kostet ein Sonnet-Agent laut Katalog das 2,4- bzw. 5,6-Fache von `high`. Deshalb trägt jede Agentendatei `effort:`. In Workflows bekommt jeder `agent()`-Aufruf `model`; den Effort liefert mit `agentType` dessen Frontmatter, ohne `agentType` wird er mitgegeben, und ein `effort`-Parameter neben `agentType` steht nur für eine bewusste Abweichung. **Wie viele Opus-Agenten gleichzeitig laufen, entscheidet der Verbrauch:** Vor jeder Opus-Staffel liest der Workflow `verbrauch` das 5-Stunden-Fenster und die Woche ab und senkt die Plätze bei hohem Verbrauch bis auf keinen. Bis zur Eichung der Stufen laufen drei Opus-Agenten gleichzeitig nur in den Linsen der Plankritik, sonst höchstens zwei; danach bis vier für Reviews und Erstplanung. Ohne Ablesung, also außerhalb der Desktop-App oder als Kind eines Workflows, gilt die Zwei; wer Opus-Agenten über das Agent-Werkzeug startet, liest vorher selbst `get_usage`. Für Plankritik, Executer-Welle, Prüfung und Erstplanung gibt es gespeicherte Workflows unter `.claude/workflows/`, in denen das eingebaut ist (`plankritik`, `executer-welle`, `pruefung`, `planung`), dazu `welle-mit-pruefung` für Schritt 3 und 4 in einem Aufruf. Eine geänderte Agentendatei oder ein neuer gespeicherter Workflow wirkt in der laufenden Sitzung nicht sofort, sondern erst nach einer Weile (am 2026-09-23 knapp eine Stunde, am 2026-09-24 gut 20 Minuten) oder in der nächsten Sitzung. Ob ein Effort greift, zeigt nur das Feld `effort` im Transkript; ein noch unbekannter Workflow geht so lange über `scriptPath`. Messung und Begründung je Rolle: [[AI/Regelwerk/codeworkflow|codeworkflow.md]], „Effort je Rolle"; Stufen und Belege zum Verbrauch dort unter „Verbrauch und Opus-Plätze". ## Regelwerk (ausgelagert, nur bei Bedarf laden)