diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index cfc9de2..54b414d 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -32,11 +32,16 @@ eine andere Persona annimmt: sie, führt Tests aus und erstellt Git-Commits. **Judge-Persona**: Überprüft den Code mit dem Blick eines skeptischen Senior-Entwicklers. -Prüft Korrektheit, Robustheit, Randfälle, Sicherheit und Produktionsreife. Gibt ein Urteil: -- `PASS` — Code ist in Ordnung -- `PASS WITH CONCERNS` — hat Anmerkungen, die behoben werden müssen (wird wie `FAIL` behandelt, - außer du verwendest `--approve-concerns`) -- `FAIL` — enthält Blocker, die behoben werden müssen +Prüft Korrektheit, Robustheit, Randfälle, Sicherheit und Produktionsreife. Maßstab für das Urteil +sind `TASK.md` und, falls vorhanden, `SPEC.md`: +- `PASS` — alle Anforderungen erfüllt, Tests grün, kein Blocker und kein Major +- `PASS WITH CONCERNS` — alle Anforderungen erfüllt, aber mindestens ein Major: ein konkretes Risiko im + geforderten Einsatz, mit Reproduktion (wird wie `FAIL` behandelt, außer du verwendest `--approve-concerns`) +- `FAIL` — eine Anforderung fehlt, Tests schlagen fehl oder ein reproduzierbarer Fehler im geforderten Verhalten + +Härtung über die Anforderungen hinaus, Skalierung, Stil und Wünsche schreibt der Judge unter „Hinweise“. Sie +ändern das Urteil nicht, und der Coder bearbeitet sie bei `/fix` nicht. Ein Major ohne Reproduktion gilt als +Hinweis. Ohne diese Regeln urteilte der Judge mit Strata am 15.10.2026 in keiner Aufgabe PASS. Die Umschaltung zwischen den Rollen erfolgt über unterschiedliche System-Prompts, die die Extension automatisch vor jeder Inference einsetzt. Es läuft kein zweiter Server. @@ -144,6 +149,21 @@ Falls die GGUF-Datei an einem anderen Ort liegt: HF_HOME=/mnt/daten/huggingface ./start-coding-server.sh ``` +### Anderes Modell für die Schleife + +Ohne weitere Angabe arbeiten Coder und Judge mit `llama-cpp-single/coding_model` (Port 8001). Ein anderes Modell aus +`models.json` setzt die Umgebungsvariable `PI_CODER_MODEL` beim Start von pi, als `/`: + +```bash +PI_CODER_MODEL=strata/qwen3.8-flash-next-iq3_s \ + pi --provider strata --model qwen3.8-flash-next-iq3_s +``` + +`--provider`/`--model` machen das Modell auch für freie Eingaben zum Standard dieses Laufs; die globalen +Einstellungen bleiben unverändert. Die Bereitschaftsprüfung fragt den Endpunkt aus `baseUrl` des Modells ab. Der +Provider `strata` (lokaler Strata-Server auf `127.0.0.1:9090`) steht in `models.json`; den Server startet man außerhalb +dieses Repos. + --- ## 4. Neues Projekt anlegen @@ -234,21 +254,22 @@ Urteil: PASS WITH CONCERNS Blocker: keine Major: -- Stoppwortliste ist hardcoded; große Projekte erwarten --stopwords-file Option +- Nicht-existente Datei: Traceback statt Meldung und Exit-Code 2 (TASK.md verlangt Exit 2). + Reproduktion: python3 textcount.py fehlt.txt; echo $? → Traceback, Exit 1 Minor: -- Keine --version Flag -- Fehlermeldung bei nicht-existenter Datei gibt keinen Exit-Code 1 zurück +- Fehlermeldung nennt den Dateinamen nicht Fehlende Tests: - Test für leere Datei fehlt - Test für Datei mit nur Leerzeichen fehlt -Produktionsrisiken: +Hinweise (ändern das Urteil nicht): - Bei sehr großen Dateien (>1 GB) wird alles in den RAM geladen +- Keine --version Flag Konkrete Fix-Aufträge: -1. exit(1) bei FileNotFoundError +1. FileNotFoundError abfangen, Meldung mit Dateiname, exit(2) 2. Test für leere Eingabedatei ``` @@ -415,12 +436,15 @@ Stand und der Fix-Zyklus läuft automatisch bis PASS oder max. N Runden. ### Loop-Erkennung -Wenn zweimal hintereinander genau dieselben Blocker oder Concerns auftreten, bricht -`/optimize` ab: +Wenn zweimal hintereinander dieselbe Beanstandung auftritt, bricht `/optimize` ab: ``` -⚠ Derselbe Blocker tritt erneut auf – Schleife abgebrochen. Bitte manuell prüfen. +⚠ Schleife: Gleicher Blocker zweimal – manuelle Intervention nötig ``` +Verglichen wird der Blocker-Abschnitt des Judges. Steht dort nur „Keine“, werden stattdessen die Major-Punkte +verglichen; fehlen beide Abschnitte, der ganze Judge-Text. Zwei Runden mit „Blocker: Keine“, aber verschiedenen +Major-Punkten sind also keine Schleife. + In diesem Fall: `/judge` manuell ausführen, Blocker lesen, mit `/fix` manuell eingreifen. ### Max. Runden ohne PASS @@ -842,6 +866,13 @@ Die Checkboxen werden automatisch abgehakt: ## 12. Fehlermeldungen und Lösungen +### "Urteil unklar: … Judge-Urteil nicht erkennbar" + +**Ursache:** In der Judge-Antwort steht keine Zeile „Urteil: PASS | PASS WITH CONCERNS | FAIL“. Markdown dazwischen +wird erkannt (`## Urteil: **PASS**`, `**Urteil:** FAIL`); fehlt das Wort ganz, gilt die Runde als nicht bestanden. + +**Lösung:** Antwort im Chat lesen, dann `/judge` erneut oder `/fix` mit der Kritik. + ### "Modell-Datei nicht gefunden" ``` diff --git a/README.md b/README.md index 82295fa..fd310fa 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ nur die Persona im System-Prompt ändert sich. Coder- und Judge-Rollen werden durch verschiedene System-Prompts realisiert — dasselbe Modell, derselbe Container, unterschiedliche Persona. +Ein anderes Modell für beide Rollen (z. B. den lokalen Strata-Server) setzt `PI_CODER_MODEL=/`, +siehe [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md#anderes-modell-für-die-schleife). + --- ## Voraussetzungen @@ -93,9 +96,14 @@ Das Install-Skript sichert eine vorhandene `~/.pi/agent/settings.json` als `.bak kopiert dann `settings.json` aus dem Repo. Damit ist der korrekte Default-Provider (`llama-cpp-single/coding_model`) automatisch konfiguriert. -Nach späteren Änderungen an `pi-coding-extension.ts` oder `models.json`: +Die Extension wird nicht kopiert, sondern verlinkt +(`~/.pi/agent/extensions/pi-coding-extension.ts` → Repo). Änderungen an `pi-coding-extension.ts` gelten +damit für jeden pi-Aufruf ab dem nächsten Start bzw. `/reload`, ohne erneutes Deploy. Das gilt auch für noch +nicht committete Änderungen. + +Nach späteren Änderungen an `models.json` oder `settings.json`: ```bash -./deploy-pi-config.sh # kopiert nach ~/.pi/agent/ +./deploy-pi-config.sh # verlinkt die Extension, kopiert models.json, settings.json (nur packages) # dann /reload in pi agent ``` @@ -256,7 +264,7 @@ und Extension-Guards gegen Regressionen. | `start-coding-server.sh` | Single-Server starten (Port 8001, Coder + Judge) | | `stop-coding-server.sh` | Container stoppen | | `status-coding-server.sh` | Laufstatus anzeigen | -| `deploy-pi-config.sh` | Extension + models.json + settings.json nach `~/.pi/agent/` deployen | +| `deploy-pi-config.sh` | Extension nach `~/.pi/agent/extensions/` verlinken, models.json + settings.json kopieren | | `examples/` | Demo-Projekte (Python, Rust, Go, C, Bash, HTML/JS) für Live-Demos | | `examples/restore-all.sh` | Examples nach Demo-Lauf in Ausgangszustand zurücksetzen | @@ -292,6 +300,9 @@ einen weiteren Fix-Auftrag, genau wie bei `FAIL`. Erst sauberes `PASS` beendet d Opt-out: `--approve-concerns` lässt `PASS WITH CONCERNS` als bestanden gelten. +Wann der Judge welches Urteil gibt (Urteilsregeln, Abschnitt „Hinweise“): siehe +[BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md), Judge-Persona. + --- ## Live-Aktivitätsstatus diff --git a/deploy-pi-config.sh b/deploy-pi-config.sh index b0b85ff..21398bf 100755 --- a/deploy-pi-config.sh +++ b/deploy-pi-config.sh @@ -1,13 +1,20 @@ #!/usr/bin/env bash -# Kopiert die versionierten Dateien aus dem Repo nach ~/.pi/agent/. -# Nach jeder Änderung im Repo ausführen, damit pi agent die neue Version lädt. +# Verlinkt die Extension und kopiert models.json/settings.json aus dem Repo nach ~/.pi/agent/. +# Die Extension ist ein Symlink: Änderungen im Repo gelten ab dem nächsten pi-Start bzw. /reload. +# Erneut ausführen nur nach Änderungen an models.json oder settings.json. set -euo pipefail REPO="$(cd "$(dirname "$0")" && pwd)" mkdir -p ~/.pi/agent/extensions -cp "$REPO/pi-coding-extension.ts" ~/.pi/agent/extensions/pi-coding-extension.ts -echo "Kopiert: pi-coding-extension.ts → ~/.pi/agent/extensions/" +# A previous, differing copy is kept as .bak before it is replaced by the symlink. +EXT=~/.pi/agent/extensions/pi-coding-extension.ts +if [ -f "$EXT" ] && [ ! -L "$EXT" ] && ! cmp -s "$EXT" "$REPO/pi-coding-extension.ts"; then + cp "$EXT" "$EXT.bak" + echo "Backup: pi-coding-extension.ts → pi-coding-extension.ts.bak" +fi +ln -sfn "$REPO/pi-coding-extension.ts" "$EXT" +echo "Verlinkt: ~/.pi/agent/extensions/pi-coding-extension.ts → $REPO/pi-coding-extension.ts" cp "$REPO/models.json" ~/.pi/agent/models.json echo "Kopiert: models.json → ~/.pi/agent/" diff --git a/models.json b/models.json index 8f08ae0..1b7afae 100644 --- a/models.json +++ b/models.json @@ -39,6 +39,28 @@ ] }, + "strata": { + "baseUrl": "http://127.0.0.1:9090/v1", + "api": "openai-completions", + "apiKey": "none", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": true, + "maxTokensField": "max_tokens" + }, + "models": [ + { + "id": "qwen3.8-flash-next-iq3_s", + "name": "Strata Qwen3.8-Flash-Next IQ3_S (lokal :9090)", + "reasoning": true, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 16384, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } + } + ] + }, + "openrouter": { "models": [ { "id": "qwen/qwen3-235b-a22b:free", "name": "Qwen3 235B (Free)" }, diff --git a/pi-coding-extension.ts b/pi-coding-extension.ts index ac411d1..d610921 100644 --- a/pi-coding-extension.ts +++ b/pi-coding-extension.ts @@ -1,6 +1,6 @@ // pi-coding-extension.ts // Automatisierter Coder-Judge-Fix-Workflow für AI-Coding-Assistenten -// Ein Modell (coding_model) auf Single-Server (Port 8001); Rollen Coder/Judge via System-Prompt +// Ein Modell (Standard: coding_model auf Single-Server :8001, sonst PI_CODER_MODEL); Rollen Coder/Judge via System-Prompt import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; @@ -51,6 +51,28 @@ function coderKickoff(task: string): string { ].join("\n"); } +// Urteilsregeln und Ausgabeformat beider Judge-Prompts. Ohne feste Regeln fand der Judge (Strata, 15.10.) in jeder +// Runde neue Härtungen über die Anforderungen hinaus und urteilte nie PASS, auch bei erfüllter SPEC. +function judgeVerdictSection(): string[] { + return [ + "Urteilsregeln (verbindlich; Maßstab ist TASK.md und, falls vorhanden, SPEC.md):", + "- FAIL: eine Anforderung ist nicht erfüllt, Tests schlagen fehl, oder ein reproduzierbarer Fehler im geforderten Verhalten.", + "- PASS WITH CONCERNS: alle Anforderungen erfüllt, aber mindestens ein Major: ein konkretes Risiko im geforderten Einsatz, mit Reproduktion (Befehl oder Eingabe → falsches Ergebnis).", + "- PASS: alle Anforderungen erfüllt, Tests grün, kein Blocker und kein Major. Minor-Punkte und Hinweise ändern daran nichts.", + "- Härtung über die Anforderungen hinaus, Skalierung, Stil, Wünsche und Risiken in nicht geforderten Umgebungen gehören unter „Hinweise“. Sie ändern das Urteil nicht und sind keine Fix-Aufträge.", + "- Ein Major ohne Reproduktion ist ein Hinweis.", + "", + "Ausgabeformat:", + "- Urteil: PASS | PASS WITH CONCERNS | FAIL", + "- Blocker", + "- Major", + "- Minor", + "- Fehlende Tests", + "- Hinweise (ändern das Urteil nicht)", + "- Konkrete Fix-Aufträge an den Coder (nur für Blocker, Major, Minor und fehlende Tests)", + ]; +} + function judgePrompt(extra: string): string { const suffix = extra?.trim() ? "\n\nZusätzlicher Fokus des Users:\n" + extra.trim() : ""; return [ @@ -65,14 +87,7 @@ function judgePrompt(extra: string): string { "4. Bewerte Korrektheit, Robustheit, Fehlerbehandlung, Sicherheit, Logging, Wartbarkeit und Produktionsreife.", "5. Wenn etwas fehlt, sage es klar und direkt.", "", - "Ausgabeformat:", - "- Urteil: PASS | PASS WITH CONCERNS | FAIL", - "- Blocker", - "- Major", - "- Minor", - "- Fehlende Tests", - "- Produktionsrisiken", - "- Konkrete Fix-Aufträge an den Coder", + ...judgeVerdictSection(), "", "Wenn du etwas behauptest, nenne die Datei, den Befehl, den Test oder den Reproduktionshinweis." ].join("\n") + suffix; @@ -98,14 +113,7 @@ function judgeWithTestsPrompt(testOutput: string, extra: string): string { "3. Versuche weitere Fehler im Code aktiv zu finden (Randfälle, Sicherheit, Robustheit).", "4. Wenn du etwas behauptest, nenne die Datei, die Zeile oder den Reproduktionshinweis.", "", - "Ausgabeformat:", - "- Urteil: PASS | PASS WITH CONCERNS | FAIL", - "- Blocker", - "- Major", - "- Minor", - "- Fehlende Tests", - "- Produktionsrisiken", - "- Konkrete Fix-Aufträge an den Coder", + ...judgeVerdictSection(), ].join("\n") + suffix; } @@ -115,7 +123,7 @@ function fixPrompt(extra: string): string { "Wechsle in den Reparaturmodus.", "Lies TASK.md als Referenz — stelle sicher, dass nach den Fixes alle Anforderungen erfüllt bleiben.", "Nutze den letzten Judge-Bericht als verbindliche Aufgabenliste.", - "Behebe zuerst Blocker, dann Major, dann Minor.", + "Behebe zuerst Blocker, dann Major, dann Minor. Der Abschnitt „Hinweise“ ist kein Auftrag.", "Lies jede betroffene Datei unmittelbar vor dem Editieren erneut ein.", "Wenn du mehrere Stellen in derselben Datei änderst: Erzeuge einen unified diff und nutze das apply_patch-Tool — nicht mehrere edit-Aufrufe.", "Fallback: Datei komplett neu schreiben wenn apply_patch nicht möglich.", @@ -432,13 +440,30 @@ async function tickTaskMdStatus( ); } -// Single-Server: genau ein Modell (coding_model auf :8001) für beide Rollen. +// Single-Modell: genau ein Modell (Standard coding_model auf :8001, oder PI_CODER_MODEL) für beide Rollen. // Der Rollenwechsel Coder/Judge erfolgt NICHT mehr über den Modellwechsel, sondern // über den System-Prompt (siehe before_agent_start-Hook + currentRole). Diese Funktion // stellt nur sicher, dass das eine Single-Modell aktiv ist; nach dem ersten Aufruf // ist der Wechsel via currentModelKey gecacht und damit ein No-op. -const SINGLE_PROVIDER = "llama-cpp-single"; -const SINGLE_MODEL_ID = "coding_model"; +// PI_CODER_MODEL="/" setzt ein anderes Modell für beide Rollen (z. B. strata/qwen3.8-flash-next-iq3_s); +// ohne die Variable bleibt es beim Single-Server auf :8001. +const DEFAULT_SINGLE_MODEL = "llama-cpp-single/coding_model"; +const SINGLE_MODEL_SPEC = (process.env.PI_CODER_MODEL || DEFAULT_SINGLE_MODEL).trim(); +const SINGLE_PROVIDER = SINGLE_MODEL_SPEC.slice(0, SINGLE_MODEL_SPEC.indexOf("/")); +const SINGLE_MODEL_ID = SINGLE_MODEL_SPEC.slice(SINGLE_MODEL_SPEC.indexOf("/") + 1); +const SINGLE_LABEL = `${SINGLE_PROVIDER}/${SINGLE_MODEL_ID}`; + +// Endpunkt des Single-Modells laut models.json, für Bereitschaftsprüfung und Meldungen. +function singleEndpoint(ctx: ExtensionCommandContext): string { + const model = ctx.modelRegistry.find(SINGLE_PROVIDER, SINGLE_MODEL_ID); + return model?.baseUrl ? model.baseUrl.replace(/\/+$/, "") : ""; +} + +function notReadyMessage(ctx: ExtensionCommandContext): string { + const where = singleEndpoint(ctx) || SINGLE_LABEL; + const hint = SINGLE_MODEL_SPEC === DEFAULT_SINGLE_MODEL ? "start-coding-server.sh ausführen" : "Server starten"; + return `Server nicht bereit (${where}) — ${hint}`; +} async function ensureSingleModel( pi: ExtensionAPI, @@ -518,17 +543,20 @@ async function sendAndWait( async function waitUntilModelReady( pi: ExtensionAPI, ctx: ExtensionCommandContext, - port: number, - modelAlias: string, maxWaitMs = 180_000 ): Promise { + const endpoint = singleEndpoint(ctx); + if (!endpoint) { + ctx.ui.notify(`Modell ${SINGLE_LABEL} nicht in models.json gefunden`, "error"); + return false; + } const deadline = Date.now() + maxWaitMs; const body = JSON.stringify({ - model: modelAlias, + model: SINGLE_MODEL_ID, messages: [{ role: "user", content: "ping" }], max_tokens: 1, temperature: 0.0, stream: false, }); - // Body als Datei — verhindert Shell-Injection wenn modelAlias Sonderzeichen enthält + // Body als Datei — verhindert Shell-Injection wenn die Modell-ID Sonderzeichen enthält const tmpBody = `/tmp/pi_ready_${Date.now()}_${Math.random().toString(36).slice(2)}.json`; await pi.exec("bash", ["-c", `printf "%s" "$1" > "${tmpBody}"`, "_", body], { cwd: ctx.cwd }); let notified = false; @@ -536,13 +564,13 @@ async function waitUntilModelReady( while (Date.now() < deadline) { const r = await pi.exec("bash", ["-c", `curl -s -o /dev/null -w "%{http_code}" --max-time 5 ` + - `-X POST http://localhost:${port}/v1/chat/completions ` + + `-X POST "$1/chat/completions" ` + `-H "Content-Type: application/json" ` + - `-d "@${tmpBody}"` + `-d "@${tmpBody}"`, "_", endpoint ], { cwd: ctx.cwd }); if (r.stdout?.trim() === "200") return true; if (!notified) { - ctx.ui.notify(`Modell-Server (Port ${port}) lädt noch — warte bis zu 3 min…`, "info"); + ctx.ui.notify(`Modell-Server (${endpoint}) lädt noch — warte bis zu 3 min…`, "info"); notified = true; } await new Promise(res => setTimeout(res, 3000)); @@ -644,10 +672,17 @@ function normalizeForComparison(s: string): string { } function parseVerdict(text: string): string { - const m = text.match(/Urteil:\s*(PASS WITH CONCERNS|PASS|FAIL)/i); + // Markdown zwischen „Urteil“ und dem Wort ist erlaubt: „## Urteil: **PASS**“, „**Urteil:** FAIL“. + const m = text.match(/Urteil[\s*_`:]*(PASS WITH CONCERNS|PASS|FAIL)/i); return m ? m[1].toUpperCase() : "UNREADABLE"; } +// SHIP-Urteil der Freigabe-Runde; Markdown wie bei parseVerdict erlaubt. "" wenn nicht erkennbar. +function parseShipVerdict(text: string): string { + const m = text.match(/Urteil[\s*_`:]*(NO-SHIP|SHIP)/i); + return m ? m[1].toUpperCase() : ""; +} + // Extrahiert den Blocker-Abschnitt für die Loop-Erkennung. // Erkennt Bullet-Listen (- / – / *), Bold (**Blocker**) und Headings (## Blocker). function parseBlockers(text: string): string { @@ -657,6 +692,23 @@ function parseBlockers(text: string): string { return m ? m[1].trim() : ""; } +// Schlüssel für die Loop-Erkennung im /optimize-Loop: der Blocker-Abschnitt; steht dort nur „Keine“ o. ä., +// der Major-Abschnitt; fehlen beide, der ganze Judge-Text. Ohne diese Unterscheidung galten zwei Runden mit +// „Blocker: Keine“ und unterschiedlichen Major-Punkten als „gleicher Blocker“ (Fehlalarm). +function loopKey(text: string): string { + const stop = "(?:\\n(?:\\*\\*(?:Blocker|Major|Minor)\\*\\*|##\\s*(?:Blocker|Major|Minor)|[-–*]\\s*(?:Blocker|Major|Minor))|$)"; + const section = (name) => { + const m = text.match(new RegExp(`(?:\\*\\*${name}\\*\\*|##\\s*${name}|[-–*]\\s*${name})[:\\n]([\\s\\S]*?)${stop}`, "i")); + return m ? m[1].trim() : ""; + }; + const isNone = (s) => /^[\s\-–*•:]*(keine?r?|none|n\/a|nichts|—|-)?[\s.!]*$/i.test(s); + const blockers = section("Blocker"); + if (blockers && !isNone(blockers)) return blockers; + const majors = section("Major"); + if (majors && !isNone(majors)) return "MAJOR: " + majors; + return text; +} + // Gibt geänderte Quelldateien seit einem Git-Tag zurück. // null = Tag existiert nicht (erster Lauf) → alles verarbeiten // [] = nichts geändert → Phase überspringen @@ -1128,15 +1180,15 @@ export default function (pi: ExtensionAPI) { // ── Manuelle Kommandos ─────────────────────────────────────────────────── pi.registerCommand("coder", { - description: "Implementiert ohne Review-Loop → Coder-Rolle (:8001).", + description: "Implementiert ohne Review-Loop → Coder-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { const task = (args || "").trim(); if (!task) { ctx.ui.notify("Benutzung: /coder ", "error"); return; } - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await ensureLocalGitRepo(pi, ctx); @@ -1149,10 +1201,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("judge", { - description: "Review gegen TASK.md + git show HEAD → Judge-Rolle (:8001).", + description: "Review gegen TASK.md + git show HEAD → Judge-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "judge"); @@ -1163,10 +1215,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("fix", { - description: "Fixt Judge-Kritik, committet Ergebnis → Coder-Rolle (:8001).", + description: "Fixt Judge-Kritik, committet Ergebnis → Coder-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await ensureLocalGitRepo(pi, ctx); @@ -1178,10 +1230,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("shipit", { - description: "Finale Freigabe gegen TASK.md + git log → Judge-Rolle (:8001).", + description: "Finale Freigabe gegen TASK.md + git log → Judge-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "judge"); @@ -1189,7 +1241,7 @@ export default function (pi: ExtensionAPI) { currentActivity = "Judge: finale Freigabe…"; await sendAndWait(pi, ctx, shipitPrompt(args || "")); const shipText = getLastAssistantText(ctx); - const shipVerdict = shipText.match(/Urteil:\s*(SHIP|NO-SHIP)/i)?.[1]?.toUpperCase() ?? ""; + const shipVerdict = parseShipVerdict(shipText); if (shipVerdict === "SHIP") { await autoCommitIfDirty(pi, ctx); notifyShipSuccess(ctx); @@ -1246,11 +1298,11 @@ export default function (pi: ExtensionAPI) { : `--continue: Überspringe Implementierung, starte direkt mit Judge-Prüfung.`; ctx.ui.notify(continueMsg, "info"); - // Im --continue-Modus: Single-Server prüfen (Port 8001 — Coder + Judge in einem). - ctx.ui.setStatus("optimize", "Single-Server wird geprüft (Port 8001)…"); - const serverReady = await waitUntilModelReady(pi, ctx, 8001, "coding_model"); + // Im --continue-Modus: Single-Modell prüfen (Coder + Judge in einem). + ctx.ui.setStatus("optimize", `Single-Modell wird geprüft (${SINGLE_LABEL})…`); + const serverReady = await waitUntilModelReady(pi, ctx); if (!serverReady) { - finalNotify(ctx, "⛔ Server nicht erreichbar", "Port 8001 — kein HTTP 200 nach 3 min. start-coding-server.sh ausführen"); + finalNotify(ctx, "⛔ Server nicht erreichbar", `${notReadyMessage(ctx)} (kein HTTP 200 nach 3 min)`); return; } } else { @@ -1260,7 +1312,7 @@ export default function (pi: ExtensionAPI) { const taskPreview = task.length > 55 ? task.slice(0, 52) + "…" : task; ctx.ui.setStatus("optimize", `◉ Coder liest Anforderungen + implementiert: ${taskPreview}`); if (!await selectRole(pi, ctx, "coder")) { - finalNotify(ctx, "⛔ Modell-Fehler", "Modell (llama-cpp-single) nicht verfügbar"); + finalNotify(ctx, "⛔ Modell-Fehler", `Modell (${SINGLE_LABEL}) nicht verfügbar`); return; } currentActivity = "Coder implementiert…"; @@ -1274,8 +1326,8 @@ export default function (pi: ExtensionAPI) { // GPU-Ladevorgangs und ist kein verlässliches Signal. Nur HTTP 200 auf einen // echten Completion-Request bedeutet: Modell ist im VRAM und bereit. ctx.ui.setStatus("optimize", "Single-Server wird geprüft…"); - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - finalNotify(ctx, "⛔ Server nicht erreichbar", "Port 8001 — kein HTTP 200 nach 3 min. start-coding-server.sh ausführen"); + if (!await waitUntilModelReady(pi, ctx)) { + finalNotify(ctx, "⛔ Server nicht erreichbar", `${notReadyMessage(ctx)} (kein HTTP 200 nach 3 min)`); return; } } @@ -1313,7 +1365,7 @@ export default function (pi: ExtensionAPI) { for (let round = 1; round <= maxRounds; round++) { const prog = "●".repeat(round - 1) + "◉" + "○".repeat(maxRounds - round); if (!await selectRole(pi, ctx, "judge")) { - finalNotify(ctx, "⛔ Modell-Fehler", "Modell (llama-cpp-single) nicht verfügbar"); + finalNotify(ctx, "⛔ Modell-Fehler", `Modell (${SINGLE_LABEL}) nicht verfügbar`); return; } @@ -1352,10 +1404,9 @@ export default function (pi: ExtensionAPI) { } // Loop-Erkennung: gleiche Beanstandung zweimal → manuell eingreifen. - // Fallback auf den gesamten Judge-Text, wenn kein Blocker-Abschnitt vorhanden ist - // (z.B. reine Minor-Concerns) — sonst würde die Erkennung dort nie greifen. + // Schlüssel: Blocker, bei „Blocker: Keine“ die Major-Punkte, sonst der ganze Judge-Text (loopKey). // Normalisierung verhindert False-Negatives durch minimale Formulierungsunterschiede. - const currentBlockers = parseBlockers(judgeText) || judgeText; + const currentBlockers = loopKey(judgeText); if (currentBlockers && normalizeForComparison(currentBlockers) === normalizeForComparison(lastBlockers)) { ctx.ui.setStatus("optimize", `${prog} ⚠ Gleicher Blocker in Runde ${round} – manuelle Intervention nötig`); finalNotify(ctx, "⚠ Schleife", "Gleicher Blocker zweimal – manuelle Intervention nötig"); @@ -1378,7 +1429,7 @@ export default function (pi: ExtensionAPI) { ? (currentBlockers.length > 50 ? currentBlockers.slice(0, 47) + "…" : currentBlockers) : "Kritikpunkte aus Judge-Bericht"; if (!await selectRole(pi, ctx, "coder")) { - finalNotify(ctx, "⛔ Modell-Fehler", "Modell (llama-cpp-single) nicht verfügbar"); + finalNotify(ctx, "⛔ Modell-Fehler", `Modell (${SINGLE_LABEL}) nicht verfügbar`); return; } currentActivity = `Coder fixt: ${blockerHint}`; @@ -1471,15 +1522,15 @@ export default function (pi: ExtensionAPI) { // ── Schlanke Kommandos für kleine Änderungen ───────────────────────────── pi.registerCommand("patch", { - description: "Gezielte Minimaländerung ohne Refactoring, committet → Coder-Rolle (:8001).", + description: "Gezielte Minimaländerung ohne Refactoring, committet → Coder-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { const change = (args || "").trim(); if (!change) { ctx.ui.notify("Benutzung: /patch ", "error"); return; } - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await ensureLocalGitRepo(pi, ctx); @@ -1491,10 +1542,10 @@ export default function (pi: ExtensionAPI) { }); pi.registerCommand("quick_check", { - description: "Schnelle OK/PROBLEM-Prüfung einer kleinen Codeänderung → Judge-Rolle (:8001).", + description: "Schnelle OK/PROBLEM-Prüfung einer kleinen Codeänderung → Judge-Rolle (Single-Modell).", handler: async function (args: string, ctx: ExtensionCommandContext) { - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "judge"); @@ -1562,8 +1613,8 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("Benutzung: /plan ", "error"); return; } - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await writeTaskMd(pi, ctx, task); @@ -1645,8 +1696,8 @@ export default function (pi: ExtensionAPI) { } // Standard-Verhalten: unterbrochenen Prozess wieder aufnehmen - if (!await waitUntilModelReady(pi, ctx, 8001, "coding_model")) { - ctx.ui.notify("Server nicht bereit (Port 8001) — start-coding-server.sh ausführen", "error"); + if (!await waitUntilModelReady(pi, ctx)) { + ctx.ui.notify(notReadyMessage(ctx), "error"); return; } await selectRole(pi, ctx, "coder"); diff --git a/tests/config.test.mjs b/tests/config.test.mjs index 606d923..592a581 100644 --- a/tests/config.test.mjs +++ b/tests/config.test.mjs @@ -15,6 +15,13 @@ test("models.json: Provider llama-cpp-single hat genau ein Modell coding_model a assert.deepEqual(p.models.map((m) => m.id), ["coding_model"]); }); +test("models.json: Provider strata zeigt auf den lokalen Strata-Server (:9090)", () => { + const p = models.providers["strata"]; + assert.ok(p, "Provider strata fehlt"); + assert.match(p.baseUrl, /^http:\/\/127\.0\.0\.1:9090\/v1$/); + assert.deepEqual(p.models.map((m) => m.id), ["qwen3.8-flash-next-iq3_s"]); +}); + test("models.json: tote Dual-/Alt-Provider sind entfernt", () => { for (const dead of ["llama-cpp", "llama-cpp-coder", "llama-cpp-judge"]) { assert.ok(!(dead in models.providers), `Toter Provider '${dead}' noch vorhanden`); diff --git a/tests/extension-guards.test.mjs b/tests/extension-guards.test.mjs index 158f811..0f281f5 100644 --- a/tests/extension-guards.test.mjs +++ b/tests/extension-guards.test.mjs @@ -38,7 +38,7 @@ test("Fix-Auslösung: PASS WITH CONCERNS gilt nur mit --approve-concerns als bes }); test("Loop-Erkennung greift auch ohne Blocker-Abschnitt (Fallback auf Gesamttext)", () => { - assert.match(extSource, /parseBlockers\(judgeText\)\s*\|\|\s*judgeText/); + assert.match(extSource, /const currentBlockers = loopKey\(judgeText\)/); }); test("currentRole wird nach jedem Kommando auf 'idle' zurückgesetzt (kein Persona-Leak in freien Chat)", () => { @@ -61,3 +61,14 @@ test("LICENCE.md: deterministisch in der Doku-Phase, nur wenn nicht vorhanden, p assert.match(extSource, /git config user\.name/); assert.match(extSource, /new Date\(\)\.getFullYear\(\)/); }); + +test("Single-Modell: Standard bleibt llama-cpp-single/coding_model, PI_CODER_MODEL überschreibt", () => { + assert.match(extSource, /DEFAULT_SINGLE_MODEL = "llama-cpp-single\/coding_model"/); + assert.match(extSource, /process\.env\.PI_CODER_MODEL \|\| DEFAULT_SINGLE_MODEL/); +}); + +test("Bereitschaftsprüfung: Endpunkt aus models.json statt fest Port 8001", () => { + assert.doesNotMatch(extSource, /waitUntilModelReady\(pi, ctx, 8001/); + assert.doesNotMatch(extSource, /localhost:\$\{port\}/); + assert.match(extSource, /function singleEndpoint\b/); +}); diff --git a/tests/pure-logic.test.mjs b/tests/pure-logic.test.mjs index af1f446..a91f15e 100644 --- a/tests/pure-logic.test.mjs +++ b/tests/pure-logic.test.mjs @@ -9,6 +9,8 @@ const parseVerdict = extractFn("parseVerdict"); const parseBlockers = extractFn("parseBlockers"); const normalizeForComparison = extractFn("normalizeForComparison"); const licenceMd = extractFn("licenceMd"); +const loopKey = extractFn("loopKey"); +const parseShipVerdict = extractFn("parseShipVerdict"); test("parseVerdict: klares PASS", () => { assert.equal(parseVerdict("Review fertig.\nUrteil: PASS\nKeine Blocker."), "PASS"); @@ -78,3 +80,63 @@ test("licenceMd: proprietärer Default mit eingesetztem Jahr und Inhaber", () => test("licenceMd: Platzhalter-Inhaber wird unverändert eingesetzt", () => { assert.match(licenceMd("2030", "[Rechteinhaber eintragen]"), /Copyright \(c\) 2030 \[Rechteinhaber eintragen\]/); }); + +test("loopKey: echte Blocker sind der Schlüssel", () => { + const txt = "## Blocker\n- B1 Absturz bei leerer Eingabe\n\n## Major\n- M1 x"; + assert.match(loopKey(txt), /B1 Absturz/); + assert.doesNotMatch(loopKey(txt), /M1/); +}); + +test("loopKey: 'Blocker: Keine' → Major-Punkte statt Fehlalarm (Strata-Probe 15.10.)", () => { + const r1 = "## Urteil: **PASS WITH CONCERNS**\n\n## Blocker\n- Keine.\n\n## Major\n- M1 Flags als Name\n\n## Minor\n- N1"; + const r2 = "## Urteil: **PASS WITH CONCERNS**\n\n## Blocker\n- Keine.\n\n## Major\n- M1 UTF-8-Absturz\n\n## Minor\n- N1"; + assert.notEqual(loopKey(r1), loopKey(r2)); + assert.match(loopKey(r1), /^MAJOR: - M1 Flags/); +}); + +test("loopKey: gleiche Major-Punkte zweimal → gleicher Schlüssel (Loop wird erkannt)", () => { + const r = "**Blocker**\nkeine\n**Major**\n- M1 gleich\n**Minor**\n- n"; + assert.equal(loopKey(r), loopKey(r + "\n")); +}); + +test("loopKey: weder Blocker noch Major → ganzer Text", () => { + assert.equal(loopKey("Urteil: FAIL\nirgendwas"), "Urteil: FAIL\nirgendwas"); + assert.equal(loopKey("## Blocker\nNone\n## Major\n-"), "## Blocker\nNone\n## Major\n-"); +}); + +test("parseVerdict: Urteil in Markdown-Fett (Strata-Judge 15.10.)", () => { + assert.equal(parseVerdict("## Urteil: **PASS WITH CONCERNS**\n### Blocker"), "PASS WITH CONCERNS"); + assert.equal(parseVerdict("**Urteil:** FAIL"), "FAIL"); + assert.equal(parseVerdict("## Urteil: **PASS**"), "PASS"); + assert.equal(parseVerdict("Urteil: `PASS`"), "PASS"); +}); + +test("parseShipVerdict: SHIP, NO-SHIP, Markdown, unklar", () => { + assert.equal(parseShipVerdict("Urteil: SHIP"), "SHIP"); + assert.equal(parseShipVerdict("## Urteil: **NO-SHIP**"), "NO-SHIP"); + assert.equal(parseShipVerdict("**Urteil:** ship"), "SHIP"); + assert.equal(parseShipVerdict("kein Urteil"), ""); +}); + +// Judge-Prompts rufen judgeVerdictSection() auf; extractFn wertet jede Funktion einzeln aus, freie Namen landen +// im globalen Scope. +globalThis.judgeVerdictSection = extractFn("judgeVerdictSection"); +const judgePrompt = extractFn("judgePrompt"); +const judgeWithTestsPrompt = extractFn("judgeWithTestsPrompt"); +const fixPrompt = extractFn("fixPrompt"); + +test("Judge-Prompts: Urteilsregeln und Hinweise in beiden Varianten (Strata-Judge 15.10.)", () => { + for (const p of [judgePrompt(""), judgeWithTestsPrompt("1 passed", "")]) { + assert.match(p, /Urteilsregeln \(verbindlich/); + assert.match(p, /- PASS: alle Anforderungen erfüllt, Tests grün, kein Blocker und kein Major/); + assert.match(p, /„Hinweise“\. Sie ändern das Urteil nicht/); + assert.match(p, /- Hinweise \(ändern das Urteil nicht\)/); + assert.match(p, /- Urteil: PASS \| PASS WITH CONCERNS \| FAIL/); + assert.doesNotMatch(p, /Produktionsrisiken/); + } + assert.match(judgeWithTestsPrompt("3 passed", ""), /3 passed/); +}); + +test("fixPrompt: Hinweise sind kein Auftrag", () => { + assert.match(fixPrompt(""), /„Hinweise“ ist kein Auftrag/); +}); diff --git a/tests/scripts.test.mjs b/tests/scripts.test.mjs index 686bad2..a5f88a3 100644 --- a/tests/scripts.test.mjs +++ b/tests/scripts.test.mjs @@ -44,3 +44,9 @@ test("install-Skript deployt settings.json mit vorherigem Backup", () => { assert.match(s, /cp "\$REPO\/models\.json"/); assert.match(s, /pi-coding-extension\.ts/); }); + +test("install-Skript verlinkt die Extension statt sie zu kopieren", () => { + const s = readRepoFile("deploy-pi-config.sh"); + assert.match(s, /ln -sfn "\$REPO\/pi-coding-extension\.ts" "\$EXT"/); + assert.doesNotMatch(s, /cp "\$REPO\/pi-coding-extension\.ts"/, "cp auf den Symlink bricht mit 'same file' ab"); +});