Compare commits

..
4 Commits
Author SHA1 Message Date
Pascal MichelandClaude Opus 5 3784322a76 Automatischer Push: 8 Datei(en) geändert
- .vault-tools/trading-plattform-pruefung/LIESMICH.md
- .vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py
- .vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh
- .vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh
- .vault-tools/trading-plattform-pruefung/abbau_restbestand.py
- .vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py
- .vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1
- AI/Regelwerk/windows-shell.md

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 04:00:01 +02:00
Pascal MichelandClaude Opus 5.5 30ed04a643 Mutantenlaeufer: pytest-Konfiguration wie pytest, nie oberhalb der Wurzel; Abbruch von pytest mit Ursache statt FEHLT; Gleichwertigkeitsprobe, Treiber 46 Faelle; zwei Pruefrunden
Anlass: Plankritik 2 von C1 (After Install), fremde pyproject.toml im Windows-Temp.
Gegenprobe an sechs mutanten.toml im Vault: gleiche Ergebnisse, C1 unter Windows-Temp jetzt 69 OK statt 101 FEHLT.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 17:41:05 +02:00
Pascal MichelandClaude Opus 5.5 ed55848b00 Quorum: Task 06 (Freitext im Bericht entschaerft), Grund nennt blockierende ohne Beleg, Abschluss
quorum.js: klartext() schreibt Wikilinks, Einbettungen und Tags aus Freitext als Klartext
(Fund aus dem ersten echten Lauf); der Grund eines freien Urteils nennt blockierende Befunde
ohne Beleg. faelle_workflows.mjs 248 Faelle gruen, mutanten_workflows.py 145 Mutanten,
0 ueberlebt. codeworkflow.md: Regel zum Befund ohne Beleg und Ablageort beim Kundendokument
praezisiert, Vermerk 'noch nicht abgenommen' entfernt. LIESMICH.md fuer .vault-tools/quorum/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 06:36:34 +02:00
Pascal MichelandClaude Opus 5.5 7a74a8acb9 Waisen und Quorum: Ausnahmemuster fuer Programm- und Vorlagendateien, Abschluss Waisen, Plan Quorum Task 06
waisen-ausnahmen.txt: Skills, Plugins, Testfixtures, Programmausgaben und Vorlagen
zaehlen nicht als Waisen. Waisen-Uebersicht: Abschluss mit Belegen je Fertig-heisst-Punkt.
Quorum: Task 06 entschaerft Freitext im Bericht (Fund aus dem ersten echten Lauf).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 05:07:31 +02:00
25 changed files with 5892 additions and 46 deletions

No files matched your search

