pi-system/extensions/rule-enforcer.ts
Raimund Bauer fed300c170 feat/extensions: rule-enforcer.ts — before_agent_start + message_end + 30s-Check
- before_agent_start: injiziert W06/W07/W09/W10 + WATCH-Regel in jeden System-Prompt-Turn,
  Selbstkorrektur wenn letzter Turn W06 verletzt, Subagenten-Alerts prominent eingeblendet
- message_end: scannt Pi-Output auf Deliberations-Muster, setzt Korrektur-Flag
- setInterval 30s: prüft tmux-Sessions auf offene Bestätigungs-Dialoge unabhängig
  von Pi's Tool-Calls, schreibt in Alert-File für nächsten Turn
2026-06-02 16:02:03 +02:00

203 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Rule Enforcer Extension
*
* Zwei Mechanismen:
*
* 1. before_agent_start — injiziert Kern-Regeln in jeden System-Prompt-Turn
* + Selbstkorrektur wenn letzter Turn W06 verletzt hat
* + Subagenten-Alerts aus Alert-File (geschrieben von SubConfirm/setInterval)
*
* 2. message_end — scannt Pi's Ausgabe auf W06-Deliberations-Muster,
* setzt Flag für nächsten before_agent_start
*
* 3. setInterval (30s) — prüft tmux-Sessions auf offene Bestätigungs-Dialoge
* und schreibt in Alert-File, damit Pi beim nächsten Turn reagiert.
* Ergänzt SubConfirm-Daemon — auch wenn SubConfirm nicht läuft.
*
* Nur aktiv wenn PI_ORCHESTRATOR=1.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import { execSync } from "node:child_process";
const ORCHESTRATOR_AKTIV = !!process.env.PI_ORCHESTRATOR;
const ALERT_FILE = "/tmp/.pi-subagent-alert";
const CHECK_INTERVAL_MS = 30_000;
// ---------------------------------------------------------------------------
// Kern-Regeln — werden in jeden System-Prompt-Turn injiziert
// ---------------------------------------------------------------------------
const KERN_REGELN = `
## PFLICHT-REGELN — gelten für JEDE Antwort ohne Ausnahme
**W06** Kein Deliberieren: Niemals "Let me", "Actually", "I should", "Ich sollte", "Das liegt daran", "Weil ich" in Antworten ausgeben. Nur Ergebnis oder Entscheidung.
**W07** Subagenten nicht aufgeben: Bei Problemen Benutzer kurz informieren, auf Anweisung warten. Nie eigenständig auf direkte Ausführung umschwenken.
**W09** sleep verboten: Stattdessen watch_subagents nutzen oder Benutzer informieren.
**W10** Subagent nie doppelt starten: Einmal starten, dann watch_subagents abwarten.
**WATCH** watch_subagents SOFORT nach jeder Antwort erneut aufrufen — permanent, ohne Ausnahme.
`.trim();
// ---------------------------------------------------------------------------
// W06-Deliberations-Muster
// ---------------------------------------------------------------------------
const DELIBERATIONS_MUSTER: RegExp[] = [
/\bLet me\b/,
/\bActually,?\s/,
/\bI should\b/,
/\bI need to\b/,
/\bI'll check\b/,
/\bI'll look\b/,
/\bLet me check\b/,
/\bLet me look\b/,
/\bLet me verify\b/,
/\bLet me first\b/,
/\bHmm,?\s/i,
/\bWait,?\s/,
/\bIch sollte\b/,
/\bLass mich\b/,
/\bDas liegt daran\b/,
/\bWeil ich\b/,
/\bIch muss\b/,
/\bIch werde\b.*\bprüfen\b/,
];
function hatDeliberation(text: string): boolean {
return DELIBERATIONS_MUSTER.some((p) => p.test(text));
}
function textAusNachricht(message: any): string {
const content = message?.content;
if (!Array.isArray(content)) return "";
return content
.filter((c: any) => c.type === "text")
.map((c: any) => String(c.text ?? ""))
.join(" ");
}
// ---------------------------------------------------------------------------
// Alert-File lesen und leeren
// ---------------------------------------------------------------------------
function leseAlerts(): string | null {
try {
if (!fs.existsSync(ALERT_FILE)) return null;
const content = fs.readFileSync(ALERT_FILE, "utf-8").trim();
if (!content) return null;
fs.writeFileSync(ALERT_FILE, "");
return content;
} catch {
return null;
}
}
function schreibeAlert(text: string): void {
try {
const existing = fs.existsSync(ALERT_FILE)
? fs.readFileSync(ALERT_FILE, "utf-8")
: "";
const neuerInhalt = existing.trim()
? existing.trim() + "\n" + text
: text;
fs.writeFileSync(ALERT_FILE, neuerInhalt);
} catch {}
}
// ---------------------------------------------------------------------------
// tmux-Sessions auf Bestätigungs-Dialoge prüfen
// ---------------------------------------------------------------------------
function pruefeSubagentenBestaetigungen(): string | null {
try {
const raw = execSync("tmux ls -F '#{session_name}' 2>/dev/null || true", {
encoding: "utf-8",
timeout: 2000,
}).trim();
if (!raw) return null;
const sessions = raw.split("\n").map((s) => s.trim()).filter(Boolean);
const wartend: string[] = [];
for (const name of sessions) {
try {
const pane = execSync(
`tmux capture-pane -t '${name}' -p 2>/dev/null | tail -10`,
{ encoding: "utf-8", timeout: 1000 },
).trim();
const wartetAufBestaetigung =
pane.includes("Erlauben?") ||
pane.includes("→ Yes") ||
(pane.includes("navigate") && pane.includes("select")) ||
pane.includes("ACHTUNG:") ||
pane.includes("Überschreiben?") ||
pane.includes("Bestätigung");
if (wartetAufBestaetigung) {
wartend.push(`${name}: WARTET AUF BESTÄTIGUNG`);
}
} catch {}
}
return wartend.length > 0 ? wartend.join("\n") : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
if (!ORCHESTRATOR_AKTIV) return;
let letzterVerstoss: string | null = null;
// ── 30s Background-Check ─────────────────────────────────────────────────
// Prüft tmux-Sessions alle 30s auf offene Dialoge, unabhängig von Pi's
// Tool-Calls. Schreibt in Alert-File → wird in before_agent_start injiziert.
const interval = setInterval(() => {
const wartend = pruefeSubagentenBestaetigungen();
if (wartend) {
schreibeAlert(`[30s-Check] Subagent wartet auf Bestätigung:\n${wartend}`);
}
}, CHECK_INTERVAL_MS);
pi.on("session_shutdown", async () => {
clearInterval(interval);
});
// ── message_end: W06-Scan ─────────────────────────────────────────────────
// Scannt Pi's abgeschlossene Antwort auf Deliberations-Muster.
// Setzt Flag für nächsten before_agent_start-Turn.
pi.on("message_end", async (event) => {
const text = textAusNachricht(event.message);
if (hatDeliberation(text)) {
letzterVerstoss = "W06 (Internes Deliberieren)";
}
});
// ── before_agent_start: System-Prompt-Injection ───────────────────────────
// Injiziert Kern-Regeln + Selbstkorrektur + Subagenten-Alerts
// in den System-Prompt jedes Turns.
pi.on("before_agent_start", async (event) => {
let zusatz = `\n\n---\n${KERN_REGELN}`;
// Selbstkorrektur wenn letzter Turn W06 verletzt hat
if (letzterVerstoss) {
zusatz += `\n\n⚠ REGELVERSTOSS erkannt (${letzterVerstoss}): Diese Antwort OHNE Gedankenkommentar. Nur Ergebnis oder Entscheidung.`;
letzterVerstoss = null;
}
// Subagenten-Alerts
const alerts = leseAlerts();
if (alerts) {
zusatz += `\n\n🚨 SUBAGENT WARTET AUF BESTÄTIGUNG:\n${alerts}\n→ Sofort watch_subagents aufrufen oder Benutzer informieren.`;
}
return { systemPrompt: event.systemPrompt + zusatz };
});
}