feat: --test-cmd für /optimize — Tests laufen in Extension, nicht im Judge

Neues Flag: /optimize <auftrag> --test-cmd "pytest -x"

Die Extension führt den Test-Befehl vor jedem Judge-Call selbst aus (pi.exec).
Judge bekommt den Output fertig übergeben und muss keine Tests mehr starten.
Das entkoppelt Test-Laufzeit vom LLM-Call und spart Judge-Inferenz-Zeit.

- judgeWithTestsPrompt(): wie judgePrompt, aber mit Test-Output im Prompt,
  explizites Verbot weitere Tests zu starten
- runTests(): führt Shell-Befehl aus, kürzt Output auf 6000 Zeichen
- Ohne --test-cmd: bisheriges Verhalten unverändert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-05-20 21:10:25 +02:00
commit 6afcd6a271

View file

@ -57,6 +57,38 @@ function judgePrompt(extra: string): string {
].join("\n") + suffix; ].join("\n") + suffix;
} }
// Wie judgePrompt, aber Tests werden NICHT vom Judge ausgeführt —
// die Extension hat sie bereits extern gestartet und übergibt den Output.
function judgeWithTestsPrompt(testOutput: string, extra: string): string {
const suffix = extra?.trim() ? "\n\nZusätzlicher Fokus des Users:\n" + extra.trim() : "";
return [
"Du bist ein pingeliger, skeptischer Senior-Reviewer und QA-Ingenieur.",
"Deine Aufgabe ist NICHT, nett zu sein, sondern Fehler, Risiken, Randfälle und Produktionsprobleme zu finden.",
"",
"Die Test-Suite wurde bereits extern ausgeführt. Das Ergebnis steht unten.",
"Führe KEINE weiteren Tests aus — weder dieselben noch andere.",
"",
"Pflichten:",
"0. Lies TASK.md und prüfe, ob alle dort beschriebenen Anforderungen vollständig umgesetzt sind.",
"1. Sieh dir den letzten Commit an: 'git log -1 --stat' und 'git show HEAD'.",
"2. Analysiere das folgende Test-Ergebnis und leite daraus Blocker/Major/Minor ab:",
"```",
testOutput,
"```",
"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",
].join("\n") + suffix;
}
function fixPrompt(extra: string): string { function fixPrompt(extra: string): string {
const suffix = extra?.trim() ? "\n\nZusätzlicher User-Hinweis:\n" + extra.trim() : ""; const suffix = extra?.trim() ? "\n\nZusätzlicher User-Hinweis:\n" + extra.trim() : "";
return [ return [
@ -369,6 +401,22 @@ async function sendAndWait(
await ctx.waitForIdle(); await ctx.waitForIdle();
} }
// Führt einen Shell-Befehl aus und gibt stdout+stderr zurück (max. 6000 Zeichen).
// Wird von /optimize --test-cmd genutzt, damit Judge keine Tests selbst starten muss.
async function runTests(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
cmd: string
): Promise<string> {
const result = await pi.exec("bash", ["-c", cmd], { cwd: ctx.cwd });
const output = (result.stdout + (result.stderr ? "\n" + result.stderr : "")).trim();
const MAX = 6000;
if (output.length > MAX) {
return output.slice(0, MAX) + `\n\n[… Ausgabe gekürzt, ${output.length} Zeichen gesamt]`;
}
return output || "(kein Output)";
}
// Liest den Text der letzten Assistenten-Antwort aus dem Session-Branch. // Liest den Text der letzten Assistenten-Antwort aus dem Session-Branch.
function getLastAssistantText(ctx: ExtensionCommandContext): string { function getLastAssistantText(ctx: ExtensionCommandContext): string {
const entries = ctx.sessionManager.getBranch(); const entries = ctx.sessionManager.getBranch();
@ -520,7 +568,7 @@ export default function (pi: ExtensionAPI) {
pi.on("session_start", async function (_event, ctx) { pi.on("session_start", async function (_event, ctx) {
ctx.ui.setWidget("coder-judge", [ ctx.ui.setWidget("coder-judge", [
"Workflow: /coder <auftrag> | /judge | /fix | /shipit", "Workflow: /coder <auftrag> | /judge | /fix | /shipit",
"Auto-Loop: /optimize <auftrag> [--rounds N] [--with-doku] [--continue]", "Auto-Loop: /optimize <auftrag> [--rounds N] [--with-doku] [--continue] [--test-cmd \"cmd\"]",
"Planung: /plan <auftrag> → /coder | /optimize --continue | /discard", "Planung: /plan <auftrag> → /coder | /optimize --continue | /discard",
"Patch: /patch <änderung> → /quick_check [was]", "Patch: /patch <änderung> → /quick_check [was]",
"Doku: /update_doku | Neues Projekt: /new_project <pfad>", "Doku: /update_doku | Neues Projekt: /new_project <pfad>",
@ -607,20 +655,25 @@ export default function (pi: ExtensionAPI) {
// ── Automatische Optimierungsschleife ──────────────────────────────────── // ── Automatische Optimierungsschleife ────────────────────────────────────
pi.registerCommand("optimize", { pi.registerCommand("optimize", {
description: "Coder→Judge→Fix-Schleife bis PASS + optional Doku. /optimize <auftrag> [--rounds N] [--with-doku] [--continue]", description: "Coder→Judge→Fix-Schleife bis PASS + optional Doku. /optimize <auftrag> [--rounds N] [--with-doku] [--continue] [--test-cmd \"befehl\"]",
handler: async function (args: string, ctx: ExtensionCommandContext) { handler: async function (args: string, ctx: ExtensionCommandContext) {
const roundsMatch = (args || "").match(/--rounds\s+(\d+)/); const roundsMatch = (args || "").match(/--rounds\s+(\d+)/);
const maxRounds = roundsMatch ? Math.max(1, parseInt(roundsMatch[1], 10)) : 3; const maxRounds = roundsMatch ? Math.max(1, parseInt(roundsMatch[1], 10)) : 3;
const withDoku = /--with-doku/.test(args || ""); const withDoku = /--with-doku/.test(args || "");
const continueMode = /--continue/.test(args || ""); const continueMode = /--continue/.test(args || "");
// --test-cmd "befehl" oder --test-cmd befehl (ohne Leerzeichen im Befehl)
const testCmdMatch = (args || "").match(/--test-cmd\s+"([^"]+)"|--test-cmd\s+(\S+)/);
const testCmd: string | null = testCmdMatch ? (testCmdMatch[1] ?? testCmdMatch[2]) : null;
const task = (args || "") const task = (args || "")
.replace(/--rounds\s+\d+/, "") .replace(/--rounds\s+\d+/, "")
.replace(/--with-doku/, "") .replace(/--with-doku/, "")
.replace(/--continue/, "") .replace(/--continue/, "")
.replace(/--test-cmd\s+"[^"]*"/, "")
.replace(/--test-cmd\s+\S+/, "")
.trim(); .trim();
if (!continueMode && !task) { if (!continueMode && !task) {
ctx.ui.notify("Benutzung: /optimize <auftrag> [--rounds N] [--with-doku] [--continue]", "error"); ctx.ui.notify("Benutzung: /optimize <auftrag> [--rounds N] [--with-doku] [--continue] [--test-cmd \"befehl\"]", "error");
return; return;
} }
@ -651,9 +704,18 @@ export default function (pi: ExtensionAPI) {
// Schleife: Judge → (PASS? fertig : Fix → nächste Runde) // Schleife: Judge → (PASS? fertig : Fix → nächste Runde)
for (let round = 1; round <= maxRounds; round++) { for (let round = 1; round <= maxRounds; round++) {
const prog = "●".repeat(round - 1) + "◉" + "○".repeat(maxRounds - round); const prog = "●".repeat(round - 1) + "◉" + "○".repeat(maxRounds - round);
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Judge — TASK.md + letzter Commit + Tests…`);
await switchModel(pi, ctx, "llama-cpp-judge", "qwen3.5-judge"); await switchModel(pi, ctx, "llama-cpp-judge", "qwen3.5-judge");
await sendAndWait(pi, ctx, judgePrompt(""));
if (testCmd) {
// Tests laufen in der Extension — Judge bekommt den Output fertig geliefert
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Tests laufen (${testCmd})…`);
const testOutput = await runTests(pi, ctx, testCmd);
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Judge analysiert Test-Ergebnis…`);
await sendAndWait(pi, ctx, judgeWithTestsPrompt(testOutput, ""));
} else {
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Judge — TASK.md + letzter Commit + Tests…`);
await sendAndWait(pi, ctx, judgePrompt(""));
}
if (cancelRequested) { cancelRequested = false; finalNotify(ctx, "⛔ Abgebrochen", `Nach Judge Runde ${round}`); return; } if (cancelRequested) { cancelRequested = false; finalNotify(ctx, "⛔ Abgebrochen", `Nach Judge Runde ${round}`); return; }
const judgeText = getLastAssistantText(ctx); const judgeText = getLastAssistantText(ctx);