Umbau Stufe 1-5: Governor v2 + OpenCode-Plugin + Subagenten-Mannschaft

Die Intelligenz sitzt nicht mehr NEBEN dem Coden, sondern DRIN. Alles auf der
Box gemessen, nicht angenommen.

Stufe 1 — Speicher-Haushalt (llama-swap-Config, nicht im Repo):
  Coder dauerwarm statt ttl 600 (Kaltstart 23 s -> 491 ms), Devstral als
  'kritiker' verdrahtet, Swap 11 GB -> 0. Zwei Befunde: Kontext 131k->65k spart
  bei Qwen3-Next 0 GB (Hybrid-Attention), und llama-swap haelt nur EINE Gruppe
  resident -> alles Ko-Residente in dieselbe Gruppe. `persistent: true` verhindert
  dabei das Freiraeumen vor grossen Modellen -> ausgeloester Kernel-OOM, behoben
  durch persistent:false + TTLs (Vision laedt jetzt in 10 s statt 117 s + Absturz).

Stufe 2 — Governor v2 (deploy/governor/):
  Steht jetzt IM Pfad (:8100 -> MC2 :9001) statt daneben. Zaehlt den GANZEN
  Anfragekoerper inkl. tools/tool_calls und kalibriert sich aus den echten
  usage.prompt_tokens jeder Antwort nach: Schaetzfehler 200 % -> 0,3 %.
  Soft-Einschub nur noch, wenn die Nachrichtenkette es erlaubt (kein Dazwischen-
  funken in offene tool_calls). Neuer Status-Endpunkt + systemd-Unit.
  Lucys Alltagsmodelle sind vom Schnitt ausgenommen.

Stufe 3 — OpenCode-Plugin (deploy/opencode/plugin/mc2-governor.ts):
  Werkzeug-Zaun (git push, rm -rf, sudo, curl|sh — bewiesen), Pruef-Tor auf
  session.idle mit Selbstreparatur, Savepoint statt Kompression, Meldungen an
  Lucys Briefkasten mit eigenem Absender 'loop'.

Stufe 4 — Mannschaft (opencode.json):
  plan+build -> coder (51,5 t/s) · explore -> hermes (69,6 t/s, warm, gratis) ·
  review -> Devstral (15,0 t/s, FREMDE Modellfamilie gegen blinde Flecken).
  Der 63-GB-Planer faellt aus der Tagesrolle raus.

Stufe 5 — ein Regelwerk, zwei Ausloeser:
  deploy/opencode-lauf.sh faehrt dieselbe Bau-Pruef-Schleife unbeaufsichtigt
  (die Schleife liegt hier UND im Plugin: bei `opencode run` endet der Prozess,
  bevor session.idle fertig ist — gemessen). Bricht ab, wenn der Governor fehlt.
  gitea-repo-create.sh saet jetzt VERIFY neben der CI-Ampel: jedes neue Repo
  wird mit Innen- UND Aussen-Pruefung geboren.

Oberflaeche:
  Token-Waechter-Kachel im Cockpit (Fuellstand, Marken, Ehrlichkeits-Nachweis),
  /api/governor als gleichursprüngliches Fenster, Devstral im Modellkatalog,
  Rollen-Texte auf die neue Mannschaft aktualisiert.

