pi_coder/pi-coder-judge-extension.ts

1392 lines
61 KiB
TypeScript
Raw Normal View History

// pi-coder-judge-extension.ts
// Automatisierter Coder-Judge-Fix-Workflow für AI-Coding-Assistenten
// Modelle: qwen3.5-coder (Port 8001), qwen3.5-judge (Port 8002)
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
// ── Prompt-Bausteine ────────────────────────────────────────────────────────
function coderKickoff(task: string): string {
return [
"Du bist der Coding-Agent.",
"Lies TASK.md für die vollständige Aufgabenbeschreibung.",
"Halte dich strikt an die dort beschriebenen Anforderungen.",
"Arbeite sorgfältig, konkret und produktionsorientiert.",
"Lies jede Datei unmittelbar vor dem Editieren neu 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.",
"",
"Git-Pflichten:",
"- Falls noch kein git-Repository existiert, initialisiere es mit 'git init'.",
"- Führe nach der Implementierung einen Commit durch: git add -A && git commit -m 'feat: ...'",
"",
"Führe nach der Implementierung passende Tests oder Checks aus.",
"Melde knapp, was du geändert hast, welche Risiken bleiben und welche Tests du ausgeführt hast.",
"",
"Auftrag:",
task
].join("\n");
}
function judgePrompt(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.",
"Arbeite reproduzierbar und konkret.",
"",
"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. Führe relevante Tests, Linter oder Startchecks aus.",
"3. Versuche Fehler aktiv zu finden.",
"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",
"",
"Wenn du etwas behauptest, nenne die Datei, den Befehl, den Test oder den Reproduktionshinweis."
].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 {
const suffix = extra?.trim() ? "\n\nZusätzlicher User-Hinweis:\n" + extra.trim() : "";
return [
"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.",
"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.",
"Führe nach den Fixes passende Tests aus.",
"Führe danach einen Commit durch: git add -A && git commit -m 'fix: ...'",
"Wenn ein Punkt nicht sinnvoll umsetzbar ist, begründe das präzise.",
"Liefere am Ende nur:",
"- Was geändert wurde",
"- Welche Judge-Punkte geschlossen wurden",
"- Welche Punkte offen bleiben",
"- Welche Tests ausgeführt wurden"
].join("\n") + suffix;
}
function shipitPrompt(extra: string): string {
return [
"Führe die finale Freigabeprüfung durch.",
"Lies TASK.md und prüfe, ob alle dort beschriebenen Anforderungen im finalen Stand enthalten sind.",
"Lies die relevanten geänderten Dateien und 'git log --oneline -10'.",
"Führe sinnvolle Tests, Linter und Startchecks aus.",
"Beurteile, ob der Stand produktionsreif ist.",
"",
"Ausgabeformat:",
"- Urteil: SHIP | NO-SHIP",
"- Letzte Blocker",
"- Restrisiken",
"- Empfohlene Sofortmaßnahmen vor Deployment"
].join("\n") + (extra?.trim() ? "\n\nZusätzlicher Fokus:\n" + extra.trim() : "");
}
function patchPrompt(change: string): string {
return [
"Du machst eine kleine, gezielte Codeänderung. Nichts weiter.",
"Ändere AUSSCHLIESSLICH das Folgende:",
change,
"",
"Regeln:",
"- Kein Refactoring, keine weiteren Verbesserungen, keine Umbenennungen",
"- Lies die Datei unmittelbar vor dem Editieren neu ein",
"- Wenn du mehrere Stellen in derselben Datei änderst: Erzeuge einen unified diff und nutze das apply_patch-Tool",
"- Fallback: Datei komplett neu schreiben",
"- Finde die betroffene Stelle direkt (grep oder gezielte Datei-Suche)",
"- Ändere nur die notwendigen Zeilen",
"- Prüfe danach nur: Kompiliert/startet es noch?",
"- Commit: git add -A && git commit -m 'fix: <ein Satz>'",
"- Melde: Datei, Zeile(n), was geändert wurde. Fertig."
].join("\n");
}
function quickCheckPrompt(what: string): string {
const focus = what?.trim() ? "\n\nZu prüfende Änderung:\n" + what.trim() : "";
return [
"Schnelle Prüfung einer kleinen Codeänderung.",
"Lies 'git show HEAD' um zu sehen was geändert wurde.",
"Prüfe NUR:",
"- Ist die Änderung korrekt umgesetzt?",
"- Gibt es offensichtliche Fehler oder Randfälle die übersehen wurden?",
"- Kompiliert/startet der Code?",
"",
"Ausgabeformat (kurz):",
"- Urteil: OK | PROBLEM",
"- Falls PROBLEM: konkret was falsch ist und wie zu fixen",
"",
"Kein vollständiger Review, keine Stilkritik, kein Refactoring-Vorschlag."
].join("\n") + focus;
}
function commentCodePrompt(): string {
return [
"Lies TASK.md und alle Quelldateien des Projekts.",
"Füge wartungsfreundliche Kommentare ein, die Entwicklern ohne Vorkenntnis helfen, den Code zu verstehen und zu warten.",
"",
"Regeln:",
"- Kommentiere das WARUM, nicht das WAS (kein 'x += 1 // increment x')",
"- Erkläre nicht-offensichtliche Algorithmen, Randfälle und Design-Entscheidungen",
"- Füge Modul-/Datei-Level-Kommentare ein, die den Gesamtzweck der Datei erklären",
"- Keine trivialen Kommentare, die nur den Code wiederholen",
"- Sprache der Kommentare: Deutsch",
"",
"Wenn du mehrere Stellen in derselben Datei kommentierst: 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.",
"",
"Führe danach einen Build/Test aus um sicherzustellen, dass die Kommentare nichts kaputt gemacht haben.",
"Melde welche Dateien du kommentiert hast."
].join("\n");
}
function readmeMdPrompt(): string {
return [
"Lies TASK.md und alle Quelldateien des Projekts.",
"Schreibe oder aktualisiere README.md aus Entwicklerperspektive.",
"",
"Pflichtabschnitte:",
"- Projektbeschreibung (Was macht das Programm? Warum?)",
"- Voraussetzungen (Dependencies, Toolchain)",
"- Installation und Build",
"- Verwendung (alle Kommandozeilenoptionen und Flags)",
"- Beispiele mit konkreter Ausgabe",
"- Projektstruktur (Dateien und ihre Aufgabe)",
"",
"Halte es technisch präzise und korrekt. Sprache: Deutsch."
].join("\n");
}
function bedienungsanleitungPrompt(): string {
return [
"Lies TASK.md und README.md.",
"Schreibe oder aktualisiere BEDIENUNGSANLEITUNG.md aus Endnutzer-Perspektive.",
"Setze kein Entwicklerwissen voraus — die Zielgruppe sind normale Anwender.",
"",
"Pflichtabschnitte:",
"- Zweck des Programms (was kann der Nutzer damit tun?)",
"- Installation für Endnutzer (Schritt für Schritt)",
"- Erste Schritte / Schnellstart",
"- Alle Optionen mit verständlicher Erklärung und Beispielen",
"- Typische Anwendungsfälle",
"- Fehlermeldungen und ihre Lösung",
"",
"Sprache: Deutsch. Einfach, klar, ohne Jargon."
].join("\n");
}
function commentCodePromptIncremental(files: string[]): string {
const fileList = files.map(f => ` - ${f}`).join("\n");
return [
"Kommentiere NUR die folgenden Quelldateien — sie haben sich seit dem letzten Kommentar-Update geändert:",
fileList,
"Alle anderen Dateien haben bereits aktuelle Kommentare — lass sie vollständig unberührt.",
"",
"Regeln:",
"- Kommentiere das WARUM, nicht das WAS (kein 'x += 1 // increment x')",
"- Erkläre nicht-offensichtliche Algorithmen, Randfälle und Design-Entscheidungen",
"- Füge Modul-/Datei-Level-Kommentare ein, die den Gesamtzweck der Datei erklären",
"- Keine trivialen Kommentare, die nur den Code wiederholen",
"- Sprache der Kommentare: Deutsch",
"",
"Wenn du mehrere Stellen in derselben Datei kommentierst: 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.",
"",
"Führe danach einen Build/Test aus um sicherzustellen, dass die Kommentare nichts kaputt gemacht haben.",
"Melde welche Dateien du kommentiert hast."
].join("\n");
}
function readmeMdPromptIncremental(files: string[]): string {
const fileList = files.map(f => ` - ${f}`).join("\n");
return [
"Folgende Quelldateien haben sich seit dem letzten README-Update geändert:",
fileList,
"",
"Prüfe: Haben diese Änderungen Auswirkungen auf Installation, Verwendung, Optionen oder Projektstruktur?",
"- Falls JA: Lies README.md und aktualisiere NUR die betroffenen Abschnitte.",
"- Falls NEIN: Antworte nur mit dem Satz: 'README.md ist aktuell keine Änderung nötig.'",
"",
"Wenn du mehrere Abschnitte in README.md aktualisierst: Erzeuge einen unified diff und nutze das apply_patch-Tool.",
"Halte es technisch präzise und korrekt. Sprache: Deutsch."
].join("\n");
}
function bedienungsanleitungPromptIncremental(files: string[]): string {
const fileList = files.map(f => ` - ${f}`).join("\n");
return [
"Folgende Quelldateien haben sich seit dem letzten Bedienungsanleitung-Update geändert:",
fileList,
"",
"Prüfe: Haben diese Änderungen Auswirkungen auf die Benutzung des Programms durch Endnutzer?",
"(z.B. neue Optionen, geändertes Verhalten, neue Fehlermeldungen)",
"- Falls JA: Lies BEDIENUNGSANLEITUNG.md und aktualisiere NUR die betroffenen Abschnitte.",
"- Falls NEIN: Antworte nur mit dem Satz: 'BEDIENUNGSANLEITUNG.md ist aktuell keine Änderung nötig.'",
"",
"Wenn du mehrere Abschnitte in BEDIENUNGSANLEITUNG.md aktualisierst: Erzeuge einen unified diff und nutze das apply_patch-Tool.",
"Sprache: Deutsch. Einfach, klar, ohne Jargon."
].join("\n");
}
function planPrompt(task: string): string {
return [
"Du bist ein erfahrener Software-Architekt im PLANUNGSMODUS.",
"",
"ABSOLUTE VERBOTE — du darfst NICHT:",
"- Dateien editieren, schreiben oder löschen (kein edit, write, apply_patch)",
"- Git-Commits durchführen",
"- Tests oder Skripte ausführen die Seiteneffekte haben",
"",
"ERLAUBT:",
"- Dateien lesen (read, cat, grep, find)",
"- Git-History lesen (git log, git show, git diff)",
"- PLAN.md anlegen oder überschreiben (das ist dein Ausgabe-Dokument)",
"",
"Analysiere den Auftrag gründlich und erstelle einen konkreten Implementierungsplan.",
"",
"Auftrag:",
task,
"",
"Struktur deiner Ausgabe:",
"1. IST-Analyse (relevante Dateien, Architektur, Abhängigkeiten)",
"2. Implementierungsplan (nummerierte Schritte, konkret und umsetzbar)",
"3. Kritische Entscheidungen (Alternativen + Empfehlung)",
"4. Risiken und offene Fragen",
"5. Geschätzte Komplexität: einfach / mittel / komplex",
"",
"Schreibe den vollständigen Plan in PLAN.md.",
"Schließe ab mit: 'Plan bereit. Starte Umsetzung mit /coder oder /optimize --continue'",
].join("\n");
}
// ── Hilfsfunktionen ─────────────────────────────────────────────────────────
// Legt TASK.md neu an oder hängt einen Zusatzauftrag an.
async function writeTaskMd(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
task: string
): Promise<void> {
const check = await pi.exec("bash", ["-c", "test -f TASK.md && echo exists"], { cwd: ctx.cwd });
const exists = check.stdout.trim() === "exists";
let content: string;
if (exists) {
content = [
"",
"---",
"",
"## Zusatzauftrag",
"",
new Date().toISOString(),
"",
task,
"",
"## Status",
"- [ ] Implementierung",
"- [ ] Review bestanden (PASS)",
"- [ ] Produktionsreif (SHIP)",
].join("\n") + "\n";
} else {
content = [
"# Aufgabe",
"",
task,
"",
"## Erstellt",
new Date().toISOString(),
"",
"## Status",
"- [ ] Implementierung",
"- [ ] Review bestanden (PASS)",
"- [ ] Produktionsreif (SHIP)",
].join("\n") + "\n";
}
const redirect = exists ? ">>" : ">";
await pi.exec("bash", ["-c", `printf "%s" "$1" ${redirect} TASK.md`, "_", content], { cwd: ctx.cwd });
ctx.ui.notify(exists ? "TASK.md erweitert" : "TASK.md angelegt", "info");
}
// Hakt einen Status-Eintrag in TASK.md ab.
// label: exakt wie in der Checkbox, z.B. "Implementierung"
async function tickTaskMdStatus(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
label: string
): Promise<void> {
const check = await pi.exec("bash", ["-c", "test -f TASK.md && echo exists"], { cwd: ctx.cwd });
if (check.stdout.trim() !== "exists") return;
// Python übernimmt den String-Ersatz — kein Shell-Escaping-Problem
await pi.exec(
"python3",
["-c",
"import sys; f=open('TASK.md','r'); c=f.read(); f.close(); " +
"c=c.replace('- [ ] '+sys.argv[1], '- [x] '+sys.argv[1]); " +
"f=open('TASK.md','w'); f.write(c); f.close()",
label
],
{ cwd: ctx.cwd }
);
}
async function switchModel(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
provider: string,
modelId: string
): Promise<boolean> {
const model = ctx.modelRegistry.find(provider, modelId);
if (!model) {
ctx.ui.notify(`Modell ${provider}/${modelId} nicht gefunden`, "error");
return false;
}
const ok = await pi.setModel(model);
if (!ok) ctx.ui.notify(`Kein API-Key für ${modelId}`, "warning");
return ok !== false;
}
// Sendet eine Nachricht und wartet bis der Agent fertig ist.
// Retry-Schleife fängt "Agent is already processing" ab — tritt auf wenn
// waitForIdle() zu früh zurückkehrt (Race Condition im pi-Agent).
async function sendAndWait(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
content: string
): Promise<void> {
await ctx.waitForIdle();
for (let attempt = 1; attempt <= 5; attempt++) {
try {
pi.sendUserMessage(content, { deliverAs: "followUp" });
break;
} catch (e: any) {
if (attempt === 5) throw e;
// Exponentieller Backoff: 500ms, 1s, 2s, 4s
await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
await ctx.waitForIdle();
}
}
await new Promise(r => setTimeout(r, 400));
await ctx.waitForIdle();
}
// Prüft via POST /v1/chat/completions ob das Modell im VRAM bereit ist.
// /health und /v1/models antworten bereits während des GPU-Ladevorgangs — nur
// ein echter Completion-Request liefert zuverlässig HTTP 200 wenn das Modell ready ist.
async function waitUntilModelReady(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
port: number,
modelAlias: string,
maxWaitMs = 180_000
): Promise<boolean> {
const deadline = Date.now() + maxWaitMs;
const body = JSON.stringify({
model: modelAlias,
messages: [{ role: "user", content: "ping" }],
max_tokens: 1, temperature: 0.0, stream: false,
});
// Body als Datei — verhindert Shell-Injection wenn modelAlias 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;
try {
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 ` +
`-H "Content-Type: application/json" ` +
`-d "@${tmpBody}"`
], { 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");
notified = true;
}
await new Promise(res => setTimeout(res, 3000));
}
return false;
} finally {
await pi.exec("bash", ["-c", `rm -f "${tmpBody}"`], { cwd: ctx.cwd });
}
}
// Führt einen Shell-Befehl aus und gibt stdout+stderr zurück (max. 6000 Zeichen).
// Erkennt Test-Suiten im Projektverzeichnis anhand von Framework-Markern.
// Alle Checks laufen parallel — konservativ, keine False Positives.
async function detectTestCommands(
pi: ExtensionAPI,
ctx: ExtensionCommandContext
): Promise<string[]> {
const [hasPytest, hasNpm, hasCargo, hasGo, hasMake] = await Promise.all([
pi.exec("bash", ["-c",
"test -f pytest.ini || test -f conftest.py || " +
"(test -f pyproject.toml && grep -q 'pytest' pyproject.toml) || " +
"find . -maxdepth 4 \\( -name 'test_*.py' -o -name '*_test.py' \\) 2>/dev/null | grep -q ."
], { cwd: ctx.cwd }),
pi.exec("bash", ["-c",
"test -f package.json && " +
"grep -q '\"test\"' package.json && " +
"! grep -q 'no test' package.json"
], { cwd: ctx.cwd }),
pi.exec("bash", ["-c", "test -f Cargo.toml"], { cwd: ctx.cwd }),
pi.exec("bash", ["-c",
"test -f go.mod && find . -maxdepth 4 -name '*_test.go' 2>/dev/null | grep -q ."
], { cwd: ctx.cwd }),
pi.exec("bash", ["-c",
"test -f Makefile && grep -qE '^test[[:space:]]*:' Makefile"
], { cwd: ctx.cwd }),
]);
return ([
hasPytest.code === 0 ? "pytest -x -q 2>&1" : null,
hasNpm.code === 0 ? "npm test 2>&1" : null,
hasCargo.code === 0 ? "cargo test 2>&1" : null,
hasGo.code === 0 ? "go test ./... 2>&1" : null,
hasMake.code === 0 ? "make test 2>&1" : null,
] as (string | null)[]).filter((c): c is string => c !== null);
}
// Führt mehrere Test-Befehle parallel als CPU-Prozesse aus und liefert einen
// kombinierten Output-Block für judgeWithTestsPrompt().
async function runTestsParallel(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
cmds: string[],
timeoutSecs: number = 120
): Promise<string> {
const results = await Promise.all(
// timeout-Wrapper: verhindert hängende Tests (Exit 124 = Timeout)
cmds.map(cmd => pi.exec(
"bash",
["-c", `timeout ${timeoutSecs} bash -c ${JSON.stringify(cmd)}`],
{ cwd: ctx.cwd }
))
);
const MAX_PER = Math.max(1000, Math.floor(6000 / cmds.length));
return results.map((r, i) => {
const raw = (r.stdout + (r.stderr ? "\n" + r.stderr : "")).trim();
const out = raw.length > MAX_PER
? raw.slice(0, MAX_PER) + `\n[… gekürzt, ${raw.length} Zeichen]`
: raw || "(kein Output)";
const status = r.code === 0 ? "✓ OK"
: r.code === 124 ? `✗ Timeout (>${timeoutSecs}s)`
: `✗ Exit ${r.code}`;
return `=== ${cmds[i]} [${status}] ===\n${out}`;
}).join("\n\n");
}
// Liest den Text der letzten Assistenten-Antwort aus dem Session-Branch.
function getLastAssistantText(ctx: ExtensionCommandContext): string {
const entries = ctx.sessionManager.getBranch();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
const msg = (entry as any).message;
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
return msg.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text as string)
.join("\n");
}
}
}
return "";
}
// Extrahiert das Urteil aus einer Judge-Antwort.
// "UNREADABLE" wenn kein Urteil erkennbar — unterscheidbar von einem expliziten FAIL.
function parseVerdict(text: string): string {
const m = text.match(/Urteil:\s*(PASS WITH CONCERNS|PASS|FAIL)/i);
return m ? m[1].toUpperCase() : "UNREADABLE";
}
// Extrahiert den Blocker-Abschnitt für die Loop-Erkennung.
// Erkennt Bullet-Listen (- / / *), Bold (**Blocker**) und Headings (## Blocker).
function parseBlockers(text: string): string {
const m = text.match(
/(?:\*\*Blocker\*\*|##\s*Blocker|[-*]\s*Blocker)[:\n]([\s\S]*?)(?:\n(?:\*\*Major\*\*|##\s*Major|[-*]\s*Major)|\n(?:\*\*Minor\*\*|##\s*Minor|[-*]\s*Minor)|$)/i
);
return m ? m[1].trim() : "";
}
// Gibt geänderte Quelldateien seit einem Git-Tag zurück.
// null = Tag existiert nicht (erster Lauf) → alles verarbeiten
// [] = nichts geändert → Phase überspringen
// [...] = nur diese Dateien verarbeiten
async function getFilesSinceTag(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
tagName: string
): Promise<string[] | null> {
const tagCheck = await pi.exec("bash", ["-c", `git tag -l "${tagName}"`], { cwd: ctx.cwd });
if (!tagCheck.stdout.trim()) return null;
const diff = await pi.exec(
"bash",
["-c", `git diff "${tagName}" --name-only 2>/dev/null`],
{ cwd: ctx.cwd }
);
// Bei git-Fehler alles verarbeiten (sicherer als stilles Überspringen)
if (diff.code !== 0) return null;
return diff.stdout.trim()
.split("\n")
.filter(f =>
f.length > 0 &&
!f.endsWith(".md") &&
!f.endsWith(".lock") &&
!f.endsWith(".toml") &&
!f.startsWith("target/") &&
!f.endsWith(".gitignore")
);
}
// Dokumentations-Phase: inkrementell via Git-Tags, nur geänderte Dateien werden verarbeitet.
// Wird von /update_doku und /optimize --with-doku genutzt.
async function runUpdateDoku(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
if (!await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder")) {
ctx.ui.notify("Coder-Modell nicht verfügbar — Dokumentations-Phase abgebrochen", "error");
return;
}
// Jede Phase läuft unabhängig — Fehler in Phase 1 blockieren nicht Phase 2/3.
// Tag wird nur NACH erfolgreichem sendAndWait gesetzt.
// Phase 1: Code-Kommentare
try {
const commentFiles = await getFilesSinceTag(pi, ctx, "docs-last-commented");
if (commentFiles === null) {
ctx.ui.setStatus("update_doku", "1/3: Code wird kommentiert (alle Dateien)…");
currentActivity = "Coder kommentiert Code…";
await sendAndWait(pi, ctx, commentCodePrompt());
await pi.exec("bash", ["-c", "git tag -f docs-last-commented"], { cwd: ctx.cwd });
} else if (commentFiles.length === 0) {
ctx.ui.notify("Code-Kommentare: keine Änderungen seit letztem Lauf übersprungen.", "info");
} else {
ctx.ui.setStatus("update_doku", `1/3: Code wird kommentiert (${commentFiles.length} Datei(en))…`);
currentActivity = "Coder kommentiert Code…";
await sendAndWait(pi, ctx, commentCodePromptIncremental(commentFiles));
await pi.exec("bash", ["-c", "git tag -f docs-last-commented"], { cwd: ctx.cwd });
}
} catch (e: any) {
ctx.ui.notify(`1/3 Code-Kommentare fehlgeschlagen: ${String(e?.message ?? e)}`, "error");
}
// Phase 2: README.md
try {
const readmeFiles = await getFilesSinceTag(pi, ctx, "docs-last-readme");
if (readmeFiles === null) {
ctx.ui.setStatus("update_doku", "2/3: README.md wird geschrieben…");
currentActivity = "Coder schreibt README…";
await sendAndWait(pi, ctx, readmeMdPrompt());
await pi.exec("bash", ["-c", "git tag -f docs-last-readme"], { cwd: ctx.cwd });
} else if (readmeFiles.length === 0) {
ctx.ui.notify("README.md: keine Änderungen seit letztem Lauf übersprungen.", "info");
} else {
ctx.ui.setStatus("update_doku", `2/3: README.md wird geprüft (${readmeFiles.length} Datei(en) geändert)…`);
currentActivity = "Coder schreibt README…";
await sendAndWait(pi, ctx, readmeMdPromptIncremental(readmeFiles));
await pi.exec("bash", ["-c", "git tag -f docs-last-readme"], { cwd: ctx.cwd });
}
} catch (e: any) {
ctx.ui.notify(`2/3 README.md fehlgeschlagen: ${String(e?.message ?? e)}`, "error");
}
// Phase 3: BEDIENUNGSANLEITUNG.md
try {
const bedFiles = await getFilesSinceTag(pi, ctx, "docs-last-bedienungsanleitung");
if (bedFiles === null) {
ctx.ui.setStatus("update_doku", "3/3: BEDIENUNGSANLEITUNG.md wird geschrieben…");
currentActivity = "Coder schreibt Bedienungsanleitung…";
await sendAndWait(pi, ctx, bedienungsanleitungPrompt());
await pi.exec("bash", ["-c", "git tag -f docs-last-bedienungsanleitung"], { cwd: ctx.cwd });
} else if (bedFiles.length === 0) {
ctx.ui.notify("BEDIENUNGSANLEITUNG.md: keine Änderungen seit letztem Lauf übersprungen.", "info");
} else {
ctx.ui.setStatus("update_doku", `3/3: BEDIENUNGSANLEITUNG.md wird geprüft (${bedFiles.length} Datei(en) geändert)…`);
currentActivity = "Coder schreibt Bedienungsanleitung…";
await sendAndWait(pi, ctx, bedienungsanleitungPromptIncremental(bedFiles));
await pi.exec("bash", ["-c", "git tag -f docs-last-bedienungsanleitung"], { cwd: ctx.cwd });
}
} catch (e: any) {
ctx.ui.notify(`3/3 BEDIENUNGSANLEITUNG.md fehlgeschlagen: ${String(e?.message ?? e)}`, "error");
}
// Abschließender Dokumentations-Commit (immer, auch bei Teilfehlern)
await pi.exec(
"bash",
["-c", "git add -A && git commit -m 'docs: update comments, README, BEDIENUNGSANLEITUNG' || true"],
{ cwd: ctx.cwd }
);
// TASK.md: Produktionsreif abhaken
await tickTaskMdStatus(pi, ctx, "Produktionsreif (SHIP)");
ctx.ui.setStatus("update_doku", "✓ Dokumentation abgeschlossen");
ctx.ui.notify("Dokumentations-Phase abgeschlossen. Commit angelegt.", "info");
}
// ── Versions-Verwaltung (SemVer + Git-Tags) ──────────────────────────────────
// Liest den höchsten vX.Y.Z-Tag via `git tag -l`. Gibt null zurück wenn kein Tag existiert.
async function getCurrentVersion(
pi: ExtensionAPI,
ctx: ExtensionCommandContext
): Promise<[number, number, number] | null> {
const res = await pi.exec("bash", ["-c", "git tag -l 'v*' | sort -V | tail -1"], { cwd: ctx.cwd });
const raw = (res.stdout ?? "").trim();
const m = raw.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
return m ? [+m[1], +m[2], +m[3]] : null;
}
// Analysiert Commit-Subjects seit dem letzten Tag nach Conventional Commits.
// feat! / BREAKING CHANGE → major, feat: → minor, alles andere → patch.
async function analyzeBumpType(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
since?: string
): Promise<"major" | "minor" | "patch"> {
const range = since ? `${since}..HEAD` : "HEAD";
const res = await pi.exec("bash", ["-c", `git log ${range} --format="%s" 2>/dev/null`], { cwd: ctx.cwd });
const lines = (res.stdout ?? "").split("\n");
if (lines.some(l => /^feat!:|BREAKING CHANGE/.test(l))) return "major";
if (lines.some(l => /^feat(\(.+\))?:/.test(l))) return "minor";
return "patch";
}
// Findet die erste vorhandene Versions-Manifest-Datei im Arbeitsverzeichnis.
async function detectVersionFile(
pi: ExtensionAPI,
ctx: ExtensionCommandContext
): Promise<"package.json" | "Cargo.toml" | "pyproject.toml" | "VERSION" | null> {
for (const f of ["package.json", "Cargo.toml", "pyproject.toml"]) {
const r = await pi.exec("bash", ["-c", `test -f ${f}`], { cwd: ctx.cwd });
if (r.exitCode === 0) return f as "package.json" | "Cargo.toml" | "pyproject.toml";
}
const r = await pi.exec("bash", ["-c", "test -f VERSION"], { cwd: ctx.cwd });
return r.exitCode === 0 ? "VERSION" : null;
}
// Schreibt die neue Version in die Manifest-Datei und erstellt einen chore-Commit.
async function applyVersionBump(
pi: ExtensionAPI,
ctx: ExtensionCommandContext,
manifest: string,
version: string
): Promise<void> {
let cmd: string;
if (manifest === "package.json") {
cmd = `npm version --no-git-tag-version ${version}`;
} else if (manifest === "Cargo.toml") {
cmd = `sed -i 's/^version = ".*"/version = "${version}"/' Cargo.toml`;
} else if (manifest === "pyproject.toml") {
cmd = `sed -i 's/^version = ".*"/version = "${version}"/' pyproject.toml`;
} else {
cmd = `printf 'v%s\\n' '${version}' > VERSION`;
}
await pi.exec("bash", ["-c", cmd], { cwd: ctx.cwd });
await pi.exec(
"bash",
["-c", `git add ${manifest} && git commit -m "chore: bump version to v${version}"`],
{ cwd: ctx.cwd }
);
}
// Hauptfunktion: ermittelt aktuelle Version, analysiert Commits, zeigt Dialog, setzt Tag.
async function runVersionBump(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
// Early exit wenn kein git-Repo vorhanden
const gitCheck = await pi.exec("bash", ["-c", "git rev-parse --is-inside-work-tree 2>/dev/null"], { cwd: ctx.cwd });
if (gitCheck.exitCode !== 0) return;
const current = await getCurrentVersion(pi, ctx);
const tag = current ? `v${current[0]}.${current[1]}.${current[2]}` : undefined;
const bump = await analyzeBumpType(pi, ctx, tag);
const [maj, min, pat] = current ?? [0, 0, 0];
const initial = !current;
const versions: Record<"patch" | "minor" | "major", string> = initial
? { patch: "v0.0.1", minor: "v0.1.0", major: "v1.0.0" }
: { patch: `v${maj}.${min}.${pat + 1}`, minor: `v${maj}.${min + 1}.0`, major: `v${maj + 1}.0.0` };
const recommended: "patch" | "minor" | "major" = initial ? "minor" : bump;
const labels = (["patch", "minor", "major"] as const).map(
t => `${t}${versions[t]}${t === recommended ? " (empfohlen)" : ""}`
);
const choice = await ctx.ui.select({
title: "Version",
message: current
? `Aktuelle Version: ${tag}. Commits seit letztem Tag: ${bump}-Bump erkannt.`
: "Noch kein Versions-Tag vorhanden.",
options: [...labels, "Überspringen"],
});
if (!choice || choice.startsWith("Überspringen")) return;
const chosen = (["patch", "minor", "major"] as const).find(t => choice.startsWith(t))!;
const newVersion = versions[chosen].replace(/^v/, "");
const newTag = `v${newVersion}`;
const manifest = await detectVersionFile(pi, ctx);
if (manifest) {
await applyVersionBump(pi, ctx, manifest, newVersion);
}
const tagResult = await pi.exec("bash", ["-c", `git tag ${newTag}`], { cwd: ctx.cwd });
if (tagResult.exitCode !== 0) {
ctx.ui.notify(`Tag ${newTag} existiert bereits — manuell löschen mit: git tag -d ${newTag}`, "error");
return;
}
ctx.ui.notify(`Version ${newTag} getaggt.`, "info");
}
// Prominente Abschluss-Notification + Widget-Update mit Uhrzeit und Ergebnis.
function finalNotify(
ctx: ExtensionCommandContext,
verdict: string,
detail: string
): void {
const timestamp = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
const level = verdict.startsWith("🚀") ? "info"
: verdict.includes("NO-SHIP") || verdict.startsWith("⛔") ? "error"
: verdict.includes("⚠") ? "warning"
: "info";
ctx.ui.notify(`${verdict}: ${detail}`, level);
ctx.ui.setWidget("coder-judge", [
`Letzter Lauf: ${verdict}${detail} (${timestamp})`,
"/optimize <auftrag> [--rounds N] [--with-doku] [--continue] [--test-cmd \"cmd\"]",
"/fix · /judge · /shipit · /cancel · /continue · /help",
]);
}
// ── Extension ────────────────────────────────────────────────────────────────
let cancelRequested = false;
let currentActivity = ""; // Working-Message für den aktuellen Command-Kontext
// Erzeugt eine knappe Statuszeile aus Tool-Name und Argumenten.
function toolExecutionLabel(toolName: string, args: Record<string, any>): string {
switch (toolName) {
case "edit":
return `Editiere ${args.path ?? "Datei"}`;
case "write":
return `Schreibe ${args.path ?? "Datei"} neu…`;
case "read":
return `Lese ${args.path ?? "Datei"}`;
case "grep":
return `Suche in ${args.path ?? args.pattern ?? "Dateien"}`;
case "find":
return `Suche Dateien: ${args.pattern ?? ""}`;
case "ls":
return `Verzeichnis: ${args.path ?? "."}`;
case "bash": {
const cmd = String(args.command ?? "").trim().replace(/\n[\s\S]*/s, "");
if (/git\s+commit/.test(cmd)) return "Git-Commit…";
if (/git\s+add/.test(cmd)) return "Stage Änderungen…";
if (/git\s+tag/.test(cmd)) return "Git-Tag setzen…";
if (/pytest|npm test|cargo test|go test|make test/.test(cmd)) return "Tests laufen…";
if (/git\s+(diff|log|show|tag -l)/.test(cmd)) return "Git-History lesen…";
if (/patch\s+-p1/.test(cmd)) return "Wende Patch an…";
if (/curl/.test(cmd)) return "HTTP-Request…";
return `Shell: ${cmd.slice(0, 55)}${cmd.length > 55 ? "…" : ""}`;
}
case "apply_patch":
return "Wende Patch an…";
default:
return "";
}
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async function (_event, ctx) {
ctx.ui.setWidget("coder-judge", [
"/optimize <auftrag> [--rounds N] [--with-doku] [--continue] [--test-cmd \"cmd\"]",
"/fix · /judge · /shipit · /cancel · /continue · /help",
]);
});
// ── Live-Aktivitätsstatus ────────────────────────────────────────────────
// turn_start: Working-Text auf aktuellen Command-Kontext setzen
pi.on("turn_start", function (_event, ctx) {
if (currentActivity) ctx.ui.setWorkingMessage(currentActivity);
});
// tool_execution_start: präzise Statuszeile während Tool-Ausführung
pi.on("tool_execution_start", function (event, ctx) {
const label = toolExecutionLabel(event.toolName, (event as any).args ?? {});
if (label) ctx.ui.setStatus("agent", label);
});
// tool_execution_end: Statuszeile löschen
pi.on("tool_execution_end", function (_event, ctx) {
ctx.ui.setStatus("agent", undefined);
});
// agent_end: Working-Text und Statuszeile zurücksetzen
pi.on("agent_end", function (_event, ctx) {
ctx.ui.setWorkingMessage();
ctx.ui.setStatus("agent", undefined);
currentActivity = "";
});
// ── Robustes edit: Bottom-up-Reordering via tool_call-Hook ─────────────
// Behebt "edits[n] doesn't match": Mehrere Edits auf dieselbe Datei werden
// von hinten nach vorne sortiert, damit frühere Edits spätere Positionen nicht verschieben.
pi.on("tool_call", async function (event, ctx) {
if (event.toolName !== "edit") return;
const input = event.input as {
path: string;
edits: Array<{ oldText: string; newText: string }>;
};
if (!input?.edits || input.edits.length <= 1) return;
const readResult = await pi.exec(
"bash",
["-c", `cat "$1"`, "_", input.path],
{ cwd: ctx.cwd }
);
if (readResult.code !== 0) return;
const content = readResult.stdout;
const positioned = input.edits.map(edit => ({
edit,
idx: content.indexOf(edit.oldText)
}));
// Nicht gefundene Einträge (idx === -1) ans Ende — sie schlagen sowieso fehl
positioned.sort((a, b) => b.idx - a.idx);
input.edits.splice(0, input.edits.length, ...positioned.map(p => p.edit));
});
// ── Manuelle Kommandos ───────────────────────────────────────────────────
pi.registerCommand("coder", {
description: "Implementiert <auftrag> ohne Review-Loop → qwen3.5-coder (:8001).",
handler: async function (args: string, ctx: ExtensionCommandContext) {
const task = (args || "").trim();
if (!task) {
ctx.ui.notify("Benutzung: /coder <auftrag>", "error");
return;
}
if (!await waitUntilModelReady(pi, ctx, 8001, "qwen3.5-coder")) {
ctx.ui.notify("Coder-Server nicht bereit (Port 8001) — start-coder.sh ausführen", "error");
return;
}
await writeTaskMd(pi, ctx, task);
await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder");
currentActivity = "Coder implementiert…";
await sendAndWait(pi, ctx, coderKickoff(task));
}
});
pi.registerCommand("judge", {
description: "Review gegen TASK.md + git show HEAD → qwen3.5-judge (:8002).",
handler: async function (args: string, ctx: ExtensionCommandContext) {
if (!await waitUntilModelReady(pi, ctx, 8002, "qwen3.5-judge")) {
ctx.ui.notify("Judge-Server nicht bereit (Port 8002) — start-judge.sh ausführen", "error");
return;
}
await switchModel(pi, ctx, "llama-cpp-judge", "qwen3.5-judge");
currentActivity = "Judge reviewt…";
await sendAndWait(pi, ctx, judgePrompt(args || ""));
}
});
pi.registerCommand("fix", {
description: "Fixt Judge-Kritik, committet Ergebnis → qwen3.5-coder (:8001).",
handler: async function (args: string, ctx: ExtensionCommandContext) {
if (!await waitUntilModelReady(pi, ctx, 8001, "qwen3.5-coder")) {
ctx.ui.notify("Coder-Server nicht bereit (Port 8001) — start-coder.sh ausführen", "error");
return;
}
await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder");
currentActivity = "Coder fixt Judge-Kritik…";
await sendAndWait(pi, ctx, fixPrompt(args || ""));
}
});
pi.registerCommand("shipit", {
description: "Finale Freigabe gegen TASK.md + git log → qwen3.5-judge (:8002).",
handler: async function (args: string, ctx: ExtensionCommandContext) {
if (!await waitUntilModelReady(pi, ctx, 8002, "qwen3.5-judge")) {
ctx.ui.notify("Judge-Server nicht bereit (Port 8002) — start-judge.sh ausführen", "error");
return;
}
await switchModel(pi, ctx, "llama-cpp-judge", "qwen3.5-judge");
ctx.ui.notify("Judge prüft finale Freigabe — Ergebnis erscheint im Chat (SHIP / NO-SHIP)", "info");
currentActivity = "Judge: finale Freigabe…";
await sendAndWait(pi, ctx, shipitPrompt(args || ""));
}
});
// ── Automatische Optimierungsschleife ────────────────────────────────────
pi.registerCommand("optimize", {
description: "Coder→Judge→Fix-Schleife bis PASS. Tests werden automatisch erkannt und parallel ausgeführt. /optimize <auftrag> [--rounds N] [--with-doku] [--continue] [--test-cmd \"override\"] [--test-timeout N]",
handler: async function (args: string, ctx: ExtensionCommandContext) {
const roundsMatch = (args || "").match(/--rounds\s+(\d+)/);
const maxRounds = roundsMatch ? Math.max(1, parseInt(roundsMatch[1], 10)) : 3;
const withDoku = /--with-doku/.test(args || "");
const continueMode = /--continue/.test(args || "");
const testCmdMatch = (args || "").match(/--test-cmd\s+"([^"]+)"|--test-cmd\s+'([^']+)'|--test-cmd\s+(\S+)/);
const testCmd: string | null = testCmdMatch ? (testCmdMatch[1] ?? testCmdMatch[2] ?? testCmdMatch[3]) : null;
const testTimeoutMatch = (args || "").match(/--test-timeout\s+(\d+)/);
const testTimeout = testTimeoutMatch ? Math.max(1, parseInt(testTimeoutMatch[1], 10)) : 120;
const task = (args || "")
.replace(/--rounds\s+\d+/, "")
.replace(/--test-timeout\s+\d+/, "")
.replace(/--with-doku/, "")
.replace(/--continue/, "")
.replace(/--test-cmd\s+"[^"]*"/, "")
.replace(/--test-cmd\s+\S+/, "")
.trim();
if (!continueMode && !task) {
ctx.ui.notify("Benutzung: /optimize <auftrag> [--rounds N] [--with-doku] [--continue] [--test-cmd \"befehl\"]", "error");
return;
}
try {
if (continueMode) {
// --continue: Implementierungsphase überspringen, direkt in Judge→Fix-Schleife
// Erweiterter Auftrag wird als Zusatzauftrag in TASK.md eingetragen (falls angegeben)
if (task) await writeTaskMd(pi, ctx, task);
ctx.ui.setStatus("optimize", `Setze fort (max ${maxRounds} Runden Judge→Fix)…`);
const continueMsg = task
? `--continue: Zusatzauftrag in TASK.md eingetragen, überspringe Implementierung.`
: `--continue: Überspringe Implementierung, starte direkt mit Judge-Prüfung.`;
ctx.ui.notify(continueMsg, "info");
// Im --continue-Modus: Coder-Server jetzt prüfen, da er für die Fix-Phase gebraucht wird
// (in normalem Modus wird er beim coderKickoff implizit geprüft)
ctx.ui.setStatus("optimize", "Coder-Server wird geprüft…");
if (!await waitUntilModelReady(pi, ctx, 8001, "qwen3.5-coder")) {
finalNotify(ctx, "⛔ Coder nicht erreichbar", "Port 8001 — kein HTTP 200 nach 3 min. start-coder.sh ausführen");
return;
}
} else {
// TASK.md anlegen und Implementierung starten
await writeTaskMd(pi, ctx, task);
ctx.ui.setStatus("optimize", `Starte Optimierung (max ${maxRounds} Runden)…`);
const taskPreview = task.length > 55 ? task.slice(0, 52) + "…" : task;
ctx.ui.setStatus("optimize", `◉ Coder liest Anforderungen + implementiert: ${taskPreview}`);
if (!await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder")) {
finalNotify(ctx, "⛔ Modell-Fehler", "Coder-Modell (llama-cpp-coder) nicht verfügbar");
return;
}
currentActivity = "Coder implementiert…";
await sendAndWait(pi, ctx, coderKickoff(task));
await tickTaskMdStatus(pi, ctx, "Implementierung");
if (cancelRequested) { finalNotify(ctx, "⛔ Abgebrochen", "Nach Implementierung"); return; }
}
// Judge-Bereitschaft via Completion-Check — /health antwortet bereits während des
// 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", "Judge-Server wird geprüft…");
if (!await waitUntilModelReady(pi, ctx, 8002, "qwen3.5-judge")) {
finalNotify(ctx, "⛔ Judge nicht erreichbar", "Port 8002 — kein HTTP 200 nach 3 min. start-judge.sh ausführen");
return;
}
// Test-Suiten einmalig ermitteln: --test-cmd überschreibt Auto-Erkennung.
// Läuft nach Coder, damit neu angelegte Test-Dateien bereits erkannt werden.
ctx.ui.setStatus("optimize", "Test-Suiten werden erkannt…");
const autoTestCmds: string[] = testCmd
? [testCmd]
: await detectTestCommands(pi, ctx);
if (autoTestCmds.length > 0) {
const label = autoTestCmds.map(c => c.split(" ")[0]).join(", ");
ctx.ui.notify(
`${autoTestCmds.length} Test-Suite${autoTestCmds.length > 1 ? "n" : ""} erkannt: ${label}`,
"info"
);
} else {
ctx.ui.notify("Keine Test-Suiten erkannt — Judge führt Tests selbst aus.", "info");
}
let lastBlockers = "";
let verdict = "";
// Schleife: Judge → (PASS? fertig : Fix → nächste Runde)
for (let round = 1; round <= maxRounds; round++) {
const prog = "●".repeat(round - 1) + "◉" + "○".repeat(maxRounds - round);
if (!await switchModel(pi, ctx, "llama-cpp-judge", "qwen3.5-judge")) {
finalNotify(ctx, "⛔ Modell-Fehler", "Judge-Modell (llama-cpp-judge) nicht verfügbar");
return;
}
if (autoTestCmds.length > 0) {
const label = autoTestCmds.length === 1
? autoTestCmds[0].split(" ")[0]
: `${autoTestCmds.length} Suiten parallel`;
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Tests laufen (${label}, max. ${testTimeout}s)…`);
const testOutput = await runTestsParallel(pi, ctx, autoTestCmds, testTimeout);
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Judge analysiert Test-Ergebnis…`);
currentActivity = `Judge reviewt (Runde ${round}/${maxRounds})…`;
await sendAndWait(pi, ctx, judgeWithTestsPrompt(testOutput, ""));
} else {
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Judge — TASK.md + letzter Commit + Tests…`);
currentActivity = `Judge reviewt (Runde ${round}/${maxRounds})…`;
await sendAndWait(pi, ctx, judgePrompt(""));
}
if (cancelRequested) { finalNotify(ctx, "⛔ Abgebrochen", `Nach Judge Runde ${round}`); return; }
const judgeText = getLastAssistantText(ctx);
verdict = parseVerdict(judgeText);
if (verdict === "PASS" || verdict === "PASS WITH CONCERNS") {
await tickTaskMdStatus(pi, ctx, "Review bestanden (PASS)");
ctx.ui.setStatus("optimize", `${"●".repeat(round)}${verdict} nach Runde ${round}/${maxRounds} — ShipIt…`);
break;
}
// Loop-Erkennung: gleicher Blocker zweimal → manuell eingreifen
const currentBlockers = parseBlockers(judgeText);
if (currentBlockers && currentBlockers === 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");
return;
}
lastBlockers = currentBlockers;
if (round === maxRounds) {
ctx.ui.setStatus("optimize", `${"●".repeat(maxRounds)} ⚠ Max. ${maxRounds} Runden ohne PASS`);
if (verdict === "UNREADABLE") {
finalNotify(ctx, "⚠ Urteil unklar", `${maxRounds} Runden Judge-Urteil nicht erkennbar, Antwort im Chat prüfen`);
} else {
finalNotify(ctx, "⚠ Kein PASS", `${maxRounds} Runden ohne PASS bitte /judge und /fix manuell`);
}
return;
}
// Fix-Phase: Blocker-Preview aus Judge-Bericht anzeigen
const blockerHint = currentBlockers
? (currentBlockers.length > 50 ? currentBlockers.slice(0, 47) + "…" : currentBlockers)
: "Kritikpunkte aus Judge-Bericht";
ctx.ui.setStatus("optimize", `${prog} Runde ${round}/${maxRounds}: Coder fixt — ${blockerHint}`);
if (!await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder")) {
finalNotify(ctx, "⛔ Modell-Fehler", "Coder-Modell (llama-cpp-coder) nicht verfügbar");
return;
}
currentActivity = "Coder fixt Blocker…";
await sendAndWait(pi, ctx, fixPrompt(""));
if (cancelRequested) { finalNotify(ctx, "⛔ Abgebrochen", `Nach Fix Runde ${round}`); return; }
}
// Finale ShipIt-Prüfung nur bei PASS
if (verdict === "PASS" || verdict === "PASS WITH CONCERNS") {
ctx.ui.setStatus("optimize", `${"●".repeat(maxRounds)}◉ ShipIt — SHIP oder NO-SHIP?…`);
if (!await switchModel(pi, ctx, "llama-cpp-judge", "qwen3.5-judge")) {
finalNotify(ctx, "⛔ Modell-Fehler", "Judge-Modell (llama-cpp-judge) nicht verfügbar");
return;
}
currentActivity = "Judge: finale Freigabe…";
await sendAndWait(pi, ctx, shipitPrompt(""));
const shipText = getLastAssistantText(ctx);
const shipVerdict = shipText.match(/Urteil:\s*(SHIP|NO-SHIP)/i)?.[1]?.toUpperCase() ?? "";
if (shipVerdict === "SHIP") {
ctx.ui.setStatus("optimize", "🚀 SHIP produktionsreif");
finalNotify(ctx, "🚀 SHIP", "Programm ist produktionsreif");
await runVersionBump(pi, ctx);
if (withDoku) {
await runUpdateDoku(pi, ctx);
} else {
ctx.ui.notify("Nächster Schritt: /update_doku für Code-Kommentare, README.md und BEDIENUNGSANLEITUNG.md", "info");
}
} else if (shipVerdict === "NO-SHIP") {
ctx.ui.setStatus("optimize", "⛔ NO-SHIP noch nicht bereit");
finalNotify(ctx, "⛔ NO-SHIP", "Noch Blocker offen bitte /judge und /fix manuell");
} else {
ctx.ui.setStatus("optimize", "ShipIt abgeschlossen");
finalNotify(ctx, "ShipIt", "Kein klares Urteil Antwort im Chat prüfen");
}
}
} catch (e: any) {
finalNotify(ctx, "⛔ Fehler", String(e?.message ?? e));
} finally {
// Sicherstellen dass cancelRequested nie in einen späteren /optimize-Aufruf leckt
cancelRequested = false;
}
}
});
// ── Schlanke Kommandos für kleine Änderungen ─────────────────────────────
pi.registerCommand("patch", {
description: "Gezielte Minimaländerung ohne Refactoring, committet → qwen3.5-coder (:8001).",
handler: async function (args: string, ctx: ExtensionCommandContext) {
const change = (args || "").trim();
if (!change) {
ctx.ui.notify("Benutzung: /patch <beschreibung der änderung>", "error");
return;
}
if (!await waitUntilModelReady(pi, ctx, 8001, "qwen3.5-coder")) {
ctx.ui.notify("Coder-Server nicht bereit (Port 8001) — start-coder.sh ausführen", "error");
return;
}
await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder");
currentActivity = "Coder patcht…";
await sendAndWait(pi, ctx, patchPrompt(change));
}
});
pi.registerCommand("quick_check", {
description: "Schnelle OK/PROBLEM-Prüfung einer kleinen Codeänderung → qwen3.5-judge (:8002).",
handler: async function (args: string, ctx: ExtensionCommandContext) {
if (!await waitUntilModelReady(pi, ctx, 8002, "qwen3.5-judge")) {
ctx.ui.notify("Judge-Server nicht bereit (Port 8002) — start-judge.sh ausführen", "error");
return;
}
await switchModel(pi, ctx, "llama-cpp-judge", "qwen3.5-judge");
currentActivity = "Judge: Schnellcheck…";
await sendAndWait(pi, ctx, quickCheckPrompt(args || ""));
}
});
// ── Dokumentations-Phase ─────────────────────────────────────────────────
pi.registerCommand("update_doku", {
description: "Inkrementelle Code-Kommentare + README.md + BEDIENUNGSANLEITUNG.md via Git-Tags.",
handler: async function (_args: string, ctx: ExtensionCommandContext) {
await runUpdateDoku(pi, ctx);
}
});
// ── Robustes Editieren via GNU patch ─────────────────────────────────────
pi.registerTool({
name: "apply_patch",
label: "Patch anwenden",
description: [
"Wendet einen unified diff (git-Format) auf Dateien an.",
"Zuverlässiger als das edit-Tool bei mehrfachen Änderungen an derselben Datei.",
"Format: --- a/pfad/datei +++ b/pfad/datei @@ -n,m +n,m @@ ...",
"Verwende dieses Tool wenn du mehrere Stellen in einer Datei änderst."
].join(" "),
parameters: Type.Object({
patch: Type.String({
description: "Unified diff im git-Format mit --- a/... und +++ b/... Headern."
}),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
const tmpFile = `/tmp/pi_patch_${Date.now()}_${Math.random().toString(36).slice(2)}.diff`;
await pi.exec(
"bash",
["-c", `printf "%s" "$1" > "${tmpFile}"`, "_", params.patch],
{ cwd: ctx.cwd }
);
// -p1 entfernt führende a/ b/ Präfixe (git-Standard)
const result = await pi.exec(
"bash",
["-c", `patch -p1 < "${tmpFile}"; rm -f "${tmpFile}"`],
{ cwd: ctx.cwd }
);
if (result.code !== 0) {
return {
content: [{ type: "text", text: `Patch fehlgeschlagen:\n${result.stderr}\n${result.stdout}` }],
isError: true
};
}
return { content: [{ type: "text", text: result.stdout || "Patch erfolgreich angewendet." }] };
}
});
// ── Planungsmodus ────────────────────────────────────────────────────────
pi.registerCommand("plan", {
description: "Erstellt Implementierungsplan in PLAN.md ohne Dateiänderungen → qwen3.5-coder.",
handler: async function (args: string, ctx: ExtensionCommandContext) {
const task = (args || "").trim();
if (!task) {
ctx.ui.notify("Benutzung: /plan <auftrag>", "error");
return;
}
if (!await waitUntilModelReady(pi, ctx, 8001, "qwen3.5-coder")) {
ctx.ui.notify("Coder-Server nicht bereit (Port 8001) — start-coder.sh ausführen", "error");
return;
}
await writeTaskMd(pi, ctx, task);
await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder");
ctx.ui.setStatus("plan", "Analysiere und plane (keine Dateiänderungen)…");
currentActivity = "Coder plant (kein Code)…";
await sendAndWait(pi, ctx, planPrompt(task));
ctx.ui.setStatus("plan", "");
finalNotify(ctx, "📋 Plan", "Analyse abgeschlossen — PLAN.md + Chat");
}
});
pi.registerCommand("version", {
description: "Versionsnummer des Projekts erhöhen (SemVer + Git-Tag). Analysiert Commits seit letztem Tag.",
handler: async function (_args: string, ctx: ExtensionCommandContext) {
await runVersionBump(pi, ctx);
}
});
pi.registerCommand("help", {
description: "Zeigt alle Kommandos der pi-coder-judge-Extension.",
handler: async function (_args: string, ctx: ExtensionCommandContext) {
ctx.ui.notify([
"── Kern-Workflow ─────────────────────────────────────────",
"/optimize <auftrag> [--rounds N] [--with-doku] [--continue]",
" [--test-cmd \"cmd\"] [--test-timeout N]",
" Coder→Judge→Fix-Schleife bis PASS (empfohlener Einstieg)",
"/fix [kommentar] Fixt Judge-Kritik, committet → Coder",
"/judge [kommentar] Review gegen TASK.md + HEAD → Judge",
"/shipit [kommentar] Finale Freigabe (SHIP/NO-SHIP) → Judge",
"",
"── Steuerung ─────────────────────────────────────────────",
"/continue Unterbrochenen Prozess fortsetzen",
"/cancel Laufenden Loop nach aktuellem Schritt abbrechen",
"",
"── Erweiterte Kommandos (immer tippbar, nicht im Menü) ───",
"/coder <auftrag> Nur Implementierung ohne Review-Loop → Coder",
"/patch <änderung> Gezielte Minimaländerung → Coder",
"/quick_check [was] Schnelle OK/PROBLEM-Prüfung → Judge",
"/plan <auftrag> Implementierungsplan in PLAN.md → Coder",
"/update_doku Code-Kommentare + README.md + BEDIENUNGSANLEITUNG.md",
"/version Versionsnummer erhöhen (SemVer + Git-Tag)",
"/discard Verwirft PLAN.md",
"/new_project <pfad> Projektverzeichnis + git init + .gitignore",
].join("\n"), "info");
}
});
pi.registerCommand("cancel", {
description: "Bricht laufenden Optimize-Loop nach dem aktuellen Schritt ab.",
handler: async function (_args: string, ctx: ExtensionCommandContext) {
cancelRequested = true;
ctx.ui.notify("Abbruch angefordert — wird nach aktuellem Schritt gestoppt", "warning");
}
});
pi.registerCommand("discard", {
description: "Löscht PLAN.md und verwirft den aktuellen Plan.",
handler: async function (_args: string, ctx: ExtensionCommandContext) {
await pi.exec("bash", ["-c", "rm -f PLAN.md"], { cwd: ctx.cwd });
ctx.ui.notify("PLAN.md gelöscht — Plan verworfen", "info");
finalNotify(ctx, "🗑 Plan verworfen", "Neu starten mit /plan oder /coder");
}
});
pi.registerCommand("continue", {
description: "Nimmt unterbrochenen Prozess wieder auf — liest TASK.md, PLAN.md, git log und entscheidet den nächsten Schritt.",
handler: async function (_args: string, ctx: ExtensionCommandContext) {
if (!await waitUntilModelReady(pi, ctx, 8001, "qwen3.5-coder")) {
ctx.ui.notify("Coder-Server nicht bereit (Port 8001) — start-coder.sh ausführen", "error");
return;
}
await switchModel(pi, ctx, "llama-cpp-coder", "qwen3.5-coder");
ctx.ui.setStatus("continue", "Analysiere unterbrochenen Prozess…");
currentActivity = "Coder analysiert Stand…";
await sendAndWait(pi, ctx, [
"Ein Prozess wurde unterbrochen. Analysiere den aktuellen Stand und führe ihn sinnvoll fort:",
"1. Lies TASK.md für den Auftrag",
"2. Lies PLAN.md falls vorhanden (war ein Plan in Arbeit?)",
"3. Führe 'git log --oneline -5' aus um zu sehen was bereits committed wurde",
"4. Entscheide: Muss noch implementiert werden? Ist ein Review fällig? Müssen Fixes nachgezogen werden?",
"5. Fahre direkt mit dem nächsten sinnvollen Schritt fort — kein langer Bericht, einfach weitermachen.",
].join("\n"));
ctx.ui.setStatus("continue", "");
}
});
// ── Projekt-Scaffolding ──────────────────────────────────────────────────
pi.registerCommand("new_project", {
description: "Legt Projektverzeichnis, git-Repo und .gitignore an.",
handler: async function (args: string, ctx: ExtensionCommandContext) {
const rawPath = (args || "").trim();
if (!rawPath) {
ctx.ui.notify("Benutzung: /new_project <pfad>", "error");
return;
}
// ~ expandieren
const projectPath = rawPath.startsWith("~/")
? rawPath.replace("~/", (process.env.HOME || "") + "/")
: rawPath;
// Verzeichnis anlegen
const mkResult = await pi.exec("bash", ["-c", 'mkdir -p "$1"', "_", projectPath], { cwd: ctx.cwd });
if (mkResult.code !== 0) {
ctx.ui.notify(`Fehler: ${mkResult.stderr}`, "error");
return;
}
// git init (nur wenn noch kein Repo vorhanden)
const gitCheck = await pi.exec("bash", ["-c", "test -d .git && echo exists"], { cwd: projectPath });
if (gitCheck.stdout.trim() !== "exists") {
await pi.exec("bash", ["-c", "git init"], { cwd: projectPath });
}
// .gitignore anlegen
const gitignore = "target/\n*.o\n*.d\n*.swp\n.env\n.DS_Store\n";
await pi.exec("bash", ["-c", 'printf "%s" "$1" > .gitignore', "_", gitignore], { cwd: projectPath });
await pi.exec(
"bash",
["-c", "git add .gitignore && git commit -m 'chore: init project' || true"],
{ cwd: projectPath }
);
ctx.ui.notify(`Projekt angelegt: ${projectPath}`, "info");
ctx.ui.notify(
`⚠ Pi läuft noch in: ${ctx.cwd} — Session-Verzeichnis kann nicht gewechselt werden.\n` +
`Neues Projekt starten: cd ${projectPath} && pi`,
"warning"
);
ctx.ui.setStatus("new_project", `Neues Projekt → cd ${projectPath} && pi`);
}
});
}