+24 -9
View File
@@ -41,10 +41,14 @@ export const meta = {
// verfälschen; dass der Agent keine Datei liest, sagt nur sein Auftrag, ein Fall sichert den Satz.
// Nachprüfen: quorum-stimme auf sonnet für Gruppen ohne Mehrheit mit Schwere blockierend oder
// wichtig. Ein blockierender Einzelbefund ohne Urteil sperrt wie ein bestätigter.
// Urteil: frei heißt kein bestätigter und kein ungeprüfter blockierender Befund. Mit weniger als
// Urteil: frei heißt kein bestätigter und kein ungeprüfter blockierender Befund mit Beleg; ein
// blockierender Befund ohne Beleg sperrt nicht (Plan, Offener Punkt 8), der Grund nennt ihn. Mit weniger als
// zwei abgegebenen Stimmen gibt es kein Urteil und keinen Bericht, die Basis bleibt die alte.
// Laufzeit: Date.now() und new Date() gibt es im Laufzeitsystem nicht. Die Codex-Laufzeit kommt
// aus dem Job, die der Claude-Stimmen steht im Bericht als nicht gemessen.
// Bericht: Freitext aus Stimmen, Zusammenführen und Nachprüfung geht nur durch klartext() in
// bericht_md. Ein zitierter Wikilink, eine Einbettung oder ein Tag würde im Vault sonst lebendig;
// Hub-Zeile und Frontmatter baut der Workflow selbst, sie bleiben echte Links bzw. Felder.
const ANLAESSE = ['einspielen', 'version', 'kundendokument']
const RANG = { blockierend: 0, wichtig: 1, hinweis: 2 }
const KUERZEL = { opus: 'O', sonnet: 'S', codex: 'C', haiku: 'H' }
@@ -482,6 +486,17 @@ function berichtLeer(a, p, urteil, grund) {
return [kopf(a, p, urteil, `Urteil ${urteil}: ${grund}.`, 'nicht gelaufen'), '', `# Quorum ${p.nummer}: ${a.anlass}`, '', hubZeile(a), '', '## Urteil', '', `**${urteilText(urteil)}**: Seit dem letzten Quorum hat sich nichts geändert. Das Urteil stammt aus ${vorher}; keine Stimme ist gelaufen.`, ''].join('\n')
}
// Freitext als Klartext für Obsidian: Jede öffnende eckige Klammer in einer Folge von zwei oder
// mehr bekommt einen Backslash, damit kein Wikilink und keine Einbettung entsteht. Eine Raute
// bekommt einen Backslash, wenn ihr kein Buchstabe, keine Ziffer, kein Unterstrich und kein
// Backslash vorausgeht und weder Leerraum noch eine zweite Raute folgt; so entsteht kein Tag.
// Die Leseansicht zeigt den Text wie zitiert. Zweimal angewendet ändert sich nichts mehr.
function klartext(s) {
return String(s)
.replace(/\[{2,}/g, folge => folge.replace(/\[/g, '\\['))
.replace(/(^|[^\p{L}\p{N}_\\])#(?=[^\s#])/gu, '$1\\#')
}
function berichtVoll(a, p, x) {
const namen = Object.keys(x.stimmen).filter(n => x.stimmen[n].abgegeben).map(n => NAMEN[n]).join(', ')
const codexFeld = x.stimmen.codex.abgegeben ? 'abgegeben' : 'ersetzt'
@@ -492,7 +507,7 @@ function berichtVoll(a, p, x) {
const s = x.stimmen[n]
const c = x.zaehlung[n]
const name = n === 'haiku' ? 'Haiku (Ersatz für Codex)' : NAMEN[n]
z.push(`| ${name} | ${s.abgegeben ? 'ja' : 'nein: ' + s.grund} | ${c.gesamt} | ${c.belegt} | ${c.bestaetigt} | ${c.alleinfunde} | ${laufzeit(s.dauer_s)} |`)
z.push(`| ${name} | ${s.abgegeben ? 'ja' : 'nein: ' + klartext(s.grund)} | ${c.gesamt} | ${c.belegt} | ${c.bestaetigt} | ${c.alleinfunde} | ${laufzeit(s.dauer_s)} |`)
}
z.push('')
if (x.stimmen.haiku) z.push('Haiku stand statt Codex im Quorum. Dieses Quorum war nur mit Claude besetzt.', '')
@@ -501,20 +516,20 @@ function berichtVoll(a, p, x) {
if (!x.gruppen.length) z.push('keine belegten Befunde', '')
for (const g of x.gruppen) {
const erste = g.mitglieder[0]
z.push(`### G${g.nr}: ${g.schwere}, ${STATUS_TEXT[g.status]} (${g.stimmen.map(n => NAMEN[n]).join(', ')})`, '', `\`${erste.datei}\`, Stelle ${erste.stelle}`, '')
for (const b of g.mitglieder) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}, Stelle ${b.stelle}): ${b.aussage}`, ` Beleg: ${b.beleg}`)
if (g.nachpruefung) z.push('', `Nachprüfung: ${g.nachpruefung.urteil}, ${g.nachpruefung.fundstelle}. ${g.nachpruefung.begruendung}`)
z.push(`### G${g.nr}: ${g.schwere}, ${STATUS_TEXT[g.status]} (${g.stimmen.map(n => NAMEN[n]).join(', ')})`, '', `\`${erste.datei}\`, Stelle ${klartext(erste.stelle)}`, '')
for (const b of g.mitglieder) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}, Stelle ${klartext(b.stelle)}): ${klartext(b.aussage)}`, ` Beleg: ${klartext(b.beleg)}`)
if (g.nachpruefung) z.push('', `Nachprüfung: ${g.nachpruefung.urteil}, ${klartext(g.nachpruefung.fundstelle)}. ${klartext(g.nachpruefung.begruendung)}`)
z.push('')
}
z.push('## Unbelegt', '')
if (!x.unbelegt.length) z.push('keine')
for (const b of x.unbelegt) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}) \`${b.datei}\`, Stelle ${b.stelle}: ${b.aussage}`)
for (const b of x.unbelegt) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}) \`${b.datei}\`, Stelle ${klartext(b.stelle)}: ${klartext(b.aussage)}`)
z.push('', '## Nicht prüfbar', '')
const offen = Object.keys(x.stimmen).flatMap(n => (x.stimmen[n].nicht_pruefbar || []).map(t => `- ${NAMEN[n]}: ${t}`))
const offen = Object.keys(x.stimmen).flatMap(n => (x.stimmen[n].nicht_pruefbar || []).map(t => `- ${NAMEN[n]}: ${klartext(t)}`))
z.push(...(offen.length ? offen : ['keine']))
z.push('', '## Hinweise des Laufs', '')
const alleHinweise = [...p.hinweise, ...laufHinweise]
z.push(...(alleHinweise.length ? alleHinweise.map(h => '- ' + h) : ['keine']), '')
z.push(...(alleHinweise.length ? alleHinweise.map(h => '- ' + klartext(h)) : ['keine']), '')
return z.join('\n')
}
@@ -621,7 +636,7 @@ const sperrend = gruppen.filter(g => g.schwere === 'blockierend' && (gilt(g) ||
const urteil = sperrend.length ? 'nicht_frei' : 'frei'
const grund = sperrend.length
? `${sperrend.filter(gilt).length} bestätigte und ${sperrend.filter(g => !gilt(g)).length} ungeprüfte blockierende Befunde`
: 'kein bestätigter und kein ungeprüfter blockierender Befund'
: 'kein bestätigter und kein ungeprüfter blockierender Befund mit Beleg' + (unbelegt.some(b => b.schwere === 'blockierend') ? `; ${unbelegt.filter(b => b.schwere === 'blockierend').length} blockierende ohne Beleg stehen unter Unbelegt und sperren nicht` : '')
await ablesen('Ende', 0)
const zaehlung = {}
for (const n of Object.keys(stimmen)) {
@@ -1,11 +1,11 @@
---
type: plan
title: "Waisen im Graphen: Hub-Regel, Zähler im Sitzungsstart, Bestandslauf"
status: draft
status: done
summary: "waisen.py zählt verwaiste Notizen so, wie Obsidian den Graphen baut, der Sitzungsstart meldet die Zahl, Regeltexte und Planer verlinken künftig den Hub, und ein einmaliger Bestandslauf hängt den Bestand an, nur anhängend und mit Probelauf."
tags: [plan, vault-tools]
created: 2026-09-29
updated: 2026-09-30
updated: 2026-10-05
---
# Waisen im Graphen
@@ -186,3 +186,22 @@ Der Planer hatte keine Shell. Was gemessen werden muss, steht als Schritt in ein
4. **Laufzeitziel des Sitzungsstarts:** `faelle_sitzungsstart.py` erwartet bisher unter 5 s. Liegt M2 darüber, entscheidet Pascal, ob das Ziel steigt (der Hook hat 30 s).
5. **IT-Workspace-Projekte ohne `_arbeitsstand`:** Die Spec-Zeile „Arbeitsdatei eines Projekts im IT Workspace“ ist für den Bestand nur für `_arbeitsstand` als Muster umgesetzt. Andere Arbeitsdateien kommen auf die Liste.
6. **Specs in der Inbox** wie die Spec zu diesem Plan haben laut Hub-Tabelle das Projekt-README als Hub, liegen aber außerhalb eines Projekts. Die Regel sagt dazu nichts. Vorschlag: bei Abnahme in den Projektordner verschieben oder aus der Tagesnotiz verlinken.
## Abschluss 2026-10-05
| Fertig heißt | Ergebnis | Beleg |
|---|---|---|
| 1, 2 Zähler | erfüllt | `faelle_waisen.py` 109 von 109, Rotlauf 33 Mutanten getötet; `--echt` 2,1 s |
| 3 Sitzungsstart | erfüllt | `faelle_sitzungsstart.py` 75 von 75; `--echt` 5,0 s, Ziel seit 2026-10-05 8 s (Entscheidung in `.vault-tools/session-hooks/ENTWURF.md`) |
| 4 Bestandsskript | erfüllt | `faelle_waisen_bestand.py` 31 von 31, Rotlauf 18 Mutanten getötet |
| 5 Linkcheck-Fälle | erfüllt | `faelle.py` 27 von 27 |
| 6 regelwerk_check | erfüllt | nur der alte Befund zu `frontmatter.md` |
| 7 Byte-Vergleich | erfüllt | je Welle alle Zieldateien byte-gleich; die übrigen Meldungen von `welle_vergleich.py` waren fremde oder nicht committete Dateien |
| 8 Phase A | erfüllt | 164 Ziele, 7 übersprungen, 52 auf der Liste; keine Taskdatei und keine Ausnahme als Ziel |
| 9 Phase B | erfüllt bis auf die Abnahme | Probelauf und echter Lauf gleich, 164 Dateien byte-genau Sicherung plus Anhang, Sicherung unter `D:/Claude-Sicherung-2026-10-05-waisen`; Abnahme per Bildschirmfoto des Graphen steht bei Pascal |
| 10 bis 13 | erfüllt | `faelle_nach_edit.py` 58 von 58; kein neuer toter Link aus den Zieldateien; M4 ohne Befund |
- **Prüfungen:** Task 01 durch einen `pruefer` (2 wichtige Befunde, umgesetzt in Task 06), Tasks 02, 03, 04 und 06 kritisch mit `xhigh`: 0 blockierend, 0 wichtig, 10 klein. Sechs kleine Befunde zum Wortlaut sind in `navigation.md`, Stamm und `seo.md` behoben, das Laufzeitziel ist entschieden.
- **Bewusst offen (klein):** Zwei Schutzprüfungen in `waisen_bestand.py` (Byte-Vergleich der Sicherung, Kontrolle nach dem Anhängen) sind laut Prüfer richtig, aber durch keinen Fall abgesichert. Ein verwaister Git-Arbeitsbaum sperrt alle Ziele seines Repos.
- **Handliste:** 34 von 37 Dateien einzeln angebunden; Programm- und Vorlagendateien als Muster in `waisen-ausnahmen.txt` (Skills, Plugins, Testfixtures, Programmausgaben, Vorlagen).
- **Ergebnis:** 880 Waisen vor dem Lauf, 45 danach. Die 45 sind gesperrt durch offene Zweige und Arbeitsbäume, nicht versionierte Dateien oder gemischte Zeilenenden; nach dem Merge der betroffenen Zweige `waisen_bestand.py` noch einmal fahren.
@@ -11,3 +11,12 @@ Bookstack/**
# Programmausgaben in Code-Projekten; das Programm schreibt sie täglich neu.
Code Projekte/*/data/**
# 2026-10-05: Programm- und Vorlagendateien, keine Notizen. Skill- und Plugin-Definitionen,
# Testfixtures und Programmausgaben bekommen keine Hub-Zeile; in einer Vorlage wuerde sie
# in jede neue Notiz wandern.
Code Projekte/*/skill/**
Code Projekte/*/plugin/**
Code Projekte/*/tools/testdaten/**
Code Projekte/*/artifacts/**
*/Vorlagen/**
+8 -2
View File
@@ -5,7 +5,7 @@ title: "Planwerkzeuge: Plan auf eine Kopie anwenden und jede Welle dagegen vergl
summary: "Skripte für die Standardschleife bei Plänen mit ausgeschriebenem Code — schlanke Kopie bauen, Plan auf die Kopie anwenden, je Welle Menge und Bytes vergleichen, Aus-wird-Blöcke prüfen, Codeblöcke auf unsichtbare Zeichen absuchen, Escape-Folgen per Codepunkt setzen. Entstanden 2026-09-17 in der Trading Plattform (Teil 3b)."
tags: [vault-tools, codeworkflow]
created: 2026-09-17
updated: 2026-09-27
updated: 2026-10-06
---
# Planwerkzeuge für die Kopie mit angewendetem Plan
@@ -23,7 +23,7 @@ Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]] („Die Ko
7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test] [--venv <wsl-pfad>] [--frontend <ordner>] [--projektname <ordnername>]`. **Für ein anderes Projekt als die Trading Plattform braucht es die drei letzten Schalter (seit 2026-09-24):** Ihre Vorgaben sind die der Trading Plattform (`~/venvs/trading`, `dashboard`, `trading-plattform`). Jedes andere Projekt nennt seine Werte, für den Nexhorizons Hub `--venv '~/venvs/hub' --frontend frontend --projektname 'active - Nexhorizons Hub'`, das Venv in einfachen Anführungszeichen, sonst macht die Git-Bash aus `~` einen Windows-Pfad. Das Skript druckt die benutzten Werte in der ersten Zeile. Die Vorgabe `trading-plattform` passt nur auf Pläne mit Arbeitsbaum-Pfaden; ältere Trading-Pläne mit absoluten Pfaden unter `active - Trading Plattform` brauchen `--projektname 'active - Trading Plattform'`, sonst endet der Lauf mit einem `ValueError`. Weiter fest verdrahtet bleiben die Distribution `Debian`, die Ordner `tests/` und `tests/fixtures/`, die Endungen `.test.ts`/`.test.tsx` und `npx.cmd vitest`. **Vorbedingung:** Das Projekt ist im Venv nicht editierbar installiert (`pip install -e`); sonst löst der Finder von setuptools ein fehlendes Untermodul gegen das echte Projekt auf, und ein Test ohne Umsetzung läuft grün. `tests/conftest.py` gilt dabei als Produktivseite und wird zurückgesetzt; nur wenn die neue Fixture eines Tasks dort liegt, lässt `--conftest-als-test` sie stehen (Beleg unten, 2026-09-22). `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich.
8. **Mutanten fahren (seit 2026-09-27):** `mutanten_lauf.py --wurzel <wegwerfkopie> --mutanten <planordner>/mutanten.toml [--nur ID,…] [--nur-muster] [--bericht <datei.json>] [--zeitlimit <s>]`, in WSL mit einem Python ab 3.11. Die Mutanten stehen nicht mehr als Prosa im Plan, sondern in `mutanten.toml` im Planordner. Das Format, die Status und die Abdichtung stehen in `mutanten_lauf_ENTWURF.md`. Das Skript verweigert eine Wurzel mit `.git` und jeden Pfad aus `ohne`. Es fährt zuerst die Grundlinie, dann je Mutant nur die genannten Tests, setzt die Datei zurück und prüft das per SHA-256. Je Test gibt es die Fehlermeldung mit aus; ob ein Test an der gemeinten Stelle fällt, liest man dort. `--nur-muster` prüft nur, ob jedes Muster genau einmal dasteht, ohne pytest. Die Kopie legt man in WSL unter `/tmp` an (`cp -a`, danach `__pycache__` löschen). Auf `/mnt/d` läuft es auch, nur langsamer. **Aus der Git-Bash ein Skript nie mit `wsl … bash /mnt/d/…` starten:** Die Git-Bash macht aus dem Pfad einen Windows-Pfad. Aus der PowerShell starten oder `MSYS_NO_PATHCONV=1` davorsetzen. Ein SIGTERM auf `mutanten_lauf.py` selbst (etwa `timeout -k` oder ein Hilfsagent, der abgebrochen wird) löst dort einen eigenen Handler aus: Der laufende Mutant wird per SHA-Probe zurückgesetzt, die Temp-Ordner werden geräumt, und die Ausgabe trägt `ABBRUCH: SIGTERM` (Treiber-Fall 35, `mutanten_lauf_treiber.py`).
8. **Mutanten fahren (seit 2026-09-27):** `mutanten_lauf.py --wurzel <wegwerfkopie> --mutanten <planordner>/mutanten.toml [--nur ID,…] [--nur-muster] [--bericht <datei.json>] [--zeitlimit <s>]`, in WSL mit einem Python ab 3.11. Die Mutanten stehen nicht mehr als Prosa im Plan, sondern in `mutanten.toml` im Planordner. Das Format, die Status und die Abdichtung stehen in `mutanten_lauf_ENTWURF.md`. Das Skript verweigert eine Wurzel mit `.git` und jeden Pfad aus `ohne`. Seit 2026-10-06 sucht es die pytest-Konfiguration wie pytest selbst, aber nie oberhalb der Wurzel, und gibt sie mit `-c` vor; eine fremde `pyproject.toml` im Windows-Temp stört damit nicht mehr, und ein Bedienfehler von pytest steht mit dem Ende der pytest-Ausgabe im Befund statt als `FEHLT` je Test (Entwurf, Abschnitt 3, „Konfiguration“ und „Abbruch von pytest“). Ob die Suche pytest selbst entspricht, prüft `mutanten_lauf_probe_konfig.py <mutanten_lauf.py> <arbeitsordner>` mit dem Python eines Venvs, in dem pytest liegt (26 Aufbauten, Exit 0 ohne Abweichung). Es fährt zuerst die Grundlinie, dann je Mutant nur die genannten Tests, setzt die Datei zurück und prüft das per SHA-256. Je Test gibt es die Fehlermeldung mit aus; ob ein Test an der gemeinten Stelle fällt, liest man dort. `--nur-muster` prüft nur, ob jedes Muster genau einmal dasteht, ohne pytest. Die Kopie legt man in WSL unter `/tmp` an (`cp -a`, danach `__pycache__` löschen). Auf `/mnt/d` läuft es auch, nur langsamer. **Aus der Git-Bash ein Skript nie mit `wsl … bash /mnt/d/…` starten:** Die Git-Bash macht aus dem Pfad einen Windows-Pfad. Aus der PowerShell starten oder `MSYS_NO_PATHCONV=1` davorsetzen. Ein SIGTERM auf `mutanten_lauf.py` selbst (etwa `timeout -k` oder ein Hilfsagent, der abgebrochen wird) löst dort einen eigenen Handler aus: Der laufende Mutant wird per SHA-Probe zurückgesetzt, die Temp-Ordner werden geräumt, und die Ausgabe trägt `ABBRUCH: SIGTERM` (Treiber-Fall 35, `mutanten_lauf_treiber.py`).
**Die Plankritik fährt die Mutanten selbst (seit 2026-09-27, nachgebessert 2026-09-28):** Liegt `mutanten.toml` im Planordner, startet `plankritik.js` in der Vorbereitung `mutanten_lauf_kopie.sh`. Das Skript kopiert die Kopie mit angewendetem Plan nach `/tmp` in WSL, fährt dort den Läufer in einem eigenen Netz-Namensraum (`unshare --map-current-user --net`, gemessen in WSL Debian als Benutzer `claude`: UID innen bleibt 1000, nur Schnittstelle `lo`, jede Verbindung nach außen scheitert) und gibt die Ausgabe an die Linse `arithmetik`. Fehlt `unshare` ohne Netz, bricht das Skript vorher mit `MUTANTEN_LAUF_FEHLER` ab, statt ungesperrt zu laufen. Der Arg `mutanten` wählt `voll` (Vorgabe), `muster` oder `aus`; die Rückgabe trägt dazu `mutanten_status` (`aus`, `ohne_kopie`, `keine_toml`, `ohne_ergebnis` oder `exit_<n>`) und `mutanten_modus` (`voll`, `muster`, `aus`). Der Wrapper gibt vor dem Lauf eine Zeile `modus: voll` oder `modus: muster (…)` aus; bei `modus: muster` weiß die Linse, dass kein Mutant gefahren ist. Der ganze Lauf hat 540 s (`BUDGET_S`, `timeout -k 15`), weil das Bash-Werkzeug eines Hilfsagenten nach zehn Minuten endet; eine überschrittene Grenze meldet die Zeile `ZEITGRENZE`. Je Mutant gilt zusätzlich ein Zeitlimit `ZEITLIMIT_S` (Vorgabe 120 s, muss kleiner sein als `BUDGET_S`): B1 mit 74 Mutanten brauchte 168 s gesamt, rund 2 s je Mutant. Bei Exit 2 oder 3 des Läufers sagt die Linse, der Lauf sei vor oder während der Mutanten gescheitert. Pfade übergibt man mit Vorwärtsschrägstrichen: `wsl -d Debian -- …` reicht die Befehlszeile an die Linux-Shell, und die verschluckt Backslashes.
## Was belegt ist
@@ -43,6 +43,12 @@ Das Skript dazu liegt außerhalb des Vaults und ist nicht Teil der Werkzeuge. Er
Nachbesserung vom 2026-09-28 (Prüfbericht desselben Tages): `mutanten_lauf_kopie.sh` läuft jetzt unter `unshare --map-current-user --net`, mit `python3 -u` und einem Zeitlimit je Mutant (`ZEITLIMIT_S`, kleiner als `BUDGET_S`), schreibt vor dem Lauf eine Modus-Zeile und räumt bei SIGTERM auf (`timeout -k 15`, eigener Handler in `mutanten_lauf.py`). `plankritik.js` gibt zusätzlich `mutanten_status` und `mutanten_modus` zurück und erklärt der Linse `arithmetik` Modus `muster` und Exit 2/3. Treiber-Fall 35 (SIGTERM mitten in einem hängenden Mutanten) und die zusätzlichen Fälle in `faelle_workflows.mjs`/`mutanten_workflows.py` sind gefahren und grün; die Rot-Ausgaben davor stehen im Bericht des Executers vom selben Tag.
Nachbesserung vom 2026-10-06 (Plankritik 2 von C1 im Projekt After Install, Plan `docs/plans/2026-10-06-pytest-konfiguration.md` und seine Nachbesserung): pytest-Konfiguration wie pytest selbst, aber nie oberhalb der Wurzel; Abbruch von pytest mit Ursache statt `FEHLT`. Belegt am selben Tag:
- Treiber 45 von 45 grün. Die neuen Fälle 36 bis 45 waren je mindestens einmal rot: gegen den Läufer vor der Änderung (36, 38, 40), gegen den Stand vor der Nachbesserung (40, 41, 43, 45) und gegen kaputte Varianten (37 ohne eigene Konfiguration, 39 ohne `--confcutdir`, 42 ohne Rückgabewert 3, 43 ohne `setup.py`-Zweig oder ohne zweite Suche, 44 nach dem ersten Abbruch ohne weitere Gruppe).
- Gegenprobe an allen sechs `mutanten.toml` im Vault, alter gegen neuer Läufer auf Kopien aus `git archive HEAD` unter `/tmp`: Headscale A, Mattermost, After Install C1 und D-40 gleich (175, 130, 69 und 41 Mutanten `OK`); Headscale B und Trading Plattform WH enden bei beiden mit Exit 2, weil ihr Stand nicht zum Plan passt. Unter dem Windows-Temp endete C1 mit dem alten Läufer mit 101 × `FEHLT`, mit dem neuen mit 69 `OK`.
- Ein `pruefer` fand in der ersten Fassung den Symlink-Fall und fehlende Fälle; die Nachbesserung behob beides. Ein zweiter `pruefer` fand danach keinen Verhaltensfehler mehr, nur eine Aufräumliste (Kopfzeile je Interpreter ohne eigenen Fall, Kurzfassung bei Rückgabewert 3, `;` und Seitenvorschub in `tox.ini`, Selbstprüfung der Probe, lange Fassung im Bericht, zwei Lücken benennen); sie ist in Nachbesserung 2 erledigt, Treiber 46 von 46.
- Rohdaten: `AI/secondbrain/linux-haertung-after-install-memory/2026-10-06-c1-abschluss/roh/` (`02-*`, `03-gegenprobe/`, `07-*`, `08-gegenprobe2/` mit dem nachgebesserten Läufer, `09-*` für Nachbesserung 2).
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).
@@ -0,0 +1,634 @@
---
type: plan
title: "Mutantenläufer: Nachbesserung 2 (Aufräumliste der zweiten Prüfung)"
status: active
summary: "Delta zur Nachbesserung 1: Kopfzeile je Interpreter mit eigenem Fall, Kurzfassung bei Rückgabewert 3 aus der letzten INTERNALERROR-Zeile, lange Fassung im Bericht, Abschnittszeile mit splitlines, Probe mit Selbstprüfung und zwei neuen Aufbauten, Treiber-Fall 46, zwei Lücken benannt."
tags: [plan, vault-tools, pruefwerkzeuge]
created: 2026-10-06
updated: 2026-10-06
modell: sonnet
effort: low
modell_begruendung: "Jeder Block steht wörtlich da; Entwurf und LIESMICH tragen typografische Zeichen, die Haiku still ersetzt."
---
Übersicht: [LIESMICH der Planwerkzeuge](../../LIESMICH.md) · Vorgänger: [Nachbesserung 1](2026-10-06-pytest-konfiguration-nachbesserung-1.md)
# Mutantenläufer: Nachbesserung 2
## Warum so
Der zweite `pruefer` fand am 2026-10-06 keinen Verhaltensfehler mit Wirkung auf die Projekte, nur eine Aufräumliste, und hielt eine weitere volle Prüfrunde für entbehrlich. Umgesetzt:
| Befund | Hier |
|---|---|
| Kopfzeile je Interpreter ohne eigenen Fall (wichtig) | Fall 44 zählt genau zwei Kopfzeilen, je eine je Interpreter |
| Kurzfassung bei Rückgabewert 3 ohne Fall | Fall 46 (Mutant lässt `conftest.py` werfen). Dabei gefunden: „letzte Zeile“ war die Summenzeile `no tests ran …`; jetzt die letzte `INTERNALERROR>`-Zeile |
| `;` als Kommentarzeichen ohne Fall | Aufbau `tox_abschnitt_mit_semikolon` in der Probe |
| iniconfig trennt Zeilen mit `splitlines` | Läufer auch; Aufbau `tox_seitenvorschub_vor_abschnitt` |
| Lange Fassung unter einem Mutanten verloren | Bericht trägt beim Test `ausgabe`; Fälle 40 und 46 prüfen es |
| Probe prüft ihre Voraussetzung nicht | Selbstprüfung: ohne `-c` Rückgabewert 0 (außer `knoten_fehl*`), mit `conftest.py` im Aufbau mindestens eines geladen |
| `rootdir` unter der Wurzel, Zeilentrenner | als Lücken benannt |
| `safe_exists` fängt auch `ValueError` | `_existiert` ebenso |
| LIESMICH: Rohdaten `08-gegenprobe2/`, Satz zur Nachbesserung | nachgezogen |
**Gemessen vom Koordinator am 2026-10-06** (Rohdaten `AI/secondbrain/linux-haertung-after-install-memory/2026-10-06-c1-abschluss/roh/09-*`): neuer Stand 46 von 46; Stand nach Nachbesserung 1 rot bei 40, 43, 46; Varianten rot genau an ihrem Fall (eine Kopfzeile → 44 an beiden Stellen; erste statt letzte `INTERNALERROR>`-Zeile → 46; ohne den Zweig für 3 → 46; kaputtes `conftest.py` der ersten Probe → 43; ohne `;` → 43).
## Zieldateien
Pfade relativ zu `D:/Claude/.vault-tools/plan-kopie/`:
- `mutanten_lauf.py` (geändert)
- `mutanten_lauf_treiber.py` (geändert)
- `mutanten_lauf_probe_konfig.py` (geändert)
- `mutanten_lauf_ENTWURF.md` (geändert)
- `LIESMICH.md` (geändert)
## Verbote
- Nur die fünf Zieldateien ändern. Kein `git add`, kein `git commit`.
- Jeden Block byte-genau übernehmen; der ganze Aus-Block als `old_string`, der ganze wird-Block als `new_string`. Steht ein Aus-Block nicht genau einmal da oder weicht eine Ausgabe ab: anhalten und melden.
- `python -m py_compile` legt `__pycache__` an; den Ordner danach löschen.
## Schritt 1 — Tests zuerst: Treiber und Probe
### `mutanten_lauf_treiber.py`, Stelle 1 von 9
Aus:
```python
#!/usr/bin/env python3
"""Treiber fuer mutanten_lauf.py: 45 Angriffsfaelle auf einer Wegwerfkopie.
```
wird:
```python
#!/usr/bin/env python3
"""Treiber fuer mutanten_lauf.py: 46 Angriffsfaelle auf einer Wegwerfkopie.
```
### `mutanten_lauf_treiber.py`, Stelle 2 von 9
Aus:
```python
Interpretergruppen an erster und an letzter Stelle, ein ueberlanger Knoten.
"""
```
wird:
```python
Interpretergruppen an erster und an letzter Stelle, ein ueberlanger Knoten.
Fall 46 (Nachbesserung 2): Rueckgabewert 3 unter einem Mutanten, Kurzfassung in
der Mutantenzeile und lange Fassung im Bericht.
"""
```
### `mutanten_lauf_treiber.py`, Stelle 3 von 9
Aus:
```python
import hashlib
import os
```
wird:
```python
import hashlib
import json
import os
```
### `mutanten_lauf_treiber.py`, Stelle 4 von 9
Aus:
```python
))
ergebnis = starte_laeufer(wurzel, toml)
mutantenzeile = [z for z in ergebnis.stdout.split("\n") if z.startswith("M40 ")]
ok = (
```
wird:
```python
))
bericht = rahmen / "bericht40.json"
ergebnis = starte_laeufer(wurzel, toml, bericht_pfad=bericht)
mutantenzeile = [z for z in ergebnis.stdout.split("\n") if z.startswith("M40 ")]
try:
ausgabe_bericht = json.loads(bericht.read_text(encoding="utf-8"))["mutanten"][0]["tests"][0].get("ausgabe", "")
except (OSError, ValueError, KeyError, IndexError):
ausgabe_bericht = ""
ok = (
```
### `mutanten_lauf_treiber.py`, Stelle 5 von 9
Aus:
```python
and "--gibt-es-nicht" in mutantenzeile[0]
)
```
wird:
```python
and "--gibt-es-nicht" in mutantenzeile[0]
and "Ende der Ausgabe:" in ausgabe_bericht
)
```
### `mutanten_lauf_treiber.py`, Stelle 6 von 9
Aus:
```python
ausgabe = ergebnis.stdout + ergebnis.stderr
ok = (
```
wird:
```python
ausgabe = ergebnis.stdout + ergebnis.stderr
kopfzeilen = [z for z in ergebnis.stdout.split("\n") if z.startswith("pytest-konfiguration (Grundlinie, ")]
ok = (
```
### `mutanten_lauf_treiber.py`, Stelle 7 von 9
Aus:
```python
and "test_f1_positiv ist" not in ausgabe
)
```
wird:
```python
and "test_f1_positiv ist" not in ausgabe
and len(kopfzeilen) == 2
and sum(1 for z in kopfzeilen if z.startswith("pytest-konfiguration (Grundlinie, {0}): ".format(INTERPRETER))) == 1
and sum(1 for z in kopfzeilen if z.startswith("pytest-konfiguration (Grundlinie, {0}): ".format(zweit_python))) == 1
)
```
### `mutanten_lauf_treiber.py`, Stelle 8 von 9
Aus:
```python
FAELLE = [
```
wird:
```python
def fall_46(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=True, fremde_konfig=False, fremdes_conftest=False)
try:
(wurzel / "conftest.py").write_text("X = 1\n", encoding="utf-8")
dateien = dateien + [wurzel / "conftest.py"]
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, kopf() + (
"[[mutant]]\n"
"id = \"M46\"\n"
"datei = \"conftest.py\"\n"
"aus = '''\n"
"X = 1\n"
"'''\n"
"wird = '''\n"
"def pytest_collection_modifyitems(items):\n"
" raise RuntimeError(\"intern Fall 46\")\n"
"'''\n"
"rot_bei = [\"tests/test_modul.py::test_f1_positiv\"]\n"
))
bericht = rahmen / "bericht46.json"
ergebnis = starte_laeufer(wurzel, toml, bericht_pfad=bericht)
mutantenzeile = [z for z in ergebnis.stdout.split("\n") if z.startswith("M46 ")]
try:
ausgabe_bericht = json.loads(bericht.read_text(encoding="utf-8"))["mutanten"][0]["tests"][0].get("ausgabe", "")
except (OSError, ValueError, KeyError, IndexError):
ausgabe_bericht = ""
ok = (
ergebnis.returncode == 1
and len(mutantenzeile) == 1
and "M46 UNKLAR" in mutantenzeile[0]
and "pytest-Abbruch mit internem Fehler (Rueckgabewert 3): " in mutantenzeile[0]
and "intern Fall 46" in mutantenzeile[0]
and "Ende der Ausgabe:" in ausgabe_bericht
)
melde("Fall 46", ok, "exit={0}\n{1}{2}".format(ergebnis.returncode, ergebnis.stdout, ergebnis.stderr))
pruefe_unveraendert(dateien, snap, "Fall 46")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
FAELLE = [
```
### `mutanten_lauf_treiber.py`, Stelle 9 von 9
Aus:
```python
fall_31, fall_32, fall_33, fall_34, fall_35, fall_36, fall_37, fall_38, fall_39, fall_40,
fall_41, fall_42, fall_43, fall_44, fall_45,
]
```
wird:
```python
fall_31, fall_32, fall_33, fall_34, fall_35, fall_36, fall_37, fall_38, fall_39, fall_40,
fall_41, fall_42, fall_43, fall_44, fall_45, fall_46,
]
```
### `mutanten_lauf_probe_konfig.py`, Stelle 1 von 3
Aus:
```python
prueft Treiber-Fall 41.
```
wird:
```python
prueft Treiber-Fall 41.
Die Probe prueft ihre eigene Voraussetzung mit: Ohne -c im sauberen Rahmen
muss pytest mit 0 enden (ausser bei den Aufbauten knoten_fehl*), und wo der
Aufbau ein conftest.py hat, muss eines geladen sein. Sonst endeten beide
Laeufe gleich kaputt, und "gleich" hiesse nichts.
```
### `mutanten_lauf_probe_konfig.py`, Stelle 2 von 3
Aus:
```python
"tox_abschnitt_mit_kommentar": ({"tox.ini": "[pytest] # k\nmarkers = x\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"setup_cfg_tool_pytest": ({"setup.cfg": "[tool:pytest]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
```
wird:
```python
"tox_abschnitt_mit_kommentar": ({"tox.ini": "[pytest] # k\nmarkers = x\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"tox_abschnitt_mit_semikolon": ({"tox.ini": "[pytest] ; k\nmarkers = x\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"tox_seitenvorschub_vor_abschnitt": ({"tox.ini": "[tox]" + chr(12) + "[pytest]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"setup_cfg_tool_pytest": ({"setup.cfg": "[tool:pytest]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
```
### `mutanten_lauf_probe_konfig.py`, Stelle 3 von 3
Aus:
```python
gleich_lauf = ohne_c == mit_c
if not (gleich_suche and gleich_lauf):
abweichungen += 1
print("{0:34s} suche={1} lauf={2}".format(
name, "gleich" if gleich_suche else "ABWEICHUNG", "gleich" if gleich_lauf else "ABWEICHUNG"))
if not gleich_suche:
```
wird:
```python
gleich_lauf = ohne_c == mit_c
voraussetzung = name.startswith("knoten_fehl") or ohne_c[0] == 0
if any(rel.endswith("conftest.py") for rel in dateien) and not ohne_c[2]:
voraussetzung = False
if not (gleich_suche and gleich_lauf and voraussetzung):
abweichungen += 1
print("{0:34s} suche={1} lauf={2} voraussetzung={3}".format(
name, "gleich" if gleich_suche else "ABWEICHUNG", "gleich" if gleich_lauf else "ABWEICHUNG",
"ok" if voraussetzung else "VERLETZT"))
if not gleich_suche:
```
### Erwarteter Fehlschlag vor der Umsetzung
In der Git-Bash:
```bash
# Laeufer im jetzigen Stand, neuer Treiber und neue Probe in einen leeren Rot-Ordner
R=/c/Users/PASCAL~1/AppData/Local/Temp/mlauf-rot3
rm -rf "$R" && mkdir -p "$R"
cp /d/Claude/.vault-tools/plan-kopie/mutanten_lauf.py /d/Claude/.vault-tools/plan-kopie/mutanten_lauf_treiber.py /d/Claude/.vault-tools/plan-kopie/mutanten_lauf_probe_konfig.py "$R/"
```
In der PowerShell:
```powershell
# Treiber in WSL gegen den Laeufer vor Nachbesserung 2
wsl -d Debian -- timeout 1500 /home/claude/venvs/trading/bin/python /mnt/c/Users/PASCAL~1/AppData/Local/Temp/mlauf-rot3/mutanten_lauf_treiber.py *> "C:\Users\PASCAL~1\AppData\Local\Temp\mlauf-rot3\ausgabe.txt"
"exit=$LASTEXITCODE"
Select-String -Path "C:\Users\PASCAL~1\AppData\Local\Temp\mlauf-rot3\ausgabe.txt" -Pattern "FEHLER:|gerechnete"
```
Erwartet: `exit=1`, und die Treffer enden genau auf diese vier Zeilen:
```
Fall 40 FEHLER: exit=1
Fall 43 FEHLER: exit=1
Fall 46 FEHLER: exit=1
gerechnete Fehler: 3
```
## Schritt 2 — Umsetzung
### `mutanten_lauf.py`, Stelle 1 von 7
Aus:
```python
Konfiguration in der Wurzel ist config.inipath die leere Datei, ini-Werte vom
Typ paths aus -o oder PYTEST_ADDOPTS gelten dann relativ zum Plugin-Ordner.
```
wird:
```python
Konfiguration in der Wurzel ist config.inipath die leere Datei, ini-Werte vom
Typ paths aus -o oder PYTEST_ADDOPTS gelten dann relativ zum Plugin-Ordner;
iniconfig trennt Zeilen mit splitlines, der Laeufer auch. Liegt rootdir unter
der Wurzel (Konfiguration oder setup.py in einem Unterordner der Tests),
bildet pytest die Knotennamen relativ dazu, und rot_bei passt nicht mehr: Die
Grundlinie meldet FEHLT, die Kopfzeile nennt die Datei.
```
### `mutanten_lauf.py`, Stelle 2 von 7
Aus:
```python
return True
for zeile in text.split("\n"):
zeile = zeile.rstrip()
```
wird:
```python
return True
for zeile in text.splitlines():
zeile = zeile.rstrip()
```
### `mutanten_lauf.py`, Stelle 3 von 7
Aus:
```python
return pfad.exists()
except OSError:
return False
```
wird:
```python
return pfad.exists()
except (OSError, ValueError):
return False
```
### `mutanten_lauf.py`, Stelle 4 von 7
Aus:
```python
"""Die Zeile, die die Ursache nennt: bei 4 die erste mit "error" ausser der
Gebrauchszeile von argparse, bei 3 und ohne Treffer die letzte."""
if rueckgabe == 4:
```
wird:
```python
"""Die Zeile, die die Ursache nennt: bei 4 die erste mit "error" ausser der
Gebrauchszeile von argparse, bei 3 die letzte mit INTERNALERROR> (danach
folgt noch die Summenzeile, Treiber-Fall 46), ohne Treffer die letzte."""
if rueckgabe == 4:
```
### `mutanten_lauf.py`, Stelle 5 von 7
Aus:
```python
return zeile.strip()
return ende[-1].strip() if ende else "(keine Ausgabe)"
```
wird:
```python
return zeile.strip()
if rueckgabe == 3:
interne = [zeile for zeile in ende if zeile.startswith("INTERNALERROR>")]
if interne:
return interne[-1].strip()
return ende[-1].strip() if ende else "(keine Ausgabe)"
```
### `mutanten_lauf.py`, Stelle 6 von 7
Aus:
```python
ergebnis, meldung = ergebnis_bestimmen(knoten, reports, deselected)
if ergebnis == "FEHLT":
```
wird:
```python
ergebnis, meldung = ergebnis_bestimmen(knoten, reports, deselected)
abbruch = None
if ergebnis == "FEHLT":
```
### `mutanten_lauf.py`, Stelle 7 von 7
Aus:
```python
luecke = True
test_ergebnisse.append({"eintrag": eintrag, "knoten": knoten, "ergebnis": ergebnis, "meldung": meldung})
```
wird:
```python
luecke = True
eintrag_bericht = {"eintrag": eintrag, "knoten": knoten, "ergebnis": ergebnis, "meldung": meldung}
if ergebnis == "FEHLT" and abbruch:
eintrag_bericht["ausgabe"] = abbruch["lang"]
test_ergebnisse.append(eintrag_bericht)
```
### `mutanten_lauf_ENTWURF.md`, Stelle 1 von 3
Aus:
```markdown
- Die Kopfzeilen `pytest-konfiguration (Grundlinie, <python>): …` nennen je Interpreter, was für seinen Aufruf in der Grundlinie gilt.
- Gegen pytest selbst geprüft wird das mit `mutanten_lauf_probe_konfig.py` (Treiber-Fall 43): 24 Aufbauten, je `pytest_konfig_bestimmen` gegen `determine_setup` und ein echtes `pytest --co` ohne `-c` gegen einen Lauf mit den Argumenten des Läufers unter fremder Konfiguration.
- Benannte Lücken: Eine `tox.ini` oder ein `setup.cfg`, die iniconfig nicht lesen kann, übergeht der Läufer, pytest bricht dort mit 4 ab; eine nicht lesbare `pyproject.toml` oder `pytest.toml` gilt dagegen als Treffer, damit pytest den Fehler selbst meldet. Ein `setup.cfg` mit `[pytest]`, bei pytest ein Fehler, gilt hier als keine Konfiguration. Ohne Konfiguration in der Wurzel ist `config.inipath` die leere Datei; ini-Werte vom Typ `paths`, die über `-o` oder `PYTEST_ADDOPTS` kommen, gelten dann relativ zum Plugin-Ordner.
**Abbruch von pytest (seit 2026-10-06):** Endet ein pytest-Aufruf mit Rückgabewert 3 (`INTERNAL_ERROR`) oder 4 (`USAGE_ERROR`, auch bei einem Testknoten, den es nicht gibt), hält der Läufer die letzten 20 nicht leeren Zeilen von stdout und stderr fest. In der Grundlinie wird daraus je Interpreter ein Befund `Grundlinie: pytest-Bedienfehler (Rueckgabewert 4) mit <python>; Ende der Ausgabe: …` (bei 3: `pytest-Abbruch mit internem Fehler`) statt einer `FEHLT`-Zeile je Test; der Bericht führt die Tests mit `FEHLT` und der Meldung „pytest abgebrochen, siehe Befunde“. Unter einem Mutanten tragen die Tests ohne Bericht und ohne Sammelfehler eine kurze Fassung als Meldung, die Ursache vorn, damit sie in die 120 Zeichen der Mutantenzeile passt: `pytest-Bedienfehler (Rueckgabewert 4): <zeile>`. Die Zeile ist bei 4 die erste mit „error“ außer der Gebrauchszeile `ERROR: usage:` von argparse, bei 3 die letzte. Diese Tests machen den Mutanten wie bisher `UNKLAR`. Ein Sammelfehler bleibt `ROT`, auch wenn pytest dabei mit 4 endet: Ein Knoten in einer Datei, die sich nicht sammeln lässt, ergibt zusätzlich `found no collectors for …` (Treiber-Fall 11, am 2026-10-06 an einer ersten Fassung gesehen, die jeden Abbruch als `UNKLAR` zählte).
```
wird:
```markdown
- Die Kopfzeilen `pytest-konfiguration (Grundlinie, <python>): …` nennen je Interpreter, was für seinen Aufruf in der Grundlinie gilt.
- Gegen pytest selbst geprüft wird das mit `mutanten_lauf_probe_konfig.py` (Treiber-Fall 43): 26 Aufbauten, je `pytest_konfig_bestimmen` gegen `determine_setup` und ein echtes `pytest --co` ohne `-c` gegen einen Lauf mit den Argumenten des Läufers unter fremder Konfiguration. Die Probe sichert ihre Voraussetzung mit: Ohne `-c` endet pytest mit 0 (außer bei fehlenden Knoten), und wo der Aufbau ein `conftest.py` hat, ist eines geladen.
- Benannte Lücken: Eine `tox.ini` oder ein `setup.cfg`, die iniconfig nicht lesen kann, übergeht der Läufer, pytest bricht dort mit 4 ab; eine nicht lesbare `pyproject.toml` oder `pytest.toml` gilt dagegen als Treffer, damit pytest den Fehler selbst meldet. Ein `setup.cfg` mit `[pytest]`, bei pytest ein Fehler, gilt hier als keine Konfiguration. Ohne Konfiguration in der Wurzel ist `config.inipath` die leere Datei; ini-Werte vom Typ `paths`, die über `-o` oder `PYTEST_ADDOPTS` kommen, gelten dann relativ zum Plugin-Ordner. Liegt `rootdir` unter der Wurzel (Konfiguration oder `setup.py` in einem Unterordner der Tests), bildet pytest die Knotennamen relativ dazu, und `rot_bei` passt nicht mehr: Die Grundlinie meldet `FEHLT`, die Kopfzeile nennt die gefundene Datei; heute betrifft das kein Projekt im Vault. Zeilen trennt der Läufer wie iniconfig mit `splitlines`, also auch an Seitenvorschub und Unicode-Zeilentrennern.
**Abbruch von pytest (seit 2026-10-06):** Endet ein pytest-Aufruf mit Rückgabewert 3 (`INTERNAL_ERROR`) oder 4 (`USAGE_ERROR`, auch bei einem Testknoten, den es nicht gibt), hält der Läufer die letzten 20 nicht leeren Zeilen von stdout und stderr fest. In der Grundlinie wird daraus je Interpreter ein Befund `Grundlinie: pytest-Bedienfehler (Rueckgabewert 4) mit <python>; Ende der Ausgabe: …` (bei 3: `pytest-Abbruch mit internem Fehler`) statt einer `FEHLT`-Zeile je Test; der Bericht führt die Tests mit `FEHLT` und der Meldung „pytest abgebrochen, siehe Befunde“. Unter einem Mutanten tragen die Tests ohne Bericht und ohne Sammelfehler eine kurze Fassung als Meldung, die Ursache vorn, damit sie in die 120 Zeichen der Mutantenzeile passt: `pytest-Bedienfehler (Rueckgabewert 4): <zeile>`. Die Zeile ist bei 4 die erste mit „error“ außer der Gebrauchszeile `ERROR: usage:` von argparse, bei 3 die letzte mit `INTERNALERROR>` (danach steht noch die Summenzeile `no tests ran …`, Treiber-Fall 46), ohne Treffer die letzte überhaupt. Die lange Fassung steht im Bericht beim Test unter `ausgabe`. Diese Tests machen den Mutanten wie bisher `UNKLAR`. Ein Sammelfehler bleibt `ROT`, auch wenn pytest dabei mit 4 endet: Ein Knoten in einer Datei, die sich nicht sammeln lässt, ergibt zusätzlich `found no collectors for …` (Treiber-Fall 11, am 2026-10-06 an einer ersten Fassung gesehen, die jeden Abbruch als `UNKLAR` zählte).
```
### `mutanten_lauf_ENTWURF.md`, Stelle 2 von 3
Aus:
```markdown
`--bericht` schreibt dasselbe als JSON: `{"wurzel", "mutanten_datei", "befunde": [...], "grundlinie": {knoten: {ergebnis, meldung}}, "mutanten": [{"id", "datei", "status", "hinweis", "treffer", "tests": [{"eintrag", "knoten", "ergebnis", "meldung"}]}], "zusammenfassung": {...}}`. Der Bericht wird auch bei einem Abbruch geschrieben, mit allem, was bis dahin feststand.
```
wird:
```markdown
`--bericht` schreibt dasselbe als JSON: `{"wurzel", "mutanten_datei", "befunde": [...], "grundlinie": {knoten: {ergebnis, meldung}}, "mutanten": [{"id", "datei", "status", "hinweis", "treffer", "tests": [{"eintrag", "knoten", "ergebnis", "meldung", "ausgabe" (nur bei einem Abbruch von pytest)}]}], "zusammenfassung": {...}}`. Der Bericht wird auch bei einem Abbruch geschrieben, mit allem, was bis dahin feststand.
```
### `mutanten_lauf_ENTWURF.md`, Stelle 3 von 3
Aus:
```markdown
| 39 | Wurzel ohne eigene Konfiguration, darüber ein `conftest.py`, das beim Laden abbricht | Exit 0, zweimal `OK`; prüft `--confcutdir` |
| 40 | ein Mutant schreibt in die eigene `pyproject.toml` `addopts = "--gibt-es-nicht"` | Exit 1; die Mutantenzeile selbst trägt `UNKLAR`, `pytest-Bedienfehler (Rueckgabewert 4)` und `--gibt-es-nicht` |
| 41 | Wurzel über einen Symlink angegeben (`link/k` → `echt/k`), seit der Nachbesserung vom 2026-10-06 | Exit 0, zweimal `OK` |
| 42 | ein `conftest.py` der Wurzel wirft in `pytest_collection_modifyitems` | Exit 2, `pytest-Abbruch mit internem Fehler (Rueckgabewert 3)` und der Text der Ausnahme, keine Zeile `ist FEHLT` |
| 43 | `mutanten_lauf_probe_konfig.py`: 24 Aufbauten gegen `determine_setup` und gegen echte `pytest --co`-Läufe | Exit 0, `abweichungen: 0` |
| 44 | zwei Interpretergruppen (`python_je_datei`), nur in einer ein Knoten, den es nicht gibt; diese Gruppe einmal an erster, einmal an letzter Stelle | je Exit 2, genau ein Befund mit dem Interpreter dieser Gruppe, keiner für die andere, keine Zeile `ist FEHLT` |
| 45 | ein Knoten mit einem Dateinamen über 255 Zeichen | Exit 2 mit einem Befund `Grundlinie: pytest-…`, kein `unerwarteter Abbruch` |
```
wird:
```markdown
| 39 | Wurzel ohne eigene Konfiguration, darüber ein `conftest.py`, das beim Laden abbricht | Exit 0, zweimal `OK`; prüft `--confcutdir` |
| 40 | ein Mutant schreibt in die eigene `pyproject.toml` `addopts = "--gibt-es-nicht"` | Exit 1; die Mutantenzeile selbst trägt `UNKLAR`, `pytest-Bedienfehler (Rueckgabewert 4)` und `--gibt-es-nicht`; im Bericht steht beim Test `ausgabe` mit „Ende der Ausgabe:“ |
| 41 | Wurzel über einen Symlink angegeben (`link/k` → `echt/k`), seit der Nachbesserung vom 2026-10-06 | Exit 0, zweimal `OK` |
| 42 | ein `conftest.py` der Wurzel wirft in `pytest_collection_modifyitems` | Exit 2, `pytest-Abbruch mit internem Fehler (Rueckgabewert 3)` und der Text der Ausnahme, keine Zeile `ist FEHLT` |
| 43 | `mutanten_lauf_probe_konfig.py`: 26 Aufbauten gegen `determine_setup` und gegen echte `pytest --co`-Läufe, mit Selbstprüfung der Voraussetzung | Exit 0, `abweichungen: 0` |
| 44 | zwei Interpretergruppen (`python_je_datei`), nur in einer ein Knoten, den es nicht gibt; diese Gruppe einmal an erster, einmal an letzter Stelle | je Exit 2, genau ein Befund mit dem Interpreter dieser Gruppe, keiner für die andere, keine Zeile `ist FEHLT`; genau zwei Kopfzeilen, je eine je Interpreter |
| 45 | ein Knoten mit einem Dateinamen über 255 Zeichen | Exit 2 mit einem Befund `Grundlinie: pytest-…`, kein `unerwarteter Abbruch` |
| 46 | ein Mutant lässt ein `conftest.py` der Wurzel in `pytest_collection_modifyitems` werfen | Exit 1, die Mutantenzeile trägt `UNKLAR`, `pytest-Abbruch mit internem Fehler (Rueckgabewert 3): ` und den Text der Ausnahme; im Bericht `ausgabe` mit „Ende der Ausgabe:“ |
```
### `LIESMICH.md`, Stelle 1 von 2
Aus:
```markdown
8. **Mutanten fahren (seit 2026-09-27):** `mutanten_lauf.py --wurzel <wegwerfkopie> --mutanten <planordner>/mutanten.toml [--nur ID,…] [--nur-muster] [--bericht <datei.json>] [--zeitlimit <s>]`, in WSL mit einem Python ab 3.11. Die Mutanten stehen nicht mehr als Prosa im Plan, sondern in `mutanten.toml` im Planordner. Das Format, die Status und die Abdichtung stehen in `mutanten_lauf_ENTWURF.md`. Das Skript verweigert eine Wurzel mit `.git` und jeden Pfad aus `ohne`. Seit 2026-10-06 sucht es die pytest-Konfiguration wie pytest selbst, aber nie oberhalb der Wurzel, und gibt sie mit `-c` vor; eine fremde `pyproject.toml` im Windows-Temp stört damit nicht mehr, und ein Bedienfehler von pytest steht mit dem Ende der pytest-Ausgabe im Befund statt als `FEHLT` je Test (Entwurf, Abschnitt 3, „Konfiguration“ und „Abbruch von pytest“). Ob die Suche pytest selbst entspricht, prüft `mutanten_lauf_probe_konfig.py <mutanten_lauf.py> <arbeitsordner>` mit dem Python eines Venvs, in dem pytest liegt (24 Aufbauten, Exit 0 ohne Abweichung). Es fährt zuerst die Grundlinie, dann je Mutant nur die genannten Tests, setzt die Datei zurück und prüft das per SHA-256. Je Test gibt es die Fehlermeldung mit aus; ob ein Test an der gemeinten Stelle fällt, liest man dort. `--nur-muster` prüft nur, ob jedes Muster genau einmal dasteht, ohne pytest. Die Kopie legt man in WSL unter `/tmp` an (`cp -a`, danach `__pycache__` löschen). Auf `/mnt/d` läuft es auch, nur langsamer. **Aus der Git-Bash ein Skript nie mit `wsl … bash /mnt/d/…` starten:** Die Git-Bash macht aus dem Pfad einen Windows-Pfad. Aus der PowerShell starten oder `MSYS_NO_PATHCONV=1` davorsetzen. Ein SIGTERM auf `mutanten_lauf.py` selbst (etwa `timeout -k` oder ein Hilfsagent, der abgebrochen wird) löst dort einen eigenen Handler aus: Der laufende Mutant wird per SHA-Probe zurückgesetzt, die Temp-Ordner werden geräumt, und die Ausgabe trägt `ABBRUCH: SIGTERM` (Treiber-Fall 35, `mutanten_lauf_treiber.py`).
**Die Plankritik fährt die Mutanten selbst (seit 2026-09-27, nachgebessert 2026-09-28):** Liegt `mutanten.toml` im Planordner, startet `plankritik.js` in der Vorbereitung `mutanten_lauf_kopie.sh`. Das Skript kopiert die Kopie mit angewendetem Plan nach `/tmp` in WSL, fährt dort den Läufer in einem eigenen Netz-Namensraum (`unshare --map-current-user --net`, gemessen in WSL Debian als Benutzer `claude`: UID innen bleibt 1000, nur Schnittstelle `lo`, jede Verbindung nach außen scheitert) und gibt die Ausgabe an die Linse `arithmetik`. Fehlt `unshare` ohne Netz, bricht das Skript vorher mit `MUTANTEN_LAUF_FEHLER` ab, statt ungesperrt zu laufen. Der Arg `mutanten` wählt `voll` (Vorgabe), `muster` oder `aus`; die Rückgabe trägt dazu `mutanten_status` (`aus`, `ohne_kopie`, `keine_toml`, `ohne_ergebnis` oder `exit_<n>`) und `mutanten_modus` (`voll`, `muster`, `aus`). Der Wrapper gibt vor dem Lauf eine Zeile `modus: voll` oder `modus: muster (…)` aus; bei `modus: muster` weiß die Linse, dass kein Mutant gefahren ist. Der ganze Lauf hat 540 s (`BUDGET_S`, `timeout -k 15`), weil das Bash-Werkzeug eines Hilfsagenten nach zehn Minuten endet; eine überschrittene Grenze meldet die Zeile `ZEITGRENZE`. Je Mutant gilt zusätzlich ein Zeitlimit `ZEITLIMIT_S` (Vorgabe 120 s, muss kleiner sein als `BUDGET_S`): B1 mit 74 Mutanten brauchte 168 s gesamt, rund 2 s je Mutant. Bei Exit 2 oder 3 des Läufers sagt die Linse, der Lauf sei vor oder während der Mutanten gescheitert. Pfade übergibt man mit Vorwärtsschrägstrichen: `wsl -d Debian -- …` reicht die Befehlszeile an die Linux-Shell, und die verschluckt Backslashes.
```
wird:
```markdown
8. **Mutanten fahren (seit 2026-09-27):** `mutanten_lauf.py --wurzel <wegwerfkopie> --mutanten <planordner>/mutanten.toml [--nur ID,…] [--nur-muster] [--bericht <datei.json>] [--zeitlimit <s>]`, in WSL mit einem Python ab 3.11. Die Mutanten stehen nicht mehr als Prosa im Plan, sondern in `mutanten.toml` im Planordner. Das Format, die Status und die Abdichtung stehen in `mutanten_lauf_ENTWURF.md`. Das Skript verweigert eine Wurzel mit `.git` und jeden Pfad aus `ohne`. Seit 2026-10-06 sucht es die pytest-Konfiguration wie pytest selbst, aber nie oberhalb der Wurzel, und gibt sie mit `-c` vor; eine fremde `pyproject.toml` im Windows-Temp stört damit nicht mehr, und ein Bedienfehler von pytest steht mit dem Ende der pytest-Ausgabe im Befund statt als `FEHLT` je Test (Entwurf, Abschnitt 3, „Konfiguration“ und „Abbruch von pytest“). Ob die Suche pytest selbst entspricht, prüft `mutanten_lauf_probe_konfig.py <mutanten_lauf.py> <arbeitsordner>` mit dem Python eines Venvs, in dem pytest liegt (26 Aufbauten, Exit 0 ohne Abweichung). Es fährt zuerst die Grundlinie, dann je Mutant nur die genannten Tests, setzt die Datei zurück und prüft das per SHA-256. Je Test gibt es die Fehlermeldung mit aus; ob ein Test an der gemeinten Stelle fällt, liest man dort. `--nur-muster` prüft nur, ob jedes Muster genau einmal dasteht, ohne pytest. Die Kopie legt man in WSL unter `/tmp` an (`cp -a`, danach `__pycache__` löschen). Auf `/mnt/d` läuft es auch, nur langsamer. **Aus der Git-Bash ein Skript nie mit `wsl … bash /mnt/d/…` starten:** Die Git-Bash macht aus dem Pfad einen Windows-Pfad. Aus der PowerShell starten oder `MSYS_NO_PATHCONV=1` davorsetzen. Ein SIGTERM auf `mutanten_lauf.py` selbst (etwa `timeout -k` oder ein Hilfsagent, der abgebrochen wird) löst dort einen eigenen Handler aus: Der laufende Mutant wird per SHA-Probe zurückgesetzt, die Temp-Ordner werden geräumt, und die Ausgabe trägt `ABBRUCH: SIGTERM` (Treiber-Fall 35, `mutanten_lauf_treiber.py`).
**Die Plankritik fährt die Mutanten selbst (seit 2026-09-27, nachgebessert 2026-09-28):** Liegt `mutanten.toml` im Planordner, startet `plankritik.js` in der Vorbereitung `mutanten_lauf_kopie.sh`. Das Skript kopiert die Kopie mit angewendetem Plan nach `/tmp` in WSL, fährt dort den Läufer in einem eigenen Netz-Namensraum (`unshare --map-current-user --net`, gemessen in WSL Debian als Benutzer `claude`: UID innen bleibt 1000, nur Schnittstelle `lo`, jede Verbindung nach außen scheitert) und gibt die Ausgabe an die Linse `arithmetik`. Fehlt `unshare` ohne Netz, bricht das Skript vorher mit `MUTANTEN_LAUF_FEHLER` ab, statt ungesperrt zu laufen. Der Arg `mutanten` wählt `voll` (Vorgabe), `muster` oder `aus`; die Rückgabe trägt dazu `mutanten_status` (`aus`, `ohne_kopie`, `keine_toml`, `ohne_ergebnis` oder `exit_<n>`) und `mutanten_modus` (`voll`, `muster`, `aus`). Der Wrapper gibt vor dem Lauf eine Zeile `modus: voll` oder `modus: muster (…)` aus; bei `modus: muster` weiß die Linse, dass kein Mutant gefahren ist. Der ganze Lauf hat 540 s (`BUDGET_S`, `timeout -k 15`), weil das Bash-Werkzeug eines Hilfsagenten nach zehn Minuten endet; eine überschrittene Grenze meldet die Zeile `ZEITGRENZE`. Je Mutant gilt zusätzlich ein Zeitlimit `ZEITLIMIT_S` (Vorgabe 120 s, muss kleiner sein als `BUDGET_S`): B1 mit 74 Mutanten brauchte 168 s gesamt, rund 2 s je Mutant. Bei Exit 2 oder 3 des Läufers sagt die Linse, der Lauf sei vor oder während der Mutanten gescheitert. Pfade übergibt man mit Vorwärtsschrägstrichen: `wsl -d Debian -- …` reicht die Befehlszeile an die Linux-Shell, und die verschluckt Backslashes.
```
### `LIESMICH.md`, Stelle 2 von 2
Aus:
```markdown
- Gegenprobe an allen sechs `mutanten.toml` im Vault, alter gegen neuer Läufer auf Kopien aus `git archive HEAD` unter `/tmp`: Headscale A, Mattermost, After Install C1 und D-40 gleich (175, 130, 69 und 41 Mutanten `OK`); Headscale B und Trading Plattform WH enden bei beiden mit Exit 2, weil ihr Stand nicht zum Plan passt. Unter dem Windows-Temp endete C1 mit dem alten Läufer mit 101 × `FEHLT`, mit dem neuen mit 69 `OK`.
- Ein `pruefer` fand in der ersten Fassung den Symlink-Fall und die fehlenden Fälle; beides ist in der Nachbesserung behoben.
- Rohdaten: `AI/secondbrain/linux-haertung-after-install-memory/2026-10-06-c1-abschluss/roh/` (`02-*`, `03-gegenprobe/`, `07-*`).
```
wird:
```markdown
- Gegenprobe an allen sechs `mutanten.toml` im Vault, alter gegen neuer Läufer auf Kopien aus `git archive HEAD` unter `/tmp`: Headscale A, Mattermost, After Install C1 und D-40 gleich (175, 130, 69 und 41 Mutanten `OK`); Headscale B und Trading Plattform WH enden bei beiden mit Exit 2, weil ihr Stand nicht zum Plan passt. Unter dem Windows-Temp endete C1 mit dem alten Läufer mit 101 × `FEHLT`, mit dem neuen mit 69 `OK`.
- Ein `pruefer` fand in der ersten Fassung den Symlink-Fall und fehlende Fälle; die Nachbesserung behob beides. Ein zweiter `pruefer` fand danach keinen Verhaltensfehler mehr, nur eine Aufräumliste (Kopfzeile je Interpreter ohne eigenen Fall, Kurzfassung bei Rückgabewert 3, `;` und Seitenvorschub in `tox.ini`, Selbstprüfung der Probe, lange Fassung im Bericht, zwei Lücken benennen); sie ist in Nachbesserung 2 erledigt, Treiber 46 von 46.
- Rohdaten: `AI/secondbrain/linux-haertung-after-install-memory/2026-10-06-c1-abschluss/roh/` (`02-*`, `03-gegenprobe/`, `07-*`, `08-gegenprobe2/` mit dem nachgebesserten Läufer, `09-*` für Nachbesserung 2).
```
## Schritt 3 — Verifikation
```bash
# Kompilieren, ASCII, keine CR, Cache wieder weg
cd /d/Claude/.vault-tools/plan-kopie
python -m py_compile mutanten_lauf.py mutanten_lauf_treiber.py mutanten_lauf_probe_konfig.py && echo kompiliert
rm -rf __pycache__
LC_ALL=C grep -cP '[^\x00-\x7F]' mutanten_lauf.py mutanten_lauf_treiber.py mutanten_lauf_probe_konfig.py
for f in mutanten_lauf.py mutanten_lauf_treiber.py mutanten_lauf_probe_konfig.py mutanten_lauf_ENTWURF.md LIESMICH.md; do printf '%s %s\n' "$f" "$(tr -cd '\r' < "$f" | wc -c)"; done
```
Erwartet: `kompiliert`, überall `0`.
```powershell
# Treiber gegen den Laeufer nach Nachbesserung 2
wsl -d Debian -- timeout 1500 /home/claude/venvs/trading/bin/python /mnt/d/Claude/.vault-tools/plan-kopie/mutanten_lauf_treiber.py *> "C:\Users\PASCAL~1\AppData\Local\Temp\mlauf-rot3\ausgabe-neu.txt"
"exit=$LASTEXITCODE"
Select-String -Path "C:\Users\PASCAL~1\AppData\Local\Temp\mlauf-rot3\ausgabe-neu.txt" -Pattern "FEHLER:|gerechnete"
```
Erwartet: `exit=0` und als einziger Treffer eine Zeile, die auf `gerechnete Fehler: 0` endet. Danach den Rot-Ordner und ein von WSL angelegtes `__pycache__` unter `plan-kopie/` löschen.
## Bericht
Je Zieldatei die Zahl der angewendeten Paare, die Ausgaben aus Schritt 1 und 3 im Wortlaut, jede Abweichung und Annahme.
## Fertig heißt (Koordinator)
1. Die fünf Zieldateien byte-gleich mit der Kopie mit angewendetem Plan (`cmp`).
2. Treiber 46 von 46, selbst gefahren.
3. Rot-Nachweis wie unter „Gemessen“.
File diff suppressed because it is too large. Load diff
+264 -9
View File
@@ -24,6 +24,37 @@ absolutem Pfad ausserhalb von HOME sind nicht abgedichtet. Die eigentliche
Sperre bleibt die Netzsperre der Testdateien selbst und eine Kopie ohne
Geheimnisdateien (der Schluessel "ohne" in mutanten.toml).
pytest-Konfiguration (seit 2026-10-06): Fuer jeden pytest-Aufruf bestimmt das
Skript die Konfigurationsdatei so, wie pytest 9.1.1 sie selbst suchen wuerde
(_pytest/config/findpaths.py: determine_setup, locate_config,
get_common_ancestor), sucht aber nie oberhalb der Wurzel, und gibt sie mit -c
vor. Ohne Konfiguration in der Wurzel bekommt pytest eine leere Datei aus dem
Plugin-Ordner, dazu --rootdir und --confcutdir. Grund: Unter dem Windows-Temp
fand pytest eine fremde pyproject.toml, deren addopts nicht zu
-p no:cacheprovider passten, und jeder Test der Grundlinie stand als FEHLT da
(Plankritik 2 von C1 im Projekt After Install, 2026-10-05). Die Wurzel wird
vorher mit realpath aufgeloest: pytest rechnet vom Arbeitsordner aus, den der
Kernel ohne Verknuepfungen meldet; ueber einen Symlink angegeben, passten die
Knotennamen sonst nicht (Pruefung vom 2026-10-06). Benannte Luecken: tox.ini
und setup.cfg werden zeilenweise nach ihrem Abschnitt durchsucht wie in
iniconfig 2.3.0 (_parse.py, _parseline), eine Datei, die iniconfig nicht lesen
kann, uebergeht der Laeufer, pytest bricht dort mit 4 ab; ein setup.cfg mit
[pytest] (bei pytest selbst ein Fehler) gilt hier als keine Konfiguration; ohne
Konfiguration in der Wurzel ist config.inipath die leere Datei, ini-Werte vom
Typ paths aus -o oder PYTEST_ADDOPTS gelten dann relativ zum Plugin-Ordner;
iniconfig trennt Zeilen mit splitlines, der Laeufer auch. Liegt rootdir unter
der Wurzel (Konfiguration oder setup.py in einem Unterordner der Tests),
bildet pytest die Knotennamen relativ dazu, und rot_bei passt nicht mehr: Die
Grundlinie meldet FEHLT, die Kopfzeile nennt die Datei.
Endet pytest mit Rueckgabewert 3 (interner Fehler) oder 4 (Bedienfehler,
ExitCode in _pytest/config/__init__.py), steht das Ende seiner Ausgabe in der
Grundlinie als eigener Befund je Interpreter und unter einem Mutanten, als
eine Zeile mit der Ursache vorn, in der Meldung der Tests ohne Bericht und
ohne Sammelfehler. Ein Sammelfehler bleibt ROT, auch wenn pytest dabei mit 4
endet: Ein Knoten in einer Datei, die sich nicht sammeln laesst, ergibt
zusaetzlich "found no collectors for" (Treiber-Fall 11).
Spezifikation: .vault-tools/plan-kopie/mutanten_lauf_ENTWURF.md
Entscheidungen, die der Entwurf offen liess (siehe Bericht des Executers):
@@ -119,6 +150,20 @@ def pytest_deselected(items):
TOP_LEVEL_SCHLUESSEL = {"format", "python", "ohne", "kuerzel", "python_je_datei", "mutant"}
MUTANT_SCHLUESSEL = {"id", "datei", "aus", "wird", "rot_bei", "hinweis"}
# Reihenfolge wie config_names in locate_config, pytest 9.1.1.
PYTEST_KONFIG_NAMEN = (
"pytest.toml",
".pytest.toml",
"pytest.ini",
".pytest.ini",
"pyproject.toml",
"tox.ini",
"setup.cfg",
)
LEERE_KONFIG_NAME = "pytest.ini"
PYTEST_ABBRUCH = {3: "pytest-Abbruch mit internem Fehler", 4: "pytest-Bedienfehler"}
AUSGABE_ENDE_ZEILEN = 20
class RuecksetzenGescheitert(Exception):
def __init__(self, mutant_id, datei):
@@ -427,6 +472,170 @@ def gruppiere_nach_interpreter(knoten_liste, standard_python, python_je_datei):
return gruppen
def _ini_hat_abschnitt(pfad, abschnitt):
"""Suche nach der Abschnittszeile wie _parseline in iniconfig 2.3.0: "[" in
Spalte 0, Kommentare nach # und ; entfernt, dann "]" am Ende. Eine nicht
lesbare Datei zaehlt als Treffer, damit pytest den Fehler selbst meldet."""
try:
text = pfad.read_text(encoding="utf-8", errors="replace")
except OSError:
return True
for zeile in text.splitlines():
zeile = zeile.rstrip()
if not zeile.startswith("["):
continue
for zeichen in "#;":
zeile = zeile.split(zeichen)[0].rstrip()
if zeile.endswith("]") and zeile[1:-1] == abschnitt:
return True
return False
def _ist_pytest_konfig(pfad):
"""Nachbau von load_config_dict_from_file(pfad) is not None, pytest 9.1.1."""
name = pfad.name
if name in ("pytest.toml", ".pytest.toml", "pytest.ini", ".pytest.ini"):
return True
if name == "tox.ini":
return _ini_hat_abschnitt(pfad, "pytest")
if name == "setup.cfg":
return _ini_hat_abschnitt(pfad, "tool:pytest")
if name == "pyproject.toml":
try:
daten = tomllib.loads(pfad.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError):
return True
werkzeug = daten.get("tool", {})
tool_pytest = werkzeug.get("pytest", {}) if isinstance(werkzeug, dict) else {}
return isinstance(tool_pytest, dict) and bool(tool_pytest)
return False
def _innerhalb(pfad, wurzel):
return pfad == wurzel or wurzel in pfad.parents
def _existiert(pfad):
"""Wie safe_exists in pytest 9.1.1: ein zu langer Name ist kein Absturz."""
try:
return pfad.exists()
except (OSError, ValueError):
return False
def _konfig_suchen(startordner, wurzel):
"""Nachbau von locate_config, pytest 9.1.1; die Suche endet an der Wurzel."""
erstes_pyproject = None
for start in startordner:
for basis in (start, *start.parents):
if not _innerhalb(basis, wurzel):
break
for name in PYTEST_KONFIG_NAMEN:
pfad = basis / name
if pfad.is_file():
if name == "pyproject.toml" and erstes_pyproject is None:
erstes_pyproject = pfad
if _ist_pytest_konfig(pfad):
return pfad
return erstes_pyproject
def _gemeinsamer_vorfahr(pfade, wurzel):
"""Nachbau von get_common_ancestor, pytest 9.1.1, mit der Wurzel als Aufrufordner."""
vorfahr = None
for pfad in pfade:
if not _existiert(pfad):
continue
if vorfahr is None:
vorfahr = pfad
elif vorfahr in pfad.parents or pfad == vorfahr:
continue
elif pfad in vorfahr.parents:
vorfahr = pfad
else:
try:
vorfahr = Path(os.path.commonpath([str(pfad), str(vorfahr)]))
except ValueError:
pass
if vorfahr is None:
return wurzel
if vorfahr.is_file():
return vorfahr.parent
return vorfahr
def pytest_konfig_bestimmen(wurzel, knoten_liste):
"""Konfiguration und rootdir eines Aufrufs, wie determine_setup in pytest
9.1.1 sie ohne -c bestimmt, aber nie oberhalb der Wurzel gesucht.
Rueckgabe (konfig, rootdir); konfig ist None, wenn die Wurzel keine hat."""
wurzel = Path(os.path.realpath(str(wurzel)))
ordner = []
for knoten in knoten_liste:
pfad = wurzel / knoten.split("::", 1)[0]
if _existiert(pfad):
ordner.append(pfad if pfad.is_dir() else pfad.parent)
vorfahr = _gemeinsamer_vorfahr(ordner, wurzel)
if not _innerhalb(vorfahr, wurzel):
vorfahr = wurzel
konfig = _konfig_suchen([vorfahr], wurzel)
if konfig is not None:
return konfig, konfig.parent
for basis in (vorfahr, *vorfahr.parents):
if not _innerhalb(basis, wurzel):
break
if (basis / "setup.py").is_file():
return None, basis
if ordner != [vorfahr]:
konfig = _konfig_suchen(ordner, wurzel)
if konfig is not None:
return konfig, konfig.parent
return None, wurzel
def pytest_konfig_argumente(wurzel, knoten_liste, leere_konfig):
"""Die Konfiguration der Wurzel mit -c; sonst die leere Datei mit --rootdir
und --confcutdir. Ohne --confcutdir laege confcutdir im Ordner der leeren
Datei, und pytest laede conftest.py-Dateien oberhalb der Wurzel
(_pytest/config/__init__.py, pytest 9.1.1)."""
konfig, rootdir = pytest_konfig_bestimmen(wurzel, knoten_liste)
if konfig is not None:
return ["-c", str(konfig)]
return ["-c", str(leere_konfig), "--rootdir", str(rootdir), "--confcutdir", str(rootdir)]
def pytest_konfig_beschreibung(wurzel, knoten_liste):
konfig, rootdir = pytest_konfig_bestimmen(wurzel, knoten_liste)
if konfig is not None:
return "{0} (aus der Wurzel)".format(konfig)
return "keine in der Wurzel, leere Datei; rootdir {0}".format(rootdir)
def ausgabe_ende(stdout_bytes, stderr_bytes):
"""Die letzten nicht leeren Zeilen von stdout und stderr, je hoechstens 300 Zeichen."""
text = (
(stdout_bytes or b"").decode("utf-8", errors="replace")
+ "\n"
+ (stderr_bytes or b"").decode("utf-8", errors="replace")
)
zeilen = [zeile.rstrip()[:300] for zeile in text.split("\n") if zeile.strip()]
return zeilen[-AUSGABE_ENDE_ZEILEN:]
def kernzeile(ende, rueckgabe):
"""Die Zeile, die die Ursache nennt: bei 4 die erste mit "error" ausser der
Gebrauchszeile von argparse, bei 3 die letzte mit INTERNALERROR> (danach
folgt noch die Summenzeile, Treiber-Fall 46), ohne Treffer die letzte."""
if rueckgabe == 4:
for zeile in ende:
if "error" in zeile.lower() and not zeile.startswith("ERROR: usage:"):
return zeile.strip()
if rueckgabe == 3:
interne = [zeile for zeile in ende if zeile.startswith("INTERNALERROR>")]
if interne:
return interne[-1].strip()
return ende[-1].strip() if ende else "(keine Ausgabe)"
def umgebung_abdichten(home_dir, plugin_dir, ergebnis_pfad):
"""Abdichtung nach Entwurf, Abschnitt 5. Siehe Kopfkommentar fuer die Luecken."""
env = os.environ.copy()
@@ -448,10 +657,17 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
ergebnis_datei.close()
ergebnis_pfad = ergebnis_datei.name
env = umgebung_abdichten(home_dir, plugin_dir, ergebnis_pfad)
cmd = [python_exe, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-p", PLUGIN_NAME] + list(knoten_liste)
konfig_argumente = pytest_konfig_argumente(wurzel, knoten_liste, Path(plugin_dir) / LEERE_KONFIG_NAME)
cmd = (
[python_exe, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-p", PLUGIN_NAME]
+ konfig_argumente
+ list(knoten_liste)
)
zeit_ueberschritten = False
absturz = None
abbruch = None
ausgabe = (b"", b"")
proc = None
try:
try:
@@ -465,14 +681,14 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
if proc is not None:
try:
proc.communicate(timeout=zeitlimit)
ausgabe = proc.communicate(timeout=zeitlimit)
except subprocess.TimeoutExpired:
zeit_ueberschritten = True
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
proc.communicate()
ausgabe = proc.communicate()
except BaseException:
# Auch ein SIGTERM (SigtermUnterbrechung) darf keinen pytest-Prozess
# zurueck lassen: Prozessgruppe beenden, dann weiterwerfen, damit die
@@ -487,6 +703,17 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
except Exception:
pass
raise
if not zeit_ueberschritten and proc.returncode in PYTEST_ABBRUCH:
ende = ausgabe_ende(*ausgabe)
abbruch = {
"lang": "{0} (Rueckgabewert {1}) mit {2}; Ende der Ausgabe:\n {3}".format(
PYTEST_ABBRUCH[proc.returncode], proc.returncode, python_exe,
"\n ".join(ende) or "(keine Ausgabe)",
),
"kurz": "{0} (Rueckgabewert {1}): {2}".format(
PYTEST_ABBRUCH[proc.returncode], proc.returncode, kernzeile(ende, proc.returncode),
),
}
meldungen = []
try:
@@ -502,7 +729,7 @@ def pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimi
except OSError:
pass
return zeit_ueberschritten, meldungen, absturz
return zeit_ueberschritten, meldungen, absturz, abbruch
finally:
# Muss auch bei einem SIGTERM mitten im Lauf greifen (N4): sonst bleibt die
# Ergebnisdatei liegen, obwohl der pytest-Prozess schon beendet ist.
@@ -518,12 +745,15 @@ def gruppen_ausfuehren(gruppen, wurzel, home_dir, plugin_dir, zeitlimit):
sammelfehler = {}
zeit_ueberschritten = False
absturzmeldungen = []
abbrueche = {}
for python_exe, knoten_liste in gruppen.items():
zeit, meldungen, absturz = pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimit)
zeit, meldungen, absturz, abbruch = pytest_lauf(python_exe, knoten_liste, wurzel, home_dir, plugin_dir, zeitlimit)
if zeit:
zeit_ueberschritten = True
if absturz:
absturzmeldungen.append(absturz)
if abbruch:
abbrueche[python_exe] = abbruch
for m in meldungen:
art = m.get("art")
if art == "test":
@@ -537,7 +767,7 @@ def gruppen_ausfuehren(gruppen, wurzel, home_dir, plugin_dir, zeitlimit):
sammelfehler[m.get("knoten")] = m.get("meldung", "")
elif art == "abgewaehlt":
deselected.add(m.get("knoten"))
return reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen
return reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche
def ergebnis_bestimmen(knoten, reports, deselected):
@@ -578,17 +808,25 @@ def baseline_pruefen(kontext, ausgewaehlte_mutanten, home_dir, plugin_dir, zeitl
alle_tests.append(knoten)
gruppen = gruppiere_nach_interpreter(alle_tests, kontext["python"], kontext["python_je_datei"])
reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen = gruppen_ausfuehren(
reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche = gruppen_ausfuehren(
gruppen, kontext["wurzel"], home_dir, plugin_dir, zeitlimit,
)
befunde = list(absturzmeldungen)
abgebrochen = set()
for python_exe, knoten_liste in gruppen.items():
if python_exe in abbrueche:
befunde.append("Grundlinie: " + abbrueche[python_exe]["lang"])
abgebrochen.update(knoten_liste)
ergebnis_je_knoten = {}
for knoten in alle_tests:
if zeit_ueberschritten:
status, meldung = "ZEIT", "Zeitlimit in der Grundlinie ueberschritten"
else:
status, meldung = ergebnis_bestimmen(knoten, reports, deselected)
if status == "FEHLT" and knoten in abgebrochen:
ergebnis_je_knoten[knoten] = {"ergebnis": status, "meldung": "pytest abgebrochen, siehe Befunde"}
continue
ergebnis_je_knoten[knoten] = {"ergebnis": status, "meldung": meldung}
if status != "GRUEN":
zusatz = " ({0})".format(meldung) if meldung else ""
@@ -615,7 +853,7 @@ def mutant_anwenden_und_pruefen(mutant, treffer_start, aus_zeilen, wird_zeilen,
knoten_liste = [knoten for _, knoten in mutant["rot_bei"]]
gruppen = gruppiere_nach_interpreter(knoten_liste, standard_python, python_je_datei)
reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen = gruppen_ausfuehren(
reports, deselected, sammelfehler, zeit_ueberschritten, absturzmeldungen, abbrueche = gruppen_ausfuehren(
gruppen, wurzel, home_dir, plugin_dir, zeitlimit,
)
@@ -630,6 +868,7 @@ def mutant_anwenden_und_pruefen(mutant, treffer_start, aus_zeilen, wird_zeilen,
unklar = True
for eintrag, knoten in mutant["rot_bei"]:
ergebnis, meldung = ergebnis_bestimmen(knoten, reports, deselected)
abbruch = None
if ergebnis == "FEHLT":
sammel_meldung = sammelfehler_fuer(knoten, sammelfehler)
if sammel_meldung is not None:
@@ -637,9 +876,15 @@ def mutant_anwenden_und_pruefen(mutant, treffer_start, aus_zeilen, wird_zeilen,
meldung = "Sammelfehler: " + sammel_meldung
else:
unklar = True
abbruch = abbrueche.get(python_je_datei.get(knoten.split("::", 1)[0], standard_python))
if abbruch:
meldung = abbruch["kurz"]
if ergebnis in ("GRUEN", "UEBERSPRUNGEN", "ABGEWAEHLT"):
luecke = True
test_ergebnisse.append({"eintrag": eintrag, "knoten": knoten, "ergebnis": ergebnis, "meldung": meldung})
eintrag_bericht = {"eintrag": eintrag, "knoten": knoten, "ergebnis": ergebnis, "meldung": meldung}
if ergebnis == "FEHLT" and abbruch:
eintrag_bericht["ausgabe"] = abbruch["lang"]
test_ergebnisse.append(eintrag_bericht)
if unklar:
status = "UNKLAR"
@@ -692,6 +937,15 @@ def kopfzeilen_ausgeben(kontext, ausgewaehlte_mutanten):
interpreter_menge.update(kontext["python_je_datei"].values())
for interpreter in sorted(interpreter_menge):
print("python {0}: {1}".format(interpreter, python_version_ermitteln(interpreter)))
alle_knoten = []
for mutant in ausgewaehlte_mutanten:
for _, knoten in mutant["rot_bei"]:
if knoten not in alle_knoten:
alle_knoten.append(knoten)
gruppen = gruppiere_nach_interpreter(alle_knoten, kontext["python"], kontext["python_je_datei"])
for interpreter, knoten_liste in gruppen.items():
print("pytest-konfiguration (Grundlinie, {0}): {1}".format(
interpreter, pytest_konfig_beschreibung(kontext["wurzel"], knoten_liste)))
def zusammenfassung_rechnen(mutant_liste):
@@ -788,6 +1042,7 @@ def ablauf(args, bericht):
aktueller_mutant_id = None
try:
(plugin_dir / (PLUGIN_NAME + ".py")).write_text(PLUGIN_QUELLTEXT, encoding="utf-8")
(plugin_dir / LEERE_KONFIG_NAME).write_text("", encoding="utf-8")
baseline_befunde, baseline_ergebnisse = baseline_pruefen(
kontext, ausgewaehlte_mutanten, home_dir, plugin_dir, args.zeitlimit,
@@ -5,7 +5,7 @@ title: "Entwurf: mutanten_lauf.py — allgemeiner Mutantenläufer aus der Planta
summary: "Entscheidungsliste, Dateiformat mutanten.toml, Ablauf, Status und Exit-Codes, Abdichtung und Angriffsliste für den allgemeinen Mutantenläufer. Punkt 3 der Arbeitsweise vom 2026-09-26."
tags: [vault-tools, codeworkflow, pruefwerkzeuge]
created: 2026-09-27
updated: 2026-09-27
updated: 2026-10-06
---
# Entwurf: `mutanten_lauf.py`
@@ -26,6 +26,7 @@ Das Werkzeug ist ein Fangnetz, kein Nachweis. Es sagt, ob die genannten Tests un
| Läuft das auf einer Wegwerfkopie? | Projektvorgabe: Die Wurzel darf kein `.git` haben (Ordner oder Datei), und keiner der Pfade aus `ohne` darf existieren |
| Welche Tests und Ergebnisse meldet pytest? | Quelle: pytest-Hooks `pytest_runtest_logreport`, `pytest_collectreport`, `pytest_deselected` (pytest 9.1.1 im Venv `trading`, nachgesehen 2026-09-27). Die Knotennamen kommen aus `report.nodeid`, nicht aus einer JUnit-Klassenbezeichnung. |
| Hält die Abdichtung Netz und echte Zugänge fern? | Annahme, aufgelöst als Projektvorgabe: Sie ist ein Fangnetz mit benannten Lücken (Abschnitt 5), kein Nachweis. |
| Welche pytest-Konfiguration gilt? | Quelle: pytest 9.1.1, `_pytest/config/findpaths.py` (`determine_setup`, `locate_config`, `get_common_ancestor`, `load_config_dict_from_file`; SHA-256 `535d149e6f3962e4…`, in den Venvs `trading` und `mattermost` gleich) und `_pytest/config/__init__.py` (Vorgabe von `confcutdir`, `ExitCode`), nachgelesen am 2026-10-06; die Abschnittszeile in `tox.ini` und `setup.cfg` aus iniconfig 2.3.0 (`_parse.py`, `_parseline`) im selben Venv. Projektvorgabe seit 2026-10-06: gesucht wird wie pytest, aber nie oberhalb der Wurzel (Abschnitt 3, „Konfiguration“). |
## 2 Dateiformat `mutanten.toml`, Format 1
@@ -96,14 +97,14 @@ python mutanten_lauf.py --wurzel <kopie> --mutanten <mutanten.toml> [--nur ID,ID
1. **Vorbedingungen und Format.** Alle Befunde sammeln, nicht beim ersten abbrechen: Wurzel fehlt oder ist kein Ordner; `.git` in der Wurzel; ein Pfad aus `ohne` existiert; TOML nicht lesbar; jede Formatregel aus Abschnitt 2; ein Interpreter fehlt oder ist nicht ausführbar; `--nur` nennt eine unbekannte ID. Gibt es einen Befund, endet der Lauf hier mit Exit 2.
2. **Muster.** Für jeden ausgewählten Mutanten: Datei als Bytes lesen, streng als UTF-8 dekodieren (sonst Befund), an `\n` trennen, die Zeilenfolge aus `aus` an jeder Startposition suchen (überlappende Treffer zählen mit). Genau ein Treffer ist gut. Sonst Status `MUSTER` mit der Trefferzahl; enthält die Datei ein `\r`, steht der Hinweis „Datei hat CR“ dabei. `aus` gleich `wird` ist ein Formatbefund aus Schritt 1 („wirkungslos“).
3. **Grundlinie.** Alle genannten Tests der ausgewählten Mutanten ohne Mutant fahren, gruppiert nach Interpreter. Jeder muss `GRUEN` sein. Fehlt einer, ist er abgewählt (etwa durch den Marker `integration`), übersprungen oder rot, ist das ein Befund mit der Meldung, und der Lauf endet mit Exit 2, bevor ein Mutant läuft.
3. **Grundlinie.** Alle genannten Tests der ausgewählten Mutanten ohne Mutant fahren, gruppiert nach Interpreter. Jeder muss `GRUEN` sein. Fehlt einer, ist er abgewählt (etwa durch den Marker `integration`), übersprungen oder rot, ist das ein Befund mit der Meldung, und der Lauf endet mit Exit 2, bevor ein Mutant läuft. Bricht pytest für einen Interpreter ab (Rückgabewert 3 oder 4), steht statt der Zeilen je Test ein Befund für diesen Interpreter (unten, „Abbruch von pytest“).
4. **Mutanten** in der Reihenfolge der Datei, `MUSTER`-Fälle ausgelassen. Je Mutant:
- SHA-256 der Originalbytes merken, die Zeilen ersetzen, mit `\n` zusammenfügen, als UTF-8-Bytes schreiben.
- Die genannten Tests fahren, gruppiert nach Interpreter.
- Im `finally` die Originalbytes zurückschreiben und den SHA-256 neu lesen. Weicht er ab, endet der ganze Lauf sofort mit dem Befund „Rücksetzen gescheitert“ und Exit 2.
5. **Zusammenfassung** und Exit-Code.
**pytest-Aufruf:** `<python> -m pytest -q -p no:cacheprovider -p _mutanten_lauf_plugin <knoten…>` mit `cwd` = Wurzel. Das Plugin ist eine kleine Datei, die das Skript in einen eigenen Temp-Ordner schreibt; der Ordner kommt vorn in `PYTHONPATH`. Das Plugin schreibt je Bericht eine JSON-Zeile in die Datei aus der Umgebungsvariable `MUTANTEN_LAUF_ERGEBNIS`:
**pytest-Aufruf:** `<python> -m pytest -q -p no:cacheprovider -p _mutanten_lauf_plugin <konfiguration…> <knoten…>` mit `cwd` = Wurzel. Das Plugin ist eine kleine Datei, die das Skript in einen eigenen Temp-Ordner schreibt; der Ordner kommt vorn in `PYTHONPATH`. Das Plugin schreibt je Bericht eine JSON-Zeile in die Datei aus der Umgebungsvariable `MUTANTEN_LAUF_ERGEBNIS`:
- `pytest_runtest_logreport`: `{"art": "test", "knoten": nodeid, "phase": when, "ergebnis": outcome, "xfail": hat wasxfail, "meldung": erste Zeile}`
- `pytest_collectreport` nur bei `failed`: `{"art": "sammeln", "knoten": nodeid, "meldung": erste Zeile}`
@@ -111,7 +112,19 @@ python mutanten_lauf.py --wurzel <kopie> --mutanten <mutanten.toml> [--nur ID,ID
Die Meldung ist `longrepr.reprcrash.message`, wenn es das gibt, sonst die erste nicht leere Zeile von `str(longrepr)`, gekürzt auf 300 Zeichen. Das Plugin schreibt jede Zeile sofort und mit `flush`.
**Auswertung je Test:** eine Phase `failed` → `ROT` (bei `xfail` nicht); sonst `skipped` in einer Phase oder `xfail` → `UEBERSPRUNGEN`; sonst `call` mit `passed` → `GRUEN`; abgewählt → `ABGEWAEHLT`; gar kein Bericht → `FEHLT`. Unter einem Mutanten zählt `FEHLT` als `ROT` mit der Meldung „Sammelfehler: …“, wenn es für seine Testdatei (oder einen übergeordneten Knoten) einen `sammeln`-Bericht gibt. In der Grundlinie ist jedes `FEHLT` ein Befund.
**Konfiguration (seit 2026-10-06):** Ohne Vorgabe sucht pytest seine Konfiguration vom gemeinsamen Vorfahren der Testpfade aufwärts bis zur Wurzel des Dateisystems. Liegt die Kopie unter dem Windows-Temp, fand pytest dort eine fremde `pyproject.toml` mit `addopts = "--new-first --failed-first"`; die Optionen brauchen den Cache, den `-p no:cacheprovider` abschaltet, pytest brach mit Rückgabewert 4 ab, und der Läufer meldete jeden Test der Grundlinie als `FEHLT` (Plankritik 2 von C1 im Projekt After Install, 2026-10-05). Seitdem bestimmt der Läufer je Aufruf, was pytest selbst wählen würde, sucht aber nie oberhalb der Wurzel:
- Die Wurzel wird zuerst mit `realpath` aufgelöst. pytest rechnet vom Arbeitsordner aus, den der Kernel ohne Verknüpfungen meldet; über einen Symlink angegeben, wurden die Knotennamen sonst zu `::test_…` (Prüfung vom 2026-10-06, Treiber-Fall 41).
- Reihenfolge der Namen und Bedingungen wie in pytest 9.1.1: `pytest.toml`, `.pytest.toml`, `pytest.ini`, `.pytest.ini` immer; `pyproject.toml` mit `[tool.pytest]` oder `[tool.pytest.ini_options]`; `tox.ini` mit `[pytest]`; `setup.cfg` mit `[tool:pytest]`; ohne Treffer die erste `pyproject.toml` überhaupt. Erst vom gemeinsamen Vorfahren aus, ohne Treffer und ohne `setup.py` dazwischen noch einmal von jedem Testordner aus. Die Abschnittszeile in `tox.ini` und `setup.cfg` erkennt der Läufer wie `_parseline` in iniconfig 2.3.0: `[` in Spalte 0, Kommentare nach `#` und `;` entfernt, dann `]` am Ende. Ein Pfad, dessen Name zu lang ist, gilt wie bei `safe_exists` in pytest als nicht vorhanden.
- Gefunden: `-c <datei>`. pytest setzt `rootdir` und `confcutdir` dann selbst auf ihren Ordner, wie bei der eigenen Suche.
- Nicht gefunden: `-c <leere pytest.ini im Plugin-Ordner> --rootdir <r> --confcutdir <r>`, `r` ist der Ordner mit `setup.py` oder sonst die Wurzel. Ohne `--confcutdir` läge `confcutdir` im Plugin-Ordner, und pytest lüde `conftest.py`-Dateien oberhalb der Wurzel.
- Die Kopfzeilen `pytest-konfiguration (Grundlinie, <python>): …` nennen je Interpreter, was für seinen Aufruf in der Grundlinie gilt.
- Gegen pytest selbst geprüft wird das mit `mutanten_lauf_probe_konfig.py` (Treiber-Fall 43): 26 Aufbauten, je `pytest_konfig_bestimmen` gegen `determine_setup` und ein echtes `pytest --co` ohne `-c` gegen einen Lauf mit den Argumenten des Läufers unter fremder Konfiguration. Die Probe sichert ihre Voraussetzung mit: Ohne `-c` endet pytest mit 0 (außer bei fehlenden Knoten), und wo der Aufbau ein `conftest.py` hat, ist eines geladen.
- Benannte Lücken: Eine `tox.ini` oder ein `setup.cfg`, die iniconfig nicht lesen kann, übergeht der Läufer, pytest bricht dort mit 4 ab; eine nicht lesbare `pyproject.toml` oder `pytest.toml` gilt dagegen als Treffer, damit pytest den Fehler selbst meldet. Ein `setup.cfg` mit `[pytest]`, bei pytest ein Fehler, gilt hier als keine Konfiguration. Ohne Konfiguration in der Wurzel ist `config.inipath` die leere Datei; ini-Werte vom Typ `paths`, die über `-o` oder `PYTEST_ADDOPTS` kommen, gelten dann relativ zum Plugin-Ordner. Liegt `rootdir` unter der Wurzel (Konfiguration oder `setup.py` in einem Unterordner der Tests), bildet pytest die Knotennamen relativ dazu, und `rot_bei` passt nicht mehr: Die Grundlinie meldet `FEHLT`, die Kopfzeile nennt die gefundene Datei; heute betrifft das kein Projekt im Vault. Zeilen trennt der Läufer wie iniconfig mit `splitlines`, also auch an Seitenvorschub und Unicode-Zeilentrennern.
**Abbruch von pytest (seit 2026-10-06):** Endet ein pytest-Aufruf mit Rückgabewert 3 (`INTERNAL_ERROR`) oder 4 (`USAGE_ERROR`, auch bei einem Testknoten, den es nicht gibt), hält der Läufer die letzten 20 nicht leeren Zeilen von stdout und stderr fest. In der Grundlinie wird daraus je Interpreter ein Befund `Grundlinie: pytest-Bedienfehler (Rueckgabewert 4) mit <python>; Ende der Ausgabe: …` (bei 3: `pytest-Abbruch mit internem Fehler`) statt einer `FEHLT`-Zeile je Test; der Bericht führt die Tests mit `FEHLT` und der Meldung „pytest abgebrochen, siehe Befunde“. Unter einem Mutanten tragen die Tests ohne Bericht und ohne Sammelfehler eine kurze Fassung als Meldung, die Ursache vorn, damit sie in die 120 Zeichen der Mutantenzeile passt: `pytest-Bedienfehler (Rueckgabewert 4): <zeile>`. Die Zeile ist bei 4 die erste mit „error“ außer der Gebrauchszeile `ERROR: usage:` von argparse, bei 3 die letzte mit `INTERNALERROR>` (danach steht noch die Summenzeile `no tests ran …`, Treiber-Fall 46), ohne Treffer die letzte überhaupt. Die lange Fassung steht im Bericht beim Test unter `ausgabe`. Diese Tests machen den Mutanten wie bisher `UNKLAR`. Ein Sammelfehler bleibt `ROT`, auch wenn pytest dabei mit 4 endet: Ein Knoten in einer Datei, die sich nicht sammeln lässt, ergibt zusätzlich `found no collectors for …` (Treiber-Fall 11, am 2026-10-06 an einer ersten Fassung gesehen, die jeden Abbruch als `UNKLAR` zählte).
**Auswertung je Test:** eine Phase `failed` → `ROT` (bei `xfail` nicht); sonst `skipped` in einer Phase oder `xfail` → `UEBERSPRUNGEN`; sonst `call` mit `passed` → `GRUEN`; abgewählt → `ABGEWAEHLT`; gar kein Bericht → `FEHLT`. Unter einem Mutanten zählt `FEHLT` als `ROT` mit der Meldung „Sammelfehler: …“, wenn es für seine Testdatei (oder einen übergeordneten Knoten) einen `sammeln`-Bericht gibt. In der Grundlinie ist jedes `FEHLT` ein Befund, außer pytest ist für diesen Interpreter abgebrochen; dann steht der eine Befund aus „Abbruch von pytest“.
**Zeitlimit:** Prozess mit `start_new_session=True` starten; bei Überschreitung die ganze Prozessgruppe mit `SIGKILL` beenden. Der Mutant bekommt `ZEIT`, das Rücksetzen läuft trotzdem.
@@ -131,7 +144,7 @@ Die Meldung ist `longrepr.reprcrash.message`, wenn es das gibt, sonst die erste
Ausgabe auf stdout, UTF-8, je Mutant eine Zeile: `D1 OK DA::test_x=ROT(<meldung, 120 Zeichen>) …`. Tests erscheinen in der Kurzform aus `rot_bei`. Ein vorhandener `hinweis` folgt in der nächsten Zeile, eingerückt. Oben stehen Wurzel, Mutantendatei, Anzahl gesamt und ausgewählt und je Interpreter die Python-Version. Unten eine gerechnete Zusammenfassung: `ergebnis: <n> Mutanten, <a> OK, <b> LUECKE, <c> UNKLAR, <d> ZEIT, <e> MUSTER`.
`--bericht` schreibt dasselbe als JSON: `{"wurzel", "mutanten_datei", "befunde": [...], "grundlinie": {knoten: {ergebnis, meldung}}, "mutanten": [{"id", "datei", "status", "hinweis", "treffer", "tests": [{"eintrag", "knoten", "ergebnis", "meldung"}]}], "zusammenfassung": {...}}`. Der Bericht wird auch bei einem Abbruch geschrieben, mit allem, was bis dahin feststand.
`--bericht` schreibt dasselbe als JSON: `{"wurzel", "mutanten_datei", "befunde": [...], "grundlinie": {knoten: {ergebnis, meldung}}, "mutanten": [{"id", "datei", "status", "hinweis", "treffer", "tests": [{"eintrag", "knoten", "ergebnis", "meldung", "ausgabe" (nur bei einem Abbruch von pytest)}]}], "zusammenfassung": {...}}`. Der Bericht wird auch bei einem Abbruch geschrieben, mit allem, was bis dahin feststand.
| Exit | Bedeutung |
|---|---|
@@ -158,7 +171,7 @@ Alle Temp-Ordner liegen außerhalb der Wurzel und werden am Ende entfernt, auch
## 6 Angriffsliste für den Treiber `mutanten_lauf_treiber.py`
Der Treiber baut in einem Temp-Ordner ein kleines Projekt (ein Modul, eine Testdatei, `pyproject.toml` mit `[tool.pytest.ini_options]`), schreibt je Fall eine `mutanten.toml`, startet den Läufer als Unterprozess und prüft Exit-Code und ein Meldungsstück. **Zuerst die Grundlinie:** Der saubere Fall muss Exit 0 und nur `OK` liefern, sonst misst der Rest nichts. Nach **jedem** Fall prüft der Treiber, dass jede Datei des kleinen Projekts byte-gleich mit dem Ausgangsstand ist.
Der Treiber baut in einem Temp-Ordner ein kleines Projekt (ein Modul, eine Testdatei, `pyproject.toml` mit `[tool.pytest.ini_options]`; für die Fälle 36 bis 42 je ein eigenes als Unterordner `k/` eines Rahmen-Ordners), schreibt je Fall eine `mutanten.toml`, startet den Läufer als Unterprozess und prüft Exit-Code und ein Meldungsstück. **Zuerst die Grundlinie:** Der saubere Fall muss Exit 0 und nur `OK` liefern, sonst misst der Rest nichts. Nach **jedem** Fall prüft der Treiber, dass jede Datei des kleinen Projekts byte-gleich mit dem Ausgangsstand ist.
| Nr | Fall | erwartet |
|---|---|---|
@@ -197,6 +210,17 @@ Der Treiber baut in einem Temp-Ordner ein kleines Projekt (ein Modul, eine Testd
| 33 | `--bericht` schreibt gültiges JSON mit den Status aus Fall 2 | Datei lesbar, Status stimmt |
| 34 | `python_je_datei` setzt für eine Testdatei einen zweiten Interpreter (derselbe Pfad über einen anderen Namen, etwa eine Verknüpfung im Temp) | Exit 0 |
| 35 | ein Mutant hängt, der Läufer bekommt nach drei Sekunden SIGTERM (eigenes `TMPDIR`), seit 2026-09-28 | Zeile `ABBRUCH: SIGTERM`, kein Prozess mit `HOME` im Fall-Tmpdir, keine `mutanten_lauf_*`-Reste, Zieldatei byte-gleich |
| 36 | Wurzel ohne eigene Konfiguration, darüber eine fremde `pyproject.toml` mit `addopts = "--new-first --failed-first"` (der Fall der Plankritik 2 von C1), seit 2026-10-06 | Exit 0, zweimal `OK`, Kopfzeile „keine in der Wurzel, leere Datei“ |
| 37 | Wurzel mit eigener `pyproject.toml` (Marker `integration` abgewählt), darüber dieselbe fremde | sauberer Fall Exit 0 mit Kopfzeile `k/pyproject.toml (aus der Wurzel)`; Test mit Marker in der Grundlinie Exit 2 und `ABGEWAEHLT`, die eigene Konfiguration gilt also |
| 38 | eigene Konfiguration mit `addopts = "--gibt-es-nicht"` | Exit 2, `pytest-Bedienfehler (Rueckgabewert 4)` und `--gibt-es-nicht` aus der pytest-Ausgabe, keine Zeile `ist FEHLT` |
| 39 | Wurzel ohne eigene Konfiguration, darüber ein `conftest.py`, das beim Laden abbricht | Exit 0, zweimal `OK`; prüft `--confcutdir` |
| 40 | ein Mutant schreibt in die eigene `pyproject.toml` `addopts = "--gibt-es-nicht"` | Exit 1; die Mutantenzeile selbst trägt `UNKLAR`, `pytest-Bedienfehler (Rueckgabewert 4)` und `--gibt-es-nicht`; im Bericht steht beim Test `ausgabe` mit „Ende der Ausgabe:“ |
| 41 | Wurzel über einen Symlink angegeben (`link/k` → `echt/k`), seit der Nachbesserung vom 2026-10-06 | Exit 0, zweimal `OK` |
| 42 | ein `conftest.py` der Wurzel wirft in `pytest_collection_modifyitems` | Exit 2, `pytest-Abbruch mit internem Fehler (Rueckgabewert 3)` und der Text der Ausnahme, keine Zeile `ist FEHLT` |
| 43 | `mutanten_lauf_probe_konfig.py`: 26 Aufbauten gegen `determine_setup` und gegen echte `pytest --co`-Läufe, mit Selbstprüfung der Voraussetzung | Exit 0, `abweichungen: 0` |
| 44 | zwei Interpretergruppen (`python_je_datei`), nur in einer ein Knoten, den es nicht gibt; diese Gruppe einmal an erster, einmal an letzter Stelle | je Exit 2, genau ein Befund mit dem Interpreter dieser Gruppe, keiner für die andere, keine Zeile `ist FEHLT`; genau zwei Kopfzeilen, je eine je Interpreter |
| 45 | ein Knoten mit einem Dateinamen über 255 Zeichen | Exit 2 mit einem Befund `Grundlinie: pytest-…`, kein `unerwarteter Abbruch` |
| 46 | ein Mutant lässt ein `conftest.py` der Wurzel in `pytest_collection_modifyitems` werfen | Exit 1, die Mutantenzeile trägt `UNKLAR`, `pytest-Abbruch mit internem Fehler (Rueckgabewert 3): ` und den Text der Ausnahme; im Bericht `ausgabe` mit „Ende der Ausgabe:“ |
Der Treiber gibt je Fall eine Zeile aus (`Fall 5 ok` oder `Fall 5 FEHLER: erwartet …, bekommen …`) und am Ende die gerechnete Zahl der Fehler. Exit 0 nur, wenn alle Fälle wie erwartet ausgehen und jedes Mal byte-gleich zurückgesetzt wurde.
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Gleichwertigkeitsprobe fuer die Konfigurationssuche von mutanten_lauf.py.
Zweck: Je Aufbau zwei Vergleiche. Erstens pytest_konfig_bestimmen aus
mutanten_lauf.py gegen determine_setup aus pytest selbst (Konfigurationsdatei
und rootdir). Zweitens ein echtes "pytest --co" ohne -c in einem sauberen
Rahmen gegen einen Lauf mit den Argumenten des Laeufers in einem Rahmen, ueber
dem eine fremde pyproject.toml, ein fremdes conftest.py und ein fremdes
setup.py liegen. Verglichen werden Rueckgabewert, Knotennamen und die Liste
der geladenen conftest.py-Dateien.
Aufruf (in WSL, mit dem Python eines Venvs, in dem pytest liegt):
python mutanten_lauf_probe_konfig.py <mutanten_lauf.py> <arbeitsordner>
Exit 0 ohne Abweichung, 1 mit Abweichung, 2 bei falschem Aufruf.
Geltungsbereich: ein Fangnetz fuer die Aufbauten unten, kein Nachweis fuer
jeden denkbaren Baum. Ein Knoten mit ".." fehlt, weil mutanten_lauf.py ihn
schon beim Lesen der mutanten.toml abweist. Eine Wurzel ueber einen Symlink
prueft Treiber-Fall 41.
Die Probe prueft ihre eigene Voraussetzung mit: Ohne -c im sauberen Rahmen
muss pytest mit 0 enden (ausser bei den Aufbauten knoten_fehl*), und wo der
Aufbau ein conftest.py hat, muss eines geladen sein. Sonst endeten beide
Laeufe gleich kaputt, und "gleich" hiesse nichts.
Grundlage: Probe des Pruefers vom 2026-10-06 (25 Aufbauten). Das conftest.py
der Probe schreibt den Zeilenumbruch hier mit chr(10), damit kein Backslash
durch die Zeichenkette muss.
"""
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
TEST = "def test_a():\n pass\n\n\ndef test_b():\n pass\n"
CONFTEST = "import os\nopen(os.environ['CFLOG'], 'a').write(__file__ + chr(10))\n"
INI = "[pytest]\n"
AUFBAUTEN = {
"wurzel_pytest_ini": ({"pytest.ini": INI, "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"unterordner_ini": (
{"tests/pytest.ini": INI, "tests/test_a.py": TEST, "conftest.py": CONFTEST, "tests/conftest.py": CONFTEST},
["tests/test_a.py::test_a"],
),
"zwei_ordner_ini_im_ersten": (
{"tests/a/pytest.ini": INI, "tests/a/test_a.py": TEST, "tests/b/test_b.py": TEST},
["tests/a/test_a.py::test_a", "tests/b/test_b.py::test_b"],
),
"zwei_ordner_ini_im_zweiten": (
{"tests/b/pytest.ini": INI, "tests/a/test_a.py": TEST, "tests/b/test_b.py": TEST},
["tests/a/test_a.py::test_a", "tests/b/test_b.py::test_b"],
),
"setup_py_wurzel": ({"setup.py": "", "tests/test_a.py": TEST, "conftest.py": CONFTEST}, ["tests/test_a.py::test_a"]),
"setup_py_unterordner": (
{"sub/setup.py": "", "sub/tests/test_a.py": TEST, "conftest.py": CONFTEST, "sub/conftest.py": CONFTEST},
["sub/tests/test_a.py::test_a"],
),
"setup_py_neben_ini": (
{"tests/a/setup.py": "", "tests/b/pytest.ini": INI, "tests/a/test_a.py": TEST, "tests/b/test_b.py": TEST},
["tests/a/test_a.py::test_a", "tests/b/test_b.py::test_b"],
),
"pyproject_ohne_abschnitt": (
{"pyproject.toml": "[project]\nname = 'x'\n", "tests/test_a.py": TEST, "conftest.py": CONFTEST},
["tests/test_a.py::test_a"],
),
"pyproject_ohne_unten_ini_oben": (
{"tests/pyproject.toml": "[project]\nname = 'x'\n", "pytest.ini": INI, "tests/test_a.py": TEST},
["tests/test_a.py::test_a"],
),
"pyproject_ohne_oben_ini_unten": (
{"pyproject.toml": "[project]\nname = 'x'\n", "tests/pytest.ini": INI, "tests/test_a.py": TEST},
["tests/test_a.py::test_a"],
),
"pyproject_ini_options_leer": ({"pyproject.toml": "[tool.pytest.ini_options]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"pyproject_tool_pytest_leer": ({"pyproject.toml": "[tool.pytest]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"pyproject_toml_modus": ({"pyproject.toml": "[tool.pytest]\nminversion = '8'\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"tox_mit_abschnitt": ({"tox.ini": "[tox]\n[pytest]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"tox_ohne_abschnitt": ({"tox.ini": "[tox]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"tox_abschnitt_mit_kommentar": ({"tox.ini": "[pytest] # k\nmarkers = x\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"tox_abschnitt_mit_semikolon": ({"tox.ini": "[pytest] ; k\nmarkers = x\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"tox_seitenvorschub_vor_abschnitt": ({"tox.ini": "[tox]" + chr(12) + "[pytest]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"setup_cfg_tool_pytest": ({"setup.cfg": "[tool:pytest]\n", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"pytest_toml_leer": ({"pytest.toml": "", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"punkt_pytest_ini_unterordner": ({"tests/.pytest.ini": "", "tests/test_a.py": TEST}, ["tests/test_a.py::test_a"]),
"knoten_fehlt_teilweise": (
{"tests/pytest.ini": INI, "tests/test_a.py": TEST},
["tests/test_a.py::test_a", "gibtsnicht/test_x.py::test_a"],
),
"knoten_fehlen_alle": ({"tests/pytest.ini": INI, "tests/test_a.py": TEST}, ["gibtsnicht/test_x.py::test_a"]),
"ordner_als_knoten": ({"tests/pytest.ini": INI, "tests/test_a.py": TEST}, ["tests"]),
"testdatei_in_der_wurzel": ({"test_a.py": TEST, "conftest.py": CONFTEST}, ["test_a.py::test_a"]),
"ohne_konfiguration": (
{"tests/test_a.py": TEST, "conftest.py": CONFTEST, "tests/conftest.py": CONFTEST},
["tests/test_a.py::test_a"],
),
}
def baue(rahmen, dateien, fremd):
wurzel = rahmen / "k"
for rel, inhalt in dateien.items():
pfad = wurzel / rel
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_text(inhalt, encoding="utf-8")
if fremd:
(rahmen / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '--new-first'\n", encoding="utf-8")
(rahmen / "conftest.py").write_text(CONFTEST, encoding="utf-8")
(rahmen / "setup.py").write_text("", encoding="utf-8")
return wurzel
def sammeln(wurzel, zusatz, knoten, cflog):
umgebung = dict(os.environ, CFLOG=str(cflog), PYTHONDONTWRITEBYTECODE="1")
lauf = subprocess.run(
[sys.executable, "-m", "pytest", "--co", "-q", "-p", "no:cacheprovider"] + zusatz + knoten,
cwd=str(wurzel), env=umgebung, capture_output=True, text=True, timeout=120,
)
knotennamen = sorted(zeile for zeile in lauf.stdout.splitlines() if "::" in zeile)
geladen = []
if cflog.exists():
geladen = sorted(os.path.relpath(z, str(wurzel)) for z in cflog.read_text(encoding="utf-8").split("\n") if z)
cflog.unlink()
return lauf.returncode, knotennamen, geladen
def main():
if len(sys.argv) != 3:
print("Aufruf: mutanten_lauf_probe_konfig.py <mutanten_lauf.py> <arbeitsordner>")
return 2
spec = importlib.util.spec_from_file_location("mutanten_lauf_unter_probe", sys.argv[1])
laeufer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(laeufer)
from _pytest.config.findpaths import determine_setup
arbeit = Path(sys.argv[2])
leere_konfig = arbeit / "plugin" / "pytest.ini"
leere_konfig.parent.mkdir(parents=True, exist_ok=True)
leere_konfig.write_text("", encoding="utf-8")
cflog = arbeit / "cflog"
abweichungen = 0
for name, (dateien, knoten) in AUFBAUTEN.items():
sauber = baue(arbeit / name / "sauber", dateien, False)
fremd = baue(arbeit / name / "fremd", dateien, True)
vorher = os.getcwd()
os.chdir(sauber)
try:
rootdir, inipath, _, _ = determine_setup(
inifile=None, override_ini=None, args=knoten, rootdir_cmd_arg=None, invocation_dir=sauber,
)
erwartet = (str(inipath) if inipath else None, str(rootdir))
except Exception as exc:
erwartet = ("Ausnahme " + type(exc).__name__, None)
finally:
os.chdir(vorher)
konfig, rootdir_l = laeufer.pytest_konfig_bestimmen(sauber, knoten)
bekommen = (str(konfig) if konfig else None, str(rootdir_l))
ohne_c = sammeln(sauber, [], knoten, cflog)
mit_c = sammeln(fremd, laeufer.pytest_konfig_argumente(fremd, knoten, leere_konfig), knoten, cflog)
gleich_suche = erwartet == bekommen
gleich_lauf = ohne_c == mit_c
voraussetzung = name.startswith("knoten_fehl") or ohne_c[0] == 0
if any(rel.endswith("conftest.py") for rel in dateien) and not ohne_c[2]:
voraussetzung = False
if not (gleich_suche and gleich_lauf and voraussetzung):
abweichungen += 1
print("{0:34s} suche={1} lauf={2} voraussetzung={3}".format(
name, "gleich" if gleich_suche else "ABWEICHUNG", "gleich" if gleich_lauf else "ABWEICHUNG",
"ok" if voraussetzung else "VERLETZT"))
if not gleich_suche:
print(" pytest: {0}".format(erwartet))
print(" laeufer: {0}".format(bekommen))
if not gleich_lauf:
print(" ohne -c, sauber: {0}".format(ohne_c))
print(" laeufer, fremd: {0}".format(mit_c))
print("aufbauten: {0}, abweichungen: {1}".format(len(AUFBAUTEN), abweichungen))
return 0 if abweichungen == 0 else 1
if __name__ == "__main__":
sys.exit(main())
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Treiber fuer mutanten_lauf.py: 35 Angriffsfaelle auf einer Wegwerfkopie.
"""Treiber fuer mutanten_lauf.py: 46 Angriffsfaelle auf einer Wegwerfkopie.
Zweck: Baut in einem Temp-Ordner unter /tmp ein kleines Testprojekt, fuehrt
mutanten_lauf.py je Fall mit einer eigenen mutanten.toml aus und vergleicht
@@ -16,9 +16,23 @@ Fall 35 (Nachbesserung 2026-09-28, N11) prueft das Aufraeumen bei SIGTERM: Ein
Mutant haengt, der Laeufer bekommt nach wenigen Sekunden selbst ein SIGTERM.
Damit die Pruefung nur die Reste dieses einen Falls sieht, bekommt der Laeufer
dafuer ein eigenes TMPDIR (tempfile respektiert die Umgebungsvariable).
Faelle 36 bis 40 (2026-10-06) pruefen die pytest-Konfiguration: Die Wurzel
liegt dafuer als k/ in einem eigenen Rahmen-Ordner, darueber wahlweise eine
fremde pyproject.toml mit addopts, die zu -p no:cacheprovider nicht passen
(wie im Windows-Temp bei der Plankritik 2 von C1), oder ein fremdes conftest.py.
Dazu ein Bedienfehler von pytest in der Grundlinie und unter einem Mutanten.
Faelle 41 bis 45 (Nachbesserung nach der Pruefung vom 2026-10-06): Wurzel ueber
einen Symlink, Rueckgabewert 3, die Gleichwertigkeitsprobe gegen pytest selbst
(mutanten_lauf_probe_konfig.py), ein Abbruch in nur einer von zwei
Interpretergruppen an erster und an letzter Stelle, ein ueberlanger Knoten.
Fall 46 (Nachbesserung 2): Rueckgabewert 3 unter einem Mutanten, Kurzfassung in
der Mutantenzeile und lange Fassung im Bericht.
"""
import hashlib
import json
import os
import shutil
import signal
@@ -30,6 +44,7 @@ from pathlib import Path
HIER = Path(__file__).resolve().parent
LAEUFER = HIER / "mutanten_lauf.py"
PROBE = HIER / "mutanten_lauf_probe_konfig.py"
INTERPRETER = "/home/claude/venvs/trading/bin/python"
FEHLERLISTE = []
@@ -1010,11 +1025,301 @@ def fall_35(basis, projektpfad):
shutil.rmtree(eigenes_tmpdir, ignore_errors=True)
# --- Faelle 36 bis 40: pytest-Konfiguration ---------------------------------
FREMDE_KONFIG = (
"[tool.pytest.ini_options]\n"
"addopts = \"--new-first --failed-first\"\n"
)
FREMDES_CONFTEST = "raise RuntimeError(\"fremdes conftest oberhalb der Wurzel geladen\")\n"
def baue_rahmen(eigene_konfig, fremde_konfig, fremdes_conftest):
rahmen = Path(tempfile.mkdtemp(prefix="mutanten_lauf_treiber_rahmen_"))
if fremde_konfig:
(rahmen / "pyproject.toml").write_text(FREMDE_KONFIG, encoding="utf-8")
if fremdes_conftest:
(rahmen / "conftest.py").write_text(FREMDES_CONFTEST, encoding="utf-8")
wurzel = rahmen / "k"
dateien = baue_projekt(wurzel)
if not eigene_konfig:
(wurzel / "pyproject.toml").unlink()
dateien = [p for p in dateien if p != wurzel / "pyproject.toml"]
return rahmen, wurzel, dateien
def fall_36(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=False, fremde_konfig=True, fremdes_conftest=False)
try:
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, mutant_d1_d2())
ergebnis = starte_laeufer(wurzel, toml)
ok = (
ergebnis.returncode == 0
and "D1 OK" in ergebnis.stdout
and "D2 OK" in ergebnis.stdout
and "keine in der Wurzel, leere Datei" in ergebnis.stdout
)
melde("Fall 36", ok, "exit={0}\n{1}{2}".format(ergebnis.returncode, ergebnis.stdout, ergebnis.stderr))
pruefe_unveraendert(dateien, snap, "Fall 36")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
def fall_37(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=True, fremde_konfig=True, fremdes_conftest=False)
try:
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, mutant_d1_d2())
sauber = starte_laeufer(wurzel, toml)
schreibe_toml(toml, kopf() + mutant_luecke("M37", ["tests/test_faelle.py::test_wird_abgewaehlt"]))
abgewaehlt = starte_laeufer(wurzel, toml)
ausgabe = abgewaehlt.stdout + abgewaehlt.stderr
ok = (
sauber.returncode == 0
and "D1 OK" in sauber.stdout
and "D2 OK" in sauber.stdout
and "k/pyproject.toml (aus der Wurzel)" in sauber.stdout
and abgewaehlt.returncode == 2
and "ABGEWAEHLT" in ausgabe
)
melde("Fall 37", ok, "exit={0}/{1}\n{2}{3}\n{4}".format(
sauber.returncode, abgewaehlt.returncode, sauber.stdout, sauber.stderr, ausgabe))
pruefe_unveraendert(dateien, snap, "Fall 37")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
def fall_38(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=True, fremde_konfig=False, fremdes_conftest=False)
try:
(wurzel / "pyproject.toml").write_text(
"[tool.pytest.ini_options]\n"
"addopts = \"--gibt-es-nicht\"\n",
encoding="utf-8",
)
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, mutant_d1_d2())
ergebnis = starte_laeufer(wurzel, toml)
ausgabe = ergebnis.stdout + ergebnis.stderr
ok = (
ergebnis.returncode == 2
and "pytest-Bedienfehler (Rueckgabewert 4)" in ausgabe
and "--gibt-es-nicht" in ausgabe
and "ist FEHLT" not in ausgabe
)
melde("Fall 38", ok, "exit={0}\n{1}".format(ergebnis.returncode, ausgabe))
pruefe_unveraendert(dateien, snap, "Fall 38")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
def fall_39(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=False, fremde_konfig=False, fremdes_conftest=True)
try:
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, mutant_d1_d2())
ergebnis = starte_laeufer(wurzel, toml)
ok = ergebnis.returncode == 0 and "D1 OK" in ergebnis.stdout and "D2 OK" in ergebnis.stdout
melde("Fall 39", ok, "exit={0}\n{1}{2}".format(ergebnis.returncode, ergebnis.stdout, ergebnis.stderr))
pruefe_unveraendert(dateien, snap, "Fall 39")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
def fall_40(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=True, fremde_konfig=False, fremdes_conftest=False)
try:
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, kopf() + (
"[[mutant]]\n"
"id = \"M40\"\n"
"datei = \"pyproject.toml\"\n"
"aus = '''\n"
"addopts = '-m \"not integration\"'\n"
"'''\n"
"wird = '''\n"
"addopts = \"--gibt-es-nicht\"\n"
"'''\n"
"rot_bei = [\"tests/test_modul.py::test_f1_positiv\"]\n"
))
bericht = rahmen / "bericht40.json"
ergebnis = starte_laeufer(wurzel, toml, bericht_pfad=bericht)
mutantenzeile = [z for z in ergebnis.stdout.split("\n") if z.startswith("M40 ")]
try:
ausgabe_bericht = json.loads(bericht.read_text(encoding="utf-8"))["mutanten"][0]["tests"][0].get("ausgabe", "")
except (OSError, ValueError, KeyError, IndexError):
ausgabe_bericht = ""
ok = (
ergebnis.returncode == 1
and len(mutantenzeile) == 1
and "M40 UNKLAR" in mutantenzeile[0]
and "pytest-Bedienfehler (Rueckgabewert 4)" in mutantenzeile[0]
and "--gibt-es-nicht" in mutantenzeile[0]
and "Ende der Ausgabe:" in ausgabe_bericht
)
melde("Fall 40", ok, "exit={0}\n{1}{2}".format(ergebnis.returncode, ergebnis.stdout, ergebnis.stderr))
pruefe_unveraendert(dateien, snap, "Fall 40")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
def fall_41(basis, projektpfad):
rahmen = Path(tempfile.mkdtemp(prefix="mutanten_lauf_treiber_symlink_"))
try:
dateien = baue_projekt(rahmen / "echt" / "k")
os.symlink(rahmen / "echt", rahmen / "link")
wurzel = rahmen / "link" / "k"
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, mutant_d1_d2())
ergebnis = starte_laeufer(wurzel, toml)
ok = ergebnis.returncode == 0 and "D1 OK" in ergebnis.stdout and "D2 OK" in ergebnis.stdout
melde("Fall 41", ok, "exit={0}\n{1}{2}".format(ergebnis.returncode, ergebnis.stdout, ergebnis.stderr))
pruefe_unveraendert(dateien, snap, "Fall 41")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
def fall_42(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=True, fremde_konfig=False, fremdes_conftest=False)
try:
(wurzel / "conftest.py").write_text(
"def pytest_collection_modifyitems(items):\n"
" raise RuntimeError(\"interner Fehler fuer Fall 42\")\n",
encoding="utf-8",
)
dateien = dateien + [wurzel / "conftest.py"]
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, mutant_d1_d2())
ergebnis = starte_laeufer(wurzel, toml)
ausgabe = ergebnis.stdout + ergebnis.stderr
ok = (
ergebnis.returncode == 2
and "pytest-Abbruch mit internem Fehler (Rueckgabewert 3)" in ausgabe
and "interner Fehler fuer Fall 42" in ausgabe
and "ist FEHLT" not in ausgabe
)
melde("Fall 42", ok, "exit={0}\n{1}".format(ergebnis.returncode, ausgabe))
pruefe_unveraendert(dateien, snap, "Fall 42")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
def fall_43(basis, projektpfad):
arbeit = Path(tempfile.mkdtemp(prefix="mutanten_lauf_treiber_probe_"))
try:
ergebnis = subprocess.run(
[INTERPRETER, str(PROBE), str(LAEUFER), str(arbeit)],
capture_output=True, text=True, timeout=900,
)
ok = ergebnis.returncode == 0 and ", abweichungen: 0" in ergebnis.stdout
melde("Fall 43", ok, "exit={0}\n{1}{2}".format(ergebnis.returncode, ergebnis.stdout, ergebnis.stderr))
finally:
shutil.rmtree(arbeit, ignore_errors=True)
def fall_44(basis, projektpfad):
zweit_dir = Path(tempfile.mkdtemp(prefix="mutanten_lauf_fall44_"))
venv_link = zweit_dir / "venv_zwei"
zweit_python = venv_link / "bin" / "python"
try:
os.symlink(Path(INTERPRETER).parent.parent, venv_link)
toml = projektpfad / "mutanten.toml"
zusatz = "\n[python_je_datei]\n\"tests/test_zeit.py\" = \"{0}\"\n".format(zweit_python)
befund_zwei = "Grundlinie: pytest-Bedienfehler (Rueckgabewert 4) mit {0};".format(zweit_python)
befund_eins = "Grundlinie: pytest-Bedienfehler (Rueckgabewert 4) mit {0};".format(INTERPRETER)
reihenfolgen = [
["tests/test_zeit.py::test_gibt_es_nicht", "tests/test_modul.py::test_f1_positiv"],
["tests/test_modul.py::test_f1_positiv", "tests/test_zeit.py::test_gibt_es_nicht"],
]
for stelle, rot_bei in zip(("erste", "letzte"), reihenfolgen):
schreibe_toml(toml, kopf(zusatz=zusatz) + mutant_luecke("M44", rot_bei))
ergebnis = starte_laeufer(basis, toml)
ausgabe = ergebnis.stdout + ergebnis.stderr
kopfzeilen = [z for z in ergebnis.stdout.split("\n") if z.startswith("pytest-konfiguration (Grundlinie, ")]
ok = (
ergebnis.returncode == 2
and ausgabe.count(befund_zwei) == 1
and befund_eins not in ausgabe
and "ist FEHLT" not in ausgabe
and "test_f1_positiv ist" not in ausgabe
and len(kopfzeilen) == 2
and sum(1 for z in kopfzeilen if z.startswith("pytest-konfiguration (Grundlinie, {0}): ".format(INTERPRETER))) == 1
and sum(1 for z in kopfzeilen if z.startswith("pytest-konfiguration (Grundlinie, {0}): ".format(zweit_python))) == 1
)
melde("Fall 44 ({0} Stelle)".format(stelle), ok, "exit={0}\n{1}".format(ergebnis.returncode, ausgabe))
finally:
shutil.rmtree(zweit_dir, ignore_errors=True)
def fall_45(basis, projektpfad):
toml = projektpfad / "mutanten.toml"
schreibe_toml(toml, kopf() + mutant_luecke("M45", ["tests/" + "a" * 300 + ".py::test_x"]))
ergebnis = starte_laeufer(basis, toml)
ausgabe = ergebnis.stdout + ergebnis.stderr
ok = (
ergebnis.returncode == 2
and "Grundlinie: pytest-" in ausgabe
and "unerwarteter Abbruch" not in ausgabe
)
melde("Fall 45", ok, "exit={0}\n{1}".format(ergebnis.returncode, ausgabe[-2000:]))
def fall_46(basis, projektpfad):
rahmen, wurzel, dateien = baue_rahmen(eigene_konfig=True, fremde_konfig=False, fremdes_conftest=False)
try:
(wurzel / "conftest.py").write_text("X = 1\n", encoding="utf-8")
dateien = dateien + [wurzel / "conftest.py"]
snap = snapshot(dateien)
toml = rahmen / "mutanten.toml"
schreibe_toml(toml, kopf() + (
"[[mutant]]\n"
"id = \"M46\"\n"
"datei = \"conftest.py\"\n"
"aus = '''\n"
"X = 1\n"
"'''\n"
"wird = '''\n"
"def pytest_collection_modifyitems(items):\n"
" raise RuntimeError(\"intern Fall 46\")\n"
"'''\n"
"rot_bei = [\"tests/test_modul.py::test_f1_positiv\"]\n"
))
bericht = rahmen / "bericht46.json"
ergebnis = starte_laeufer(wurzel, toml, bericht_pfad=bericht)
mutantenzeile = [z for z in ergebnis.stdout.split("\n") if z.startswith("M46 ")]
try:
ausgabe_bericht = json.loads(bericht.read_text(encoding="utf-8"))["mutanten"][0]["tests"][0].get("ausgabe", "")
except (OSError, ValueError, KeyError, IndexError):
ausgabe_bericht = ""
ok = (
ergebnis.returncode == 1
and len(mutantenzeile) == 1
and "M46 UNKLAR" in mutantenzeile[0]
and "pytest-Abbruch mit internem Fehler (Rueckgabewert 3): " in mutantenzeile[0]
and "intern Fall 46" in mutantenzeile[0]
and "Ende der Ausgabe:" in ausgabe_bericht
)
melde("Fall 46", ok, "exit={0}\n{1}{2}".format(ergebnis.returncode, ergebnis.stdout, ergebnis.stderr))
pruefe_unveraendert(dateien, snap, "Fall 46")
finally:
shutil.rmtree(rahmen, ignore_errors=True)
FAELLE = [
fall_01, fall_02, fall_03, fall_04, fall_05, fall_06, fall_07, fall_08, fall_09, fall_10,
fall_11, fall_12, fall_13, fall_14, fall_15, fall_16, fall_17, fall_18, fall_19, fall_20,
fall_21, fall_22, fall_23, fall_24, fall_25, fall_26, fall_27, fall_28, fall_29, fall_30,
fall_31, fall_32, fall_33, fall_34, fall_35,
fall_31, fall_32, fall_33, fall_34, fall_35, fall_36, fall_37, fall_38, fall_39, fall_40,
fall_41, fall_42, fall_43, fall_44, fall_45, fall_46,
]
+59
View File
@@ -0,0 +1,59 @@
---
type: guide
title: "Quorum am Meilenstein – Werkzeug und Ablauf"
status: active
summary: "Wie das Quorum gestartet, der Bericht abgelegt und das Werkzeug geprüft wird, dazu was der erste echte Lauf belegt hat."
tags: [vault-tools, codeworkflow]
created: 2026-10-05
updated: 2026-10-05
---
# Quorum am Meilenstein
Prüft vor dem Einspielen, vor einer neuen Minor- oder Major-Version auf master und vor der Abgabe eines Kundendokuments denselben Unterschied mit drei Stimmen: Opus, Sonnet und Codex, als Ersatz Haiku. Die Regel steht in `AI/Regelwerk/codeworkflow.md`, Abschnitt „Quorum am Meilenstein“. Hier steht nur die Bedienung.
## Starten
```text
# Code-Projekt, erstes Quorum dort: basis ist der Commit der letzten abgenommenen Version
workflow({ scriptPath: "D:/Claude/.claude/workflows/quorum.js" }, { anlass: "einspielen", projekt: "<Repo-Wurzel>", basis: "<commit>", suite_datei: "<datei mit Suite-Ausgabe>" })
# Kundendokument: Ordner im Vault, Dateien relativ dazu, Übersicht des Projekts
workflow({ scriptPath: "D:/Claude/.claude/workflows/quorum.js" }, { anlass: "kundendokument", ordner: "D:/Claude/<ordner>", dateien: ["<datei>", "..."], uebersicht: "<_MOC-datei>.md" })
```
- Der Arbeitsbaum muss außerhalb von `docs/quorum/` sauber sein.
- `suite` nur für eine Suite, die in neun Minuten durchläuft und nichts Unversioniertes schreibt; sonst die Suite selbst fahren und `suite_datei` nennen.
- `codex: false` setzt sofort Haiku ein, etwa wenn das Codex-Kontingent bekannt aufgebraucht ist.
## Bericht ablegen
```bash
# bericht_md aus dem Ergebnis als bericht.md in den Ordner paket schreiben, dann:
PYTHONIOENCODING=utf-8 python -X utf8 .vault-tools/quorum/quorum_werkzeug.py ablegen --paket <paket> --bericht <paket>/bericht.md --ziel <bericht_pfad> --summe <hex>
```
- Den fertigen Befehl liefert das Ergebnis als `ablegen_befehl`. Er setzt `stand` aus `paket.json` ein, damit kein Modell die Liste der Prüfsummen abschreibt.
- Danach den Bericht im Projekt committen.
## Prüfen
```bash
# Werkzeug: Fälle und Mutanten
python .vault-tools/quorum/faelle_quorum_werkzeug.py
python .vault-tools/quorum/mutanten_quorum_werkzeug.py
# Workflow: Fälle und Mutanten über die Attrappe (Mutantenlauf über 30 Minuten, im Hintergrund mit ausreichendem Zeitlimit starten)
node .vault-tools/workflow-pruefung/faelle_workflows.mjs
python .vault-tools/workflow-pruefung/mutanten_workflows.py
```
## Was belegt ist
- **Erster echter Lauf am 2026-09-30, Anlass einspielen, in einem Code-Projekt des Vaults:**
- Alle drei Stimmen haben gültig abgegeben, Codex nach 1 min 52 s, mit Lesebeleg.
- Urteil frei. Bestätigte Befunde je Stimme: Opus 3, Sonnet 2, Codex 3. Alleinfunde: Opus 2, Sonnet 1, Codex 3 (Tabelle „Stimmen“ im Bericht).
- `ablegen` hat den Stand als Commit eingesetzt, der Bericht verlinkt sein README.
- Der Verbrauch blieb im Rahmen der Ablesung (5 Stunden 6 %, Woche 77 % vorher und nachher).
- **Fund aus diesem Lauf:** Freitext der Stimmen hat im Bericht lebendige Wikilinks erzeugt. Seit Task 06 schreibt `quorum.js` Wikilinks, Einbettungen und Tags aus Freitext als Klartext mit Backslash.
- **Bekannte Grenzen (Prüfung vom 2026-10-05, klein):** `zeilen()` liest bei Aufzählungen nur die erste Zahl („Zeilen 72 und 134“ wird 72) und kennt Formen wie „Zeile 10 bis Zeile 14“ oder einen Geviertstrich nicht; eine zu breite Gruppe des Zusammenführ-Agenten kann so eine Mehrheit kosten. `pfadnorm()` entfernt keinen führenden Schrägstrich. `klartext()` setzt den Backslash auch vor `#` in HTML-Entitäten und URL-Fragmenten und lässt `%%…%%`, `<!--` und Code-Zäune in mehrzeiligen Belegen stehen.
- **Nicht gemessen:** die Laufzeit der Claude-Stimmen, weil das Laufzeitsystem keine Uhr hat. Ob Obsidian `\[\[x]]` und `\#tag` in jeder Ansicht als Text zeigt, ist nicht in der App geprüft.
@@ -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-24
updated: 2026-10-08
---
# Prüfwerkzeuge fürs Dashboard der Trading Plattform
@@ -179,3 +179,18 @@ Nach dem Umzug auf den Docker-Host (Hyper-V-VM) prüft der Host selbst: `deploy/
| `abendkontrolle_ibkr_python_angriffe.py` | seit der Prüfung vom 2026-09-26 mit zweiter Sperre: `IBKR_ORDNER` zeigt ins Leere; dazu dichtet `rueckweg_aapl_angriffe.py` seither `WSL` und `WSL_SKRIPT` im Modul `rueckweg_aapl` ab, und `abendkontrolle_angriffe.py` räumt seinen leeren Ordner im Temp wieder weg. Sonst: vier Fälle ohne Netz für `--ibkr-python` von `abendkontrolle_wsl.py`: Schalter, Vorgabe `IBKR_PY`, `ibkr_pruefen.py lesen` läuft mit dem genannten Interpreter (Ersatzskript im Wegwerf-Ordner), der Ablauf reicht den Schalter weiter | `python abendkontrolle_ibkr_python_angriffe.py` in WSL im Venv |
Was es nicht tut: Es prüft nicht, ob der Host die Kontrolle überhaupt fährt — bleibt sie dort aus, steht jeden Abend „noch kein Ergebnis“ in der Notiz, und das ist der Hinweis. Steht `-QuelleIbkr` falsch, fällt das nicht immer auf: Prüft die Aufgabe den Teil ibkr in WSL, obwohl die Instanz schon auf dem Host läuft, meldet sie ihn dort nur leise als „nicht gestartet“, weil der Zeitgeber in WSL aus ist. Ein Anmelde-Lauf zwischen 22:30 und dem Ende der Prüfung auf dem Host bekommt einmal „noch kein Ergebnis“, weil die Frist `FRUEHESTENS` für beide Quellen gilt (die Mutanten suchen genau diese Zeile).
## Paket AB (2026-10-07): Abbau des Restbestands nach dem Netzschalter-Abriss
Am 2026-10-07 um 20:13 beendete ein Druck auf den Netzschalter beide Paper-Runner mitten im Handel, ohne Glattstellen. Pascal am selben Abend: am 2026-10-08 soweit es geht alles verkaufen, Testläufe bis Dienstag gestoppt. Die Zeitgeber `trading-paper.timer` und `trading-paper-ibkr.timer` sind seitdem aus, die Abendkontrolle ebenfalls; die Meldung dieses Werkzeugs ist in der Pause das einzige Netz. Plan im Projekt: `docs/plans/2026-10-07-paket-ab-abbau-restbestand/`.
| Datei | Was sie tut | Aufruf |
|---|---|---|
| `abbau_restbestand.py` | Windows-Seite. Im Modus `scharf` nur zwischen 2026-10-08 15:40 und 2026-10-09 21:45: Runner-Units über `systemctl show` (nur `LoadState=loaded` mit `inactive` oder `failed` gilt als stehend), Marktuhr, beide Bestände; je Instanz mit Position oder offener Order genau ein Glattstellen über `abbau_glattstellen.sh`; nach 30 s beide Bestände neu; Ergebnis in `data/paper/abbau/abbau.json` und `abbau.log` im Projekt, mit `"flach": true` enden alle späteren Läufe still; bei Eingriff, Befund oder dem ersten "flach" eine Zeile in der Tagesnotiz und eine Meldung mit hoher Priorität. Fail-closed: laufender oder nicht geladener Runner, unlesbarer Zustand, unlesbare Marktuhr oder unlesbarer Bestand heißt nicht angefasst, mit Befund; Markt zu oder weniger als 10 min bis Schluss heißt nichts, ohne Meldung. `wsl()` setzt `WSL_UTF8=1` und entfernt NUL; Befunde aus Ausnahmen nennen nur den Typ, der Traceback steht nur in `abbau.log`. `--modus lesen` fährt nur die lesenden Aufrufe, auch außerhalb des Fensters, und schreibt nichts; ohne `--modus` Exit 2 | `python abbau_restbestand.py --modus lesen`; `scharf` nur über die Aufgabe |
| `abbau_glattstellen.sh` | WSL. Liest das Bestätigungswort aus `config/killswitch.toml` der Instanz, wählt den Broker ausdrücklich (`--broker alpaca` oder `--broker ibkr`) und ruft genau einmal `scripts/killswitch.py glattstellen` im Instanzordner mit deren Venv. Die Ausgabe geht durch einen Filter: das Wort wird `[wort]`, dann `ohne_kontonummer()` (Kontonummern als `[konto]`, dann `maskiere()`), dann jede Dezimalzahl `[x]`. Kennt keinen echten Ordner, der Aufrufer nennt ihn. `--broker attrappe` ersetzt die Wahl, nur für die Probe in einer Wegwerfkopie | `bash abbau_glattstellen.sh <instanz> <instanzordner> [--broker attrappe]` in WSL, Instanz `alpaca` oder `ibkr` |
| `abbau_alpaca_lesen.py` | WSL, Venv `~/venvs/trading`: Marktuhr, Positionen (nur Symbol und Stück) und Zahl der offenen Orders des Alpaca-Kontos als eine JSON-Zeile; Fehlertexte durch `ohne_kontonummer()` und die Dezimalmaske | nur über `abbau_restbestand.py`; von Hand `python abbau_alpaca_lesen.py --projekt <alpaca-ordner>` |
| `abbau_ibkr_lesen.sh` | WSL: `scripts/ibkr_pruefen.py lesen` im IBKR-Ordner mit `~/venvs/trading-ibkr` (Client-ID 12, lesend), nie `orderweg` | nur über `abbau_restbestand.py` |
| `abbau_restbestand_angriffe.py` | Angriffsfälle als Funktionen `test_*`, lauffähig als Skript und unter pytest (für `mutanten_lauf.py`). Abgedichtet: alle Pfade nach draußen zeigen ins Leere, `wsl()` ist durch eine zählende Attrappe ersetzt, im Fall zu `wsl()` selbst auch `subprocess.run`. Die Fälle `test_wsl_*` fahren den echten Wrapper und die Venvs gegen Wegwerf-Instanzordner mit falschem `killswitch.py`, erfundenem Wort, erfundener Kontonummer und erfundenen Beträgen; unter Windows übersprungen | unter Windows `python -I -X utf8 abbau_restbestand_angriffe.py`, in WSL `~/venvs/trading/bin/python -I abbau_restbestand_angriffe.py`; Mutanten: `mutanten.toml` im Planordner |
| `abbau_restbestand_einrichten.ps1` | Aufgabe "Trading Plattform - Abbau Restbestand": Do 2026-10-08 15:45, 16:15, 17:00, 19:00, 21:00, Ersatz Fr 2026-10-09 15:45, 16:30, 19:00 und fünf Minuten nach jeder Anmeldung; nur angemeldet, Zeitlimit 45 min, keine zweite Instanz; druckt nach dem Registrieren die Einstellungen | `powershell -NoProfile -File abbau_restbestand_einrichten.ps1 [-Aktion entfernen]` |
Was es nicht tut: Es gibt keinen Killswitch frei. Jede angefasste Instanz steht danach auf `gesperrt`, Freigeben bleibt Pascal (`scripts/killswitch.py freigeben`). Es hat keinen eigenen Ordercode und keine Sperrdatei gegen einen zweiten Lauf von Hand; die Aufgabe selbst startet keine zweite Instanz. Während des Aufrufs steht das Wort in WSL in der Prozessliste von `killswitch.py`, dessen Schnittstelle es als Argument nimmt. Ob `wsl.exe --exec` eine Meldung mit Leerzeichen so weitergibt wie ein Skript mit Argumenten, war vor dem ersten scharfen Lauf nicht gemessen; was die Meldung ergab, steht in der Notizzeile unter "Meldung ans Telefon". Nach dem 2026-10-09 wird die Aufgabe mit `-Aktion entfernen` abgebaut.
@@ -0,0 +1,101 @@
"""Abbau Restbestand: liest Marktuhr, Positionen und offene Orders des Alpaca-Paper-Kontos, nur lesend.
Ersteller: Pascal Michel
Erstellt: 2026-10-07
Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB"
Laeuft in WSL mit dem Venv des Alpaca-Ordners (~/venvs/trading); gestartet von abbau_restbestand.py
ueber wsl.exe --exec. Ruft nur marktuhr(), positionen() und offene_orders() von AlpacaBroker
(trading/broker/alpaca.py: GET /v2/clock, /v2/positions, /v2/orders?status=open). Nichts davon
schreibt beim Broker oder auf der Platte.
Ausgabe: genau eine JSON-Zeile auf stdout mit
uhr {"offen": bool, "sekunden_bis_schluss": int} oder null
uhr_fehler Text oder null
positionen [{"symbol": str, "menge": int}] oder null; menge ist negativ bei short
orders Zahl der offenen Orders oder null
bestand_fehler Text oder null
Nie ein Betrag: Von einer Position gehen nur Symbol und Stueckzahl weiter, von den Orders nur die
Zahl. Fehlertexte laufen durch ohne_kontonummer() aus abendkontrolle_wsl.py (Kontonummern, dann
maskiere()) und danach durch DEZIMAL: jede Dezimalzahl wird [x], ganze Zahlen bleiben.
Den Projektordner nennt der Aufrufer (--projekt); hier steht kein echter Pfad.
Aufruf: python abbau_alpaca_lesen.py --projekt <alpaca-ordner>
Exit: 0 mit der JSON-Zeile (auch wenn darin Fehler stehen), 2 Bedienfehler.
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
from datetime import timezone
from pathlib import Path
HIER = Path(__file__).resolve().parent
sys.path.insert(0, str(HIER))
from abendkontrolle_wsl import ohne_kontonummer # noqa: E402
GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml"
RISIKO = "config/risk.toml"
FEHLERTEXT_MAX = 200
# Jede Dezimalzahl, auch ohne Geldwort davor; maskiere() faengt nur Zahlen hinter Geldwoertern,
# vor Waehrungen oder mit vielen Stellen.
DEZIMAL = re.compile(r"-?\d+[.,]\d+")
def fehlertext(ausnahme: BaseException) -> str:
"""Typ und Text einer Ausnahme: Kontonummern als [konto], Betraege als [x], gekuerzt."""
text = ohne_kontonummer(f"{type(ausnahme).__name__}: {ausnahme}")
return DEZIMAL.sub("[x]", text)[:FEHLERTEXT_MAX]
def lies(broker, jetzt: datetime) -> dict:
"""Marktuhr und Bestand; jede Frage fuer sich, ein Fehler in der einen haelt die andere nicht auf."""
ergebnis = {"uhr": None, "uhr_fehler": None, "positionen": None, "orders": None, "bestand_fehler": None}
try:
uhr = broker.marktuhr()
ergebnis["uhr"] = {
"offen": bool(uhr.is_open),
"sekunden_bis_schluss": int((uhr.next_close - jetzt).total_seconds()),
}
except Exception as ausnahme:
ergebnis["uhr_fehler"] = fehlertext(ausnahme)
try:
positionen = broker.positionen()
orders = broker.offene_orders()
ergebnis["positionen"] = [
{"symbol": position.symbol, "menge": int(position.menge)} for position in positionen
]
ergebnis["orders"] = len(orders)
except Exception as ausnahme:
ergebnis["positionen"] = None
ergebnis["orders"] = None
ergebnis["bestand_fehler"] = fehlertext(ausnahme)
return ergebnis
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False)
zerleger.add_argument("--projekt", required=True, help="Ordner der Alpaca-Instanz in WSL")
argumente = zerleger.parse_args(argv)
os.environ.setdefault("TRADING_GEHEIMNISSE", GEHEIMNISSE)
try:
os.chdir(argumente.projekt)
sys.path.insert(0, str(Path(argumente.projekt).resolve()))
from trading.broker.alpaca import AlpacaBroker
from trading.risk.limits import lade_limits
broker = AlpacaBroker.from_secrets(None, universum=lade_limits(RISIKO).universum)
except Exception as ausnahme:
grund = fehlertext(ausnahme)
print(json.dumps(
{"uhr": None, "uhr_fehler": grund, "positionen": None, "orders": None, "bestand_fehler": grund},
ensure_ascii=False,
))
return 0
print(json.dumps(lies(broker, datetime.now(timezone.utc)), ensure_ascii=False))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Abbau Restbestand: stellt eine Paper-Instanz ueber scripts/killswitch.py glattstellen glatt.
# Ersteller: Pascal Michel Erstellt: 2026-10-07
# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB"
#
# Das Bestaetigungswort verlaesst WSL nie: Dieses Skript liest es aus config/killswitch.toml der
# Instanz und reicht es nur an killswitch.py weiter. Jede Zeile von killswitch.py geht danach durch
# einen Filter: das Wort wird [wort], dann ohne_kontonummer() aus abendkontrolle_wsl.py
# (Kontonummern als [konto], dann maskiere()), dann jede Dezimalzahl als [x].
# Den Broker waehlt das Skript ausdruecklich: alpaca -> --broker alpaca, ibkr -> --broker ibkr
# (gemessen: ibkr ist im IBKR-Ordner mit Marke ibkr erlaubt, alpaca im Projektordner ohne Marke).
# --broker attrappe ersetzt die Wahl, nur fuer die Probe in einer Wegwerfkopie und die Angriffsfaelle.
# Den Instanzordner nennt immer der Aufrufer (abbau_restbestand.py); hier steht kein echter Pfad.
# Genau ein Aufruf von killswitch.py je Start, keine Schleife.
#
# Aufruf: bash abbau_glattstellen.sh <alpaca|ibkr> <instanzordner> [--broker attrappe]
# Exit: der von killswitch.py (0 erfolgreich, 1 nicht erfolgreich, 3 abgelehnt, 4 Voraussetzung),
# 2 Bedienfehler, 4 Wort nicht lesbar oder Instanzordner nicht erreichbar.
set -u
GRUND="Abbau Restbestand nach Netzschalter-Abriss 2026-10-07; Auftrag Pascal 2026-10-07, ausgefuehrt von der Aufgabe Abbau Restbestand"
HIER="$(dirname "$(readlink -f "$0")")"
export TRADING_GEHEIMNISSE=/home/claude/.config/trading-plattform/secrets.toml
export PYTHONIOENCODING=utf-8
if [ "$#" -lt 2 ]; then
echo "Bedienfehler: Aufruf abbau_glattstellen.sh <alpaca|ibkr> <instanzordner> [--broker attrappe]" >&2
exit 2
fi
instanz="$1"
ordner="$2"
shift 2
case "$instanz" in
alpaca)
py=/home/claude/venvs/trading/bin/python
broker=(--broker alpaca)
;;
ibkr)
py=/home/claude/venvs/trading-ibkr/bin/python
broker=(--broker ibkr)
;;
*)
echo "Bedienfehler: Instanz muss alpaca oder ibkr sein" >&2
exit 2
;;
esac
while [ "$#" -gt 0 ]; do
case "$1" in
--broker)
if [ "$#" -lt 2 ] || [ "$2" != "attrappe" ]; then
echo "Bedienfehler: --broker nimmt nur attrappe" >&2
exit 2
fi
broker=(--broker attrappe)
shift 2
;;
*)
echo "Bedienfehler: unbekannte Angabe" >&2
exit 2
;;
esac
done
if ! cd "$ordner"; then
echo "Voraussetzung fehlt: Instanzordner nicht erreichbar" >&2
exit 4
fi
# Das Wort, nur in dieser Shell; tomllib aus dem Python der Instanz.
wort="$("$py" -I -c 'import tomllib
with open("config/killswitch.toml", "rb") as datei:
print(tomllib.load(datei)["killswitch"]["bestaetigungswort"])' 2>/dev/null)"
if [ -z "$wort" ]; then
echo "Voraussetzung fehlt: Bestaetigungswort in config/killswitch.toml nicht lesbar" >&2
exit 4
fi
# Genau ein Aufruf; stdout und stderr zusammen, gefiltert erst hinter dem Lauf.
ausgabe="$("$py" scripts/killswitch.py "${broker[@]}" glattstellen --grund "$GRUND" --bestaetigung "$wort" 2>&1)"
code=$?
# Filter je Zeile: Wort, Kontonummern und maskiere(), Dezimalzahlen. Das Wort geht nur ueber die Umgebung.
if ! printf '%s\n' "$ausgabe" | ABBAU_WORT="$wort" "$py" -I -c 'import os
import re
import sys
sys.path.insert(0, sys.argv[1])
from abendkontrolle_wsl import ohne_kontonummer
wort = os.environ.pop("ABBAU_WORT", "")
for zeile in sys.stdin.read().splitlines():
if wort:
zeile = zeile.replace(wort, "[wort]")
zeile = ohne_kontonummer(zeile)
print(re.sub(r"-?\d+[.,]\d+", "[x]", zeile)[:300])' "$HIER"; then
echo "Ausgabe von killswitch.py nicht gefiltert und deshalb zurueckgehalten"
fi
unset wort ausgabe
exit "$code"
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Abbau Restbestand: liest Positionen und offene Orders der IBKR-Instanz, nur lesend (ibkr_pruefen.py lesen).
# Ersteller: Pascal Michel Erstellt: 2026-10-07
# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB"
#
# Faehrt scripts/ibkr_pruefen.py lesen im Instanzordner mit ~/venvs/trading-ibkr (Client-ID 12,
# lesend); den Unterbefehl orderweg ruft es nie. Den Instanzordner nennt der Aufrufer
# (abbau_restbestand.py); hier steht kein echter Pfad.
# Aufruf: bash abbau_ibkr_lesen.sh <instanzordner>
# Exit: der von ibkr_pruefen.py (0 gelesen, 1 Befund, 4 Voraussetzung fehlt), 2 Bedienfehler,
# 4 Instanzordner nicht erreichbar.
set -u
if [ "$#" -ne 1 ]; then
echo "Bedienfehler: Aufruf abbau_ibkr_lesen.sh <instanzordner>" >&2
exit 2
fi
export TRADING_GEHEIMNISSE=/home/claude/.config/trading-plattform/secrets.toml
export PYTHONIOENCODING=utf-8
if ! cd "$1"; then
echo "Voraussetzung fehlt: Instanzordner nicht erreichbar" >&2
exit 4
fi
exec /home/claude/venvs/trading-ibkr/bin/python scripts/ibkr_pruefen.py lesen
@@ -0,0 +1,575 @@
"""Abbau Restbestand: stellt am 2026-10-08 beide Paper-Konten ueber den Notweg glatt, Ersatztag 2026-10-09.
Ersteller: Pascal Michel
Erstellt: 2026-10-07
Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB"
Koordinator-Werkzeug, nicht fuers Repo. Am 2026-10-07 um 20:13 beendete ein Druck auf den
Netzschalter beide Paper-Runner mitten im Handel, ohne Glattstellen. Pascal am selben Abend:
morgen soweit es geht alles verkaufen, Testlaeufe bis Dienstag gestoppt. Plan im Projekt:
docs/plans/2026-10-07-paket-ab-abbau-restbestand/. Gestartet von der Windows-Aufgabe
"Trading Plattform - Abbau Restbestand" (abbau_restbestand_einrichten.ps1) mit --modus scharf.
Ablauf im Modus scharf, jeder Schritt mit seinem Grund:
1. Nur zwischen FRUEHESTENS und SPAETESTENS (Uhr dieses Rechners); sonst nichts, auch keine Datei.
Ergebnisdatei mit "flach": true -> nichts mehr, nur eine Zeile im Log.
2. Runner-Units (systemctl show, LoadState und ActiveState): Laeuft eine, ist sie nicht geladen
oder ihr Zustand nicht lesbar, wird diese Instanz nicht angefasst (Befund). systemctl is-active
meldete auch fuer eine Unit, die es nicht gibt, inactive. Die Zeitgeber sind seit 2026-10-07 aus.
3. Marktuhr von Alpaca (abbau_alpaca_lesen.py): zu oder weniger als MIN_BIS_SCHLUSS_S bis zum
Schluss -> nichts, nur eine Zeile im Log. Nicht lesbar -> Befund, nichts angefasst.
4. Bestaende beider Instanzen; nicht lesbar -> Befund, diese Instanz nicht angefasst.
5. Je Instanz mit Position oder offener Order genau ein Aufruf von abbau_glattstellen.sh
(scripts/killswitch.py glattstellen; das Bestaetigungswort bleibt in WSL). Keine Schleife:
Der naechste Termin der Aufgabe ist die Wiederholung. Wirft der Aufruf, gilt Exit None.
6. Nach einem Eingriff WARTEN_S warten und beide Bestaende neu lesen.
7. Ergebnisdatei abbau.json; "flach": true nur, wenn beide Instanzen nachher ohne Position und
ohne offene Order gelesen sind und keine Instanz gesperrt ist. Sonst beobachtet der naechste
Termin weiter.
8. Bei Eingriff, Befund oder dem ersten "flach" eine Zeile in Inbox/<tag>.md und eine Meldung mit
hoher Prioritaet (abendkontrolle_wsl.py --meldung). Die Abendkontrolle ist in der Pause aus;
diese Meldung ist das einzige Netz.
Modus lesen: nur die lesenden Aufrufe, ohne Fenster und ohne die Sperre durch "flach"; kein
Glattstellen, keine Meldung, keine Notiz, keine Datei. Das Ergebnis steht auf stdout.
Jeder Aufruf nach draussen geht ueber wsl(); die Angriffsfaelle ersetzen sie. wsl.exe schreibt eigene
Fehler in UTF-16LE: wsl() setzt WSL_UTF8=1 und entfernt NUL, sonst scheiterte die Meldung an einem
NUL im Argument. Ausgabe und Befunde sind einzeilig: Ein fremder Fehlertext mit Zeilenumbruch
setzte sonst eigene Zeilen, bis hin zu einer Ueberschrift, in die Tagesnotiz. Befunde aus Ausnahmen
nennen nur den Typ; der Traceback steht nur in abbau.log.
Annahme, benannt wie in abendkontrolle.py: Die Uhr dieses Rechners geht nach Berliner Zeit.
Aufruf: python abbau_restbestand.py --modus scharf|lesen [--jetzt ISO] [--projekt PFAD] [--vault PFAD]
Exit: 0 ohne Befund, 1 mit Befund, 2 Bedienfehler.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
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"
ABLAGE_TEIL = Path("data") / "paper" / "abbau"
ERGEBNIS_NAME = "abbau.json"
LOG_NAME = "abbau.log"
# Entscheidung 4 der Anforderung, Berliner Ortszeit, beide Grenzen eingeschlossen.
FRUEHESTENS = datetime(2026, 10, 8, 15, 40)
SPAETESTENS = datetime(2026, 10, 9, 21, 45)
MIN_BIS_SCHLUSS_S = 600
WARTEN_S = 30
SCHARF = "scharf"
LESEN = "lesen"
MODI = (SCHARF, LESEN)
NICHTS = "nichts"
STILL = "still"
LESEN_ENDE = "lesen_ende"
BERICHT = "bericht"
INSTANZEN = ("alpaca", "ibkr")
UNITS = {"alpaca": "trading-paper.service", "ibkr": "trading-paper-ibkr.service"}
# Die Instanzordner in WSL. Nur dieses Skript nennt sie; die WSL-Teile bekommen sie als Argument.
ORDNER_WSL = {
"alpaca": "/mnt/d/Claude/Code Projekte/active - Trading Plattform",
"ibkr": "/mnt/d/Claude/.arbeitsbaeume/trading-ibkr-betrieb",
}
HIER_WSL = "/mnt/d/Claude/.vault-tools/trading-plattform-pruefung"
ALPACA_PY = "/home/claude/venvs/trading/bin/python"
ALPACA_LESEN = HIER_WSL + "/abbau_alpaca_lesen.py"
IBKR_LESEN = HIER_WSL + "/abbau_ibkr_lesen.sh"
GLATTSTELLEN = HIER_WSL + "/abbau_glattstellen.sh"
RUNNER_LAEUFT = ("active", "activating", "deactivating", "reloading", "refreshing")
RUNNER_STEHT = ("inactive", "failed")
RUNNER_TIMEOUT_S = 60
LESEN_TIMEOUT_S = 180
# killswitch.py: Gateway-Zeitlimit 10 s je Anfrage, bis zu drei Sammelversuche, 20 s Positionsfrist.
GLATT_TIMEOUT_S = 600
MELDUNG_TIMEOUT_S = 120
ZEILEN_MAX = 12
ZEILE_MAX = 200
# CREATE_NO_WINDOW gibt es nur unter Windows; die Angriffsfaelle laufen auch in WSL.
FENSTERLOS = KEIN_FENSTER if sys.platform == "win32" else 0
NUL = chr(0)
# Die Ausgabe von scripts/ibkr_pruefen.py lesen, gemessen am 2026-10-07 gegen 22:56 (Anforderung).
IBKR_POSITIONEN = re.compile(r"^Positionen: (\d+)\s*$")
IBKR_ORDERS = re.compile(r"^Offene Orders: (\d+)\s*$")
IBKR_ZEILE = re.compile(r"^ (\S+) (-?\d+)(?:\.0+)?\s*$")
IBKR_GELESEN = "ERGEBNIS: gelesen"
TITEL = "Abbau Restbestand"
KOPF = "Abbau Restbestand (automatisch, Auftrag Pascal 2026-10-07)"
GESPERRT_HINWEIS = "Der Killswitch jeder angefassten Instanz steht danach auf gesperrt; Freigeben bleibt Pascal."
# Die Angriffsfaelle ersetzen schlafe und starte, damit kein Fall wartet und keiner einen Prozess startet.
schlafe = time.sleep
starte = subprocess.run
def sauber(text: str) -> str:
"""Text ohne NUL: wsl.exe schreibt eigene Fehler in UTF-16LE, und ein NUL im Argument laesst subprocess scheitern."""
return text.replace(NUL, "")
def einzeilig(text: str) -> str:
"""Ohne NUL und ohne Zeilenumbruch: Ein fremder Text bleibt eine Zeile in Ausgabe, Log, Notiz und Meldung."""
return sauber(text).replace("\r", " ").replace("\n", " ")
def wsl(argumente: list[str], timeout: int) -> tuple[int, str, str]:
"""Ein Aufruf in WSL ohne Shell (--exec): Exit, stdout, stderr. Der einzige Weg nach draussen."""
lauf = starte(
[WSL, "-d", "Debian", "-u", "claude", "--exec", *argumente],
capture_output=True, timeout=timeout, creationflags=FENSTERLOS,
env={**os.environ, "WSL_UTF8": "1"},
)
return (
lauf.returncode,
sauber(lauf.stdout.decode("utf-8", errors="replace")),
sauber(lauf.stderr.decode("utf-8", errors="replace")),
)
def lies_runner(instanz: str) -> str:
"""LoadState und ActiveState der Runner-Unit (systemctl show); liest nur."""
_, ausgabe, _ = wsl(["systemctl", "show", "--property=LoadState,ActiveState", UNITS[instanz]], RUNNER_TIMEOUT_S)
return ausgabe
def runner_felder(ausgabe: str) -> tuple[str, str]:
"""LoadState und ActiveState aus der Ausgabe von systemctl show; was fehlt, ist leer."""
felder = {}
for zeile in ausgabe.splitlines():
name, _, wert = zeile.partition("=")
felder[name.strip()] = wert.strip()
return felder.get("LoadState", ""), felder.get("ActiveState", "")
def runner_urteil(laden: str, aktiv: str) -> str | None:
"""laeuft, steht, oder None (fail-closed). steht nur bei einer geladenen Unit, die inactive oder failed ist."""
if aktiv in RUNNER_LAEUFT:
return "laeuft"
if laden == "loaded" and aktiv in RUNNER_STEHT:
return "steht"
return None
def lies_alpaca() -> dict:
"""Die JSON-Zeile von abbau_alpaca_lesen.py; ohne sie steht der Grund in beiden Fehlerfeldern."""
try:
code, ausgabe, _ = wsl([ALPACA_PY, ALPACA_LESEN, "--projekt", ORDNER_WSL["alpaca"]], LESEN_TIMEOUT_S)
except Exception as ausnahme:
code, ausgabe = f"nicht gestartet, {type(ausnahme).__name__}", ""
for zeile in reversed(ausgabe.splitlines()):
zeile = zeile.strip()
if not zeile.startswith("{"):
continue
try:
daten = json.loads(zeile)
except ValueError:
break
if isinstance(daten, dict):
return daten
break
grund = f"abbau_alpaca_lesen.py ohne JSON-Zeile (Exit {code})"
return {"uhr_fehler": grund, "bestand_fehler": grund}
def uhr_lesbar(uhr) -> bool:
"""Die Marktuhr in der Form von abbau_alpaca_lesen.py: offen als bool, Sekunden als ganze Zahl."""
return (
isinstance(uhr, dict)
and type(uhr.get("offen")) is bool
and type(uhr.get("sekunden_bis_schluss")) is int
)
def alpaca_bestand(daten: dict) -> dict | None:
"""Symbol -> Stueck und die Zahl offener Orders aus der JSON-Zeile, oder None, wenn nicht gelesen."""
positionen, orders = daten.get("positionen"), daten.get("orders")
if not isinstance(positionen, list) or type(orders) is not int:
return None
stueck: dict[str, int] = {}
for eintrag in positionen:
if not isinstance(eintrag, dict) or not isinstance(eintrag.get("symbol"), str):
return None
if type(eintrag.get("menge")) is not int:
return None
stueck[eintrag["symbol"]] = stueck.get(eintrag["symbol"], 0) + eintrag["menge"]
return {"positionen": stueck, "orders": orders}
def ibkr_bestand(code: int, ausgabe: str) -> dict | None:
"""Positionen und offene Orders aus stdout von scripts/ibkr_pruefen.py lesen, oder None.
Gelesen ist der Bestand nur mit Exit 0 oder 1 (1: Befund zur Kontogroesse, die Liste steht
trotzdem da), mit der Zeile "ERGEBNIS: gelesen", beiden Zahlenzeilen und genau so vielen
Positionszeilen, wie "Positionen: n" sagt.
"""
zeilen = ausgabe.splitlines()
if not any(zeile.startswith(IBKR_GELESEN) for zeile in zeilen):
return None
if code not in (0, 1):
return None
anzahl = orders = None
gezaehlt = 0
stueck: dict[str, int] = {}
im_block = False
for zeile in zeilen:
treffer = IBKR_POSITIONEN.match(zeile)
if treffer:
anzahl, im_block = int(treffer.group(1)), True
continue
treffer = IBKR_ORDERS.match(zeile)
if treffer:
orders, im_block = int(treffer.group(1)), False
continue
if im_block:
treffer = IBKR_ZEILE.match(zeile)
if treffer is None:
return None
symbol = treffer.group(1).split(".")[0]
stueck[symbol] = stueck.get(symbol, 0) + int(treffer.group(2))
gezaehlt += 1
if anzahl is None or orders is None or gezaehlt != anzahl:
return None
return {"positionen": stueck, "orders": orders}
def lies_ibkr() -> tuple[dict | None, int | None]:
"""Bestand der IBKR-Instanz ueber abbau_ibkr_lesen.sh (ibkr_pruefen.py lesen) und dessen Exit."""
try:
code, ausgabe, _ = wsl(["bash", IBKR_LESEN, ORDNER_WSL["ibkr"]], LESEN_TIMEOUT_S)
except Exception:
return None, None
return ibkr_bestand(code, ausgabe), code
def hat_bestand(bestand: dict | None) -> bool:
"""Position ungleich null oder offene Order. Ein nicht gelesener Bestand (None) hat keinen: fail-closed."""
return bestand is not None and (any(menge != 0 for menge in bestand["positionen"].values()) or bestand["orders"] > 0)
def ist_flach(bestand: dict | None) -> bool:
"""Gelesen, keine Position ungleich null und keine offene Order."""
return bestand is not None and not any(menge != 0 for menge in bestand["positionen"].values()) and bestand["orders"] == 0
def flach_markiert(datei: Path) -> bool:
"""Ob eine fruehere Ergebnisdatei "flach": true traegt; unlesbar oder anders heisst nein."""
try:
daten = json.loads(datei.read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
return isinstance(daten, dict) and daten.get("flach") is True
def beschreibe(bestand: dict | None) -> str:
"""Ein Bestand fuer Ausgabe, Notiz und Meldung: Symbol, Richtung, Stueck, offene Orders. Nie ein Betrag."""
if bestand is None:
return "nicht gelesen"
teile = [
f"{symbol} {'long' if menge > 0 else 'short'} {abs(menge)}"
for symbol, menge in sorted(bestand["positionen"].items())
if menge != 0
]
liste = ": " + ", ".join(teile) if teile else ""
return f"{len(teile)} Position(en){liste}; offene Orders {bestand['orders']}"
def glattstelle(instanz: str, modus: str) -> tuple[int, list[str]]:
"""Genau ein Aufruf von abbau_glattstellen.sh fuer eine Instanz; nur im Modus scharf.
Die Zeilen kommen aus WSL schon gefiltert (Wort, Kontonummern, Betraege); hier werden sie nur gekuerzt.
"""
if modus != SCHARF:
raise RuntimeError("glattstellen nur im Modus scharf")
code, ausgabe, fehler = wsl(["bash", GLATTSTELLEN, instanz, ORDNER_WSL[instanz]], GLATT_TIMEOUT_S)
zeilen = [zeile.strip()[:ZEILE_MAX] for zeile in (ausgabe + "\n" + fehler).splitlines() if zeile.strip()]
return code, zeilen[-ZEILEN_MAX:]
def melde(titel: str, text: str, modus: str) -> str:
"""Eine Meldung mit hoher Prioritaet ueber abendkontrolle_wsl.py --meldung; nur im Modus scharf."""
if modus != SCHARF:
return "nicht gesendet: Modus lesen"
titel, text = sauber(titel), sauber(text)
try:
code, ausgabe, _ = wsl([WSL_PY, WSL_SKRIPT, "--meldung", titel, text], MELDUNG_TIMEOUT_S)
except Exception as ausnahme:
return f"nicht gesendet: {type(ausnahme).__name__}"
for zeile in reversed(ausgabe.splitlines()):
zeile = zeile.strip()
if zeile.startswith("{"):
try:
return str(json.loads(zeile).get("meldung"))[:200]
except (ValueError, AttributeError):
break
return f"nicht gesendet: keine Antwort (Exit {code})"
def logge(ablage: Path, text: str) -> None:
"""Eine Zeile in abbau.log; ein Fehler dabei haelt den Lauf nicht auf."""
try:
ablage.mkdir(parents=True, exist_ok=True)
with (ablage / LOG_NAME).open("a", encoding="utf-8", newline="\n") as datei:
datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n")
except OSError:
pass
def schreibe_ergebnis(ablage: Path, daten: dict) -> None:
"""abbau.json ueber eine Nachbardatei und os.replace: nie eine halbe Datei."""
ablage.mkdir(parents=True, exist_ok=True)
neu = ablage / (ERGEBNIS_NAME + ".tmp")
neu.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n")
os.replace(neu, ablage / ERGEBNIS_NAME)
class Lauf:
"""Was ein Lauf gesehen und getan hat; daraus entstehen Ausgabe, Ergebnisdatei, Notiz und Meldung."""
def __init__(self, jetzt: datetime, modus: str, ablage: Path) -> None:
self.jetzt = jetzt
self.modus = modus
self.ablage = ablage
self.ausgabe: list[str] = []
self.befunde: list[str] = []
self.gesperrt: set[str] = set()
self.markt = ""
self.vorher: dict = {}
self.nachher: dict = {}
self.eingriffe: dict = {}
self.spur = ""
def sage(self, text: str) -> None:
self.ausgabe.append(einzeilig(text))
def befund(self, text: str) -> None:
self.befunde.append(einzeilig(text)[:300])
def zusammenfassung(self) -> list[str]:
"""Je Instanz eine Zeile: vorher, was geschah, nachher."""
zeilen = []
for instanz in INSTANZEN:
eingriff = self.eingriffe.get(instanz)
tat = "nicht angefasst" if eingriff is None else f"glattstellen Exit {eingriff['exit']}"
zeilen.append(
f"{instanz}: vorher {beschreibe(self.vorher.get(instanz))}; {tat}; "
f"nachher {beschreibe(self.nachher.get(instanz))}"
)
return zeilen
def fahre(lauf: Lauf) -> str:
"""Schritte 1 bis 6; gibt NICHTS, STILL, LESEN_ENDE oder BERICHT zurueck."""
scharf = lauf.modus == SCHARF
if not scharf:
lauf.sage("Modus lesen: kein Glattstellen, keine Meldung, keine Notiz, keine Datei")
# 1 Fenster und Ergebnisdatei
im_fenster = FRUEHESTENS <= lauf.jetzt <= SPAETESTENS
lage = "innerhalb" if im_fenster else "ausserhalb"
lauf.sage(f"Fenster {FRUEHESTENS:%Y-%m-%d %H:%M} bis {SPAETESTENS:%Y-%m-%d %H:%M}: {lage}")
if scharf and not im_fenster:
return NICHTS
if flach_markiert(lauf.ablage / ERGEBNIS_NAME):
lauf.sage("Ergebnisdatei sagt flach")
if scharf:
return STILL
# 2 Runner-Units: ein laufender, nicht geladener oder unlesbarer Runner sperrt seine Instanz
for instanz in INSTANZEN:
try:
laden, aktiv = runner_felder(lies_runner(instanz))
except Exception as ausnahme:
laden, aktiv = "", f"nicht lesbar, {type(ausnahme).__name__}"
zustand = f"LoadState={laden or 'leer'}, ActiveState={aktiv or 'leer'}"
lauf.sage(f"Runner {instanz} ({UNITS[instanz]}): {zustand}")
urteil = runner_urteil(laden, aktiv)
if urteil != "steht":
lauf.gesperrt.add(instanz)
grund = "laeuft" if urteil == "laeuft" else "Zustand nicht lesbar"
lauf.befund(f"{instanz}: Runner-Unit {UNITS[instanz]} {grund} ({zustand[:80]}); nicht angefasst")
# 3 Marktuhr: zu oder bald zu heisst im Modus scharf still zu Ende
alpaca = lies_alpaca()
uhr = alpaca.get("uhr")
if not uhr_lesbar(uhr):
lauf.gesperrt.update(INSTANZEN)
lauf.markt = "Marktuhr nicht lesbar"
lauf.befund(f"Marktuhr nicht lesbar ({alpaca.get('uhr_fehler') or 'ohne Angabe'}); nichts angefasst")
else:
if uhr["offen"]:
lauf.sage(f"Markt offen, {uhr['sekunden_bis_schluss'] // 60} min bis Schluss")
else:
lauf.sage("Markt zu")
handelbar = uhr["offen"] and uhr["sekunden_bis_schluss"] >= MIN_BIS_SCHLUSS_S
if not handelbar:
lauf.markt = "Markt zu oder weniger als 10 min bis Schluss"
if scharf:
return STILL
# 4 Bestaende: nicht lesbar heisst Befund; hat_bestand(None) ist falsch, die Instanz bleibt unberuehrt
lauf.vorher["alpaca"] = alpaca_bestand(alpaca)
if lauf.vorher["alpaca"] is None:
lauf.befund(f"alpaca: Bestand nicht lesbar ({alpaca.get('bestand_fehler') or 'ohne Angabe'}); nicht angefasst")
lauf.vorher["ibkr"], ibkr_exit = lies_ibkr()
if lauf.vorher["ibkr"] is None:
lauf.befund(f"ibkr: Bestand nicht lesbar (ibkr_pruefen.py lesen, Exit {ibkr_exit}); nicht angefasst")
for instanz in INSTANZEN:
lauf.sage(f"Bestand {instanz}: {beschreibe(lauf.vorher[instanz])}")
# 5 Je Instanz hoechstens ein Eingriff, nur im Modus scharf; wirft er, gilt Exit None
zu_glatt = [instanz for instanz in INSTANZEN if instanz not in lauf.gesperrt and hat_bestand(lauf.vorher[instanz])]
if lauf.modus == LESEN:
entscheidung = f"keine ({lauf.markt})" if lauf.markt else (", ".join(zu_glatt) or "keine")
lauf.sage(f"Glattstellen im Modus scharf: {entscheidung}")
return LESEN_ENDE
for instanz in zu_glatt:
try:
ergebnis_code, zeilen = glattstelle(instanz, lauf.modus)
except Exception as ausnahme:
ergebnis_code, zeilen = None, [f"abgebrochen: {type(ausnahme).__name__}"]
lauf.eingriffe[instanz] = {"exit": ergebnis_code, "zeilen": zeilen}
if ergebnis_code != 0:
lauf.befund(f"{instanz}: glattstellen endete mit Exit {ergebnis_code}")
# 6 Nach einem Eingriff warten und beide Bestaende neu lesen
if lauf.eingriffe:
schlafe(WARTEN_S)
lauf.nachher["alpaca"] = alpaca_bestand(lies_alpaca())
lauf.nachher["ibkr"], _ = lies_ibkr()
for instanz in INSTANZEN:
if lauf.nachher[instanz] is None:
lauf.befund(f"{instanz}: Bestand nachher nicht lesbar; Ergebnis offen")
else:
lauf.nachher = dict(lauf.vorher)
return BERICHT
def ergebnis_daten(lauf: Lauf, flach: bool) -> dict:
"""Inhalt von abbau.json: je Instanz vorher, nachher und das Glattstellen; dazu flach und die Befunde."""
return {
"zeit": lauf.jetzt.isoformat(timespec="seconds"),
"flach": flach,
"befunde": lauf.befunde,
"instanzen": {
instanz: {
"vorher": lauf.vorher.get(instanz),
"nachher": lauf.nachher.get(instanz),
"glattstellen": lauf.eingriffe.get(instanz),
}
for instanz in INSTANZEN
},
}
def titel_fuer(lauf: Lauf, flach: bool) -> str:
"""Rangfolge: flach mit Befund, Befund, flach, Rest offen."""
if flach and lauf.befunde:
return f"{TITEL}: flach, mit Befund"
if lauf.befunde:
return f"{TITEL}: Befund"
if flach:
return f"{TITEL}: flach"
return f"{TITEL}: Rest offen"
def notizzeile(lauf: Lauf, flach: bool, gemeldet: str) -> str:
"""Die Zeile unter "## Agentenprotokoll", mit Unterpunkten. Nur Symbol, Richtung, Stueck, Zahlen."""
if flach and lauf.befunde:
urteil = "flach, mit Befund #needs-review"
elif lauf.befunde:
urteil = "**Befund** #needs-review"
elif flach:
urteil = "flach"
else:
urteil = "Rest offen"
zeilen = [f"- {lauf.jetzt:%H:%M} Uhr - {KOPF}: {urteil}."]
zeilen += [f" - {teil}" for teil in lauf.zusammenfassung()]
for instanz, eingriff in lauf.eingriffe.items():
if eingriff["exit"] != 0:
zeilen += [f" - {instanz}, killswitch.py: {text}" for text in eingriff["zeilen"][-5:]]
zeilen += [f" - Befund: {befund}" for befund in lauf.befunde]
if lauf.eingriffe:
zeilen.append(f" - {GESPERRT_HINWEIS}")
zeilen.append(f" - Meldung ans Telefon: {gemeldet}")
return "\n".join(zeilen)
def berichte(lauf: Lauf, vault: Path) -> int:
"""Schritte 7 und 8: Ergebnisdatei und Log; Meldung und Notizzeile bei Eingriff, Befund oder flach."""
# flach nur ohne gesperrte Instanz: Ein laufender oder unlesbarer Runner oder eine unlesbare
# Marktuhr kann den Bestand gleich wieder aendern; der naechste Termin beobachtet weiter.
flach = not lauf.gesperrt and all(ist_flach(lauf.nachher.get(instanz)) for instanz in INSTANZEN)
try:
schreibe_ergebnis(lauf.ablage, ergebnis_daten(lauf, flach))
except OSError as ausnahme:
lauf.befund(f"Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__})")
logge(lauf.ablage, f"scharf: {' | '.join(lauf.ausgabe + lauf.zusammenfassung() + lauf.befunde)} | flach={flach}")
if lauf.spur:
logge(lauf.ablage, lauf.spur)
code = 1 if lauf.befunde else 0
# Still nur ohne Eingriff, ohne Befund und ohne flach. Das erste flach ist die Entwarnung;
# Laeufe nach der Marke enden schon in fahre().
if not lauf.eingriffe and not lauf.befunde and not flach:
return code
titel = titel_fuer(lauf, flach)
text = "; ".join(lauf.zusammenfassung() + [f"Befund: {befund}" for befund in lauf.befunde])
gemeldet = melde(titel, text[:1500].replace('"', "'"), lauf.modus)
try:
schreibe_notiz(vault, lauf.jetzt.date().isoformat(), notizzeile(lauf, flach, gemeldet))
except Exception as ausnahme:
code = 1
logge(lauf.ablage, f"Notiz nicht geschrieben: {type(ausnahme).__name__}")
return code
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False)
zerleger.add_argument(
"--modus", choices=MODI, required=True,
help="lesen: nur lesende Aufrufe, schreibt nichts; scharf: alles, nur ueber die Windows-Aufgabe",
)
zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)")
zerleger.add_argument("--projekt", default=str(PROJEKT), help="Projektordner unter Windows, fuer die Ablage")
zerleger.add_argument("--vault", default=str(VAULT))
argumente = zerleger.parse_args(argv)
try:
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
except ValueError:
zerleger.error("--jetzt ist kein ISO-Zeitpunkt")
lauf = Lauf(jetzt, argumente.modus, Path(argumente.projekt) / ABLAGE_TEIL)
try:
ende = fahre(lauf)
except Exception as ausnahme:
# Nur der Typ: Der Text einer fremden Ausnahme kann Kontonummer oder Betrag tragen.
# Der Traceback geht nur nach abbau.log (data/, nicht veroeffentlicht).
lauf.befund(f"abgebrochen: {type(ausnahme).__name__}")
lauf.spur = traceback.format_exc()
ende = BERICHT if lauf.modus == SCHARF else LESEN_ENDE
if ende == NICHTS:
return 0
if ende == STILL:
logge(lauf.ablage, f"scharf, nichts angefasst: {' | '.join(lauf.ausgabe)}")
return 0
if ende == LESEN_ENDE:
for zeile in lauf.ausgabe:
print(zeile)
for befund in lauf.befunde:
print(f"BEFUND: {befund}")
print(f"ERGEBNIS: gelesen, {'mit' if lauf.befunde else 'ohne'} Befund")
return 1 if lauf.befunde else 0
return berichte(lauf, Path(argumente.vault))
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,817 @@
"""Angriffsfaelle fuer den Abbau des Restbestands (Paket AB): abbau_restbestand.py und seine WSL-Teile.
Ersteller: Pascal Michel
Erstellt: 2026-10-07
Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB"
Koordinator-Werkzeug, nicht fuers Repo. Jeder Fall ist eine Funktion test_<was er angreift>; so laufen
dieselben Faelle als Skript und unter pytest (mutanten_lauf.py mit mutanten.toml im Planordner).
Abdichtung, doppelt: Alle Pfade nach draussen zeigen unten ins Leere (wsl.exe, die WSL-Skripte, die
Instanzordner), und jeder Fall ersetzt wsl() durch die Attrappe Welt, die jeden Aufruf zaehlt und nichts
startet; der Fall zu wsl() selbst ersetzt starte (subprocess.run). Kein Fall erreicht wsl.exe, das Netz
oder ntfy. Die Attrappe lehnt wie CPython jedes Argument mit NUL ab.
Die Faelle test_wsl_* fahren bash und die Venvs in WSL gegen Wegwerf-Instanzordner mit einem falschen
scripts/killswitch.py, dem erfundenen Wort TESTWORT-NICHT-ECHT, der erfundenen Kontonummer DUQ123456 und
erfundenen Betraegen, nie gegen einen echten Instanzordner. Unter Windows sind sie uebersprungen.
Aufruf unter Windows: python -I -X utf8 abbau_restbestand_angriffe.py
Aufruf in WSL: /home/claude/venvs/trading/bin/python -I abbau_restbestand_angriffe.py
Exit 0, wenn jeder Fall wie erwartet ausgeht.
"""
import contextlib
import io
import json
import subprocess
import sys
import tempfile
from collections import namedtuple
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from pathlib import Path
from types import SimpleNamespace
HIER = Path(__file__).resolve().parent
sys.path.insert(0, str(HIER))
import abbau_restbestand as ab # noqa: E402
# Abdichtung (Vorfall 2026-09-26: ein Mutant schickte ueber den absoluten WSL_SKRIPT Meldungen ans
# Telefon). Jeder Pfad nach draussen zeigt ins Leere, bevor ein Fall laeuft.
ab.WSL = str(Path(tempfile.gettempdir()) / "abbau-angriffe-kein-wsl" / "wsl.exe")
ab.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py"
ab.ALPACA_LESEN = "/nicht-vorhanden/abbau_alpaca_lesen.py"
ab.IBKR_LESEN = "/nicht-vorhanden/abbau_ibkr_lesen.sh"
ab.GLATTSTELLEN = "/nicht-vorhanden/abbau_glattstellen.sh"
ab.ORDNER_WSL = {"alpaca": "/nicht-vorhanden/alpaca", "ibkr": "/nicht-vorhanden/ibkr"}
WRAPPER = HIER / "abbau_glattstellen.sh"
IBKR_SKRIPT = HIER / "abbau_ibkr_lesen.sh"
NUL = chr(0)
TESTWORT = "TESTWORT-NICHT-ECHT"
KONTO = "DUQ123456"
BETRAG = "12345.67"
BETRAG_OHNE_GELDWORT = "98.76"
JETZT = "2026-10-08T15:45:00"
GRUND = (
"Abbau Restbestand nach Netzschalter-Abriss 2026-10-07; Auftrag Pascal 2026-10-07, "
"ausgefuehrt von der Aufgabe Abbau Restbestand"
)
LOG = "projekt/data/paper/abbau/abbau.log"
ERGEBNIS = "projekt/data/paper/abbau/abbau.json"
OFFEN = {"offen": True, "sekunden_bis_schluss": 6 * 3600}
ZU = {"offen": False, "sekunden_bis_schluss": 18 * 3600}
FLACH = {"positionen": [], "orders": 0}
AAPL10 = {"positionen": [("AAPL", 10)], "orders": 0}
IBKR7 = {
"positionen": [
("AAPL.XNAS", 10), ("MSFT.XNAS", 10), ("NVDA.XNAS", 20), ("JPM.XNYS", 10),
("CAT.XNYS", 10), ("KO.XNYS", 20), ("NEE.XNYS", 20),
],
"orders": 0,
}
WRAPPER_AUSGABE = (
"Zustand vorher: angehalten\nBroker: alpaca\nErgebnis: glattstellen, erfolgreich=True\n"
"Zustand nachher: gesperrt\n"
)
# Ein falsches scripts/killswitch.py fuer die Faelle test_wsl_*: schreibt seine Argumente (mit dem Wort)
# in den Wegwerf-Ordner, druckt das Wort auf stderr und eine erfundene Kontonummer und erfundene Betraege
# auf stdout. Nichts davon darf aus dem Wrapper herauskommen.
FALSCHER_KILLSWITCH = """import sys
from pathlib import Path
Path("aufgerufen").write_text(" ".join(sys.argv[1:]), encoding="utf-8")
print("Zustand vorher: aktiv")
print("Interpreter: " + sys.executable)
print("Argumente: " + " ".join(sys.argv[1:]))
print("Ergebnis: glattstellen, erfolgreich=False")
print(" Fehler: Kontowert 12345.67 USD nicht gedeckt")
print(" Fehler: Order an DUQ123456 abgelehnt, limit 98.76")
print("Zustand nachher: gesperrt", flush=True)
print("Abgelehnt, es wurde nichts geaendert: " + sys.argv[-1], file=sys.stderr)
sys.exit(int(Path("exit.txt").read_text(encoding="utf-8")))
"""
Roh = namedtuple("Roh", "exit text")
class Uebersprungen(Exception):
"""Ein Fall, der bash und die Venvs in WSL braucht, unter Windows."""
def nur_in_wsl() -> None:
if sys.platform == "win32":
raise Uebersprungen("nur in WSL")
def alpaca_json(uhr, bestand, fehler="BrokerFehler: Test") -> str:
"""Die JSON-Zeile, die abbau_alpaca_lesen.py drucken wuerde; fehler ist der bestand_fehler."""
daten = {"uhr": uhr, "uhr_fehler": None, "positionen": None, "orders": None, "bestand_fehler": None}
if uhr is None:
daten["uhr_fehler"] = "BrokerFehler: Marktuhr nicht lesbar"
if bestand is None:
daten["bestand_fehler"] = fehler
else:
daten["positionen"] = [{"symbol": symbol, "menge": menge} for symbol, menge in bestand["positionen"]]
daten["orders"] = bestand["orders"]
return json.dumps(daten) + "\n"
def ibkr_text(bestand, schluss="ERGEBNIS: gelesen") -> str:
"""stdout von scripts/ibkr_pruefen.py lesen in der gemessenen Form, ohne Kontonummer."""
zeilen = [
"verbunden: Gateway 127.0.0.1:4004, Client-ID 12, Handelsmodus paper, Konto XX (8 Zeichen), bestaetigt",
"Konto: Kontowert, Bargeld und Kaufkraft gelesen (Betraege werden nicht ausgegeben)",
f"Positionen: {len(bestand['positionen'])}",
]
zeilen += [f" {kennung} {menge}" for kennung, menge in bestand["positionen"]]
zeilen += [f"Offene Orders: {bestand['orders']}", schluss]
return "\n".join(zeilen) + "\n"
def unit_text(stand) -> str:
"""Ausgabe von systemctl show --property=LoadState,ActiveState: Text als Roh, (LoadState, ActiveState)
als Paar, oder nur ActiveState einer geladenen Unit."""
if isinstance(stand, Roh):
return stand.text
laden, aktiv = stand if isinstance(stand, tuple) else ("loaded", stand)
return f"LoadState={laden}\nActiveState={aktiv}\n"
class Welt:
"""Attrappe fuer alles hinter ab.wsl(): Units, Bestaende, Wrapper, Meldung. Zaehlt jeden Aufruf."""
def __init__(self, *, units=("inactive", "inactive"), uhr=OFFEN, alpaca=AAPL10, ibkr=IBKR7,
alpaca_nachher=FLACH, ibkr_nachher=FLACH, alpaca_fehler="BrokerFehler: Test",
wrapper_exit=0, wrapper_wirft=False, echter_wrapper=None):
self.units = dict(zip(ab.INSTANZEN, units))
self.uhr = uhr
self.bestand = {"alpaca": alpaca, "ibkr": ibkr}
self.nachher = {"alpaca": alpaca_nachher, "ibkr": ibkr_nachher}
self.alpaca_fehler = alpaca_fehler
self.wrapper_exit = wrapper_exit
self.wrapper_wirft = wrapper_wirft
self.echter_wrapper = echter_wrapper
self.aufrufe = []
self.glatt = []
self.ordner = []
self.meldungen = []
self.schlaf = []
self.gelesen = {"alpaca": 0, "ibkr": 0}
def schlafe(self, sekunden):
self.schlaf.append(sekunden)
def __call__(self, argumente, timeout):
argumente = list(argumente)
if any(NUL in argument for argument in argumente):
raise ValueError("embedded null character")
self.aufrufe.append(argumente)
if argumente[:2] == ["systemctl", "show"]:
instanz = next(name for name, unit in ab.UNITS.items() if unit == argumente[-1])
return 0, unit_text(self.units[instanz]), ""
if argumente[:2] == [ab.ALPACA_PY, ab.ALPACA_LESEN]:
self.gelesen["alpaca"] += 1
return 0, alpaca_json(self.uhr, self.bestand["alpaca"], self.alpaca_fehler), ""
if argumente[:2] == ["bash", ab.IBKR_LESEN]:
self.gelesen["ibkr"] += 1
stand = self.bestand["ibkr"]
if stand is None:
return 4, "", "Voraussetzung fehlt: Gateway nicht erreichbar\n"
if isinstance(stand, Roh):
return stand.exit, stand.text, ""
return 0, ibkr_text(stand), ""
if argumente[:2] == ["bash", ab.GLATTSTELLEN]:
instanz = argumente[2]
self.glatt.append(instanz)
self.ordner.append(argumente[3])
if self.wrapper_wirft:
raise subprocess.TimeoutExpired(argumente, timeout)
self.bestand[instanz] = self.nachher[instanz]
if self.echter_wrapper is not None:
return self.echter_wrapper(argumente[2:])
return self.wrapper_exit, WRAPPER_AUSGABE, ""
if argumente[:3] == [ab.WSL_PY, ab.WSL_SKRIPT, "--meldung"]:
self.meldungen.append((argumente[3], argumente[4]))
return 0, '{"meldung": "gesendet"}\n', ""
raise AssertionError(f"unbekannter Aufruf: {argumente!r}")
@contextlib.contextmanager
def attrappe(welt):
"""Ersetzt wsl() und schlafe() fuer die Dauer eines Falls und stellt beide danach zurueck."""
alt = ab.wsl, ab.schlafe
ab.wsl, ab.schlafe = welt, welt.schlafe
try:
yield welt
finally:
ab.wsl, ab.schlafe = alt
def lies_json(text):
try:
return json.loads(text) if text else None
except ValueError:
return None
def lauf_mit(welt, *, jetzt=JETZT, modus="scharf", ergebnis_vorher=None):
"""Ein Lauf von ab.main() in Wegwerf-Ordnern fuer Projekt und Vault; gibt alles Sichtbare zurueck."""
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
projekt, vault = tmp / "projekt", tmp / "vault"
projekt.mkdir()
(vault / "Inbox").mkdir(parents=True)
ablage = projekt / ab.ABLAGE_TEIL
if ergebnis_vorher is not None:
ablage.mkdir(parents=True)
(ablage / ab.ERGEBNIS_NAME).write_text(ergebnis_vorher, encoding="utf-8")
puffer = io.StringIO()
with attrappe(welt), contextlib.redirect_stdout(puffer):
code = ab.main(["--modus", modus, "--jetzt", jetzt, "--projekt", str(projekt), "--vault", str(vault)])
ergebnis = ablage / ab.ERGEBNIS_NAME
log = ablage / ab.LOG_NAME
notizen = sorted((vault / "Inbox").glob("*.md"))
text = ergebnis.read_text(encoding="utf-8") if ergebnis.exists() else None
return SimpleNamespace(
code=code,
welt=welt,
ausgabe=puffer.getvalue(),
dateien=sorted(p.relative_to(tmp).as_posix() for p in tmp.rglob("*") if p.is_file()),
ergebnis_text=text,
json=lies_json(text),
log=log.read_text(encoding="utf-8") if log.exists() else "",
notiz=notizen[0].read_text(encoding="utf-8") if notizen else "",
)
def titel_von(e) -> list[str]:
return [titel for titel, _ in e.welt.meldungen]
def kurz(e) -> str:
flach = None if e.json is None else e.json.get("flach")
return (
f"code={e.code} glatt={e.welt.glatt} ordner={e.welt.ordner} schlaf={e.welt.schlaf} "
f"gelesen={e.welt.gelesen} meldungen={titel_von(e)} flach={flach} dateien={e.dateien} "
f"ausgabe={e.ausgabe[-400:]!r}"
)
def instanzordner(pfad: Path, *, wort=TESTWORT, exit_code=0) -> Path:
"""Ein Wegwerf-Instanzordner mit falschem scripts/killswitch.py und, wenn wort gesetzt ist, killswitch.toml."""
(pfad / "scripts").mkdir(parents=True)
(pfad / "config").mkdir()
if wort is not None:
(pfad / "config" / "killswitch.toml").write_text(
f'[killswitch]\nbestaetigungswort = "{wort}"\n', encoding="utf-8"
)
(pfad / "scripts" / "killswitch.py").write_text(FALSCHER_KILLSWITCH, encoding="utf-8")
(pfad / "exit.txt").write_text(str(exit_code), encoding="utf-8")
return pfad
def wrapper(tmp: Path, *argumente: str) -> tuple[int, str]:
"""Der echte abbau_glattstellen.sh aus einem leeren Arbeitsordner; stdout und stderr zusammen."""
leer = tmp / "leer"
leer.mkdir(exist_ok=True)
lauf = subprocess.run(
["bash", str(WRAPPER), *argumente], cwd=leer, capture_output=True, text=True, timeout=120
)
return lauf.returncode, lauf.stdout + lauf.stderr
class FalscherAlpaca:
"""Ein Broker mit den drei Leseaufrufen; die Betraege sind erfunden und duerfen nie herauskommen."""
def __init__(self, jetzt, fehler=None):
self.jetzt = jetzt
self.fehler = fehler
def marktuhr(self):
return SimpleNamespace(is_open=True, next_open=self.jetzt, next_close=self.jetzt + timedelta(minutes=20))
def positionen(self):
if self.fehler is not None:
raise RuntimeError(self.fehler)
return [
SimpleNamespace(symbol="AAPL", menge=10, seite="long", marktwert=1234.56, einstandskurs=98.76),
SimpleNamespace(symbol="KO", menge=-20, seite="short", marktwert=-1111.11, einstandskurs=55.55),
]
def offene_orders(self):
return [SimpleNamespace(id="o-1", menge=10, limit=98.76)]
# --- Bedienung und Grundlinie ---------------------------------------------------------------------
def test_ohne_modus_bricht_mit_exit_2_ab_und_ruft_nichts():
welt = Welt()
codes = []
for argumente in (["--jetzt", JETZT], ["--modus", "probe", "--jetzt", JETZT]):
with attrappe(welt), contextlib.redirect_stderr(io.StringIO()):
try:
ab.main(argumente)
except SystemExit as ende:
codes.append(ende.code)
else:
codes.append(None)
assert codes == [2, 2], codes
assert welt.aufrufe == [], welt.aufrufe
def test_grundlinie_je_instanz_genau_ein_eingriff_dann_flach():
e = lauf_mit(Welt())
assert e.code == 0, kurz(e)
assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
assert e.welt.ordner == [ab.ORDNER_WSL["alpaca"], ab.ORDNER_WSL["ibkr"]], kurz(e)
assert e.welt.schlaf == [ab.WARTEN_S], kurz(e)
assert e.welt.gelesen == {"alpaca": 2, "ibkr": 2}, kurz(e)
assert titel_von(e) == ["Abbau Restbestand: flach"], kurz(e)
assert e.json["flach"] is True, kurz(e)
assert e.json["instanzen"]["alpaca"]["vorher"] == {"positionen": {"AAPL": 10}, "orders": 0}, kurz(e)
assert e.json["instanzen"]["ibkr"]["nachher"] == {"positionen": {}, "orders": 0}, kurz(e)
assert "Abbau Restbestand (automatisch, Auftrag Pascal 2026-10-07): flach." in e.notiz, e.notiz
assert "AAPL long 10" in e.notiz and "NEE long 20" in e.notiz, e.notiz
assert "#needs-review" not in e.notiz, e.notiz
# --- Fenster ---------------------------------------------------------------------------------------
def test_vor_dem_fenster_eine_sekunde_tut_nichts():
e = lauf_mit(Welt(), jetzt="2026-10-08T15:39:59")
assert e.code == 0 and e.welt.aufrufe == [] and e.dateien == [], kurz(e)
def test_fensterbeginn_genau_1540_greift():
e = lauf_mit(Welt(), jetzt="2026-10-08T15:40:00")
assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
def test_fensterende_genau_2145_am_freitag_greift():
e = lauf_mit(Welt(), jetzt="2026-10-09T21:45:00")
assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
def test_nach_dem_fenster_eine_sekunde_tut_nichts():
e = lauf_mit(Welt(), jetzt="2026-10-09T21:45:01")
assert e.code == 0 and e.welt.aufrufe == [] and e.dateien == [], kurz(e)
# --- Marke flach und Entwarnung --------------------------------------------------------------------
def test_zweiter_lauf_nach_flach_tut_nichts():
vorher = json.dumps({"zeit": "2026-10-08T15:45:00", "flach": True})
e = lauf_mit(Welt(), ergebnis_vorher=vorher)
assert e.code == 0 and e.welt.aufrufe == [] and e.welt.meldungen == [], kurz(e)
assert e.ergebnis_text == vorher and e.notiz == "", kurz(e)
assert e.dateien == [ERGEBNIS, LOG], kurz(e)
def test_ergebnisdatei_ohne_echtes_true_laeuft_weiter():
for vorher in ('{"flach": false}', '{"flach": "ja"}', '{"flach": 1}', "{kaputt", "[true]"):
e = lauf_mit(Welt(), ergebnis_vorher=vorher)
assert e.welt.glatt == ["alpaca", "ibkr"], (vorher, kurz(e))
def test_erstes_flach_ohne_eingriff_meldet_entwarnung():
vorher = json.dumps({"flach": False, "befunde": ["ibkr: Bestand nachher nicht lesbar; Ergebnis offen"]})
e = lauf_mit(Welt(alpaca=FLACH, ibkr=FLACH), ergebnis_vorher=vorher)
assert e.code == 0 and e.welt.glatt == [] and e.welt.schlaf == [], kurz(e)
assert titel_von(e) == ["Abbau Restbestand: flach"], kurz(e)
assert ": flach." in e.notiz and "#needs-review" not in e.notiz, e.notiz
assert e.json["flach"] is True, kurz(e)
def test_gesperrte_instanz_verhindert_flach_auch_bei_flachem_bestand():
e = lauf_mit(Welt(units=("inactive", "active"), alpaca=FLACH, ibkr=FLACH))
assert e.code == 1 and e.welt.glatt == [], kurz(e)
assert e.json["flach"] is False, kurz(e)
assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e)
assert "**Befund** #needs-review" in e.notiz, e.notiz
# --- Modus lesen -----------------------------------------------------------------------------------
def test_modus_lesen_ruft_den_wrapper_nie_und_schreibt_nichts():
e = lauf_mit(Welt(), modus="lesen")
assert e.code == 0, kurz(e)
assert e.welt.glatt == [] and e.welt.meldungen == [] and e.welt.schlaf == [], kurz(e)
assert e.dateien == [], kurz(e)
for erwartet in (
"Bestand alpaca: 1 Position(en): AAPL long 10; offene Orders 0",
"Glattstellen im Modus scharf: alpaca, ibkr",
"ERGEBNIS: gelesen, ohne Befund",
):
assert erwartet in e.ausgabe, (erwartet, e.ausgabe)
def test_modus_lesen_nachts_bei_markt_zu_liest_beide_bestaende_ohne_eingriff():
e = lauf_mit(Welt(uhr=ZU), modus="lesen", jetzt="2026-10-08T00:30:00")
assert e.code == 0, kurz(e)
assert e.welt.glatt == [] and e.welt.meldungen == [] and e.dateien == [], kurz(e)
assert e.welt.gelesen == {"alpaca": 1, "ibkr": 1}, kurz(e)
for erwartet in (
": ausserhalb",
"Runner alpaca (trading-paper.service): LoadState=loaded, ActiveState=inactive",
"Markt zu",
"Bestand ibkr: 7 Position(en): AAPL long 10, CAT long 10, JPM long 10, KO long 20, MSFT long 10, "
"NEE long 20, NVDA long 20; offene Orders 0",
"Glattstellen im Modus scharf: keine (Markt zu",
):
assert erwartet in e.ausgabe, (erwartet, e.ausgabe)
def test_modus_lesen_liest_auch_nach_flach():
e = lauf_mit(Welt(), modus="lesen", ergebnis_vorher='{"flach": true}')
assert e.welt.gelesen == {"alpaca": 1, "ibkr": 1} and e.welt.glatt == [], kurz(e)
assert "Ergebnisdatei sagt flach" in e.ausgabe, e.ausgabe
assert e.dateien == [ERGEBNIS], kurz(e)
# --- Marktuhr --------------------------------------------------------------------------------------
def test_markt_zu_scharf_still_ohne_eingriff():
e = lauf_mit(Welt(uhr=ZU))
assert e.code == 0 and e.welt.glatt == [] and e.welt.meldungen == [], kurz(e)
assert e.welt.gelesen == {"alpaca": 1, "ibkr": 0}, kurz(e)
assert e.dateien == [LOG] and e.notiz == "", kurz(e)
assert "Markt zu" in e.log, e.log
def test_markt_schliesst_in_599_s_still():
e = lauf_mit(Welt(uhr={"offen": True, "sekunden_bis_schluss": 599}))
assert e.code == 0 and e.welt.glatt == [] and e.dateien == [LOG], kurz(e)
def test_markt_schliesst_in_genau_600_s_greift():
e = lauf_mit(Welt(uhr={"offen": True, "sekunden_bis_schluss": 600}))
assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
def test_marktuhr_unlesbar_befund_ohne_eingriff():
e = lauf_mit(Welt(uhr=None))
assert e.code == 1 and e.welt.glatt == [] and e.welt.schlaf == [], kurz(e)
assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e)
assert e.json["flach"] is False, kurz(e)
assert "**Befund** #needs-review" in e.notiz and "Marktuhr nicht lesbar" in e.notiz, e.notiz
# --- Runner-Units ----------------------------------------------------------------------------------
def test_laufender_runner_sperrt_genau_seine_instanz():
e = lauf_mit(Welt(units=("active", "inactive")))
assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e)
erwartet = "alpaca: Runner-Unit trading-paper.service laeuft (LoadState=loaded, ActiveState=active); nicht angefasst"
assert erwartet in e.notiz, e.notiz
assert "#needs-review" in e.notiz, e.notiz
e = lauf_mit(Welt(units=("inactive", "deactivating")))
assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e)
def test_runner_zustand_unlesbar_instanz_nicht_angefasst():
for zustand in ("", "unknown", Roh(0, "")):
e = lauf_mit(Welt(units=(zustand, "inactive")))
assert e.code == 1 and e.welt.glatt == ["ibkr"], (zustand, kurz(e))
def test_runner_unit_not_found_sperrt_die_instanz():
e = lauf_mit(Welt(units=(("not-found", "inactive"), "inactive")))
assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e)
assert "LoadState=not-found, ActiveState=inactive" in e.notiz and "#needs-review" in e.notiz, e.notiz
def test_runner_failed_gilt_als_stehend():
e = lauf_mit(Welt(units=("failed", "failed")))
assert e.code == 0 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
# --- Bestaende -------------------------------------------------------------------------------------
def test_ibkr_ohne_ergebnis_zeile_gilt_als_unlesbar():
roh = Roh(1, "Positionen: 1\n AAPL.XNAS 10\nOffene Orders: 0\n")
e = lauf_mit(Welt(ibkr=roh))
assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e)
assert "ibkr: Bestand nicht lesbar" in e.notiz, e.notiz
def test_ibkr_exit_4_gilt_als_unlesbar():
e = lauf_mit(Welt(ibkr=Roh(4, ibkr_text(IBKR7))))
assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e)
def test_ibkr_zahl_und_zeilen_passen_nicht():
roh = Roh(0, "Positionen: 2\n AAPL.XNAS 10\nOffene Orders: 0\nERGEBNIS: gelesen\n")
e = lauf_mit(Welt(ibkr=roh))
assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e)
def test_ibkr_exit_1_mit_ergebnis_gelesen_gilt_als_gelesen():
roh = Roh(1, ibkr_text(IBKR7, schluss="ERGEBNIS: gelesen, mit Befund"))
e = lauf_mit(Welt(ibkr=roh))
assert e.code == 0 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
def test_alpaca_bestand_unlesbar_nicht_angefasst():
e = lauf_mit(Welt(alpaca=None))
assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e)
assert "alpaca: Bestand nicht lesbar (BrokerFehler: Test); nicht angefasst" in e.notiz, e.notiz
def test_fremder_fehlertext_mit_zeilenumbruch_bleibt_einzeilig():
e = lauf_mit(Welt(alpaca=None, alpaca_fehler="BrokerFehler: HTTP 500\n## Zweite Zeile"))
assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e)
assert "BrokerFehler: HTTP 500 ## Zweite Zeile" in e.notiz, e.notiz
assert not any(zeile.startswith("## Zweite") for zeile in e.notiz.splitlines()), e.notiz
assert not any(zeile.startswith("## Zweite") for zeile in e.log.splitlines()), e.log
for titel, text in e.welt.meldungen:
assert "\n" not in text and "\r" not in text, text
def test_nur_offene_order_ohne_position_wird_glattgestellt():
e = lauf_mit(Welt(alpaca={"positionen": [], "orders": 1}, ibkr=FLACH))
assert e.code == 0 and e.welt.glatt == ["alpaca"], kurz(e)
def test_flache_instanz_wird_nicht_angefasst():
e = lauf_mit(Welt(alpaca=FLACH))
assert e.code == 0 and e.welt.glatt == ["ibkr"], kurz(e)
assert e.json["instanzen"]["alpaca"]["glattstellen"] is None, kurz(e)
# --- Ergebnis nach dem Eingriff --------------------------------------------------------------------
def test_rest_offen_nach_eingriff():
e = lauf_mit(Welt(ibkr_nachher={"positionen": [("NEE.XNYS", 20)], "orders": 0}))
assert e.code == 0 and e.json["flach"] is False, kurz(e)
assert titel_von(e) == ["Abbau Restbestand: Rest offen"], kurz(e)
assert ": Rest offen." in e.notiz and "#needs-review" not in e.notiz, e.notiz
def test_offene_order_nachher_ist_nicht_flach():
e = lauf_mit(Welt(alpaca_nachher={"positionen": [], "orders": 1}))
assert e.json["flach"] is False, kurz(e)
assert titel_von(e) == ["Abbau Restbestand: Rest offen"], kurz(e)
def test_wrapper_exit_1_und_danach_flach_heisst_flach_mit_befund():
e = lauf_mit(Welt(wrapper_exit=1))
assert e.code == 1, kurz(e)
assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
assert e.json["flach"] is True, kurz(e)
assert titel_von(e) == ["Abbau Restbestand: flach, mit Befund"], kurz(e)
assert ": flach, mit Befund #needs-review." in e.notiz, e.notiz
assert "alpaca: glattstellen endete mit Exit 1" in e.notiz, e.notiz
def test_glattstellen_wirft_exit_none_befund_nachlesen_und_meldung():
e = lauf_mit(Welt(wrapper_wirft=True))
assert e.code == 1 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
assert e.welt.schlaf == [ab.WARTEN_S] and e.welt.gelesen == {"alpaca": 2, "ibkr": 2}, kurz(e)
assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e)
assert "alpaca: glattstellen endete mit Exit None" in e.notiz, e.notiz
assert e.json["instanzen"]["alpaca"]["glattstellen"]["exit"] is None, kurz(e)
def test_nachher_unlesbar_ist_befund_und_nicht_flach():
e = lauf_mit(Welt(ibkr_nachher=None))
assert e.code == 1 and e.json["flach"] is False, kurz(e)
assert "ibkr: Bestand nachher nicht lesbar" in e.notiz, e.notiz
def test_ausnahme_traegt_nur_den_typnamen_in_notiz_und_meldung():
welt = Welt()
def wirft(sekunden):
raise RuntimeError(f"Order an {KONTO} limit {BETRAG}")
welt.schlafe = wirft
e = lauf_mit(welt)
assert e.code == 1, kurz(e)
assert "Befund: abgebrochen: RuntimeError" in e.notiz, e.notiz
texte = [e.notiz, e.ergebnis_text or ""] + [titel + " " + text for titel, text in e.welt.meldungen]
for text in texte:
assert KONTO not in text and BETRAG not in text, text
assert "Traceback" in e.log, e.log
# --- wsl.exe-Fehler und NUL ------------------------------------------------------------------------
def test_aufruf_nach_wsl_entfernt_nul_und_setzt_wsl_utf8():
gesehen = []
def falscher_start(befehl, **optionen):
gesehen.append((befehl, optionen))
return SimpleNamespace(
returncode=4294967295,
stdout="Es ist keine Distribution".encode("utf-16-le"),
stderr="Fehler".encode("utf-16-le"),
)
alt = ab.starte
ab.starte = falscher_start
try:
code, ausgabe, fehler = ab.wsl(["systemctl", "show", "trading-paper.service"], 5)
finally:
ab.starte = alt
assert code == 4294967295, code
assert NUL not in ausgabe and NUL not in fehler, (ausgabe, fehler)
assert ausgabe == "Es ist keine Distribution" and fehler == "Fehler", (ausgabe, fehler)
assert len(gesehen) == 1 and gesehen[0][0][0] == ab.WSL, gesehen
assert gesehen[0][1]["env"]["WSL_UTF8"] == "1", gesehen[0][1].get("env", {}).get("WSL_UTF8")
def test_runner_zustand_mit_nul_kommt_sauber_in_notiz_log_und_meldung():
roh = Roh(0, "LoadState=loaded\nActiveState=E" + NUL + "s" + NUL + "\n")
e = lauf_mit(Welt(units=(roh, "inactive")))
assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e)
assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e)
assert NUL not in e.notiz and NUL not in e.log, (e.notiz, e.log)
welt = Welt()
with attrappe(welt):
antwort = ab.melde("Abbau Restbestand: Probe" + NUL, "Text" + NUL, ab.SCHARF)
assert antwort == "gesendet", antwort
assert welt.meldungen == [("Abbau Restbestand: Probe", "Text")], welt.meldungen
# --- Abdichtung ------------------------------------------------------------------------------------
def test_glattstellen_und_melden_ohne_scharf_rufen_nie_wsl():
welt = Welt()
with attrappe(welt):
try:
ab.glattstelle("alpaca", ab.LESEN)
except RuntimeError:
abgelehnt = True
else:
abgelehnt = False
antwort = ab.melde("Abbau Restbestand: Probe", "Text", ab.LESEN)
assert abgelehnt, "glattstelle lief im Modus lesen"
assert welt.aufrufe == [], welt.aufrufe
assert antwort.startswith("nicht gesendet"), antwort
def test_abdichtung_alle_pfade_nach_draussen_zeigen_ins_leere():
assert not Path(ab.WSL).exists(), ab.WSL
assert ab.starte is subprocess.run, ab.starte
for pfad in (ab.WSL_SKRIPT, ab.ALPACA_LESEN, ab.IBKR_LESEN, ab.GLATTSTELLEN, *ab.ORDNER_WSL.values()):
assert pfad.startswith("/nicht-vorhanden/"), pfad
try:
ab.wsl(["systemctl", "show", "trading-paper.service"], 5)
except FileNotFoundError:
return
raise AssertionError("wsl() startete einen Prozess, obwohl WSL ins Leere zeigt")
# --- WSL: Wrapper, Kette, Helfer -------------------------------------------------------------------
def test_wsl_wrapper_waehlt_alpaca_ersetzt_das_wort_und_maskiert_konto_und_betraege():
nur_in_wsl()
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ordner = instanzordner(tmp / "alpaca", exit_code=1)
code, text = wrapper(tmp, "alpaca", str(ordner))
angekommen = (ordner / "aufgerufen").read_text(encoding="utf-8")
assert code == 1, (code, text)
assert angekommen == f"--broker alpaca glattstellen --grund {GRUND} --bestaetigung {TESTWORT}", angekommen
assert TESTWORT not in text and "[wort]" in text, text
assert BETRAG not in text and "Kontowert [x]" in text, text
assert KONTO not in text and "Order an [konto]" in text, text
assert BETRAG_OHNE_GELDWORT not in text and "limit [x]" in text, text
assert "venvs/trading/bin/python" in text, text
def test_wsl_wrapper_ibkr_mit_attrappe_nimmt_ihr_venv_und_broker_vor_dem_unterbefehl():
nur_in_wsl()
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ordner = instanzordner(tmp / "ibkr")
code, text = wrapper(tmp, "ibkr", str(ordner), "--broker", "attrappe")
angekommen = (ordner / "aufgerufen").read_text(encoding="utf-8")
assert code == 0, (code, text)
assert angekommen.startswith("--broker attrappe glattstellen --grund "), angekommen
assert "venvs/trading-ibkr/bin/python" in text, text
def test_wsl_wrapper_lehnt_anderen_broker_und_fremde_angaben_ab():
nur_in_wsl()
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ordner = instanzordner(tmp / "alpaca")
faelle = (
("alpaca", str(ordner), "--broker", "alpaca"),
("alpaca", str(ordner), "--broker"),
("alpaca", str(ordner), "--weiter"),
("live", str(ordner)),
("alpaca",),
)
codes = [wrapper(tmp, *fall)[0] for fall in faelle]
aufgerufen = (ordner / "aufgerufen").exists()
assert codes == [2, 2, 2, 2, 2], codes
assert not aufgerufen, "killswitch.py wurde trotz Bedienfehler gerufen"
def test_wsl_wrapper_ohne_wort_ruft_killswitch_nicht():
nur_in_wsl()
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ordner = instanzordner(tmp / "alpaca", wort=None)
code, text = wrapper(tmp, "alpaca", str(ordner))
aufgerufen = (ordner / "aufgerufen").exists()
assert code == 4 and not aufgerufen, (code, aufgerufen, text)
def test_wsl_kette_wort_konto_und_betrag_stehen_nirgends():
nur_in_wsl()
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
ordner = {instanz: instanzordner(tmp / instanz, exit_code=1) for instanz in ab.INSTANZEN}
def echter_wrapper(argumente):
assert Path(argumente[1]).resolve().is_relative_to(tmp.resolve()), argumente
code, text = wrapper(tmp, *argumente)
return code, text, ""
alt = ab.ORDNER_WSL
ab.ORDNER_WSL = {instanz: str(pfad) for instanz, pfad in ordner.items()}
try:
e = lauf_mit(Welt(echter_wrapper=echter_wrapper))
finally:
ab.ORDNER_WSL = alt
angekommen = (ordner["ibkr"] / "aufgerufen").read_text(encoding="utf-8")
assert e.code == 1 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e)
assert angekommen.startswith("--broker ibkr glattstellen --grund "), angekommen
assert angekommen.endswith("--bestaetigung " + TESTWORT), angekommen
assert "[wort]" in e.ergebnis_text and "Kontowert [x]" in e.notiz, (e.ergebnis_text, e.notiz)
assert "[konto]" in e.notiz, e.notiz
texte = [e.ausgabe, e.ergebnis_text, e.log, e.notiz] + [titel + " " + text for titel, text in e.welt.meldungen]
for text in texte:
for geheim in (TESTWORT, KONTO, BETRAG, BETRAG_OHNE_GELDWORT):
assert geheim not in text, (geheim, text)
def test_wsl_alpaca_helfer_gibt_stueck_ohne_betrag():
nur_in_wsl()
import abbau_alpaca_lesen as helfer
jetzt = datetime(2026, 10, 8, 14, 0, tzinfo=timezone.utc)
daten = helfer.lies(FalscherAlpaca(jetzt), jetzt)
text = json.dumps(daten)
assert daten["uhr"] == {"offen": True, "sekunden_bis_schluss": 1200}, daten
assert daten["positionen"] == [{"symbol": "AAPL", "menge": 10}, {"symbol": "KO", "menge": -20}], daten
assert daten["orders"] == 1 and daten["bestand_fehler"] is None, daten
for betrag in ("1234.56", "98.76", "1111.11", "55.55"):
assert betrag not in text, (betrag, text)
def test_wsl_alpaca_helfer_maskiert_konto_und_betraege_im_fehlertext():
nur_in_wsl()
import abbau_alpaca_lesen as helfer
jetzt = datetime(2026, 10, 8, 14, 0, tzinfo=timezone.utc)
fehler = f"Order an {KONTO}: limit {BETRAG_OHNE_GELDWORT}, Kontowert {BETRAG} USD"
daten = helfer.lies(FalscherAlpaca(jetzt, fehler=fehler), jetzt)
text = json.dumps(daten)
assert daten["positionen"] is None and daten["orders"] is None, daten
assert daten["bestand_fehler"].startswith("RuntimeError: "), daten
assert "Order an [konto]" in daten["bestand_fehler"] and "limit [x]" in daten["bestand_fehler"], daten
for geheim in (KONTO, BETRAG, BETRAG_OHNE_GELDWORT):
assert geheim not in text, (geheim, text)
assert daten["uhr"] == {"offen": True, "sekunden_bis_schluss": 1200}, daten
def test_wsl_ibkr_lesen_ohne_erreichbaren_ordner_startet_nichts():
nur_in_wsl()
with tempfile.TemporaryDirectory() as tmp:
leer = Path(tmp)
ohne = subprocess.run(["bash", str(IBKR_SKRIPT)], cwd=leer, capture_output=True, text=True, timeout=60)
fehlt = subprocess.run(
["bash", str(IBKR_SKRIPT), "/nicht-vorhanden/ibkr"], cwd=leer, capture_output=True, text=True, timeout=60
)
assert ohne.returncode == 2, (ohne.returncode, ohne.stderr)
assert fehlt.returncode == 4, (fehlt.returncode, fehlt.stderr)
# --- Lauf als Skript -------------------------------------------------------------------------------
def main() -> int:
faelle = [(name, wert) for name, wert in globals().items() if name.startswith("test_") and callable(wert)]
abweichungen = 0
uebersprungen = 0
for name, fall in faelle:
try:
fall()
except Uebersprungen as grund:
uebersprungen += 1
print(f"UEBERSPRUNGEN {name}: {grund}")
continue
except (Exception, SystemExit) as ausnahme:
abweichungen += 1
print(f"FEHL {name}: {type(ausnahme).__name__}: {str(ausnahme)[:400]!r}")
continue
print(f"OK {name}")
print(f"ERGEBNIS: {len(faelle)} Faelle, {abweichungen} Abweichung(en), {uebersprungen} uebersprungen")
return 1 if abweichungen else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,54 @@
# Richtet die einmalige Windows-Aufgabe "Trading Plattform - Abbau Restbestand" ein oder entfernt sie.
# Ersteller: Pascal Michel Erstellt: 2026-10-07
# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB"
#
# Pascal 2026-10-07: am 2026-10-08 soweit es geht alles verkaufen. Neun Ausloeser: acht Termine
# (Do 2026-10-08 15:45, 16:15, 17:00, 19:00, 21:00; Ersatz Fr 2026-10-09 15:45, 16:30, 19:00) und
# fuenf Minuten nach jeder Anmeldung. Der Anmelde-Ausloeser holt einen verpassten Termin nach, wie bei
# der Abendkontrolle; dass StartWhenAvailable das fuer eine Aufgabe mit Interactive allein tut, ist
# nicht belegt. abbau_restbestand.py tut ausserhalb seines Fensters und nach "flach" nichts; ein Lauf
# zur falschen Zeit ist also harmlos. Nach dem 2026-10-09 mit -Aktion entfernen abbauen, sonst bleibt
# der Anmelde-Ausloeser stehen.
#
# Aufruf: powershell -NoProfile -File abbau_restbestand_einrichten.ps1 [-Aktion einrichten|entfernen]
param(
[ValidateSet("einrichten", "entfernen")]
[string]$Aktion = "einrichten"
)
$Name = "Trading Plattform - Abbau Restbestand"
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\abbau_restbestand.py"
if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" }
if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" }
$Termine = @(
"2026-10-08T15:45:00", "2026-10-08T16:15:00", "2026-10-08T17:00:00", "2026-10-08T19:00:00",
"2026-10-08T21:00:00", "2026-10-09T15:45:00", "2026-10-09T16:30:00", "2026-10-09T19:00:00"
)
$Benutzer = "$env:USERDOMAIN\$env:USERNAME"
$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --modus scharf') -WorkingDirectory "D:\Claude"
$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer
$Anmeldung.Delay = "PT5M"
$Ausloeser = @($Termine | ForEach-Object { New-ScheduledTaskTrigger -Once -At ([datetime]$_) }) + @($Anmeldung)
$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 $Ausloeser -Principal $Wer `
-Settings $Regeln -Force `
-Description "Abbau des Paper-Restbestands nach dem Netzschalter-Abriss vom 2026-10-07 (Auftrag Pascal). Siehe abbau_restbestand.py." | Out-Null
$Aufgabe = Get-ScheduledTask -TaskName $Name
"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State
"Ausloeser: {0}" -f @($Aufgabe.Triggers).Count
$Aufgabe.Triggers | ForEach-Object { "Termin: {0} {1} {2}" -f $_.CimClass.CimClassName, $_.StartBoundary, $_.Delay }
"LogonType: {0}" -f $Aufgabe.Principal.LogonType
"StartWhenAvailable: {0}" -f $Aufgabe.Settings.StartWhenAvailable
"MultipleInstances: {0}" -f $Aufgabe.Settings.MultipleInstances
"ExecutionTimeLimit: {0}" -f $Aufgabe.Settings.ExecutionTimeLimit
"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime
@@ -1,11 +1,11 @@
---
type: plan
title: "Quorum am Meilenstein: drei Stimmen, Mehrheit, Nachprüfung"
status: draft
status: done
summary: "Nach dem Plan prüft ein neuer Workflow quorum vor Einspielen, neuer Version auf master und Abgabe eines Kundendokuments denselben Unterschied seit dem letzten Quorum mit Opus, Sonnet und Codex (Ersatz Haiku), zählt Mehrheiten, lässt Einzelbefunde von Sonnet nachprüfen und liefert Urteil und Bericht."
tags: [plan, vault-tools, codeworkflow]
created: 2026-09-29
updated: 2026-09-30
updated: 2026-10-05
---
# Quorum am Meilenstein
@@ -49,7 +49,7 @@ quorum.js (Args: anlass, projekt | ordner+dateien+uebersicht, basis?, kontext?,
└─ Urteil, Zählung je Stimme, bericht_md; verbrauch.js am Ende (wunsch 0)
```
Die Taskdateien 01 bis 04 enthalten den Code wörtlich.
Die Taskdateien 01 bis 06 enthalten den Code wörtlich; 05 und 06 sind Nachbesserungen als Delta gegen den Baum.
## Codex: welcher Befehl, und warum so
@@ -106,8 +106,9 @@ Nachgelesen im Quelltext des Plugins `openai-codex/codex/1.0.6` (unter `~/.claud
| 03 | `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, `mutanten_workflows.py` | geändert | sonnet, low | 3 |
| 04 | `.claude/agents/quorum-stimme.md` (neu), `CLAUDE.md`, `AI/Regelwerk/codeworkflow.md` (geändert) | neu und geändert | sonnet, low | 1 |
| 05 | `.vault-tools/quorum/quorum_werkzeug.py`, `faelle_quorum_werkzeug.py`, `mutanten_quorum_werkzeug.py`, `.claude/workflows/quorum.js` | Nachbesserung nach der Prüfung von Welle 1 (2026-09-30): Teilgruppen nach Datei und Zeilen mit Toleranz drei, `GIT_OPTIONAL_LOCKS=0`, `--summe` in `ablegen`; Delta gegen den Baum | sonnet, low | 2 |
| 06 | `.claude/workflows/quorum.js`, `.vault-tools/workflow-pruefung/faelle_workflows.mjs`, `mutanten_workflows.py` | Nachbesserung nach dem ersten echten Lauf (2026-10-05): Freitext aus Stimmen und Nachprüfung erzeugt im Bericht keinen Wikilink, keine Einbettung und keinen Tag (`klartext()`); Delta gegen den Baum | sonnet, low | 4 |
Die Zieldateien sind disjunkt. **Welle 1** (Task 01, 02, 04) läuft parallel. **Welle 2** (Task 03) folgt, weil ihre Fälle und Mutanten gegen `quorum.js` aus Task 02 laufen.
Die Zieldateien sind disjunkt. **Welle 1** (Task 01, 02, 04) läuft parallel, Task 04 wegen `CLAUDE.md` nach dem Regeltext-Task des Waisen-Plans. **Welle 2** ist Task 05, **Welle 3** Task 03, weil sein Rot-Nachweis die Sicherung aus Task 05 braucht, **Welle 4** Task 06.
## Messungen vor und nach den Wellen
@@ -129,6 +130,7 @@ Die Zieldateien sind disjunkt. **Welle 1** (Task 01, 02, 04) läuft parallel. **
8. Byte-Vergleich: `welle_vergleich.py` je Welle gegen die Kopie mit angewendetem Plan, dazu `plan_bloecke.py nach` für Task 03 und 04 ohne Befund.
9. M1 wie oben: Exit 0 und JSON.
10. Lauf mit echten Daten wie unten: `status: fertig` mit Bericht, und die eingebaute Macke steht darin als Mehrheit oder als bestätigter Einzelbefund; danach ein Leerlauf mit `status: leer`.
11. Task 06: Der erste echte Bericht und jeder folgende erzeugt im Linkcheck keinen toten Link aus zitiertem Freitext; in Obsidian zeigt eine Probenotiz mit `\[\[a/b]]`, `!\[\[c]]` und `\#tag` keinen Link, keine Einbettung und keinen Tag (Probe in der App, offen).
## Lauf mit echten Daten
@@ -185,3 +187,19 @@ Der erste echte Meilenstein eines Projekts ist der zweite Lauf mit echten Daten;
14. **Codex-Modell und -Effort** setzt der Plan nicht; es gilt die Vorgabe der Codex-Konfiguration.
15. **Messungen M1 bis M3** siehe oben; M2 und M3 gehen nur mit echtem Kontingent.
16. **`updated` in `codeworkflow.md`** setzt der Koordinator beim Commit; Task 04 fasst das Frontmatter nicht an. `pending-review` entfällt, wenn Pascal Spec und Plan abnimmt, denn dann ist die Änderung angeordnet.
## Abschluss 2026-10-05
| Fertig heißt | Ergebnis | Beleg |
|---|---|---|
| 1 bis 3 Werkzeug | erfüllt | `faelle_quorum_werkzeug.py` 44 von 44; Rot gegen die gesicherte alte Fassung genau die 5 erwarteten Fehler; `mutanten_quorum_werkzeug.py` 46 Mutanten, 0 überlebt, 0 veraltet |
| 4 bis 6 Workflow | erfüllt | `faelle_workflows.mjs` 248 Fälle, 0 gescheitert; Rot ohne `quorum.js` 88, mit alter Fassung 20, nach Task 06 5 erwartete Fehler; `mutanten_workflows.py` 145 Mutanten, 0 überlebt, 0 veraltet |
| 7 regelwerk_check | erfüllt | nur der alte Befund zu `frontmatter.md` |
| 8 Byte-Vergleich | erfüllt | je Welle Zieldateien byte-gleich mit der Kopie; `plan_bloecke nach` ohne Befund für Task 05 und 06 |
| 9 M1 | erfüllt | im ersten echten Lauf: `status` vor dem Lauf gelesen, Stimme gültig |
| 10 Lauf mit echten Daten | erfüllt | erster echter Lauf am 2026-09-30 in einem Code-Projekt des Vaults: drei gültige Stimmen, Codex 1 min 52 s, Urteil frei, Bericht über `ablegen` abgelegt |
| 11 Task 06 | erfüllt bis auf die App-Probe | Linkcheck findet im ersten Bericht keinen toten Link mehr; die Probe in Obsidian steht bei Pascal |
- **Prüfungen:** Welle 1 (Task 01, 02) durch einen `pruefer`, dann Task 03 bis 06 zusammen: 0 blockierend, 1 wichtig, 9 klein. Der wichtige Befund (Regel und Berichtsgrund beim blockierenden Befund ohne Beleg) ist behoben: Regel in `codeworkflow.md` und Grund in `quorum.js` nennen den Fall; das Verhalten bleibt nach Offenem Punkt 8.
- **Bewusst offen (klein):** Grenzen von `zeilen()`, `pfadnorm()` und `klartext()`, aufgeführt in `.vault-tools/quorum/LIESMICH.md`.
- **Vermerk „noch nicht abgenommen“** in `codeworkflow.md` am 2026-10-05 entfernt; Auslöser und Sperre gelten.
@@ -0,0 +1,504 @@
---
type: plan-task
title: "Task 06 — Nachbesserung: Bericht entschärft Obsidian-Syntax aus Stimmen und Nachprüfung"
status: draft
summary: "quorum.js schickt jeden Freitext aus Stimmen, Zusammenführen und Nachprüfung durch eine neue Funktion klartext(), damit zitierte Wikilinks, Einbettungen und Tags im Bericht lesbarer Klartext bleiben; Hub-Zeile und Frontmatter bleiben unverändert."
tags: [plan-task, vault-tools, codeworkflow]
created: 2026-10-05
updated: 2026-10-05
modell: sonnet
effort: low
modell_begruendung: "Abschrift per plan_materialisiere.py mit genannten Zieldateien und Verboten, ohne Fließtext und ohne typografische Zeichen in Codeblöcken; sonnet mit effort low statt haiku, weil der Mutantenlauf länger dauert als ein Aufruf des Bash-Werkzeugs und im Hintergrund gestartet und abgefragt werden muss."
---
Übersicht: [00-uebersicht.md](00-uebersicht.md)
# Task 06 — Nachbesserung: Bericht entschärft Obsidian-Syntax aus Stimmen und Nachprüfung
Die globalen Vorgaben stehen in `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben“. Arbeitsverzeichnis ist `D:/Claude`.
**Voraussetzung:** Task 01 bis 05 sind angewendet und committet. Dieser Task ist ein Delta gegen den heutigen Baum; die Taskdateien 01 bis 05 bleiben unverändert.
## Anlass
- `quorum.js` übernimmt Freitext aus Stimmen, Zusammenführen und Nachprüfung ungefiltert in `bericht_md`.
- Zitiert eine Stimme Obsidian-Syntax, wird sie im Vault lebendig. Beleg: Der erste echte Bericht enthält zwei tote Wikilinks, einen unter „Nicht prüfbar“, einen in einer Befundaussage; der Linkcheck meldet beide, im Graphen entstehen Geisterknoten.
- Ein zitierter Tag wie `#needs-review` würde den Bericht als Review-Notiz zählen lassen, eine Einbettung wie `![[c]]` eine fremde Datei einbetten.
- Der erste echte Bericht selbst wird hier nicht angefasst (offener Punkt unten).
## Entscheidung: Backslash vor Klammer und Raute
Eine neue Funktion `klartext()` setzt einen Backslash vor jede öffnende eckige Klammer in einer Folge von zwei oder mehr und vor jede Raute, die einen Tag beginnen könnte. Die Leseansicht zeigt den Text wie zitiert.
| Zitat der Stimme | im Quelltext des Berichts | Regel |
|---|---|---|
| `[[a/b]]` | `\[\[a/b]]` | Klammerfolge |
| `![[c]]` | `!\[\[c]]` | Klammerfolge, das `!` bleibt |
| `#needs-review` | `\#needs-review` | Raute am Wortanfang vor einem Zeichen, das kein Leerraum und keine Raute ist |
| `[[#x]]` | `\[\[\#x]]` | beides; vor der Raute steht eine Klammer, kein Buchstabe |
| `x = 1 # Kommentar` | unverändert | Raute vor Leerzeichen |
| `C#`, `seite.md#abschnitt` | unverändert | am Wortende bzw. Buchstabe davor |
Warum diese Form:
- Backslash ist das Markdown-Escape für `[` und `#` (CommonMark erlaubt es für jedes ASCII-Satzzeichen). Es wirkt je Zeichen, braucht keine Grenzen eines Links und ändert bei zweiter Anwendung nichts mehr.
- Backticks um das Zitat scheiden aus: Freitext trägt selbst Backticks, dann bricht die Code-Spanne, und Anfang und Ende jedes Links müssten erkannt werden.
- Gelesen im Quelltext der Vault-Werkzeuge: `linkcheck.py` und `waisen.py` sehen in `\[\[x]]` keinen Link (Befund des Koordinators). `sitzungsstart.py` zählt `#needs-review` nur nach Leerraum oder am Zeilenanfang (Regex `(?<!\S)#needs-review`, Zeile 66), `\#needs-review` also nicht.
- **Nicht gemessen:** Ob Obsidian 1.13.7 den Backslash vor Wikilink, Einbettung und Tag beachtet (Leseansicht, Live-Vorschau, Graph, Tag-Index, `Review-Queue.base`). Das bestätigen Pascal oder der Koordinator in der App, siehe „Offene Punkte“.
- Die Tag-Regel ist bewusst weiter als die, die Obsidian vermutlich anwendet (nur nach Leerraum): Sie entschärft auch eine Raute hinter `[`, `(` oder einem Anführungszeichen. Kostet nur einen Backslash im Quelltext.
Wo `klartext()` greift und wo nicht:
| Stelle in `bericht_md` | Quelle | entschärft |
|---|---|---|
| Stimmentabelle, Spalte abgegeben, Grund | Codex-Hilfsagent (`job_status`), Absturzmeldung | ja |
| Gruppenüberschrift, Stelle | Stimme | ja |
| Befundzeile, Stelle und Aussage; Beleg | Stimme | ja |
| Nachprüfung, Fundstelle und Begründung | Nachprüfung | ja |
| Unbelegt, Stelle und Aussage | Stimme | ja |
| Nicht prüfbar | Stimme | ja |
| Hinweise des Laufs | Paket, Zusammenführen (IDs, Stellen), Codex-Grund | ja |
| Hub-Zeile (`Projekt: …` bzw. `Übersicht: …`) | Workflow | nein, bleibt Link |
| Frontmatter, Urteil-Zeile, Basis und Stand | Workflow, Paket | nein |
| Datei in Backticks | Stimme | nein, Obsidian lässt Code-Spannen in Ruhe |
| Felder des JSON-Ergebnisses (`gruppen`, `hinweise`) | alle | nein, sie gehen nicht in den Vault |
Die Fälle und Mutanten unten binden genau diese Form. Fällt die Bestätigung in Obsidian negativ aus, braucht es eine neue Nachbesserung mit anderer Form, anderen Erwartungstexten und anderen Mutanten.
## Zieldateien
- `D:\Claude\.vault-tools\workflow-pruefung\faelle_workflows.mjs` (geändert)
- `D:\Claude\.vault-tools\workflow-pruefung\mutanten_workflows.py` (geändert)
- `D:\Claude\.claude\workflows\quorum.js` (geändert)
## Verbote
- Keine andere Datei im Vault, auch nicht `attrappe.mjs`, die übrigen Workflows, die Taskdateien 01 bis 05 und kein Bericht unter `docs/quorum/` eines Projekts.
- Außerhalb des Vaults nur der Arbeitsordner `/tmp/quorum-task06` in der Git-Bash. Ihn nicht löschen; der Koordinator räumt ihn nach der Prüfung.
- Kein Lauf eines echten Workflows, kein Aufruf des Codex-Plugins.
- Nichts von Hand schreiben oder umformulieren; die Blöcke wendet nur `plan_materialisiere.py` an, in den Baum kommen sie nur per `cp` aus dem Arbeitsordner. Ein Widerspruch zwischen Plan und Baum wird gemeldet.
- Während der Mutantenlauf läuft, keine Datei unter `.claude/workflows/` und `.vault-tools/workflow-pruefung/` ändern.
- Kein Commit, kein `git add`.
## Schritt 1 — Vorprüfung
```
python D:/Claude/.vault-tools/plan-kopie/plan_bloecke.py vor D:/Claude/.vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/task-06-bericht-entschaerfen.md --projekt D:/Claude
```
Erwartet: `0 Befund(e).` Merk dir die Zahl hinter `geprueft:`, Schritt 2 vergleicht damit. Bei einem Befund anhalten und melden.
```
GIT_OPTIONAL_LOCKS=0 git -C D:/Claude status --porcelain -- .claude/workflows .vault-tools/workflow-pruefung; echo "ENDE"; node D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs | tail -n 1; echo "EXIT ${PIPESTATUS[0]}"
```
Erwartet: direkt `ENDE` (beide Ordner sauber), dann eine Zeile mit `0 gescheitert`, dann `EXIT 0`. Steht vor `ENDE` eine Zeile oder scheitert ein Fall: anhalten und melden.
## Schritt 2 — Blöcke in einem Arbeitsordner anwenden
Der Arbeitsordner trägt Kopien der drei Zieldateien. So kommen Fälle und Mutanten in Schritt 3 vor der Umsetzung in Schritt 4 in den Baum.
```
A="$(cygpath -m -l /tmp)/quorum-task06" && mkdir "$A" && mkdir -p "$A/k/.claude/workflows" "$A/k/.vault-tools/workflow-pruefung" && cp D:/Claude/.claude/workflows/quorum.js "$A/k/.claude/workflows/" && cp D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs D:/Claude/.vault-tools/workflow-pruefung/mutanten_workflows.py "$A/k/.vault-tools/workflow-pruefung/" && python D:/Claude/.vault-tools/plan-kopie/plan_materialisiere.py D:/Claude/.vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum "$A/k" task-06-bericht-entschaerfen.md; echo "EXIT $?"
```
Erwartet: In der Zählerzeile `'neu': 0`, `'angehaengt': 0`, `'geloescht': 0` und `'ersetzt'` gleich der Zahl hinter `geprueft:` aus Schritt 1; dann `0 Befund(e).` und `EXIT 0`. Scheitert `mkdir "$A"`, weil der Ordner schon da ist: anhalten und melden, nichts löschen.
### Fälle in `.vault-tools/workflow-pruefung/faelle_workflows.mjs`
Neue Fälle am Ende des Abschnitts `quorum`. Jeder lässt Stimmen, Nachprüfung oder Hilfsagenten Wikilink, Einbettung, Tag und Abschnittslink zitieren und verlangt: im Bericht keins davon, der Text steht lesbar in der Form aus der Tabelle oben, die Linkzeile des Workflows bleibt ein Link.
Aus:
```
// ---------------------------------------------------------------- Lauf
```
wird:
```javascript
// Klartext im Bericht (Nachbesserung Task 06): Stimmen, Nachprüfung und Hilfsagenten zitieren
// Wikilink, Einbettung, Tag und Abschnittslink. Q_KLAR ist derselbe Text, wie er im Bericht
// stehen muss: lesbar, aber ohne Link, Einbettung und Tag.
const Q_ZITATE = '[[a/b]] ![[c]] #needs-review [[#x]]'
const Q_KLAR = '\\[\\[a/b]] !\\[\\[c]] \\#needs-review \\[\\[\\#x]]'
const qZ = schwere => ({ datei: 'src/a.py', stelle: Q_ZITATE, schwere, aussage: Q_ZITATE, beleg: Q_ZITATE })
// Was Obsidian als Wikilink, Einbettung oder Tag lesen könnte. Der Tag zählt hier schon hinter
// jedem Zeichen außer Buchstabe, Ziffer, Unterstrich und Backslash, nicht erst nach Leerraum.
function qObsidian(text) {
const funde = []
if (/(^|[^!])\[\[/.test(text)) funde.push('Wikilink')
if (text.includes('![[')) funde.push('Einbettung')
if (/(^|[^\p{L}\p{N}_\\])#[^\s#]/u.test(text)) funde.push('Tag')
return funde
}
const qSauber = (soll, text) => { const f = qObsidian(text); soll(f.length === 0, 'Obsidian-Syntax im Bericht: ' + f.join(', ')) }
fall('quorum', 'Klartext im Bericht: Stelle, Aussage und Beleg einer Gruppe ohne Wikilink, Einbettung und Tag, README-Link bleibt', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await QR(Q_ARGS, { stimmen: { opus: [qZ('blockierend')], sonnet: [qZ('blockierend')], codex: [] } })
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
for (const z of [
'### G1: blockierend, Mehrheit (Opus, Sonnet)\n\n`src/a.py`, Stelle ' + Q_KLAR + '\n',
'- O1 (Opus, blockierend, Stelle ' + Q_KLAR + '): ' + Q_KLAR + '\n Beleg: ' + Q_KLAR + '\n',
'\n\nProjekt: [README](../../README.md)\n\n',
]) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht: Fundstelle und Begründung der Nachprüfung ohne Wikilink, Einbettung und Tag', async () => {
const { fehler, soll } = pruefe()
const stimmen = { opus: [qB('src/a.py', '10', 'blockierend')], sonnet: [qB('src/b.py', '5', 'hinweis')], codex: [] }
const urteile = pr => ({ urteile: [...pr.matchAll(/^Befund ([A-Z]\d+):$/gm)].map(m => ({ id: m[1], urteil: 'bestaetigt', fundstelle: Q_ZITATE, begruendung: Q_ZITATE })) })
const { ergebnis: e } = await QR(Q_ARGS, { stimmen, urteile })
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
const z = '\nNachprüfung: bestaetigt, ' + Q_KLAR + '. ' + Q_KLAR + '\n'
soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht: Unbelegt und Nicht prüfbar ohne Wikilink, Einbettung und Tag', async () => {
const { fehler, soll } = pruefe()
const stimmen = { opus: [{ ...qZ('wichtig'), beleg: '' }], sonnet: [qB('src/b.py', '5', 'hinweis')], codex: [] }
const { ergebnis: e } = await QR(Q_ARGS, { stimmen, nichtPruefbar: { sonnet: [Q_ZITATE] } })
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
for (const z of [
'## Unbelegt\n\n- O1 (Opus, wichtig) `src/a.py`, Stelle ' + Q_KLAR + ': ' + Q_KLAR + '\n',
'## Nicht prüfbar\n\n- Sonnet: ' + Q_KLAR + '\n',
]) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht: Hinweise des Laufs und Grund der Codex-Stimme ohne Wikilink, Einbettung und Tag', async () => {
const { fehler, soll } = pruefe()
const stimmen = { opus: [qB('src/a.py', '10', 'hinweis')], sonnet: [qB('src/b.py', '5', 'hinweis')] }
const k = { stimmen, paket: { hinweise: [Q_ZITATE] }, codexEnde: { job_status: Q_ZITATE }, gruppen: () => ({ gruppen: [{ ids: [Q_ZITATE], begruendung: 'zitiert' }] }) }
const { ergebnis: e } = await QR(Q_ARGS, k)
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
for (const z of [
'| Codex | nein: Codex-Auftrag endete mit ' + Q_KLAR + ' |',
'## Hinweise des Laufs\n\n- ' + Q_KLAR + '\n',
'\n- Codex ohne gültige Stimme (Codex-Auftrag endete mit ' + Q_KLAR + '); Haiku ist eingesprungen\n',
'\n- Zusammenführen: unbekannte ID ' + Q_KLAR + ' verworfen\n',
]) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht eines Kundendokuments: Hub-Zeile bleibt Wikilink, Frontmatter bleibt, zitierte Syntax nicht', async () => {
const { fehler, soll } = pruefe()
const K = 'D:/Claude/IT Workspace/Beispiel/abgabe'
const HUB = 'Übersicht: [[IT Workspace/Beispiel/abgabe/00 Übersicht|00 Übersicht.md]]'
const args = { anlass: 'kundendokument', ordner: K, dateien: ['kapitel/01.md'], uebersicht: '00 Übersicht.md' }
const paket = { anlass: 'kundendokument', art: 'doku', wurzel: K, basis: 'erstes Quorum', stand: 'sha256:' + 'c'.repeat(64), vorher: null, nummer: 1 }
const { ergebnis: e } = await QR(args, { paket, stimmen: { opus: [qZ('blockierend')], sonnet: [qZ('blockierend')], codex: [] } })
const md = (e && e.bericht_md) || ''
qSauber(soll, md.replace(HUB, ''))
soll(md.includes('\n\n' + HUB + '\n\n') && md.split('[[').length === 2, 'Hub-Zeile nicht genau einmal als Wikilink: ' + JSON.stringify(md.split('\n').filter(x => x.includes('Übersicht'))))
for (const z of ['---\ntype: note\n', '\ntags: [quorum]\n', '- O1 (Opus, blockierend, Stelle ' + Q_KLAR + '): ' + Q_KLAR + '\n']) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
// ---------------------------------------------------------------- Lauf
```
### Mutanten in `.vault-tools/workflow-pruefung/mutanten_workflows.py`
Drei bestehende Mutanten zielen auf Stellen, die jetzt `klartext()` tragen; ohne Anpassung meldet der Lauf sie als veraltet.
Aus:
```
("quorum", "Stelle fehlt in der Befundzeile", ", ${b.schwere}, Stelle ${b.stelle}): ${b.aussage}", ", ${b.schwere}): ${b.aussage}"),
```
wird:
```python
("quorum", "Stelle fehlt in der Befundzeile", ", ${b.schwere}, Stelle ${klartext(b.stelle)}): ${klartext(b.aussage)}", ", ${b.schwere}): ${klartext(b.aussage)}"),
```
Aus:
```
("quorum", "Nachpruefung ohne Fundstelle", "${g.nachpruefung.urteil}, ${g.nachpruefung.fundstelle}.", "${g.nachpruefung.urteil}."),
```
wird:
```python
("quorum", "Nachpruefung ohne Fundstelle", "${g.nachpruefung.urteil}, ${klartext(g.nachpruefung.fundstelle)}.", "${g.nachpruefung.urteil}."),
```
Aus:
```
("quorum", "Stelle fehlt in der Gruppenueberschrift", ", Stelle ${erste.stelle}", ""),
```
wird:
```python
("quorum", "Stelle fehlt in der Gruppenueberschrift", ", Stelle ${klartext(erste.stelle)}", ""),
```
Neue Mutanten, alle mit dem Namensanfang `Klartext:`. Die ersten bauen eine falsche Regel in `klartext()` ein (Klammerfolge, erste Klammer, Einbettung, Tag, Abschnittslink), die folgenden lassen je eine Stelle roh, der letzte entschärft den ganzen Bericht samt Hub-Zeile. Muster mit Backslash stehen als Python-Raw-String mit Präfix `r`, damit der Backslash wörtlich bleibt.
Aus:
```
("quorum", "Zusammenfuehren darf Dateien lesen", "Lies keine Dateien und fahre keine Befehle; du arbeitest nur mit dieser Liste.", "Lies die Dateien, wenn du sie brauchst."),
]
```
wird:
```python
("quorum", "Zusammenfuehren darf Dateien lesen", "Lies keine Dateien und fahre keine Befehle; du arbeitest nur mit dieser Liste.", "Lies die Dateien, wenn du sie brauchst."),
("quorum", "Klartext: Klammerfolge bleibt", r".replace(/\[{2,}/g, folge => folge.replace(/\[/g, '\\['))", ""),
("quorum", "Klartext: nur die erste Klammer entschaerft", r"folge => folge.replace(/\[/g, '\\['))", r"folge => '\\' + folge)"),
("quorum", "Klartext: Einbettung ausgenommen", r".replace(/\[{2,}/g,", r".replace(/(?<!!)\[{2,}/g,"),
("quorum", "Klartext: Tag bleibt", r".replace(/(^|[^\p{L}\p{N}_\\])#(?=[^\s#])/gu, '$1\\#')", ""),
("quorum", "Klartext: Tag nur nach Leerraum", r"(^|[^\p{L}\p{N}_\\])#", r"(^|\s)#"),
("quorum", "Klartext: Gruppenueberschrift, Stelle roh", ", Stelle ${klartext(erste.stelle)}", ", Stelle ${erste.stelle}"),
("quorum", "Klartext: Befundzeile, Stelle roh", ", ${b.schwere}, Stelle ${klartext(b.stelle)})", ", ${b.schwere}, Stelle ${b.stelle})"),
("quorum", "Klartext: Befundzeile, Aussage roh", "}): ${klartext(b.aussage)}", "}): ${b.aussage}"),
("quorum", "Klartext: Beleg roh", " Beleg: ${klartext(b.beleg)}", " Beleg: ${b.beleg}"),
("quorum", "Klartext: Nachpruefung, Fundstelle roh", "${klartext(g.nachpruefung.fundstelle)}", "${g.nachpruefung.fundstelle}"),
("quorum", "Klartext: Nachpruefung, Begruendung roh", "${klartext(g.nachpruefung.begruendung)}", "${g.nachpruefung.begruendung}"),
("quorum", "Klartext: Unbelegt, Stelle roh", "Stelle ${klartext(b.stelle)}: ", "Stelle ${b.stelle}: "),
("quorum", "Klartext: Unbelegt, Aussage roh", ")}: ${klartext(b.aussage)}", ")}: ${b.aussage}"),
("quorum", "Klartext: Nicht pruefbar roh", "- ${NAMEN[n]}: ${klartext(t)}", "- ${NAMEN[n]}: ${t}"),
("quorum", "Klartext: Hinweise roh", "'- ' + klartext(h)", "'- ' + h"),
("quorum", "Klartext: Stimmentabelle, Grund roh", "'nein: ' + klartext(s.grund)", "'nein: ' + s.grund"),
("quorum", "Klartext: ganzer Bericht, Hub-Zeile entschaerft", r"return z.join('\n')", r"return klartext(z.join('\n'))"),
]
```
Welcher Fall welchen Mutanten fängt:
| Mutant | rot wird mindestens |
|---|---|
| Regel-Mutanten (Klammerfolge, erste Klammer, Einbettung, Tag, Tag nach Leerraum) | jeder neue Fall |
| Gruppenüberschrift, Befundzeile, Beleg | Stelle, Aussage und Beleg einer Gruppe |
| Nachprüfung, Fundstelle und Begründung | Fundstelle und Begründung der Nachprüfung |
| Unbelegt, Nicht prüfbar | Unbelegt und Nicht prüfbar |
| Hinweise, Stimmentabelle | Hinweise des Laufs und Grund der Codex-Stimme |
| ganzer Bericht | Bericht eines Kundendokuments (Hub-Zeile), dazu die bestehenden Hub-Fälle mit Wikilink |
### Umsetzung in `.claude/workflows/quorum.js`
Kopfkommentar:
Aus:
```
// Laufzeit: Date.now() und new Date() gibt es im Laufzeitsystem nicht. Die Codex-Laufzeit kommt
// aus dem Job, die der Claude-Stimmen steht im Bericht als nicht gemessen.
```
wird:
```javascript
// Laufzeit: Date.now() und new Date() gibt es im Laufzeitsystem nicht. Die Codex-Laufzeit kommt
// aus dem Job, die der Claude-Stimmen steht im Bericht als nicht gemessen.
// Bericht: Freitext aus Stimmen, Zusammenführen und Nachprüfung geht nur durch klartext() in
// bericht_md. Ein zitierter Wikilink, eine Einbettung oder ein Tag würde im Vault sonst lebendig;
// Hub-Zeile und Frontmatter baut der Workflow selbst, sie bleiben echte Links bzw. Felder.
```
Neue Funktion `klartext()` vor `berichtVoll()`:
Aus:
```
function berichtVoll(a, p, x) {
```
wird:
```javascript
// Freitext als Klartext für Obsidian: Jede öffnende eckige Klammer in einer Folge von zwei oder
// mehr bekommt einen Backslash, damit kein Wikilink und keine Einbettung entsteht. Eine Raute
// bekommt einen Backslash, wenn ihr kein Buchstabe, keine Ziffer, kein Unterstrich und kein
// Backslash vorausgeht und weder Leerraum noch eine zweite Raute folgt; so entsteht kein Tag.
// Die Leseansicht zeigt den Text wie zitiert. Zweimal angewendet ändert sich nichts mehr.
function klartext(s) {
return String(s)
.replace(/\[{2,}/g, folge => folge.replace(/\[/g, '\\['))
.replace(/(^|[^\p{L}\p{N}_\\])#(?=[^\s#])/gu, '$1\\#')
}
function berichtVoll(a, p, x) {
```
Stimmentabelle, Grund einer nicht abgegebenen Stimme:
Aus:
```
z.push(`| ${name} | ${s.abgegeben ? 'ja' : 'nein: ' + s.grund} | ${c.gesamt} | ${c.belegt} | ${c.bestaetigt} | ${c.alleinfunde} | ${laufzeit(s.dauer_s)} |`)
```
wird:
```javascript
z.push(`| ${name} | ${s.abgegeben ? 'ja' : 'nein: ' + klartext(s.grund)} | ${c.gesamt} | ${c.belegt} | ${c.bestaetigt} | ${c.alleinfunde} | ${laufzeit(s.dauer_s)} |`)
```
Gruppenüberschrift, Befundzeilen, Beleg und Nachprüfung:
Aus:
```
z.push(`### G${g.nr}: ${g.schwere}, ${STATUS_TEXT[g.status]} (${g.stimmen.map(n => NAMEN[n]).join(', ')})`, '', `\`${erste.datei}\`, Stelle ${erste.stelle}`, '')
for (const b of g.mitglieder) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}, Stelle ${b.stelle}): ${b.aussage}`, ` Beleg: ${b.beleg}`)
if (g.nachpruefung) z.push('', `Nachprüfung: ${g.nachpruefung.urteil}, ${g.nachpruefung.fundstelle}. ${g.nachpruefung.begruendung}`)
```
wird:
```javascript
z.push(`### G${g.nr}: ${g.schwere}, ${STATUS_TEXT[g.status]} (${g.stimmen.map(n => NAMEN[n]).join(', ')})`, '', `\`${erste.datei}\`, Stelle ${klartext(erste.stelle)}`, '')
for (const b of g.mitglieder) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}, Stelle ${klartext(b.stelle)}): ${klartext(b.aussage)}`, ` Beleg: ${klartext(b.beleg)}`)
if (g.nachpruefung) z.push('', `Nachprüfung: ${g.nachpruefung.urteil}, ${klartext(g.nachpruefung.fundstelle)}. ${klartext(g.nachpruefung.begruendung)}`)
```
Unbelegt:
Aus:
```
for (const b of x.unbelegt) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}) \`${b.datei}\`, Stelle ${b.stelle}: ${b.aussage}`)
```
wird:
```javascript
for (const b of x.unbelegt) z.push(`- ${b.id} (${NAMEN[b.stimme]}, ${b.schwere}) \`${b.datei}\`, Stelle ${klartext(b.stelle)}: ${klartext(b.aussage)}`)
```
Nicht prüfbar:
Aus:
```
const offen = Object.keys(x.stimmen).flatMap(n => (x.stimmen[n].nicht_pruefbar || []).map(t => `- ${NAMEN[n]}: ${t}`))
```
wird:
```javascript
const offen = Object.keys(x.stimmen).flatMap(n => (x.stimmen[n].nicht_pruefbar || []).map(t => `- ${NAMEN[n]}: ${klartext(t)}`))
```
Hinweise des Laufs:
Aus:
```
z.push(...(alleHinweise.length ? alleHinweise.map(h => '- ' + h) : ['keine']), '')
```
wird:
```javascript
z.push(...(alleHinweise.length ? alleHinweise.map(h => '- ' + klartext(h)) : ['keine']), '')
```
## Schritt 3 — Fälle und Mutanten in den Baum, Rot im Wortlaut
`quorum.js` im Baum ist noch die alte Fassung; sie ist die falsche Umsetzung, an der jeder neue Fall rot werden muss.
```
A="$(cygpath -m -l /tmp)/quorum-task06" && cp "$A/k/.vault-tools/workflow-pruefung/faelle_workflows.mjs" "$A/k/.vault-tools/workflow-pruefung/mutanten_workflows.py" D:/Claude/.vault-tools/workflow-pruefung/ && node D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs > "$A/rot.txt"; echo "EXIT $?"; grep -A 1 --no-group-separator '^FEHLER' "$A/rot.txt"; grep '^HINWEIS' "$A/rot.txt"; echo "ENDE"
```
Erwartet genau diese Ausgabe, Zeile für Zeile:
```
EXIT 1
FEHLER quorum: Klartext im Bericht: Stelle, Aussage und Beleg einer Gruppe ohne Wikilink, Einbettung und Tag, README-Link bleibt
Obsidian-Syntax im Bericht: Wikilink, Einbettung, Tag
FEHLER quorum: Klartext im Bericht: Fundstelle und Begründung der Nachprüfung ohne Wikilink, Einbettung und Tag
Obsidian-Syntax im Bericht: Wikilink, Einbettung, Tag
FEHLER quorum: Klartext im Bericht: Unbelegt und Nicht prüfbar ohne Wikilink, Einbettung und Tag
Obsidian-Syntax im Bericht: Wikilink, Einbettung, Tag
FEHLER quorum: Klartext im Bericht: Hinweise des Laufs und Grund der Codex-Stimme ohne Wikilink, Einbettung und Tag
Obsidian-Syntax im Bericht: Wikilink, Einbettung, Tag
FEHLER quorum: Klartext im Bericht eines Kundendokuments: Hub-Zeile bleibt Wikilink, Frontmatter bleibt, zitierte Syntax nicht
Obsidian-Syntax im Bericht: Wikilink, Einbettung, Tag
ENDE
```
- Jede Zeile unter einer `FEHLER`-Zeile beginnt mit sieben Leerzeichen.
- Keine Zeile `HINWEIS`: Die Grundlinie des Quorums bleibt grün.
- Weicht die Ausgabe ab: nichts anpassen, anhalten, `rot.txt` vollständig melden.
## Schritt 4 — Umsetzung in den Baum, grün, Byte-Vergleich
```
A="$(cygpath -m -l /tmp)/quorum-task06" && cp "$A/k/.claude/workflows/quorum.js" D:/Claude/.claude/workflows/ && node D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs > "$A/gruen.txt"; echo "EXIT $?"; grep -E '^(FEHLER|HINWEIS)' "$A/gruen.txt"; tail -n 1 "$A/gruen.txt"; echo "ENDE"
```
Erwartet: `EXIT 0`, dann genau eine Zeile, die `0 gescheitert` enthält, dann `ENDE`.
```
A="$(cygpath -m -l /tmp)/quorum-task06" && cmp "$A/k/.claude/workflows/quorum.js" D:/Claude/.claude/workflows/quorum.js && cmp "$A/k/.vault-tools/workflow-pruefung/faelle_workflows.mjs" D:/Claude/.vault-tools/workflow-pruefung/faelle_workflows.mjs && cmp "$A/k/.vault-tools/workflow-pruefung/mutanten_workflows.py" D:/Claude/.vault-tools/workflow-pruefung/mutanten_workflows.py; echo "EXIT $?"; GIT_OPTIONAL_LOCKS=0 git -C D:/Claude status --porcelain -- .claude/workflows .vault-tools/workflow-pruefung; echo "ENDE"
```
Erwartet: `EXIT 0` ohne Ausgabe von `cmp`, dann je eine Zeile ` M .claude/workflows/quorum.js`, ` M .vault-tools/workflow-pruefung/faelle_workflows.mjs` und ` M .vault-tools/workflow-pruefung/mutanten_workflows.py` in der Reihenfolge, die git ausgibt, keine weitere, dann `ENDE`.
```
python D:/Claude/.vault-tools/plan-kopie/plan_bloecke.py nach D:/Claude/.vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/task-06-bericht-entschaerfen.md --projekt D:/Claude
```
Erwartet: `0 Befund(e).`
## Schritt 5 — Mutantenlauf im Hintergrund
Der Lauf fährt für jeden Mutanten alle Fälle; zuletzt dauerte er etwa 25 Minuten, mit den neuen Mutanten etwas länger. Das ist länger als ein Aufruf des Bash-Werkzeugs, deshalb im Hintergrund.
Starten, im Bash-Werkzeug mit `run_in_background: true`:
```
A="$(cygpath -m -l /tmp)/quorum-task06" && PYTHONIOENCODING=utf-8 python -u D:/Claude/.vault-tools/workflow-pruefung/mutanten_workflows.py > "$A/mutanten.txt" 2>&1; echo "EXIT $?" >> "$A/mutanten.txt"
```
Abfragen, im Bash-Werkzeug mit `timeout` 400000; wiederholen, bis die letzte Zeile mit `EXIT` beginnt:
```
sleep 300; A="$(cygpath -m -l /tmp)/quorum-task06" && tail -n 2 "$A/mutanten.txt"
```
Auswerten:
```
A="$(cygpath -m -l /tmp)/quorum-task06" && grep -E '^(UEBERLEBT|VERALTET)' "$A/mutanten.txt"; echo "---"; grep -E 'quorum: (Klartext: |Stelle fehlt in der Befundzeile|Nachpruefung ohne Fundstelle|Stelle fehlt in der Gruppenueberschrift)' "$A/mutanten.txt"; echo "---"; tail -n 2 "$A/mutanten.txt"
```
Erwartet:
- Vor dem ersten `---` keine Zeile.
- Zwischen den beiden `---` je eine Zeile für jeden Mutanten mit dem Namensanfang `Klartext:` aus dem Block oben und für die drei angepassten; jede beginnt mit `GEFANGEN`.
- Danach eine Zeile, die auf `0 ueberlebt, 0 veraltet` endet, und `EXIT 0`.
## Meldung
Melde alle Ausgaben der Schritte 1 bis 5 vollständig, dazu die Zahl hinter `geprueft:` aus Schritt 1 und den Wert von `'ersetzt'` aus Schritt 2. Läuft etwas anders als erwartet: nichts anpassen, melden, mit jeder `FEHLER`-, `UEBERLEBT`- oder `VERALTET`-Zeile und ihrem Text.
## Offene Punkte
- **Obsidian-Bestätigung (Pascal oder Koordinator, vor der Abnahme):** In Obsidian 1.13.7 bestätigen, dass die gewählte Form keinen Link, keine Einbettung und keinen Tag erzeugt. Vorschlag: eine Probenotiz mit den vier Zeilen `\[\[a/b]]`, `!\[\[c]]`, `\#needs-review`, `\[\[\#x]]` als Fließtext anlegen, dann Leseansicht und Live-Vorschau ansehen, ausgehende Links, Tag-Bereich, Graph und `Review-Queue.base` prüfen und die Notiz wieder löschen. Fällt die Probe negativ aus, ist eine neue Nachbesserung nötig (andere Form, andere Erwartungstexte, andere Mutanten).
- **Der erste echte Bericht** behält seine zwei toten Wikilinks. Ob sie von Hand in dieselbe Form gebracht werden, entscheidet der Koordinator; dieser Task fasst keinen Bericht an.
- **Lauf mit echten Daten:** Beim nächsten echten Quorum den abgelegten Bericht mit `linkcheck.py` prüfen; erwartet ist kein toter Link aus dem Bericht. Zitiert dort eine Stimme Obsidian-Syntax, ist das zugleich die Probe in der App.
- **Übersicht:** Tasktabelle um Task 06 ergänzen; das zieht der Koordinator nach.
## Risiken
- **Weitere Markdown-Syntax im Freitext bleibt lebendig:** ein Markdown-Link der Form Klammer-Text-Klammer mit Pfad dahinter (relativ zum Berichtsordner meist tot), eine Raute mit Leerzeichen am Zeilenanfang eines mehrzeiligen Texts (Überschrift), ein senkrechter Strich im Grund der Stimmentabelle (zerlegt die Zeile). Nicht im Umfang; der Linkcheck meldet tote Markdown-Links nicht.
- **Backslash in Code-Spannen sichtbar:** Steht zitierte Syntax im Freitext selbst zwischen Backticks, zeigt die Leseansicht dort den Backslash mit. Lesbar, aber nicht wörtlich.
- **Lauf über 10 Minuten:** Bricht die Abfrage ab oder endet `mutanten.txt` ohne `EXIT`-Zeile, ist der Lauf nicht fertig oder gestorben; dann nicht neu starten, sondern melden.
@@ -1535,6 +1535,91 @@ for (const [name, a, text] of [
})
}
// Klartext im Bericht (Nachbesserung Task 06): Stimmen, Nachprüfung und Hilfsagenten zitieren
// Wikilink, Einbettung, Tag und Abschnittslink. Q_KLAR ist derselbe Text, wie er im Bericht
// stehen muss: lesbar, aber ohne Link, Einbettung und Tag.
const Q_ZITATE = '[[a/b]] ![[c]] #needs-review [[#x]]'
const Q_KLAR = '\\[\\[a/b]] !\\[\\[c]] \\#needs-review \\[\\[\\#x]]'
const qZ = schwere => ({ datei: 'src/a.py', stelle: Q_ZITATE, schwere, aussage: Q_ZITATE, beleg: Q_ZITATE })
// Was Obsidian als Wikilink, Einbettung oder Tag lesen könnte. Der Tag zählt hier schon hinter
// jedem Zeichen außer Buchstabe, Ziffer, Unterstrich und Backslash, nicht erst nach Leerraum.
function qObsidian(text) {
const funde = []
if (/(^|[^!])\[\[/.test(text)) funde.push('Wikilink')
if (text.includes('![[')) funde.push('Einbettung')
if (/(^|[^\p{L}\p{N}_\\])#[^\s#]/u.test(text)) funde.push('Tag')
return funde
}
const qSauber = (soll, text) => { const f = qObsidian(text); soll(f.length === 0, 'Obsidian-Syntax im Bericht: ' + f.join(', ')) }
fall('quorum', 'Klartext im Bericht: Stelle, Aussage und Beleg einer Gruppe ohne Wikilink, Einbettung und Tag, README-Link bleibt', async () => {
const { fehler, soll } = pruefe()
const { ergebnis: e } = await QR(Q_ARGS, { stimmen: { opus: [qZ('blockierend')], sonnet: [qZ('blockierend')], codex: [] } })
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
for (const z of [
'### G1: blockierend, Mehrheit (Opus, Sonnet)\n\n`src/a.py`, Stelle ' + Q_KLAR + '\n',
'- O1 (Opus, blockierend, Stelle ' + Q_KLAR + '): ' + Q_KLAR + '\n Beleg: ' + Q_KLAR + '\n',
'\n\nProjekt: [README](../../README.md)\n\n',
]) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht: Fundstelle und Begründung der Nachprüfung ohne Wikilink, Einbettung und Tag', async () => {
const { fehler, soll } = pruefe()
const stimmen = { opus: [qB('src/a.py', '10', 'blockierend')], sonnet: [qB('src/b.py', '5', 'hinweis')], codex: [] }
const urteile = pr => ({ urteile: [...pr.matchAll(/^Befund ([A-Z]\d+):$/gm)].map(m => ({ id: m[1], urteil: 'bestaetigt', fundstelle: Q_ZITATE, begruendung: Q_ZITATE })) })
const { ergebnis: e } = await QR(Q_ARGS, { stimmen, urteile })
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
const z = '\nNachprüfung: bestaetigt, ' + Q_KLAR + '. ' + Q_KLAR + '\n'
soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht: Unbelegt und Nicht prüfbar ohne Wikilink, Einbettung und Tag', async () => {
const { fehler, soll } = pruefe()
const stimmen = { opus: [{ ...qZ('wichtig'), beleg: '' }], sonnet: [qB('src/b.py', '5', 'hinweis')], codex: [] }
const { ergebnis: e } = await QR(Q_ARGS, { stimmen, nichtPruefbar: { sonnet: [Q_ZITATE] } })
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
for (const z of [
'## Unbelegt\n\n- O1 (Opus, wichtig) `src/a.py`, Stelle ' + Q_KLAR + ': ' + Q_KLAR + '\n',
'## Nicht prüfbar\n\n- Sonnet: ' + Q_KLAR + '\n',
]) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht: Hinweise des Laufs und Grund der Codex-Stimme ohne Wikilink, Einbettung und Tag', async () => {
const { fehler, soll } = pruefe()
const stimmen = { opus: [qB('src/a.py', '10', 'hinweis')], sonnet: [qB('src/b.py', '5', 'hinweis')] }
const k = { stimmen, paket: { hinweise: [Q_ZITATE] }, codexEnde: { job_status: Q_ZITATE }, gruppen: () => ({ gruppen: [{ ids: [Q_ZITATE], begruendung: 'zitiert' }] }) }
const { ergebnis: e } = await QR(Q_ARGS, k)
const md = (e && e.bericht_md) || ''
qSauber(soll, md)
for (const z of [
'| Codex | nein: Codex-Auftrag endete mit ' + Q_KLAR + ' |',
'## Hinweise des Laufs\n\n- ' + Q_KLAR + '\n',
'\n- Codex ohne gültige Stimme (Codex-Auftrag endete mit ' + Q_KLAR + '); Haiku ist eingesprungen\n',
'\n- Zusammenführen: unbekannte ID ' + Q_KLAR + ' verworfen\n',
]) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
fall('quorum', 'Klartext im Bericht eines Kundendokuments: Hub-Zeile bleibt Wikilink, Frontmatter bleibt, zitierte Syntax nicht', async () => {
const { fehler, soll } = pruefe()
const K = 'D:/Claude/IT Workspace/Beispiel/abgabe'
const HUB = 'Übersicht: [[IT Workspace/Beispiel/abgabe/00 Übersicht|00 Übersicht.md]]'
const args = { anlass: 'kundendokument', ordner: K, dateien: ['kapitel/01.md'], uebersicht: '00 Übersicht.md' }
const paket = { anlass: 'kundendokument', art: 'doku', wurzel: K, basis: 'erstes Quorum', stand: 'sha256:' + 'c'.repeat(64), vorher: null, nummer: 1 }
const { ergebnis: e } = await QR(args, { paket, stimmen: { opus: [qZ('blockierend')], sonnet: [qZ('blockierend')], codex: [] } })
const md = (e && e.bericht_md) || ''
qSauber(soll, md.replace(HUB, ''))
soll(md.includes('\n\n' + HUB + '\n\n') && md.split('[[').length === 2, 'Hub-Zeile nicht genau einmal als Wikilink: ' + JSON.stringify(md.split('\n').filter(x => x.includes('Übersicht'))))
for (const z of ['---\ntype: note\n', '\ntags: [quorum]\n', '- O1 (Opus, blockierend, Stelle ' + Q_KLAR + '): ' + Q_KLAR + '\n']) soll(md.includes(z), 'Bericht ohne ' + JSON.stringify(z))
return fehler
})
// ---------------------------------------------------------------- Lauf
let gescheitert = 0
const grundlinieGescheitert = new Set()
@@ -121,15 +121,15 @@ MUTANTEN = [
("quorum", "Zeilenbereich nur erste Zeile", "const bis = m[2] === undefined ? von : Number(m[2])", "const bis = von"),
("quorum", "Stelle ohne Zeilen zaehlt als Zeile 0", "if (!m) return null", "if (!m) return [0, 0]"),
("quorum", "Befundzeilen der Gruppe fehlen", "for (const b of g.mitglieder) z.push(", "for (const b of []) z.push("),
("quorum", "Stelle fehlt in der Befundzeile", ", ${b.schwere}, Stelle ${b.stelle}): ${b.aussage}", ", ${b.schwere}): ${b.aussage}"),
("quorum", "Stelle fehlt in der Befundzeile", ", ${b.schwere}, Stelle ${klartext(b.stelle)}): ${klartext(b.aussage)}", ", ${b.schwere}): ${klartext(b.aussage)}"),
("quorum", "Nachpruefungszeile fehlt", "if (g.nachpruefung) z.push(''", "if (false) z.push(''"),
("quorum", "Nachpruefung ohne Fundstelle", "${g.nachpruefung.urteil}, ${g.nachpruefung.fundstelle}.", "${g.nachpruefung.urteil}."),
("quorum", "Nachpruefung ohne Fundstelle", "${g.nachpruefung.urteil}, ${klartext(g.nachpruefung.fundstelle)}.", "${g.nachpruefung.urteil}."),
("quorum", "Unbelegt-Liste leer", "for (const b of x.unbelegt)", "for (const b of [])"),
("quorum", "Nicht pruefbar leer", "(x.stimmen[n].nicht_pruefbar || [])", "([])"),
("quorum", "Hinweise des Laufs fehlen im Bericht", "const alleHinweise = [...p.hinweise, ...laufHinweise]", "const alleHinweise = []"),
("quorum", "Lesemarke der Claude-Stimmen nicht geprueft", "lesemarke_ok: String(e.lesemarke).includes(p.lesemarke)", "lesemarke_ok: true"),
("quorum", "Lesemarke exakt verglichen", "lesemarke_ok: String(e.lesemarke).includes(p.lesemarke)", "lesemarke_ok: String(e.lesemarke) === p.lesemarke"),
("quorum", "Stelle fehlt in der Gruppenueberschrift", ", Stelle ${erste.stelle}", ""),
("quorum", "Stelle fehlt in der Gruppenueberschrift", ", Stelle ${klartext(erste.stelle)}", ""),
("quorum", "Stand statt PAKET im Frontmatter", "'stand: PAKET'", "'stand: ' + p.stand"),
("quorum", "ablegen_befehl fehlt", "ablegen_befehl: md ? ablegenBefehl(paket) : null", "ablegen_befehl: null"),
("quorum", "ablegen_befehl auch ohne Bericht", "ablegen_befehl: md ? ablegenBefehl(paket) : null", "ablegen_befehl: ablegenBefehl(paket)"),
@@ -149,6 +149,23 @@ MUTANTEN = [
("quorum", "Backslash im Ordner bleibt", "String(ordner).replace(/\\\\/g, '/')", "String(ordner)"),
("quorum", "Stimmen kennen einander", "Die anderen Stimmen kennst du nicht.", "Die anderen Stimmen sind Opus, Sonnet und Codex."),
("quorum", "Zusammenfuehren darf Dateien lesen", "Lies keine Dateien und fahre keine Befehle; du arbeitest nur mit dieser Liste.", "Lies die Dateien, wenn du sie brauchst."),
("quorum", "Klartext: Klammerfolge bleibt", r".replace(/\[{2,}/g, folge => folge.replace(/\[/g, '\\['))", ""),
("quorum", "Klartext: nur die erste Klammer entschaerft", r"folge => folge.replace(/\[/g, '\\['))", r"folge => '\\' + folge)"),
("quorum", "Klartext: Einbettung ausgenommen", r".replace(/\[{2,}/g,", r".replace(/(?<!!)\[{2,}/g,"),
("quorum", "Klartext: Tag bleibt", r".replace(/(^|[^\p{L}\p{N}_\\])#(?=[^\s#])/gu, '$1\\#')", ""),
("quorum", "Klartext: Tag nur nach Leerraum", r"(^|[^\p{L}\p{N}_\\])#", r"(^|\s)#"),
("quorum", "Klartext: Gruppenueberschrift, Stelle roh", ", Stelle ${klartext(erste.stelle)}", ", Stelle ${erste.stelle}"),
("quorum", "Klartext: Befundzeile, Stelle roh", ", ${b.schwere}, Stelle ${klartext(b.stelle)})", ", ${b.schwere}, Stelle ${b.stelle})"),
("quorum", "Klartext: Befundzeile, Aussage roh", "}): ${klartext(b.aussage)}", "}): ${b.aussage}"),
("quorum", "Klartext: Beleg roh", " Beleg: ${klartext(b.beleg)}", " Beleg: ${b.beleg}"),
("quorum", "Klartext: Nachpruefung, Fundstelle roh", "${klartext(g.nachpruefung.fundstelle)}", "${g.nachpruefung.fundstelle}"),
("quorum", "Klartext: Nachpruefung, Begruendung roh", "${klartext(g.nachpruefung.begruendung)}", "${g.nachpruefung.begruendung}"),
("quorum", "Klartext: Unbelegt, Stelle roh", "Stelle ${klartext(b.stelle)}: ", "Stelle ${b.stelle}: "),
("quorum", "Klartext: Unbelegt, Aussage roh", ")}: ${klartext(b.aussage)}", ")}: ${b.aussage}"),
("quorum", "Klartext: Nicht pruefbar roh", "- ${NAMEN[n]}: ${klartext(t)}", "- ${NAMEN[n]}: ${t}"),
("quorum", "Klartext: Hinweise roh", "'- ' + klartext(h)", "'- ' + h"),
("quorum", "Klartext: Stimmentabelle, Grund roh", "'nein: ' + klartext(s.grund)", "'nein: ' + s.grund"),
("quorum", "Klartext: ganzer Bericht, Hub-Zeile entschaerft", r"return z.join('\n')", r"return klartext(z.join('\n'))"),
]
+4 -6
View File
@@ -5,7 +5,7 @@ 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-30
updated: 2026-10-05
---
# Codeworkflow
@@ -162,8 +162,6 @@ Seit 2026-09-26 auf Pascals Freigabe:
### Quorum am Meilenstein
**Stand 2026-09-30: umgesetzt, aber noch nicht abgenommen.** Die Prüfung der letzten Nachbesserung und der erste echte Lauf stehen aus (Plan unter `.vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/`). Bis zur Abnahme läuft das Quorum nur auf Pascals Zuruf, und seine Sperre gilt noch nicht. Dieser Absatz fällt mit der Abnahme weg.
Pascals Entscheidung vom 2026-09-29. Was die Wellenprüfung übersehen hat, soll nicht in die Zielumgebung, auf master oder zum Kunden. Dafür prüfen drei Modelle aus zwei Familien unabhängig denselben Unterschied: Opus und Sonnet als `quorum-stimme`, dazu Codex über das Plugin. Die Wellenprüfung bleibt, das Quorum kommt dazu. Der Ablauf steht in `.claude/workflows/quorum.js`, die Werkzeuge unter `.vault-tools/quorum/`. Vor der Opus-Stimme liest der Workflow `verbrauch` ab; gebremst heißt kein Urteil und ein Neustart nach dem Fenster.
**Auslöser, genau drei:**
@@ -176,13 +174,13 @@ Regelwerk-Änderungen im Vault lösen kein Quorum aus.
**Kein Unterschied wird zweimal geprüft.** Der Lauf nimmt nur, was seit dem letzten Quorum dazukam: bei Code den Diff vom Stand des letzten Berichts bis HEAD, bei Dokumenten beim ersten Mal den ganzen Satz, danach die Dateien mit geänderter Prüfsumme. Das erste Quorum eines Code-Projekts braucht deshalb eine `basis`, etwa den Commit der letzten abgenommenen Version. Der Arbeitsbaum muss außerhalb von `docs/quorum/` sauber sein, weil die Stimmen ihn lesen. Ist der Unterschied leer, übernimmt der Lauf das Urteil des letzten Quorums, ohne eine Stimme zu starten.
**Urteil.** Was zwei Stimmen an derselben Datei und Stelle melden, gilt, mit der höchsten Schwere der Gruppe. Einzelbefunde der Schwere blockierend und wichtig prüft ein Sonnet-Agent nach; Hinweise mit nur einer Stimme gehen ungeprüft in den Bericht. Ein Befund ohne Beleg zählt nicht für die Mehrheit. Frei heißt: kein bestätigter blockierender Befund und keiner, der ungeprüft blieb. Mit weniger als zwei abgegebenen Stimmen gibt es kein Urteil.
**Urteil.** Was zwei Stimmen an derselben Datei und Stelle melden, gilt, mit der höchsten Schwere der Gruppe. Einzelbefunde der Schwere blockierend und wichtig prüft ein Sonnet-Agent nach; Hinweise mit nur einer Stimme gehen ungeprüft in den Bericht. Ein Befund ohne Beleg zählt nicht für die Mehrheit und wird nicht nachgeprüft. Frei heißt: kein bestätigter blockierender Befund und kein belegter, der ungeprüft blieb. Ein blockierender Befund ohne Beleg sperrt nicht; er steht im Bericht unter „Unbelegt“, und der Grund des Urteils nennt ihn. Lies ihn, bevor du einspielst. Mit weniger als zwei abgegebenen Stimmen gibt es kein Urteil.
**Ersatz.** Gibt Codex keine gültige Stimme ab, springt Haiku ein. Gültig heißt: Der Status des Plugins ist vor dem Lauf gelesen, der Job endet `completed`, sein Log ist gelesen und trägt kein „usage limit“, und die Antwort zitiert die Lesemarke aus dem Paket. Mit `codex: false` springt Haiku sofort ein, etwa wenn das Kontingent bekannt aufgebraucht ist. Der Bericht sagt, wenn Haiku statt Codex drinsteht. Steht das oft da, ist das Quorum faktisch nur mit Claude besetzt.
**Sperre.** Solange das Urteil nicht frei ist, also ein bestätigter oder ungeprüft gebliebener blockierender Befund offen ist oder der Lauf kein Urteil hat, gibt es kein Merge, keinen Push, kein Einspielen und keine Abgabe. Die Korrekturen entscheidest du, umgesetzt werden sie über die Standardschleife. Danach läuft kein neues Quorum: Eine `quorum-stimme` auf Sonnet, über das Agent-Werkzeug mit der Auftragsart Nachkontrolle gestartet, bestätigt je behobenem Befund, dass er behoben ist. Den Korrektur-Diff prüft das nächste Quorum mit, weil dessen Basis der Stand dieses Laufs ist.
**Sperre.** Solange das Urteil nicht frei ist, also ein bestätigter oder ungeprüft gebliebener blockierender Befund mit Beleg offen ist oder der Lauf kein Urteil hat, gibt es kein Merge, keinen Push, kein Einspielen und keine Abgabe. Die Korrekturen entscheidest du, umgesetzt werden sie über die Standardschleife. Danach läuft kein neues Quorum: Eine `quorum-stimme` auf Sonnet, über das Agent-Werkzeug mit der Auftragsart Nachkontrolle gestartet, bestätigt je behobenem Befund, dass er behoben ist. Den Korrektur-Diff prüft das nächste Quorum mit, weil dessen Basis der Stand dieses Laufs ist.
**Bericht.** Der Workflow liefert `bericht_md`, `bericht_pfad` und `ablegen_befehl`. Du schreibst `bericht_md` in eine Datei und legst sie mit dem Befehl ab. Er setzt `stand` aus `paket.json` ein, damit die Liste der Prüfsummen durch kein Modell abgeschrieben werden muss. Danach committest du den Bericht: im Code-Projekt unter `docs/quorum/`, beim Kundendokument unter `quorum/` im Projektordner, Dateiname `JJJJ-MM-TT-<anlass>.md`. Der Bericht verlinkt README bzw. Übersicht. Sein Frontmatter trägt `nummer`, `basis` und `stand`, und daran setzt der nächste Lauf an. Ein Lauf ohne Urteil liefert keinen Bericht, die Basis bleibt dann die alte. Je Stimme stehen darin Befunde, bestätigte und Alleinfunde, also bestätigte Befunde, die nur diese Stimme hatte, dazu die Codex-Laufzeit und der Verbrauch vor und nach dem Lauf.
**Bericht.** Der Workflow liefert `bericht_md`, `bericht_pfad` und `ablegen_befehl`. Du schreibst `bericht_md` in eine Datei und legst sie mit dem Befehl ab. Er setzt `stand` aus `paket.json` ein, damit die Liste der Prüfsummen durch kein Modell abgeschrieben werden muss. Danach committest du den Bericht: im Code-Projekt unter `docs/quorum/`, beim Kundendokument unter `quorum/` im übergebenen Dokumentordner, Dateiname `JJJJ-MM-TT-<anlass>.md`. Der Bericht verlinkt README bzw. Übersicht. Sein Frontmatter trägt `nummer`, `basis` und `stand`, und daran setzt der nächste Lauf an. Ein Lauf ohne Urteil liefert keinen Bericht, die Basis bleibt dann die alte. Je Stimme stehen darin Befunde, bestätigte und Alleinfunde, also bestätigte Befunde, die nur diese Stimme hatte, dazu die Codex-Laufzeit und der Verbrauch vor und nach dem Lauf.
**Was bleibt:** die Echtgeld-Regeln und die volle Schleife für Geld-, Sicherheits- und Netzpfade. Gekürzt wird erst, wenn die Ertragskurve es trägt. Nach fünf Quoren liest du die Berichte, schreibst die Auswertung als Dump und legst Pascal einen Vorschlag hin. Die Frage dabei: Findet das Quorum, was die Wellenprüfung übersieht, und welche Stimme trägt die Alleinfunde? Über Kürzungen entscheidet Pascal.
+3 -2
View File
@@ -1,11 +1,11 @@
---
type: resource
title: "Skripte und Shellbefehle unter Windows"
status: reference
status: pending-review
summary: "Verbindliche Regeln für Befehle und Skripte auf Pascals Windows-Maschine: welche Shell, welches Python, Kodierung, Zeilenenden, Quoting-Schichten, WSL und ssh."
tags: [regelwerk, windows, werkzeuge]
created: 2026-09-13
updated: 2026-09-13
updated: 2026-10-07
---
# Skripte und Shellbefehle unter Windows
@@ -44,6 +44,7 @@ Die Fallgeschichten stehen im Harness-Gedächtnis. Der Name der Erinnerung steht
- **`wsl -d <Distro> -- bash -lc '…'` expandiert jedes `$` schon auf der Windows-Seite**, auch in einfachen Anführungszeichen. Pfade schreibst du deshalb literal, Exit-Codes gibst du im WSL-Teil selbst aus, oder du legst das Skript als Datei ab. (`wsl-bash-lc-expandiert-windowsseitig`)
- **Befehle an entfernte Linux-Hosts laufen als LF-Datei über stdin:** `cmd /c "ssh.exe <optionen> root@<host> bash -s < datei.sh"`. Verschachtelte Anführungszeichen kommen aus PowerShell nicht heil an. (`ssh-aus-powershell-als-datei-ueber-stdin`)
- **`SSH_ASKPASS` ohne Leerzeichen im Pfad, und vor der ersten Verbindung einmal probeweise starten.** Windows-OpenSSH startet askpass per `posix_spawnp`; scheitert das an einem Leerzeichen, schickt ssh ein **leeres Passwort** und erzeugt einen Fehlversuch am Ziel, der eine Login-Sperre füttert. **`UserKnownHostsFile` ohne `~` im Pfad:** ssh expandiert die Tilde auch im Windows-Kurzpfad (`C:\Users\NAME~1\…`) und schreibt dann still keinen Hostschlüssel; Langpfad mit `/` in Anführungszeichen nehmen. (`ssh-aus-powershell-als-datei-ueber-stdin`)
- **In einem über stdin gestarteten Skript liest kein Befehl stdin.** Das heißt `ssh -n`, `ausearch … < /dev/null`, nie `| sh`. Prüfskripte legst du erst nach `/tmp` und startest sie dann. (`pruefskript-liest-stdin`)
## Geheimnisse in Ausgaben