.gitattributes: deploy/**/*.py auf LF (deploy/*.py greift nur eine Ebene tief).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-25 21:39:32 +02:00
parent 775e862652
commit 7fac17ed9a
43 changed files with 1542 additions and 610 deletions
+21
View File
@@ -0,0 +1,21 @@
# VERIFY — wie man dieses Projekt prueft.
#
# Diese Datei ist das Pruef-Tor. Das MC2-Governor-Plugin fuehrt sie aus, sobald der
# Agent "fertig" sagt. Gruen -> Etappe gilt als fertig. Rot -> der Fehler geht
# automatisch als naechster Auftrag an den Agenten zurueck, bis zu 3 Runden.
#
# Regeln:
# * Eine Zeile = ein Befehl. Alle Zeilen werden mit && verkettet.
# * Zeilen mit # sind Kommentare.
# * KEINE Datei VERIFY im Projekt = Pruef-Tor aus (nichts passiert).
# * Der Befehl muss ohne Rueckfragen durchlaufen und mit 0 enden, wenn alles gut ist.
#
# Beispiele (unzutreffende Zeilen loeschen):
#
# Python: ruff check . && pytest -q
# Node/TS: npm run lint && npm test
# Frontend: npm run build
# Nur Syntax: python -m compileall -q .
# Nichts da: git diff --stat (laeuft immer gruen — Platzhalter)
git diff --stat
+67
View File
@@ -0,0 +1,67 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"aibox": {
"npm": "@ai-sdk/openai-compatible",
"name": "AI-Box ueber Governor (lokal)",
"options": {
"baseURL": "http://127.0.0.1:8100/v1",
"apiKey": "local"
},
"models": {
"coder": {
"name": "coder — Bauen + Planen (Qwen3-Coder-Next, 51,5 t/s)",
"limit": { "context": 131072, "output": 16384 }
},
"hermes": {
"name": "hermes — Suchen (Qwen3.6-35B, immer warm, 69,6 t/s)",
"limit": { "context": 65536, "output": 8192 }
},
"kritiker": {
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 15,0 t/s)",
"limit": { "context": 65536, "output": 8192 }
},
"heavy": {
"name": "heavy — Nacht-Gutachter (gpt-oss-120b, verdraengt das warme Set!)",
"limit": { "context": 32768, "output": 8192 }
}
}
}
},
"model": "aibox/coder",
"small_model": "aibox/hermes",
"agent": {
"plan": {
"model": "aibox/coder",
"permission": { "edit": "deny", "bash": "deny" }
},
"build": {
"model": "aibox/coder",
"permission": {
"edit": "allow",
"webfetch": "allow",
"bash": {
"*": "allow",
"ssh *": "deny",
"scp *": "deny",
"sftp *": "deny",
"ssh arcane@192.168.178.162 *": "allow",
"ssh -o StrictHostKeyChecking=no arcane@192.168.178.162 *": "allow",
"scp *arcane@192.168.178.162*": "allow"
}
}
},
"explore": {
"mode": "subagent",
"description": "Codebase schnell durchsuchen, Dateien finden, Fragen zum Code beantworten — nur lesen, laeuft auf dem immer warmen Hirn",
"model": "aibox/hermes",
"permission": { "edit": "deny", "bash": "deny" }
},
"review": {
"mode": "subagent",
"description": "Kritischer Code-Review nach jeder Etappe (Pflicht laut AGENTS.md): sucht erfundene APIs/CLI-Flags, stille Abweichungen vom KONZEPT, fehlende Tests, toten Code — meldet Befunde, aendert nichts. Laeuft bewusst auf einer FREMDEN Modellfamilie (Mistral/Devstral statt Qwen), damit er andere blinde Flecken hat als der Coder.",
"model": "aibox/kritiker",
"permission": { "edit": "deny", "bash": "deny" }
}
}
}
+248
View File
@@ -0,0 +1,248 @@
/**
* MC2-Governor — der "Fahrlehrer" im OpenCode-Agenten.
*
* Der Governor-Proxy (:8100) ist die Tankuhr: er sieht nur Tokens und zieht die
* Notbremse. Dieses Plugin sitzt IM Agenten und sieht alles andere — jeden
* Werkzeuggriff, jede Datei, jedes Sitzungsende. Es macht vier Dinge:
*
* 1. WERKZEUG-ZAUN (tool.execute.before)
* Blockt Handgriffe, die ein Agent nie unbeaufsichtigt tun darf: push,
* Historie umschreiben, rekursiv loeschen, sudo, Fremd-Hosts. Genau dieser
* Zustandsautomat-Zaun hob lokale Modelle in Messungen von 2/10 auf 10/10 —
* nicht weil sie schlauer werden, sondern weil sie nicht mehr entgleisen.
*
* 2. PRUEF-TOR + SCHLEIFE (session.idle)
* Sagt der Agent "fertig", laeuft der Verify-Befehl des Projekts (Datei
* `VERIFY` im Repo-Wurzelverzeichnis). GRUEN -> Meldung. ROT -> der Fehler
* geht als naechster Auftrag automatisch zurueck an den Agenten, bis zu
* MC2_LOOP_MAX_ROUNDS mal. Das ist die "Ralph-Schleife", nur mit Bremse.
*
* 3. SAVEPOINT STATT ZUSAMMENFASSEN (session.compacted)
* Beim Komprimieren fallen still die Regeln aus dem Kontext (Paper
* "Governance Decay"). Wir schieben stattdessen den Auftrag nach, SAVEPOINT.md
* zu schreiben — Wissen lebt in Datei + git, nicht im schrumpfenden Chat.
*
* 4. STIMME (MC2 /api/voice/announce)
* Jedes Ereignis geht mit eigenem Absender `loop` in MC2s Melde-Briefkasten.
* Lucy pollt ihn ohnehin und spricht ihn — ohne eine Zeile Lucy-Code.
*
* Schalter (Umgebungsvariablen):
* MC2_BOX_URL MC2-Basis (Default http://192.168.178.151:9001)
* MC2_LOOP_AUTOFIX Selbstreparatur (1 = an, Default an)
* MC2_LOOP_MAX_ROUNDS max. Reparaturrunden (Default 3)
* MC2_LOOP_SILENT 1 = Lucy schweigt (Nachtlauf; Meldungen kommen trotzdem an)
* MC2_LOOP_ANNOUNCE 0 = gar keine Meldungen
* MC2_FENCE_OFF 1 = Werkzeug-Zaun aus (nur fuer Notfaelle)
*
* Liegt global unter ~/.config/opencode/plugin/ und wirkt damit in JEDEM Projekt —
* am Tag in Zed, nachts im Cron. Ein Regelwerk, zwei Ausloeser.
*/
const BOX_URL = process.env.MC2_BOX_URL || "http://192.168.178.151:9001"
const AUTOFIX = process.env.MC2_LOOP_AUTOFIX !== "0"
const MAX_ROUNDS = parseInt(process.env.MC2_LOOP_MAX_ROUNDS || "3", 10)
const SILENT = process.env.MC2_LOOP_SILENT === "1"
const ANNOUNCE_ON = process.env.MC2_LOOP_ANNOUNCE !== "0"
const FENCE_OFF = process.env.MC2_FENCE_OFF === "1"
/**
* Verbotene Shell-Handgriffe. Bewusst als Muster auf der ROHEN Kommandozeile —
* ein Agent, der `git push` in ein `bash -c` verpackt, wird trotzdem erwischt.
* Kein Anspruch auf Sandbox-Sicherheit: das ist ein Leitplanken-Zaun gegen
* Entgleisen, keine Abwehr gegen einen boesartigen Akteur.
*/
const FENCE: Array<{ rx: RegExp; why: string }> = [
{ rx: /\bgit\s+push\b/, why: "git push — Veroeffentlichen ist Sache des Menschen (oder der CI-Ampel)." },
{ rx: /\bgit\s+reset\s+--hard\b/, why: "git reset --hard — verwirft Arbeit unwiederbringlich." },
{ rx: /\bgit\s+clean\s+-[a-z]*f/, why: "git clean -f — loescht ungetrackte Dateien unwiederbringlich." },
{ rx: /\bgit\s+(rebase|filter-branch|reflog\s+expire)\b/, why: "Historie umschreiben ist tabu." },
{ rx: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+\/(?:\s|$)/, why: "rm -rf / — nein." },
{ rx: /\brm\s+-[a-zA-Z]*[rf]/, why: "rekursives/erzwungenes Loeschen — bitte gezielt loeschen statt pauschal." },
{ rx: /\bsudo\b/, why: "sudo — Rechteausweitung gehoert nicht in einen Agentenlauf." },
{ rx: /\b(shutdown|reboot|mkfs|dd\s+if=)/, why: "System-/Datentraeger-Eingriff." },
{ rx: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/, why: "Aus dem Netz laden und direkt ausfuehren — klassischer Fussschuss." },
{ rx: /\bssh\s+(?!arcane@192\.168\.178\.162|-o\s+StrictHostKeyChecking=no\s+arcane@)/, why: "ssh nur zur freigegebenen Arcane-VM." },
{ rx: /\bnpm\s+publish\b|\btwine\s+upload\b/, why: "Veroeffentlichen von Paketen ist Sache des Menschen." },
]
/** Zaehler je Sitzung: wie viele Selbstreparatur-Runden liefen schon? */
const rounds = new Map<string, number>()
/** Doppel-Feuern verhindern: session.idle kann mehrfach kommen. */
const busy = new Set<string>()
async function announce(subject: string, text: string, priority: "normal" | "silent" = "normal") {
if (!ANNOUNCE_ON) return
try {
await fetch(`${BOX_URL}/api/voice/announce`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject,
text,
source: "loop",
priority: SILENT ? "silent" : priority,
}),
signal: AbortSignal.timeout(4000),
})
} catch {
/* best effort — eine stumme Lucy darf den Bau nie aufhalten */
}
}
export const MC2Governor = async ({ client, $, directory, worktree }: any) => {
const root: string = worktree || directory || process.cwd()
/**
* Verify-Befehl des Projekts lesen. Fehlt die Datei, ist das Pruef-Tor AUS.
* Bewusst ueber fs statt `cat`: das Plugin laeuft am Tag auf Windows (Zed) und
* nachts auf der Box — `cat` gibt es auf Windows nicht zuverlaessig.
*/
async function readVerify(): Promise<string | null> {
try {
const { readFile } = await import("node:fs/promises")
const { join } = await import("node:path")
const raw = await readFile(join(root, "VERIFY"), "utf8")
const cmd = raw
.split("\n")
.map((l: string) => l.trim())
.filter((l: string) => l && !l.startsWith("#"))
.join(" && ")
return cmd || null
} catch {
return null
}
}
/**
* Verify ausfuehren. Rueckgabe: {ok, output} — Ausgabe auf das Wesentliche gekuerzt.
* `{ raw: cmd }` schiebt den Befehl UNESCAPED in Buns Shell; ein normales
* `${cmd}` wuerde die ganze Zeile als EIN Argument uebergeben und nie laufen.
* Buns Shell ist plattformunabhaengig — kein `bash -lc`, das auf Windows fehlt.
*/
async function runVerify(cmd: string): Promise<{ ok: boolean; out: string }> {
try {
const res = await $`${{ raw: cmd }}`.cwd(root).nothrow().quiet()
const out = `${res.stdout?.toString() ?? ""}${res.stderr?.toString() ?? ""}`
return { ok: res.exitCode === 0, out: out.slice(-4000) }
} catch (e: any) {
return { ok: false, out: String(e?.message ?? e).slice(-4000) }
}
}
/** Dem laufenden Agenten einen neuen Auftrag schicken (Selbstreparatur-Schleife). */
async function sendPrompt(sessionID: string, text: string): Promise<boolean> {
try {
await client.session.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text }] },
})
return true
} catch {
return false
}
}
return {
// ── 1. Werkzeug-Zaun ───────────────────────────────────────────────────
"tool.execute.before": async (input: any, output: any) => {
if (FENCE_OFF) return
if (input?.tool !== "bash") return
const cmd: string = output?.args?.command ?? ""
if (!cmd) return
for (const rule of FENCE) {
if (rule.rx.test(cmd)) {
await announce(
"[Zaun]",
`Ich habe einen Befehl geblockt: ${rule.why}`,
"silent",
)
// Werfen = OpenCode bricht genau diesen Werkzeugaufruf ab und gibt dem
// Modell den Grund zurueck. Der Agent arbeitet weiter, nur anders.
throw new Error(
`[MC2-ZAUN] Blockiert: ${rule.why}\n` +
`Befehl war: ${cmd}\n` +
`Waehle einen anderen Weg. Wenn das wirklich noetig ist, sag es dem Menschen — ` +
`er macht es selbst.`,
)
}
}
},
// ── 2.-4. Ereignisse ───────────────────────────────────────────────────
event: async ({ event }: any) => {
const type: string = event?.type ?? ""
const props: any = event?.properties ?? event ?? {}
const sessionID: string = props.sessionID || props.sessionId || props.id || ""
// ── Savepoint statt Zusammenfassen ──────────────────────────────────
if (type === "session.compacted" || type === "experimental.session.compacting") {
await announce(
"[Sitzung]",
"Die Sitzung wurde komprimiert — ich lasse den Stand in SAVEPOINT.md sichern.",
"silent",
)
if (sessionID) {
await sendPrompt(
sessionID,
"[MC2-GOVERNOR] Der Kontext wurde gerade komprimiert — dabei gehen still " +
"Regeln und Details verloren. Aktualisiere JETZT SAVEPOINT.md: was wirklich " +
"erledigt ist (nur was im Code steht), der genaue naechste Schritt, offene " +
"Fragen, Stolpersteine. Committe die Datei. Danach arbeite normal weiter.",
)
}
return
}
// ── Pruef-Tor + Selbstreparatur ─────────────────────────────────────
if (type !== "session.idle" || !sessionID) return
if (busy.has(sessionID)) return
const cmd = await readVerify()
if (!cmd) return // Kein VERIFY im Projekt -> Pruef-Tor bewusst aus.
busy.add(sessionID)
try {
const { ok, out } = await runVerify(cmd)
const round = rounds.get(sessionID) ?? 0
if (ok) {
rounds.delete(sessionID)
await announce("[Pruefung]", "Etappe fertig und die Tests sind gruen.", "normal")
return
}
if (!AUTOFIX || round >= MAX_ROUNDS) {
rounds.delete(sessionID)
await announce(
"[Pruefung]",
`Die Tests sind rot und ich habe ${round} Reparaturversuche verbraucht. ` +
`Hier komme ich allein nicht weiter, Commander.`,
"normal",
)
return
}
rounds.set(sessionID, round + 1)
await announce(
"[Pruefung]",
`Tests rot — ich repariere selbst weiter, Runde ${round + 1} von ${MAX_ROUNDS}.`,
"silent",
)
await sendPrompt(
sessionID,
`[MC2-PRUEFTOR] Deine Etappe gilt noch NICHT als fertig: der Verify-Befehl des ` +
`Projekts ist fehlgeschlagen.\n\n` +
`Befehl: ${cmd}\n\n` +
`Ausgabe (Ende):\n\`\`\`\n${out}\n\`\`\`\n\n` +
`Behebe die Ursache — nicht das Symptom, und schalte keinen Test ab. ` +
`Wenn du fertig bist, melde dich normal; ich pruefe dann erneut. ` +
`(Reparaturrunde ${round + 1} von ${MAX_ROUNDS}.)`,
)
} finally {
busy.delete(sessionID)
}
},
}
}
export default MC2Governor