133 Commits

Author SHA1 Message Date
Hitonabi 76d2d7c74b deploy: neues Curator-Monitoring-Skript mit 7-Tage-Schwellenwert
Ampel / ampel (push) Failing after 22s
2026-08-02 04:04:21 +02:00
Hitonabi 7638f0244d fix(cron): correct implementation for cron spam prevention via wrapper script and deploy origin
Ampel / ampel (push) Failing after 21s
2026-07-27 17:12:39 +02:00
Hitonabi 4985977492 fix(cron): use --name flag for hermes cron create
Ampel / ampel (push) Failing after 22s
2026-07-27 17:10:43 +02:00
Hitonabi f65ff48599 fix(cron): pipe alle nächtlichen crons durch notify.sh zur verhinderung von telegram spam
Ampel / ampel (push) Failing after 22s
2026-07-27 17:09:26 +02:00
Hitonabi 87f446c5ae feat(pruefstand): Etappe E5 Autonomie für Auto-Adoption und täglichen Rhythmus
Ampel / ampel (push) Failing after 22s
2026-07-26 21:28:50 +02:00
Hitonabi f524ef4375 chore(prompt): Morgen-Digest detailreicher und gehaltvoller gemacht
Ampel / ampel (push) Successful in 22s
2026-07-26 21:16:02 +02:00
Hitonabi 605c9d7dd1 feat: Morgen-Digest für gebündelte nächtliche Telegram-Nachrichten
Ampel / ampel (push) Successful in 22s
2026-07-26 21:10:31 +02:00
Hitonabi 9791f44644 UI: GovernorCard aus dem Cockpit entfernt
Ampel / ampel (push) Successful in 22s
2026-07-26 21:03:47 +02:00
Hitonabi 1e3e0066af Governor: Hard ceiling respects tool chains (OpenCode fix)
Ampel / ampel (push) Successful in 23s
2026-07-26 20:50:45 +02:00
Hitonabi 2365f7aac6 Warm-Waechter lief 11 Tage im Leerlauf: Reranker war nie erreichbar
Ampel / ampel (push) Successful in 22s
Beim Nachsehen wegen einer haengenden Gitea-Karte im Steward-Log gefunden:
600 vergebliche warmup.sh-Laeufe in 24 h, alle 90 Sekunden. Aelteste Meldung
dieser Art: 15.07.2026 — also elf Tage, nicht erst seit dem Umbau.

Ursache: Der Reranker steckt in der ko-residenten Gruppe `brains` und gilt dem
Waechter damit als warm-pflichtig. Er antwortet aber NUR auf /v1/rerank — und
genau diesen Endpunkt kannte warmup.sh nicht. Das Modell konnte also nie warm
werden, der Waechter sah es ewig als "fehlend" und rief alle 90 s ein Skript auf,
das daran nichts aendern konnte. Mit Devstral in derselben Gruppe habe ich die
Falle vorgestern verdoppelt.

Zwei Korrekturen:
1. warmup.sh waermt jetzt auch Reranker (MC_WARMUP_RERANK, Default "reranker").
2. warmer.py bekommt MC_WARMSET als Override der Gruppen-Ableitung. Ko-Residenz
   ("duerfen gleichzeitig liegen") und Warm-Pflicht ("muessen immer liegen") sind
   zwei verschiedene Aussagen; die Gruppe kann nur die erste ausdruecken. Seit
   Coder (TTL 90 min) und Kritiker (TTL 30 min) in `brains` liegen, haette der
   Waechter sonst gegen ihre TTLs gearbeitet und nachts 62 GB wieder hochgeladen.

Steward bekommt MC_WARMSET = embed + reranker + hermes (Drop-in auf der Box).
Falle dabei, live erlebt: systemd trennt `Environment=` an Leerzeichen — ohne
Anfuehrungszeichen kam nur das erste Modell an und das Warm-Ziel war still zu
klein. Der erste Fix sah deshalb erfolgreich aus (Schleife weg), war aber nur
eine kaputte Messung. Jetzt gequotet und im Prozess-Environ geprueft.

Belegt: alle drei Ziel-Modelle warm, 0 vergebliche Ausloesungen des neuen
Prozesses, Coder geladen aber korrekt kein Warm-Ziel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:13:26 +02:00
Hitonabi 56b374a0aa Ideen: Auswahl war bei leerem Feld unsichtbar
Ampel / ampel (push) Successful in 22s
User: "dein Umbau ist immer noch nicht da. Oder denke ICH falsch?" — er dachte
nicht falsch. Der ganze Auswahl-Block hing an `text.trim().length >= 3`, also
sah man bei leerem Eingabefeld NICHTS davon. Kein Hinweis, dass es ueberhaupt
eine Wahl gibt, keine Moeglichkeit sie vorher einzustellen, und nach jedem
Neuladen war alles wieder weg.

Meine Begruendung war, die Auswahl sei eine Entscheidung ZU dem Text und habe
ohne Text nichts zu suchen. Das ist logisch sauber und praktisch falsch: ein
Mechanismus, den man nicht sieht, existiert fuer den Nutzer nicht.

Jetzt immer sichtbar. Ohne Einordnung steht davor "VOREINGESTELLT" — die Zeile
ist dann eine Einstellung, keine Aussage ueber einen Text, den es noch nicht
gibt. Sobald die Box gelesen hat, verschwindet das Wort und das Etikett
"gelesen" erscheint. Aendern kann man beides jederzeit, auch vor dem Tippen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:50:00 +02:00
Hitonabi eef7fdb4df Ideen: Kopfzeile beschrieb eine Bedienung, die es nicht mehr gibt
Ampel / ampel (push) Successful in 23s
Zwei Fehler in der prominentesten Zeile der Seite, vom User gefunden:

1. "fuer Zed" statt "für Zed" — verschluckter Umlaut aus dem Anlegen der Seite.
2. "Je nach Modus baut sie es selbst, denkt es nur durch, oder legt dir ein
   startklares Repo an" — die Modi sind seit gestern weg, der Entscheidungsbaum
   ist seit heute zugeklappt. Der Satz erklaerte also eine Bedienung, die es
   nicht mehr gibt. Genau die Sorte Text-Leiche, die entsteht, wenn man die
   Mechanik umbaut und die Beschreibung stehen laesst.

Jetzt: "Schreib hin, was entstehen soll. Die Box liest mit und schlaegt vor, wie
es weitergeht — aendern kannst du das immer." Das ist, was tatsaechlich passiert.

Restliche Oberflaeche gegengeprueft: alle anderen "Modus"-Treffer sind
Variablennamen oder unverwandt (Dev-Modus, Experten-Modus), und ausser diesem
"fuer" gibt es keine weiteren ASCII-Ersatzschreibweisen in sichtbarem Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:43:15 +02:00
Hitonabi cd60974ff9 Ideen: Entscheidungsbaum zugeklappt statt Assistent
Ampel / ampel (push) Successful in 24s
Der vorgeschlagene Wizard hatte den richtigen BAUM — Wer baut das?, und nur im
Zed-Ast: wie startest du? — aber den falschen Mechanismus fuer eine taegliche
Zehn-Sekunden-Handlung. Ein Assistent haette gefragt, was der Einordner in
~500 ms schon weiss.

Jetzt: derselbe Baum, zugeklappt.
  Normalfall  Textfeld + EINE Zeile ("Du baust selbst — ich arbeite erst ein
              Konzept aus.") + Enter. Nichts steht zwischen dir und der Idee.
  "anders"    klappt genau die Wizard-Reihenfolge auf: erst Wer baut das?, und
              NUR im Zed-Ast eingerueckt darunter Wie startest du?
              Bei "die Box" folgt keine Rueckfrage — dann geht es direkt los.

Das Etikett heisst "gelesen" und erscheint nur, wenn die Box den Text wirklich
eingeordnet hat. Ohne Einordnung steht dort die Voreinstellung, und die darf sich
nicht als Verstaendnis ausgeben.

Der Entwurf steht und faellt damit, dass der Einordner meistens richtig liegt —
5 von 5 im Test ist ein huebsches, aber winziges Ergebnis. Deshalb zaehlt die
Seite jetzt mit, wie oft "anders" geklickt wird (localStorage, zwei Zahlen, kein
Endpunkt) und zeigt die Quote beim Aufklappen. Bleibt sie unter etwa jedem
fuenften Mal, war der Weg richtig; liegt sie darueber, bauen wir den Assistenten
mit Daten statt mit Meinung.

Nebenbei: das jetzt tote `vorgeschlagen`-Prop aus <Wahl> entfernt (4 Aufrufer).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:16:17 +02:00
Hitonabi 20d4f40ed9 Ideen: zwei Bereiche, drei Karten — Durchdenken nach vorn
Ampel / ampel (push) Successful in 24s
User-Befund, und er trifft einen echten Modellierungsfehler von gestern: "Eine
durchgedachte Idee wird ja sowieso mit meinem Ja zu einem IDE-Repo." Stimmt —
/ideen/weiterfuehren macht aus der durchdachten Idee GENAU die IDE-Projekt-Karte,
die der Repo-Weg direkt anlegt. Es sind zwei Stufen EINES Weges, keine zwei Achsen.

Der Haken von gestern war deshalb falsch: "Die Box, allein" + "nur denken" war
anklickbar, submit() setzte dann aber still welt:"ide" und schob einen in die
Zed-Welt, ohne das zu sagen. Genau die versteckte Umschaltung, die anderswo in
diesem Repo als Fassade gilt.

Jetzt GENAU EINE Auswahl aus drei, in zwei Bereichen:
  Du baust selbst, in Zed   [Erst durchdenken] [Direkt ein Repo]
  Oder die Box macht es     [Die Box baut allein]
Die falsche Kombination ist damit strukturell unmoeglich, nicht nur unwahrscheinlich.

Nebengewinn: beim Durchdenken entfallen Tiefe und Ziel am Eingang. Die
Weiter-Leiste fragt beides ohnehin beim "gefaellt mir" — und dann weiss man, was
das Ding ueberhaupt wird. Vorher entschied man LXC-oder-Docker, bevor ein Konzept
existierte.

Ausgewaehlt ist immer eine KARTE, nie ein Bereich — der Bereich gruppiert nur, was
denselben Weg geht. Voreinstellung ist "Erst durchdenken": die Wahl, die am
wenigsten anlegt. Der Einordner ueberschreibt sie ohnehin in ~500 ms.

API unveraendert; alle drei Abbildungen gegen den alten Vertrag geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:08:23 +02:00
Hitonabi cb649aac34 Ideen 2.0: schreiben zuerst, die Box waehlt vor
Ampel / ampel (push) Successful in 24s
Die Seite verlangte eine technische Weichenstellung, BEVOR man gesagt hat was man
will — mit Begriffen aus dem Maschinenraum (BOX / IDE: SIMPEL / IDE: GRUENDLICH).
Deshalb klebten 116 Woerter Beipackzettel unter einem einzigen Eingabefeld.

Zwei unabhaengige Entscheidungen, vorher in vier sich ausschliessende Knoepfe gepresst:
  wer       — die Box allein, oder ein Repo fuer Zed
  nurDenken — erst nur ein Konzept (passt zu beidem, jetzt ein Haken)
Die Knoepfe nennen das ERGEBNIS statt des Werkzeugs, und der erklaerende Satz steht
an der Option statt in einer Legende darunter.

Neu: POST /api/ideen/einordnen — das dauerwarme Hirn liest den getippten Text und
waehlt vor (gemessen ~500 ms, 5 von 5 Testfaellen richtig, inkl. "Lohnt sich...?"
-> nur denken). Bewusst ein VORSCHLAG mit sichtbarem Etikett: er setzt nur die
Auswahl, die ohnehin dasteht. Faellt er aus, bleibt schlicht die Voreinstellung —
das Tippen wird nie blockiert, es gibt keinen Ladebalken und keine Fehlermeldung.

Tiefe und Ziel erscheinen nur noch beim Repo-Weg, weil sie nur dort wirken. Ein
Schalter der nichts tut ist schlimmer als keiner.

Leerer Zustand zeigt drei anklickbare Beispiele statt "wirf der Box eine Idee zu".
Kopfzeile nennt nur noch den Zustand — "Ideen" stand dreimal uebereinander.

Die API bleibt unveraendert; die Abbildung passiert in submit().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:58:51 +02:00
Hitonabi d18aac0e1b Persoenliche Arbeitsregeln erreichen endlich den Coding-Agenten
Ampel / ampel (push) Successful in 25s
Befund beim Nachsehen wegen Zeds "Skills Support"-Ankuendigung: die 46 Zeilen
Arbeitsregeln in AppData\Roaming\Zed\AGENTS.md haben OpenCode NIE erreicht.
Zeds Doku ist eindeutig — AGENTS.md und Skills gelten nur fuer Zeds NATIVEN
Agenten, nicht fuer ACP-Agenten wie OpenCode. Hier wird ueber ACP gearbeitet.
Gute Regeln (Plan vor Code, kleine Schritte, beweisen statt behaupten,
Faulheits-Leiter, Kontext-Waechter) liefen also ins Leere.

Sie liegen jetzt in ~/.config/opencode/AGENTS.md — auf dem PC UND auf der Box,
also am Tag in Zed und nachts im Cron. Vorlage im Repo unter deploy/opencode/.

Beim Umzug korrigiert:
- Zeds eingebaute Commit-Vorlage entfernt. Sie war in dieselbe Datei gerutscht
  und sagt "Only return the commit message in your response" — in dauerhaft
  aktiven Regeln ein Fehlerherd.
- "Zum Bauen oben auf Coder umschalten" gestrichen: seit der Mannschafts-
  Umstellung laufen plan und build auf demselben Modell. Das war MEINE
  Regression aus Stufe 4, hier faellig geworden.
- Kontext-Waechter auf den Governor umgeschrieben (der kennt den Fuellstand
  wirklich, Zeds Anzeige war Heuristik).
- Neu: Pruef-Tor (VERIFY, inkl. "Test abschwaechen gilt als Betrug"),
  die Subagenten-Mannschaft, der Werkzeug-Zaun und ein Abschnitt fuer
  UNBEAUFSICHTIGTE Laeufe — nachts sagt niemand "los", dort darf der Agent
  nicht auf Freigabe warten.

Bewiesen: in einem leeren Ordner (nur globale Regeln wirksam) antwortet der
Agent wortgenau aus der neuen Datei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:38:08 +02:00
Hitonabi 6c14705798 Ideen: fertige Karten sammelweise wegraeumen
Ampel / ampel (push) Successful in 22s
Einzeln ging es schon (X je Karte). Bei fuenf abgeschlossenen Projekt-Ketten und
18 fertigen Karten waren das aber ~18 Klicks — praktisch raeumt dann niemand auf,
und die drei relevanten Karten gehen in der Liste unter.

Neu:
  POST /api/ideen/archivieren-alle {projekt?}  -> services.archive_done()
  Kopfzeile:      "18 fertige wegraeumen" (alle, auch die in Ketten)
  Je Projekt:     "wegraeumen" neben "N fertig"

Sicherheitseigenschaft, bewusst im Dienst statt in der UI: archive_done fasst
AUSSCHLIESSLICH status=done an — laufende, haengende, wartende und in Ausarbeitung
befindliche Karten nie, auch nicht ohne Projekt-Filter. Ein Sammel-Knopf darf
niemals versehentlich Arbeit abraeumen. Der Dialog sagt ausserdem ehrlich, dass
nichts geloescht wird: es ist Hermes' Kanban-Archiv, die Karten bleiben lesbar.

Getestet ohne Nebenwirkung: Filter auf ein nicht existierendes Projekt liefert
archiviert=0, die 19 Karten der Queue bleiben unangetastet. Das echte Wegraeumen
ist ein User-Klick, kein Deploy-Schritt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:54:35 +02:00
Hitonabi 991c9a2f82 Ideen-Queue bekommt eine eigene Seite
Ampel / ampel (push) Successful in 22s
Die Queue war 703 der 1147 Zeilen in AuftragsbuchView — die MEHRHEIT einer Seite,
die nach etwas anderem benannt ist. Und sie laufen gegenlaeufig: Auftragsbuch =
die Box schlaegt DIR vor (Eingang), Ideen = DU beauftragst die Box (Ausgang).
Seit dem Umbau ist Idee -> Konzept -> Repo -> Zed der Haupteingang des Systems
und gehoert in die Seitenleiste, nicht in einen Unterabschnitt.

Umzug (reines Verschieben, kein Logik-Umbau):
  AuftragsbuchView  1147 -> 406 Zeilen   Bundle 51,0 -> 17,8 KB
  IdeenView         neu, 785 Zeilen      Bundle 32,7 KB, laedt nur bei Bedarf
  fmtWhen           -> lib/format.ts (dessen Kopf genau davor warnt: "vorher in
                      einzelnen Views dupliziert")
  SectionLabel      -> components/ui/ (beide Seiten brauchen sie jetzt)

Zwei inhaltliche Aenderungen:
1. Die flache Einzelkarten-Liste wird gruppiert — mit der Ordnung, die
   ProjektGruppe fuer Ketten LAENGST benutzt (AKTIV_RANG): aktiv zuerst,
   Wartende und Fertige eingeklappt. Bei 19 Karten, davon 16 fertig, gingen die
   drei relevanten in der Erstellzeit-Liste unter.
2. Die Kopfzeile sagt jetzt "1 braucht dich · 2 in Arbeit · 16 fertig" statt
   einer nackten Gesamtzahl.

Das Auftragsbuch zaehlte im gruenen "Nichts zu entscheiden" bisher haengende
Ideen mit, damit es nicht luegt, waehrend direkt darunter eine Karte haengt.
Nach der Trennung wird daraus ein anklickbarer Querverweis auf die Ideen-Seite —
dieselbe Ehrlichkeit, jetzt mit Weg dorthin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:41:10 +02:00
Hitonabi ad49dd65ca Update-Kette repariert: Boot-Warmliste zurueckgenommen + Tool-Smoke mit Retry
Ampel / ampel (push) Successful in 22s
MEINE REGRESSION. Das systemd-Drop-in vom Umbau setzte
MC_WARMUP_MODELS="fast coder kritiker". warmup.sh waermt aber erst ALLE Chat-
Modelle, dann Embeddings und ERST DANN Hermes' ~70-KB-Agenten-Prompt — genau das,
was der Tool-Smoke braucht. Coder (48 GB) + Kritiker davorzuschieben kostete ~35 s
und trieb den Tool-Smoke ueber sein 90-s-Limit: Job faf1137a262c FAILED mit
"Tool-Smoke ohne verwertbares Ergebnis" (leere Antwort = curl-Timeout), obwohl
Agent und Modelle in Ordnung waren. Warm gemessen dauert derselbe Aufruf 12 s.

Zwei Korrekturen:
1. Boot-Warmliste zurueck auf "fast" (Drop-in auf der Box). Lucys Hirn und der
   Agenten-Prompt haben Vorrang; der Coder waermt beim ersten Auftrag selbst
   (23 s einmalig, dann 90 min warm).
2. Tool-Smoke bekommt 3 Versuche + 120 s statt 1 Versuch + 90 s — dieselbe Lehre,
   die der Voice-Smoke zehn Zeilen tiefer laengst umgesetzt hatte ("erster Turn
   zahlt den Session-Kaltstart"). Ein Versuch war ein Fehlalarm-Generator.

Bewiesen: llama-swap kalt neu gestartet, Postcheck sofort danach -> GEHIRN OK,
Tool-Smoke im ersten Versuch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:19:30 +02:00
Hitonabi c28aa4a6a2 README aktualisiert + Kritiker auf 16k gedeckelt (Prefill-Befund)
Ampel / ampel (push) Successful in 22s
README war auf dem Stand "Final-Version eingefroren" und kannte weder Governor,
Coding-Bahn, CI-Ampel noch den Weg einer Idee. Neu geschrieben mit den echten
Ports/Diensten, den gemessenen Modell-Rollen und den vier Qualitaets-Toren.
Alle Verweise gegen den Baum geprueft.

Dabei aufgefallen: das eigene VERDIKT vom 24.07. ("Devstral bricht beim 32K-Prefill
auf 63 t/s ein") widerlegt meine Begruendung "ein Kritiker liest viel und schreibt
wenig, also stoert die Langsamkeit nicht" — es ist genau das LESEN, das einbricht.
Nachgemessen: Prefill 265-318 t/s (Coder 754). Der Kritiker ist deshalb in
llama-swap UND in beiden opencode.json auf 16384 gedeckelt; schlimmster Fall je
Review jetzt unter einer Minute statt 8+ Minuten.

VERDIKTE um zwei Eintraege ergaenzt: die enge Kritiker-Rolle mit Deckel, und die
llama-swap-Gruppenregel (eine Gruppe resident, persistent:false, OOM-Grenze).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 22:04:44 +02:00
Hitonabi 8b76c0f749 Ampel-Rot auf main behoben: ungenutzter Import in curator_archive_logic.py
Ampel / ampel (push) Successful in 22s
Der Merge hat die Ampel zum ERSTEN MAL auf `main` laufen lassen — sie kam am
24.07. auf dem Branch dazu und war nie auf main. Sie hat sofort einen echten
Altfehler aus der autonomen Auftragsbuch-Arbeit gefunden: F401, `os` importiert
aber nirgends benutzt (deploy/curator_archive_logic.py:11).

Nicht von diesem Umbau verursacht — aber jetzt sichtbar, und deshalb behoben.
Alle drei Ampel-Gates lokal gruen: ruff · compileall · Frontend-Build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:51:50 +02:00
Hermes Worker a934deee41 Doku: Qwen-AgentWorld-35B als abgelehnt für Vision-Rolle vermerken (t_9daf088f)
Ampel / ampel (push) Failing after 22s
2026-07-25 21:46:07 +02:00
Hitonabi 543eee95d0 Merge governor-phase0: Umbau Stufe 1-5 (Ampel #3 gruen) 2026-07-25 21:45:56 +02:00
Hitonabi 59d6be8c4a OpenCode-Vorlagen vollstaendig: PC-Seite + README
Ampel / ampel (push) Successful in 22s
Die Box-Seite lag im Repo, die PC-Seite nur auf dem PC — bei Verlust waere die
Haelfte des Setups unrekonstruierbar gewesen. Jetzt liegen beide Vorlagen
nebeneinander, plus ein README mit der Mannschaftsaufstellung (inkl. der auf der
Box gemessenen Tempi) und den vier Fallen, die beim Bauen Zeit gekostet haben:
kein _comment in opencode.json, Plugin muss Windows+Linux koennen, session.idle
ueberlebt `opencode run` nicht, plugin/ vs plugins/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:40:49 +02:00
Hitonabi 7fac17ed9a 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>
2026-07-25 21:39:32 +02:00
Auftragsbuch 00f2b71a63 Auftragsbuch: 'doku/curator-autos' angenommen (Ein-Klick-Gate) 2026-07-25 04:10:55 +02:00
Hitonabi 2cecd06c5b doku: Curator Auto-Archivierung Cron dokumentieren 2026-07-25 03:53:02 +02:00
Hitonabi 775e862652 CI: MC2-Ampel auf sinnvolle Gates zugeschnitten (Lint+Import+Frontend-Build)
Ampel / ampel (push) Successful in 22s
Die universelle Vorlage will pip-install ALLER 5 requirements (inkl. schwerer ML-Deps:
Whisper/TTS) + pytest repo-weit in einem stateless Container — fuer dieses Multi-Service-
ML-Monorepo weder machbar noch aussagekraeftig (echte Tests = Pruefstand/Integration auf
der Box mit Live-Diensten). MC2-Ampel prueft stattdessen, was im Container ehrlich gruen
sein kann und echte Fehler faengt: ruff (Projekt-Politik) + compileall + Frontend-Build
inkl. tsc-Typecheck. Bewusste Abweichung von 'pytest = Pflicht' fuer dieses Repo, begruendet
im Workflow-Kopf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:56:16 +02:00
Hitonabi 47f7a85510 Ruff-Cleanup: ganzes MC2-Repo lint-grün + projekt-passende ruff.toml
Die Ampel-Nachruestung deckte 317/337 vorbestehende ruff-Verstoesse im ganzen Repo
auf. Aufgeraeumt:
- ruff.toml: intentionale Muster als Projekt-Politik ausgenommen (BLE001 blind-except,
  S110/S112 try-except-pass/continue, PLW1510 subprocess-best-effort, B008 FastAPI-
  Depends/File-Idiom, EXE001 Shebang, + wenige Stil-Regeln). __init__.py-Re-Exports
  geschuetzt (F401).
- ruff --fix: 128 mechanische (Import-Sortierung, PEP585/604-Annotationen, tote Imports,
  ueberfluessige noqa) auto-behoben.
- 12 echte Reste von Hand: PERF402/102, PLC3002 (Lambda->walrus), ISC004 (String-Concat
  geklammert), F841/RUF059 (ungenutzte Vars), PIE810 (startswith-Tuple), UP031 (f-string),
  UP035 (veraltete typing-Imports).
Ergebnis: 'ruff check .' = 0, 'compileall' grün. Kein Verhaltenswechsel (nur Stil/Modernisierung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:53:36 +02:00
Hitonabi e6502f676a CI: Ampel fuer MC2 verdrahtet (.gitea/workflows/ampel-ci.yml)
Ampel / ampel (push) Failing after 1m52s
Die universelle Ampel (aus deploy/ampel-ci.yml) war bisher nur Vorlage — MC2 hatte
keine aktive .gitea/workflows/-Datei, Pushes triggerten keinen CI-Lauf. Jetzt
nachgeruestet (offene Karte t_730a747a). Laeuft auf push+pull_request, erkennt
Projekt-Typ selbst (Python/Node) und prueft Ruff+compileall+pytest bzw. npm ci+build+test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:33:30 +02:00
Hitonabi 554c87aaee Governor: driver.py lint-sauber (Ruff gruen)
Imports nach oben (sortiert), Endpoint-Setzen in main() statt Modulebene ->
kein E402/unused-noqa mehr. governor.py war bereits sauber. 'ruff check' = 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:30:10 +02:00
Hitonabi 798de0be8f Governor Phase 0 fertig + Phase 2: Hart-Deckel default + Sprach-Signal an Lucy
Phase 0 abgeschlossen:
- Hart-Deckel jetzt Default AN (Soft+5000), da der weiche Schnitt allein bei
  Weiterarbeit ueber die Grenze eine Fassade erzeugt (P0-Befund). GOV_HARD_CEILING=0
  schaltet ihn aus. gov-ctl reicht Arg 2 nur bei Bedarf durch.
- README: Empfehlung/Doku auf Default-an aktualisiert, Commit-Msg-Restpunkt notiert
  (--no-auto-commits als saubere Option).

Phase 2 (Voice-Hook): beim Feuern (soft/hard) POSTet der Governor best-effort +
gedrosselt (Default 300 s) eine Meldung an Lucys vorhandene Announce-Pipeline
(POST :9001/api/voice/announce, source=governor, priority=normal). Lucy pollt,
dedupliziert und spricht sie via lokales TTS -- KEIN Lucy-Code noetig. E2E bewiesen:
Feuern -> ANNOUNCE status=200 -> Eintrag id 394 source=governor in der Queue.
GOV_ANNOUNCE_URL="" schaltet es ab. announce-test.sh beigelegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:06:10 +02:00
Hitonabi c13cfd2bd0 Governor Phase 0: Token-Waechter-Proxy + Aider Session-Hygiene (bewiesen)
Duenner, zustandsloser Proxy (nur stdlib) zwischen Aider und llama-swap :8080.
Schiebt an einer Token-Schwelle "SAVEPOINT.md finalisieren + stoppen" ein (weich)
bzw. antwortet oberhalb eines optionalen Hart-Deckels selbst. Beweist Session-
Hygiene per hartem Schnitt statt Auto-Compaction -- ohne eine Zeile Lucy-Code.

Alle 4 Akzeptanzkriterien gruen: feuert im Log; SAVEPOINT.md gepflegt; ehrlicher
Grenz-Savepoint am ersten Feuern; frische Sitzung liest Savepoint -> baut Tests,
9 unittest gruen, keine Fassade. CPT 3.5 kalibriert (est ~= echte prompt_tokens).
Hart-Deckel nachgeruestet (weicher Schnitt allein erzeugt Fassade bei Weiterarbeit
ueber die Grenze). Streaming-read1-Fix + 4 kleinere aus adversarialer Review.

Laeuft deployt auf der Box unter ~/governor-p0/ (gov-ctl.sh start <soft> <hart>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:17:36 +02:00
Auftragsbuch 080eaa5ff5 Auftragsbuch: 'doku/pruefstand-coder-backend-24-07' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:13 +02:00
Auftragsbuch 7dd44a3630 Auftragsbuch: 'feature/curator-auto-archiv' angenommen (Ein-Klick-Gate) 2026-07-24 16:16:12 +02:00
Hitonabi 554db10a6f VERDIKTE: Pruefstand 24.07 — Vulkan bleibt (ROCm-Recheck erledigt), Coder-Next bleibt (Devstral 4-8x langsamer), Reliabilitaet=Harness 2026-07-24 14:44:22 +02:00
Hitonabi ce030d6817 feat: Curator Auto-Archivierung (14-Tage-Inaktivität) 2026-07-24 13:46:49 +02:00
Auftragsbuch 8996c0d012 Auftragsbuch: 'wartung/curator-idle-watchdog' angenommen (Ein-Klick-Gate) 2026-07-24 09:58:38 +02:00
Hitonabi a1de1ec2ac feat: Curator Idle Watchdog implementieren
- Skript deploy/curator-idle-watchdog.sh für 14-Tage-Idle-Erkennung
- Spezifikation deploy/specs/curator-idle-watchdog.md
- Wöchentliche Prüfung (Sonntag 00:00), Trigger bei Überschreitung
2026-07-24 08:53:19 +02:00
Hitonabi 8e735df32d Gitea-Weg der Worker: intern statt DDNS + gitea-pr-Helfer + Waechter-Scope-Fix
- werkstatt-SOUL Regel 3: Klonen IMMER ueber http://192.168.178.153:3000
  (DDNS-Domain nachts wegen Zwangstrennung tot -> Worker hielt Gitea am
  24.07. faelschlich fuer kaputt und blockte). Nie SSH-Remotes, nie nach
  Passwoertern fragen.
- deploy/gitea-pr (neu): PR per Gitea-REST-API mit Token aus
  ~/.git-credentials (Vorfall 23.07.: Worker bat um Web-Passwort).
- ampel-waechter: /repos/search statt /user/repos - Token hat nur
  write:repository, /user/repos gab 403 und der stille exit 0 versteckte
  das seit Inbetriebnahme (Merkliste blieb leer, kein Alarm ging je raus).
  API jetzt intern, Telegram-Links bleiben auf der Domain.
- tabu-pfade-guard: Klon-Empfehlung in der Blockmeldung auf interne URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:40:40 +02:00
Auftragsbuch 057cc2bb47 Auftragsbuch: 'wartung/traum-duplikatscheck-v2' angenommen (Ein-Klick-Gate) 2026-07-23 08:28:59 +02:00
Hitonabi e686f95545 Traum: Duplikats-Check für Skill-Kandidaten 2026-07-23 08:03:37 +02:00
Hitonabi 8a90b7507e Ampel-Waechter: Status-Luegen-Wache (Job-Log = Wahrheit)
Alter Runner (0.2.13) meldete Abschluesse nicht -> Gitea zombie-markierte
gruene Jobs als failure. Waechter prueft jetzt vor Alarm das Job-Log auf
"Job succeeded". Runner auf 0.6.1 gehoben (eigentlicher Fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:27:05 +02:00
Hitonabi 59b09e4a9a Watchlist: LM Studio Bionic aufgenommen (Linux-Port-Issue als Anker)
Preview seit 16.07., Win+Mac, gratis, closed source. Keine Custom-
Endpoints (Box :9001 nicht anbindbar), keine CLI/API. Radar meldet,
wenn Issue #2185 (Linux-Port) zugeht; Changelog gegenlesen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 20:03:45 +02:00
Hitonabi 5894c24703 Ampel-Waechter: Telegram bei ROTEN CI-Laeufen (no-agent-Cron, 30 min)
Meldet nur NEUE Fehlschlaege (Merkliste), running zaehlt nicht als
gesehen. Teil 3 der Wasserdicht-Runde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:33:17 +02:00
Hitonabi 3f279fc7f2 Ampel-Vorlage v3: set +e (Runner-bash -e schnitt das Urteil ab)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:19:16 +02:00
Hitonabi af28a75338 Ampel-Vorlage: klare Lockfile-Pruefung statt kryptischem npm-ci-Fehler
Erster rippy-Lauf bewies: fehlendes package-lock.json = EUSAGE-Wand.
Jetzt: verstaendliche rote Meldung mit Fix-Anleitung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:11:31 +02:00
Hitonabi a121ca7dc0 Wasserdicht-Runde: universelle CI-Ampel fuer ALLE Lucy/MC2-Repos
- deploy/ampel-ci.yml: sprachneutrale Actions-Ampel (erkennt Python/Node
  selbst; Doku-Repo=gruen, Code ohne Tests=ROT)
- gitea-repo-create.sh: pflanzt die Ampel bei JEDER Repo-Anlage ein
  (Contents-API, Push-Token, defensiv)
- projektstart-SOUL: drei harte AGENTS-Regeln (Ampel-Pflicht,
  Spec-Abweichung=STOPP, Deploy nur via deploy.sh) — rippy-Lehren

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:59:50 +02:00
Hitonabi cce32810fc Watchlist: Electron-Notiz korrigiert (Fehlalarm-Wurzel)
Der '33'-Stand in der warum-Prosa war veraltet (real: 43.2.0 stable)
und liess den Rundumschlag ein 11-Major-Loch alarmieren. Notiz sagt
jetzt: Versionsstand immer live pruefen, nie der Notiz glauben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:35:42 +02:00
Hitonabi 682be9b63b VERDIKTE: Hirn auf DFlash (live 22.07.), DFlash-je-Rolle-Messung
hermes: DFlash schlaegt MTP (86-90 vs 81-83 t/s, live 89-94).
coder: DFlash verliert trotz guter Akzeptanz. heavy: wartet auf
Engine-Support (Tensor-Mismatch b10087). vision/scout: keine Drafts.
Inkl. Eigenbau-Rezept + Quantize-Falle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:24:37 +02:00
Hitonabi 96c4e417ec Rundumschlag-Prompt: 100%-lokal-Praemisse hart verankern
Probelauf empfahl GLM-5.2 per Cloud-API — widerspricht dem
Abloesungs-Zielbild. Regel 6: keine API-Empfehlungen, >122 GB
Unified = kein Kandidat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 18:05:56 +02:00
Hitonabi 90ab873924 Stack-Rundumschlag: monatlicher freier Rundumblick (Cron am 4., 06:40)
Ergaenzt die mechanischen Waechter um tagesaktuelle Web-Recherche —
Lehrbeispiel Gemma-4-Stealth-Update 15./16.07. ohne Versionssprung,
das kein Radar fing. Feed sammelt IST-Stand (Rollen, Engine, Puls,
Watch, Pruefstand, Ressourcen, Fehlerbild), Agent recherchiert und
empfiehlt max. 3 Schritte; umgesetzt wird per Karten-Klick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:35:26 +02:00
Hitonabi 6d3a0bf3a6 IDE-Skill 'projekt-uebernehmen': vorbereitetes Projekt in Zed starten
Der Weg von der Box in die IDE endete bisher am Repo: der Commander klont es,
oeffnet Zed/OpenCode — und der Agent weiss nicht, dass hier bereits ein
gehaertetes Konzept liegt. Ohne Anleitung plant er entweder neu (projekt-start
springt an) oder fragt ins Blaue.

- client/ide-skills/projekt-uebernehmen/SKILL.md (Quelle im Repo, installiert
  nach ~/.agents/skills/ — dort liegen die uebrigen PC-Skills, OpenCode 1.18
  laedt von dort). Ablauf: einlesen (Konzept/Roadmap/Savepoint + git-Stand) ->
  3-6 blockierende Fragen MIT Empfehlung -> Etappe-1-Plan -> "los" -> bauen,
  Savepoints, live testen. Zwei Abbruch-Faelle: schon Code da (dann Fortsetzung
  nach SAVEPOINT) und nur KONZEPT.md ohne ROADMAP (das ist ein geprueftes
  Ideen-Repo -> Hinweis auf "Gefaellt mir - Projekt daraus machen").
  Modus-Wechsel ist Teil des Skills: lesen/fragen im Planer, bauen im Coder.
- deploy/projektstart-SOUL.md: die von der Box erzeugte AGENTS.md nennt den
  Skill jetzt woertlich. AGENTS.md wird automatisch als Kontext geladen — das
  ist der zuverlaessige Kanal; auf spontane Skill-Wahl ist bei den lokalen
  Modellen kein Verlass (nachgemessen: heavy und coder greifen bei einem vagen
  "ich moechte hier loslegen" NICHT von selbst zum Skill).
- ~/.agents/skills/projekt-start/SKILL.md (ausserhalb des Repos) bekam eine
  Abgrenzungszeile: nicht nutzen, wenn KONZEPT.md + ROADMAP.md schon liegen.
  Sonst plant er ein vorbereitetes Projekt neu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:41:59 +02:00
Hitonabi c3fe6dbe8d No-Progress-Bremse: Schauen ist keine Schleife + Wiederaufnahme in alle SOULs
‼️ Die Bremse hat den Schaden angerichtet, den sie verhindern soll. Sie signiert
den ERGEBNISTEXT von terminal-Aufrufen; die Bestandsaufnahme in Schritt 0 besteht
aber aus vielen kurzen Schau-Befehlen (ls, cat, git log), deren Ausgaben sich stark
aehneln. Nach dreien blockte sie den naechsten Blick — ausgerechnet `ls /tmp/konzept-*`.
Der Worker las die Block-Meldung als "Verzeichnis existiert nicht", schloss daraus,
das fertige Konzept sei nicht verifizierbar, und warf eine halbe Stunde Arbeit weg
(Karte t_d26c3203, Log: "kein /tmp/konzept-* Verzeichnis gefunden (wegen
No-Progress-Bremse)"). Die Bremse verlangt in ihrem eigenen Text "Diagnose statt
Variation" — und verhinderte genau die.

- _nur_lesend(): rein lesende Befehle (ls/cat/head/find/grep/git status|log|diff|
  ls-remote …, keine Umleitung) werden weder gezaehlt noch geblockt. Strukturell
  nach Befehls-ART, nicht per Fehlertext-Liste — die Bremse bleibt generisch.
- Block-Meldung sagt jetzt ausdruecklich: "Dein Befehl wurde NICHT ausgefuehrt, das
  ist eine Bremse, KEIN Ergebnis — schliesse daraus nichts ueber Existenz oder
  Zustand." Ohne diesen Satz liest ein Agent den Block als Befund.
- Schritt 0 (Wiederaufnahme) + FORTSCHRITT.md jetzt auch in werkstatt-SOUL
  (Klon/Branch pruefen: liegt der Branch schon auf Gitea -> verifizieren und
  abschliessen statt neu bauen) und betrieb-SOUL (vorhandene Messwerte NICHT neu
  messen — ein wiederholter Bench laedt 70-GB-Modelle und gefaehrdet Lucys Warm-Set).
- projektstart: FORTSCHRITT.md gehoert ins Workspace-Wurzelverzeichnis, nicht in den
  Repo-Klon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:37:17 +02:00
Hitonabi 584f257853 SOUL: Wiederaufnahme statt Neuanfang nach Kontext-Tod
Live-Fall 21.07. (Karte t_d26c3203 "Rippy"): Lauf 1 war praktisch fertig —
Fliessband komplett (Recherche, 3 Rollen x 2 Runden, Haertetest, konzept.md
14,7 KB), Repo Hitonabi/rippy angelegt und gepusht, KONZEPT.md im Klon
geschrieben. Dann Kontext voll, Exit ohne kanban_complete -> "protocol
violation". Es fehlten git add/commit/push und die Uebergabe: zwei Minuten.

Lauf 2 fing komplett von vorne an — neuer Rollen-Cast, neue Kaskade, neuer
/tmp/konzept-*-Ordner —, obwohl Hermes den Abschnitt "Prior attempts on this
task" in den Auftrag schreibt UND der Workspace derselbe ist. Die SOUL hatte
dafuer keinen Platz: sie begann mit "Der Ablauf (genau diese vier Schritte)".

- Neuer Schritt 0 WIEDERAUFNAHME: bei "Prior attempts" erst inventarisieren
  (FORTSCHRITT.md, Workspace-Repo inkl. `git status` — uncommittete Dateien
  sind fertige Arbeit —, /tmp/konzept-*, `git ls-remote`), dann an der ERSTEN
  LUECKE ansetzen. Ein zweiter kompletter Denk-Durchlauf gilt als Fehlschlag.
- Neuer Abschnitt "Fortschritt hinterlassen": eine Zeile je Stufe in
  FORTSCHRITT.md im Workspace, SOFORT geschrieben, dazu als Heartbeat-Notiz.
  Das unterscheidet einen Kontext-Tod von einem Totalverlust.
- Schritt 2: den vom Vorgaenger vergebenen Repo-Namen weiterbenutzen, sonst
  entsteht fuer dieselbe Idee ein zweites Repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:27:41 +02:00
Hitonabi 73513f5e5b SOUL: Push-Beleg bleibt, falsche Anekdote raus
Die Begruendung zur ls-remote-Regel war eine Fehlannahme von mir: das Repo
`Hitonabi/arm-ui` fehlte nicht wegen eines verpufften Pushes, sondern weil der
Commander es selbst geloescht hat (Konzept ging in die falsche Richtung). Der
Push war echt.

Die Regel selbst bleibt — der Worker hatte seine eigene Pruefung tatsaechlich
uebersprungen und dabei gegen die No-Progress-Bremse argumentiert. Statt der
erfundenen Folge steht jetzt der eigentliche Grund da: nicht wegargumentieren,
der Commander soll "Repo liegt bereit" ohne Nachpruefen glauben koennen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:21:37 +02:00
Hitonabi 18f5522612 Konzept-Raterei enger: keine fremden Konzepte an laufende Karten haengen
Der Datei-Fallback darf den Dateinamen ueber den Kartentitel raten. Live gesehen:
eine gerade LAUFENDE neue ARM-Karte zeigte das alte arm-ui-Konzept, als waere es
ihr Ergebnis — ein einzelnes gemeinsames Wort ("arm") reichte.

- Titel-Raterei nur noch bei FERTIGEN Karten; laeuft eine noch, zaehlt allein ein
  im Text genannter Pfad.
- Und sie braucht jetzt zwei gemeinsame Woerter oder ein langes (>=6 Zeichen),
  damit nicht irgendeine Datei auf irgendeine Karte passt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:08:46 +02:00
Hitonabi 0cea01c699 Konzept nachschaerfen: Rueckmeldung statt nur annehmen-oder-wegwerfen
Nach dem Lesen eines Konzepts gab es bisher nur zwei Wege: "Gefaellt mir" oder
archivieren. Der haeufige Fall fehlte komplett — EIN Punkt passt nicht und der
Commander hat einen besseren Vorschlag.

- POST /api/ideen/nachschaerfen -> konzept_ueberarbeiten(): legt eine neue
  Idee-Karte an (kein Bau), verkettet an die Quell-Karte, mit dem Wortlaut der
  Rueckmeldung. Auftrag: bestehendes Konzept lesen, genannte Punkte UND deren
  Folgewirkungen aendern, Rest stehen lassen, kurzer Haertetest nur auf die
  geaenderten Teile. Schafft der Vorschlag ein Problem: einbauen UND die Folge
  sichtbar in die Risiken schreiben — nicht uebergehen, nicht schoenreden.
  Die neue Fassung geht an DIESELBE Stelle (Repo-Commit bzw. Datei + .bak), so
  zeigt "Konzept" ueberall den frischen Stand. Beliebig oft wiederholbar, weil
  die Ueberarbeitung selbst wieder eine Idee-Karte ist.
- UI: Rueckmeldefeld direkt unter dem gelesenen Konzept, ueber dem
  Gefaellt-mir-Weg (der haeufigere Fall gehoert nach oben und offen sichtbar).
- SOUL: neuer Sonderfall UEBERARBEITUNG (Schritt 2 entfaellt, kein zweites Repo).

Zwei Fehler nebenbei gefunden und behoben:
- _wo_liegt(): der Auftrag verwechselte "Repo bekannt" mit "Konzept kommt aus
  dem Repo". Faellt MC2 auf die lokale Datei zurueck, stand vorher "liegt im
  Repo als <lokaler Dateiname>" im Auftrag — eine Datei, die es dort nie gab.
- SOUL-Regel 4: der Push gilt erst als erledigt, wenn `git ls-remote` ihn
  BEWEIST. Ein Worker hat am 21.07. genau hier abgekuerzt, Erfolg gemeldet, und
  das Repo war nachher nicht auffindbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:05:34 +02:00
Hitonabi 94dc3fba9b Karten-Titel: kurz und an der Wortgrenze statt abgeschnittener Textwueste
Der Commander schreibt seine Ideen roh — ein ganzer Absatz wurde 1:1 zum
Kartentitel und war danach ueberall abgeschnitten ("... und lass uns ueberlegen
wie"). Drei Stellen:

- add_idea(): Zurufe ueber 90 Zeichen bekommen einen kurzen Anzeige-Titel
  (erster Satz, sonst Schnitt an der Wortgrenze). Der VOLLE Wortlaut wandert in
  den Auftrag, mit Kopfzeile "der Kartentitel ist nur eine Kurzfassung" — sonst
  arbeitet der Worker am abgeschnittenen Satz.
- _projekt_titel(): der Gruppenname nahm stur die aelteste Karte und schnitt sie
  bei 80 Zeichen mitten im Wort ab. Jetzt gewinnt bei fehlendem gemeinsamen
  Praefix der kuerzeste Mitglieds-Titel (die aus einem Konzept abgeleiteten
  Karten sind die gepflegten) — Praefixe wie "Idee:" fallen weg, Schnitt an der
  Wortgrenze. Aus der ARM-Kette wird so "ARM-UI — Modernes Dashboard fuer
  Automatic Ripping Machine" statt des halben Zurufs.
- UI: Tooltip der Zeile zeigt den vollen TITEL statt des Auftragstexts (der
  beginnt mit Vorspann-Bausteinen und half beim Lesen nie).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:46:40 +02:00
Hitonabi 3cdd5cdd10 Auftragsbuch: Konzepte sichtbar, Modi unterscheidbar, Ideen weiterfuehrbar
Drei Luecken, die die neue Idee-Bahn unbrauchbar machten (User-Fund 21.07.):

1. Konzept unsichtbar. Der projektstart-Worker folgte dem Ende des
   konzept-fliessband-Skills (Kopie nach ~/konzepte + Telegram) statt seiner
   SOUL (Repo + KONZEPT.md) und schloss die Karte ab. Die Konzept-Ansicht sucht
   nur in Gitea -> "noch kein Repo hinterlegt", obwohl das Konzept fertig war
   (zweimal passiert: arm-ui, pomodoro-timer). konzept_of() faellt jetzt auf
   ~/konzepte/*.md zurueck (Pfad aus der Abschlussmeldung, sonst ueber den Titel
   geraten; streng auf dieses Verzeichnis begrenzt) und nennt die Quelle.
   Zusaetzlich sagen SOUL und Skill jetzt ausdruecklich, dass die Skill-Ablage
   fuer Kanban-Worker NICHT das Ende ist.

2. Modus nicht erkennbar. Idee und IDE-Projekt trugen dasselbe "IDE"-Abzeichen,
   Box-Karten gar keins. list_queue() liefert jetzt `art` (aus dem Body-Marker
   AUFTRAGS-ART: IDEE PRUEFEN), die UI zeigt BOX / IDEE / IDE mit Erklaerung —
   plus eine immer sichtbare Zeile, wann man welchen Modus nimmt.

3. Idee war eine Sackgasse. Neu: POST /api/ideen/weiterfuehren legt aus einer
   fertig durchdachten Idee eine IDE-Projekt-Karte an — verkettet an die Idee,
   mit dem fertigen Konzept als Vorlage ("denk es NICHT neu"), Titel aus der
   Konzept-Ueberschrift statt aus dem Rohtext. Liegt das Konzept schon in einem
   Repo, wird genau dieses weitergenutzt statt ein zweites anzulegen.
   In der UI sitzt der Knopf unter dem gelesenen Konzept (Ziel + Aufwand).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:40:16 +02:00
Auftragsbuch 9307c8b752 Auftragsbuch: 'doku/orchestrator-skill' angenommen (Ein-Klick-Gate) 2026-07-21 08:03:33 +02:00
Auftragsbuch 204238925a Auftragsbuch: 'doku/gitea-skill-doku' angenommen (Ein-Klick-Gate) 2026-07-21 08:03:25 +02:00
Hitonabi a16cff39ce Doku: orchestrator Skill dokumentieren 2026-07-21 04:05:11 +02:00
Hitonabi 11c3416571 Doku: gitea-workflow Skill dokumentieren 2026-07-21 03:53:59 +02:00
Hitonabi 7389386791 Vierter Modus 'Idee': Produkt-Idee durchdenken statt Projekt starten
Fuer echte Software-Ideen (z.B. Game-Server-Management), die weder MC2/Lucy-
Umbau noch schon ein Zed-Projekt sind. Volle Denk-Kaskade mit PRODUKT-Blick
(was, fuer wen, was gibt es schon, kleinster Wurf, was spricht dagegen) statt
Umsetzungs-Blick. Ergebnis: NUR KONZEPT.md — keine Roadmap, kein Geruest, keine
Bau-Aufforderung; der Commander entscheidet danach. Kein Ziel-Schalter (eine
Idee braucht noch keine Plattform). Eigene Farbe (sky), Button 'Durchdenken'.
Nebenbei: projektstart-SOUL.md ist jetzt versioniert + deploy-fest (lag bisher
nur auf der Box).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:57:06 +02:00
Hitonabi 40c8ed4754 Docker-Ziel bekommt portablen Kontext statt Heimlabor-Steckbrief
Docker heisst bei diesem Commander: bewusst raus aus der LXC-Welt, portabel/
extern. Der Proxmox-/pve-/PBS-Kontext haette das Konzept nur in die falsche
Richtung geschubst. LXC-Pfad (und die Kein-Ziel-Wahl = seine Standard-Welt)
behalten den vollen Heimlabor-Steckbrief.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:45:08 +02:00
Hitonabi 656c5045da Infrastruktur-Kontext: LXC-Bestand nicht hartkodieren, nachschlagen lassen
Die fest eingetragene Container-Liste veraltet (106 homepage war schon weg) und
kostet im Steckbrief bei JEDEM Worker-Turn Kontext. Jetzt nur noch das Prinzip:
AI-Box = Werkbank, pve = separates Ziel-System — Bestand bei Bedarf per
'ssh pve pct list' nachsehen statt annehmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:43:09 +02:00
Hitonabi 828dbe13b7 Infrastruktur: AI-Box vs Proxmox-Host klar trennen (Blindfleck)
Die IDE-Planer kannten den Proxmox-Host gar nicht — der Worker laeuft AUF der
AI-Box und nahm an, 'hier' sei auch das Ziel (User musste das muehsam erklaeren).
Steckbrief + IDE-Auftrag nennen jetzt beide Maschinen explizit: AI-Box
192.168.178.151 = Werkbank (nie Deployment-Ziel), Proxmox 'pve' 192.168.178.108
= separates Ziel-System mit den LXCs (100 adguard, 101 npmplus, 102 netbird,
103 pve-scripts-local, 104 gitea/.153, 105 PBS), per 'ssh pve' erreichbar, pct.
Live verifiziert (pct list, gitea-IP).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:39:59 +02:00
Hitonabi 82a3bc3c59 Ziel Docker = NATIVES Docker (keine LXC-Nesting-Konstrukte)
Korrektur: Docker-Wahl bedeutet normales, portables Docker nach Standard-Praxis
(Dockerfile, compose, ueber Gitea-/GitLab-Pipelines baubar) — nicht 'compose im
LXC mit Nesting'. Genau dann geht der Commander bewusst aus der LXC-Welt raus.
Infrastruktur-Steckbrief bleibt Kontext (lokal, kein Cloud/Bezahldienst), zwingt
aber nicht mehr LXC-Denke auf den Docker-Pfad.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:23:16 +02:00
Hitonabi d5da853db2 IDE-Intake: Ziel-Plattform waehlbar (LXC oder Docker) + Infrastruktur-Steckbrief
Zweiter, dezenter Schalter (nur bei IDE): Ziel = LXC (Standard) oder Docker.
Die Wahl fliesst als 'ZIEL-PLATTFORM'-Zeile in den Auftrag, dazu immer ein
Infrastruktur-Steckbrief (Proxmox/LXC, PBS-Backups, systemd, self-hosted Gitea,
alles lokal) — sonst plant jedes Modell nach Industrie-Default Docker/Cloud.
Skill: Ziel + Infrastruktur sind bindend; traegt die Wahl nicht, muss das
Konzept es ausdruecklich sagen statt sie still zu ignorieren.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:18:20 +02:00
Hitonabi 7b9752261a Konzept-Ansicht: Repo-URL auch in Markdown-Backticks erkennen
Der Worker schreibt die Repo-URL in der Abschluss-Zusammenfassung als
`https://...` — die Lookahead-Liste der Regex kannte den Backtick nicht,
also fand die Konzept-Ansicht kein Repo ('wird gerade vorbereitet') obwohl
Repo + KONZEPT.md laengst da waren. Fix: greedy matchen, Treffer hinterher
saeubern (Backticks/Satzzeichen/.git) statt das Endezeichen zu raten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:14:45 +02:00
Hitonabi f77c09295a Auftragsbuch: Modus-Knoepfe farblich trennen (Box=teal, simpel=violett, gruendlich=amber)
Simpel und gruendlich waren beide violett. Jetzt drei klar unterscheidbare
Farben; der Vorbereiten-Button und die Erklaerung faerben sich mit dem
gewaehlten Modus (amber = intensiver/laenger).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:10:37 +02:00
Hitonabi cfcb5d2db5 IDE-Intake: Tiefe-Wahl (simpel/gruendlich) statt einem IDE-Knopf
Drei Modus-Knoepfe: Box · IDE: simpel · IDE: gruendlich. Die Wahl fliesst als
'AUFWAND (vom Commander gewaehlt): SIMPEL/GRUENDLICH'-Zeile in den Auftrag und
UEBERSTIMMT die Groessen-Selbsteinschaetzung von konzept-fliessband (simpel=2
Rollen/1 Runde, gruendlich=volle Kaskade). Skill: bei echter Ambition zur vollen
Tiefe (nicht 'im Zweifel kleiner'), explizite Commander-Wahl schlaegt Schaetzung.
Auto-Skalierung bleibt Fallback fuer Telegram/Lucy (ohne Knopf).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:04:19 +02:00
Hitonabi efca31c21f gitea-repo-create: Anlage-Token im ECHTEN User-Home finden (getent)
Worker-Subprozesse laufen mit abweichendem $HOME → das Skript fand das
write:user-Anlage-Token nicht und fiel aufs Push-Token (write:repository)
zurueck → 'Token hat fehlenden Scope', IDE-Vorbereiter blockte am Repo-Anlegen
(Pomodoro-Testlauf). Fix: echten Home aus /etc/passwd aufloesen (getent),
unabhaengig von $HOME.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:50:49 +02:00
Hitonabi a57e971607 konzept-fliessband: Aufwand zur Projektgroesse skalieren + Profil-Sync
Mini-Projekte bekamen die volle Kaskade (3-5 Rollen x bis 3 Runden = ~15-20
heavy-Aufrufe a ~90s → 35 min fuer einen Pomodoro-Timer). Neu: Rollen+Runden
skalieren mit Groesse (klein=2 Rollen/1 Runde, gross=volle Kaskade), frueher
raus bei WASSERDICHT. Deploy spiegelt den Skill jetzt auch in Profile, die ihn
haben (projektstart). Ergaenzend (Box-Config): projektstart-Manager coder->hermes,
damit heavy warm bleibt (coder 46G verdraengte heavy 60G bei jedem Aufruf;
hermes 22G persistent koexistiert mit heavy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:45:10 +02:00
Hitonabi bf9c22ab78 Auftragsbuch: Konzept-Ansicht fuer IDE-Projekte (KONZEPT.md inline)
IDE-Vorbereiten-Karten bekommen einen 'Konzept'-Knopf: MC2 holt KONZEPT.md
aus dem vorbereiteten Gitea-Repo (Gitea-Raw-API + Auth aus git-credentials)
und rendert es direkt im Auftragsbuch (leichter MD-Renderer lib/markdown.tsx).
So liest der Commander das ausgearbeitete Konzept in der GUI, ohne das Repo
aufzumachen (User-Wunsch: 'lesen gehoert in MC2, feilen in die IDE').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 16:02:12 +02:00
Hitonabi ff8125d2ac No-Progress-Bremse: worker.sh/fremdblick.sh ausnehmen (Fliessband-Motor)
konzept-fliessband/orchestrator rufen worker.sh pro Runde/Rolle wiederholt auf
(verschiedene Prompts, aehnliche Prosa) — die Bremse hielt das faelschlich fuer
eine Schleife und blockte (Pomodoro-IDE-Test lief 19 min dagegen an). Diese
Delegations-Helfer haben ihr eigenes Runden-Limit → von der Bremse ausnehmen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:47:45 +02:00
Hitonabi 4bb6b28297 Auftragsbuch: Intake-Toggle kuerzer beschriftet (Modus / Box / IDE)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:38:39 +02:00
Hitonabi e3b3725b6f Hermes-Runtime-Patch-Traeger + Fan-out-/Kontext-Fixes
Neuer update-fester Patch-Traeger (deploy/hermes-patches/apply.py) haelt
MC2-eigene Fixes im Hermes-Quellcode, idempotent, in deploy.sh + postcheck
verdrahtet (Exit 10 = Gateway-Neustart, Exit 1 = rot):
 0001 needs_input/capability warten auf Commander (keine triage-Eskalation
      -> keine Muenz-Schleife)
 0002 Decompose erst bei Startreife (Parents done)
 0003 Orphan-Guard: Reaper killt Worker, deren Task nicht mehr running
      (mc2_kanban_reaper.py) -> max_in_progress leckt nicht
 0004 Projekt-/Repo-Kontext vererbt sich auf Kind-Karten
 0005 Etappen-Auto-Kette: Bulk-Root-triage-Karten einer Session verketten
Steckbrief: 'Repo fehlt -> selbst anlegen' + verschaerfte Kontext-Regel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 15:22:07 +02:00
Hitonabi 77ae504b58 IDE-Welt: Vorbereiten-Bahn — Idee → Konzept + leeres Repo + Doku, Bau in Zed
Neue Intake-Weiche Box/IDE. IDE-Ideen gehen direkt an das neue Profil
'projektstart' (am Triage/Auto-Zerleger vorbei, kein Werkstatt-Bau): es
haertet via konzept-fliessband ein Konzept, legt ein leeres Gitea-Repo an
und fuellt es NUR mit Doku (Konzept/Roadmap/AGENTS.md/.aiexclude). Ergebnis
ist ein vorbereitetes Projekt, das der Commander in Zed selbst baut.
UI: Toggle 'Box baut / Ich bau in Zed', IDE-Badge auf Karten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:17:43 +02:00
Hitonabi 8153d14d9f Queue-Steuerung: Stopp/Neuer-Versuch/Prio + Haenger-Warnung (UI+Telegram)
Karten lassen sich gezielt anhalten (generischer Block + Prozessbaum-Kill),
frisch starten (Retry beendet Worker samt Kindern - uvicorn-Falle) und per
Prio-Pfeilen umsortieren (Dispatcher zieht priority DESC nativ). Laufende
Karten ohne Heartbeat >10 min bekommen ein Warn-Badge; der Ketten-Digest
alarmiert nach 15 min per Telegram, inkl. Kontext-Verdichtungs-Hinweis.
Ausloeser: Scaffold-Worker hing 45 min an selbst gestartetem uvicorn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:26:09 +02:00
Hitonabi a91d70d9b7 Auftragsbuch: Ketten-Sicht (Projekt-Gruppen) + Telegram-Ketten-Digest
Queue-UI gruppiert verkettete Karten zu Projekten: Fortschritt, laufende
Karte mit Heartbeat-Notiz, 'gleich dran', Rest eingeklappt mit 'wartet auf'.
Backend reichert die CLI-Sicht read-only aus kanban.db an (task_links +
Heartbeats). Neuer Digest-Waechter meldet per Telegram: Fragen sofort,
Projekt-Abschluss sofort, sonst 60-min-Puls (User-Wahl 20.07.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:07:15 +02:00
Auftragsbuch eb1f333bba Auftragsbuch: 'wartung/gedaechtnis-check-timer-dokumentation' angenommen (Ein-Klick-Gate) 2026-07-20 04:10:55 +02:00
Hermes-Agent 24e2017d5b Doku: Gedaechtnis-Check-Timer in Automatik-Fahrplan aufnehmen 2026-07-20 03:54:55 +02:00
Hitonabi 2383dd26fa Uebergabe: Abnahme-Ergebnis des Selbst-Audits (t_4dd12efb, 6/6 PASS)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:56:44 +02:00
Hitonabi aebe10306d Uebergabe-Dokument: Drei-Welten-Umbau 19.07. (finaler Claude-Stand)
Die Box-lesbare Kapsel des kompletten Umbaus: Architektur, neue Mechanik,
Verdikte, IDE-Pfade, offene Faeden. Claude-Gedaechtnis/Artifacts werden mit
Abo-Ende unlesbar - alles Betriebsrelevante steht ab jetzt HIER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:44:30 +02:00
Hitonabi 6b03a2c3cf Gedaechtnis-Bereiche: alltag/projekt-Feld statt zweitem mem0
- classify_facts ordnet neue Fakten zusaetzlich einem Bereich zu (alltag=Person,
  projekt=Technik); Metadatum bereich, in /memory-Items sichtbar.
- GET /memory?bereich=... filtert; Alt-Fakten ohne Feld laufen IMMER mit.
- docs/wissen/GEDAECHTNIS-BEREICHE.md: Entscheid (kein zweites mem0), Stand,
  4-Wochen-Selbstpruefung und kompletter Bauauftrag fuer den Recall-Filter -
  damit die Werkstatt das ohne externe Hilfe fertigbauen kann.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:38:12 +02:00
Hitonabi a1d7a4b6b9 mem0-Konsolidierung: monatlicher Lauf (3. des Monats)
Legt semantisch gleiche Fakten zusammen und loest Widersprueche (neuester gewinnt)
ueber /graph-Cluster + fast-Hirn (NoThink). Harte Leitplanken: pro Gruppe ueberlebt
immer ein Fakt, max 1 Update, LLM-Fehler = Gruppe unangetastet, Kappung pro Lauf.
Erster Live-Lauf 19.07.: 459 -> 420 Fakten (15 zusammengefasst, 39 geloescht).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:32:52 +02:00
Hitonabi 903f3bc074 Phase 2 Drei-Welten-Plan: No-Progress-Bremse + Betrieb-Playbook + mem0-Lern-Buendelung
- no-progress-bremse.py: generische, turn-uebergreifende Schleifenbremse (pre/post_tool_call);
  Muster-Hash statt hartkodierter Fehler-Strings, Selbst-Lernen via neue-signaturen.jsonl.
  Schliesst die Luecke der nativen guardrails (pro Turn, nur klassifizierte Fehler).
- ensure-profile-hooks.py: kann Eintraege in BESTEHENDE Event-Bloecke einfuegen
  (zweiter pre_tool_call-Key haette den Tabu-Guard lautlos verschluckt) + registriert Bremse.
- betrieb-playbook-Skill: SSH-/Deploy-Diagnose-Checklisten (Passphrase-Falle zuerst),
  Triage-Regel im Steckbrief-Hook; Skill wird in alle Profile gesynct.
- mc2-memory: Lern-Extraktion gebuendelt (4 Turns oder 180 s idle = EIN Lauf) statt pro Turn -
  Extraktion konkurrierte mit Lucys Hirn um die Slots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 14:21:18 +02:00
Hitonabi 331aafa25d gitea-repo-create: dediziertes Anlage-Token (Push-Token bleibt unberuehrt)
Sicherer als .git-credentials zu ueberschreiben: Anlage-Token (write:user) aus
$GITEA_CREATE_TOKEN oder ~/.config/gitea/create-token; Push/PR-Token in
~/.git-credentials bleibt unangetastet (kein Bruch, falls das neue Token kein
write:repository hat). User/Host nicht-geheim mit Defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:43:31 +02:00
Hitonabi cab748f32e Lucy kann Projekt-Repos anlegen (gitea-repo-create.sh) - PRIVAT-only
Lucke im "Projekte selbst starten"-Weg: Werkstatt konnte nur Branches in
bestehende Repos proposen, kein neues Projekt-Repo anlegen. Neues Skript:
POST /api/v1/user/repos, IMMER privat (Gitea ist oeffentlich erreichbar!),
auto_init + default main, Token aus ~/.git-credentials (nie ausgegeben),
Audit-Log + stiller Chronik-Spiegel je Anlage.
Braucht Token-Scope write:user (aktuelles Box-Token hat nur write:repository);
ohne das meldet das Skript GENAU, welches Token in Gitea zu generieren ist,
statt kryptisch zu scheitern. Token-Upgrade = Commander-Handgriff (Credential).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:29:40 +02:00
Auftragsbuch d91bd5a139 Auftragsbuch: 'wartung/gitea-disable-registration' angenommen (Ein-Klick-Gate) 2026-07-17 17:30:42 +02:00
Hitonabi f21fe90090 feat(gitea): DISABLE_REGISTRATION=true & Security-Memo draft 2026-07-17 17:24:53 +02:00
Hitonabi 7b826f66c4 Venv-Audit: grep -c Zaehl-Bug (doppelte Zeile -> Rechenfehler)
Erstlauf-Befund: "grep -c ... || echo 0" haengte bei 0 Treffern eine zweite
Zeile an (grep -c druckt selbst schon "0") -> "integer expected" + kaputte
Summe. || echo 0 raus, Ergebnis auf Ziffern reduziert. Fund selbst war echt:
1 CVE in mem0, 27 Pakete veraltet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:03:10 +02:00
Hitonabi e556dcc853 Venv-Audit: uv-venvs haben kein pip - Inspektion via Audit-venv pip --path
Erstlauf-Befund: mem0-/voice-venvs sind uv-erstellt (kein bin/pip, kein
pip-Modul). Das Audit-venv prueft deren site-packages jetzt von aussen
(pip list --outdated/--format=freeze --path) - Dienst-venvs bleiben unberuehrt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:59:24 +02:00
Hitonabi b26fb40cbc Restore-Probe: --ignore-ownership (Erstlauf-Befund) + Tarball-Grep nur Dateien
Erstlauf fand echten Stolperstein: pxar-Restore extrahiert als User korrekt,
scheitert aber am chown root-eigener Dateien (llamaswap) -> Fehl-Exit trotz
intakter Daten. Restore-Aufrufe jetzt mit --ignore-ownership/--ignore-acls
(uns interessiert der INHALT); llamaswap-Fallback prueft den Inhalt statt des
Exit-Codes. Kern-Datei-Grep matcht nur noch Dateien, keine Verzeichnisse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:55:28 +02:00
Hitonabi bc60c6fefc Betriebs-Luecken: Restore-Probe, Venv-Audit, Disk-Waechter, PC-Watch
Scope-Entscheid 17.07.: MC2 bleibt Box-Zentrale (KEINE Homelab-Zentrale) -
Infra-Radar (Proxmox/PBS/QNAP/Gitea) geht als eigenes Projekt an Lucys Queue.
- restore-probe.sh (Cron 1. d. M. 06:40, no-agent): stellt WIRKLICH wieder her -
  PBS-Canary aus hermes.pxar (selektiv via --pattern, sudo-frei ueber die
  User-Unit-Zugaenge) + Tarball-Probe + Frische-Check <36h; Erstlauf-Fallbacks
  (llamaswap.pxar / Kern-Datei) bis der Canary in den Sicherungen ankommt.
- venv-audit.sh (Cron 2. d. M. 06:40, no-agent): pip outdated + pip-audit-CVEs
  fuer mem0-/voice-venv ueber eigenes Audit-venv; Funde -> Kanban-Karte je Monat,
  Update bleibt Commander-Entscheid.
- self-smoke: Disk-Waechter (Check 7, Schwelle 85%, nennt die groessten Brocken).
- Trend-Radar-Watchlist: neue Typen github_release_major + pypi; Eintraege
  electron-major (PC-Shell auf 33!) und pocket-tts (PyPI-Versionswatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:50:48 +02:00
Hitonabi de21e1ac68 Pruefstand+Radar: MTP-Fairness, Tempo-Baseline-Refresh, Puls-Langzeit-Historie
Drei Luecken aus der Selbst-Review 17.07.:
1. MTP-Fairness: Kandidaten mit MTP-Kopf (Dateiname oder nextn-Tensoren im
   GGUF-Kopf) bekommen eine Zusatz-Tempomessung mit draft-mtp; Steckbrief
   rechnet best-of speed/speed_mtp - das Live-Hirn laeuft schliesslich MIT MTP.
2. Baseline-Refresh: baseline.json traegt jetzt den Engine-Build; weicht die
   installierte Engine ab (So-Auto-Update), misst der Pruefstand-Cron nur den
   Tempo-Teil aller Rollen neu (Geister-Zahlen-Schutz, Qualitaet bleibt gueltig).
3. Langzeit-Historie: Puls wird als JSONL angehaengt statt ueberschrieben;
   Radar vergleicht zusaetzlich gegen ~8 Wochen zurueck und meldet
   SCHLEICHENDE REGRESSION, die woechentlich unter der 10-%-Schwelle bleibt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 16:28:27 +02:00
Hitonabi 9c03316755 Trend-Radar: mechanische Amtsinhaber-Sperre (Testlauf-Befund 17.07.)
Der erste echte Radar-Lauf schlug prompt das eigene Hirn-Repo
(unsloth/Qwen3.6-35B-A3B-MTP-GGUF) als hermes-Kandidat vor - Zitat-Gate korrekt
passiert, aber Repackaging des Amtsinhabers ist nie ein Kandidat. Jetzt doppelt:
Raster-Regel + mechanischer Block ueber Modellnamen-Token im Gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:47:28 +02:00
Hitonabi f5e362e902 Pruefstand: Vision-Checkliste beugungsfest (Baseline-Befund 17.07.)
Beide Augen-Modelle beschrieben das Testbild korrekt ("in der oberen linken
Ecke"), scheiterten aber an undeklinierten Pflicht-Stichworten ("obere linke").
Gruppen um Beugungs-Stämme ergänzt (oberen link / unteren recht).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:29:50 +02:00
Hitonabi 2be599bfe9 Pruefstand-Harness: Think-Blöcke strippen + Budgets rauf (Baseline-Befund 17.07.)
Rohe :8080-Aufrufe an Denk-Modelle (Qwen3.6) liefern <think>-Monologe im content:
der Deutsch-Richter benotete Grübel-Text (Note 1-3), das Zitat-Gate suchte darin,
und 900 max_tokens gingen komplett fürs Denken drauf (leere Antworten).
- inhalt_von() entfernt <think>-Blöcke; abgeschnittenes Denken = ehrlich leer
- recherche 1200->2800, deutsch 900->2400 max_tokens (Denkbudget einpreisen)
- Fehlschläge speichern jetzt antwort_auszug (Diagnose ohne Nachstellen)
- pv-anteil: überstrenges Pflicht-Stichwort 412 entfernt
Mock-E2E nach Patch: coding 6/6, recherche 4/4; Think-Strip-Unit-Probe grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:09:06 +02:00
Hitonabi b0dce954e9 Trend-Radar + Pruefstand: Box beobachtet Modell-/Engine-Trends und testet Kandidaten selbst
- trend-radar-feed.sh (Cron Sa 05:15): Live-Puls ALLER Rollen mit Vorwochen-Vergleich,
  Engine-A/B gegen neuen llama.cpp-Build (Fruehwarnung vor So-Auto-Update), mechanische
  Watch-Liste (MMQ-Issue, ROCm-Releases, Community-Grid), HF-Kandidaten-Suche mit
  Zitat-Gate -> max. 1 Pruefstand-Kandidat/Woche
- pruefstand.sh + harness.py (Cron So 01:00, no-agent): volles Programm je Kandidat -
  6 Coding-Aufgaben mit echten Unit-Tests, 4 Agenten-Aufgaben (Tool-Loop + Endzustand),
  4 Recherche-Fragen mit Zitat-Ehrlichkeits-Gate, Deutsch-Richter, Vision-Testbild,
  Tempo kurz+lang; Baseline der Amtsinhaber als Vergleichsmassstab
- Leitplanken (User-Entscheid 17.07.): Download-Deckel 35 GB (drueber -> Karte),
  1 Kandidat/Woche, Cache mit Auto-Aufraeumen, RAM-/Platz-Wache, Bench-Port :5899,
  Radar/Pruefstand EMPFEHLEN nur - Rollen-Wechsel bleibt Commander-Klick
- E2E bewiesen (Mock-LLM): coding 6/6, recherche 4/4 inkl. Zitat-Gate, Agent-Roundtrip,
  Richter-Parsing; Suiten-Selbsttest mit Referenzloesungen 6/6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 14:57:07 +02:00
Auftragsbuch 8579fe9934 Auftragsbuch: 'wartung/mc2-gateway-venv-check' angenommen (Ein-Klick-Gate) 2026-07-17 12:15:27 +02:00
Auftragsbuch 32c24d7b60 Auftragsbuch: 'feature/skill-inactive-filter' angenommen (Ein-Klick-Gate) 2026-07-17 12:14:22 +02:00
Hitonabi 547851f1c3 feat: Skript zur Filterung inaktiver Skills
- filter_inactive_skills.py: Filtert Skills mit use_count=0 oder last_used_at=null
- Ausschluss von Media-Skills (gif-search, heartmula, songsee, youtube-content)
- Ausgabe sortiert nach last_used_at (älteste zuerst)

Hinweis: Die Dateien liegen im scripts/ Verzeichnis, da es dort keine
semantisch passendere Location gibt. Das Skript ist für den Hermes Skill-Index gedacht.
2026-07-17 04:27:16 +02:00
Hitonabi 1daacea468 feat(gateway): Prüfskript für Python-Interpreter im MC2-Virtualenv
- check_venv.sh prüft Existenz + Ausführbarkeit des Interpreters
- Gibt bei Erfolg 0 zurück, sonst 1 mit klarer Fehlermeldung
- Wird als ExecStartPre= in der systemd-Unit eingebunden

Hinweis für Commander: Die systemd-Unit deploy/mc2-gateway.service
muss nach Annahme manuell um ExecStartPre=... erweitert werden.
2026-07-17 03:53:19 +02:00
Hitonabi 5aded967ef Gateway: Kontext-Limit-Warnung ignoriert Warm-Pings (max_tokens<=16)
Lucys Augen-Waermer (alle 10 min, max_tokens=1 an "vision"), warmup.sh und
models/load enden konstruktionsbedingt mit finish_reason=length — die Warnung
vom 15.07. hielt jeden Ping fuer einen abgerissenen Worker und spammte den
Briefkasten (~alle 20 min, Serie 16.07. abends). Wer freiwillig auf <=16
Tokens deckelt, will keine echte Antwort -> keine Warnung; echte Abrisse
melden weiter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:01:05 +02:00
Hitonabi a22f27bc60 Gateway: No-Think-Schnellspur fuer Lucys Voice-Turns (Hirn-Latenz 6-21s -> <1s)
Live-Diagnose 16.07.: Die 'Hirn'-Zeit der Voice-Turns (Dashboard 6,4-21,5s) war fast
vollstaendig Qwen3.6-Reasoning VOR dem ersten sprechbaren Wort (reasoning_content
2500+ Zeichen bei ~100 t/s; /no_think-Softswitch wird ignoriert; Prefix-Cache war
NICHT das Problem - live gemessen: Zeitstempel-Aenderung kostet nur ~0,8s).

Fix: Lucy traegt Marker [[MC:NO_THINK]] im System-Prompt; das Gateway strippt ihn
aus allen Messages (konstanter Text -> Prefix-Cache stabil) und setzt
chat_template_kwargs enable_thinking:false. Direkt am Hirn gemessen: 9,9s -> 0,8s,
Persona-Qualitaet unveraendert. Requests ohne Marker (Crons/Telegram/Werkstatt)
denken unveraendert weiter. Abschaltbar via MC_NO_THINK_MARKER="".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 16:55:14 +02:00
Hitonabi 31fde2a68e self-smoke/postcheck: Browser-Tool-Check + Selbstheilung (agent-browser-Link-Falle, Vorfall 16.07.)
Hermes' browser_navigate starb still als "Failed to open <url>": das
Hermes-Update 15.07. 23:53 (npm ci --workspace) bog ~/.local/bin/agent-browser
per postinstall auf node_modules/ um und loeschte das Paket im selben Lauf
wieder -> toter Symlink. Kein fehlender MCP/Skill.

Neu, damit die Box das OHNE Zuruf erkennt und heilt:
- self-smoke.sh Check 6: agent-browser --version; bei totem Link auf das
  npm-global-Binary (~/.local/lib/node_modules/...) zurueckbiegen und
  hermes-gateway/hermes-builtin-ui try-restarten (browser_tool cached
  "nicht installiert" pro Prozess). Selbstheilung wird als Info gemeldet
  (neuer HEALED-Kanal), nicht als Alarm; unfixbar -> Rot wie gehabt.
- hermes-postcheck.sh: gleicher Check direkt nach jedem Hermes-Update,
  denn genau dort entsteht der Bruch.

Heil-Logik isoliert auf der Box getestet (Fake-HOME + systemctl-Stub):
toter Link -> geheilt+Restart; danach PASS ohne Restart; Binary weg -> FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:38:15 +02:00
Hitonabi 0890698750 Auftragsbuch: Ergebnis-Panel klebt nicht mehr auf Fehler (nochmal versuchen)
Fiel der erste Ladeversuch in ein Deploy-Fenster (Zentrale startet nach
Karten-Annahme kurz neu), blieb "Ergebnis nicht ladbar" dauerhaft stehen,
obwohl die API das Ergebnis liefert (Vorfall 16.07., t_2a7cfd46). Die
Fehlzeile ist jetzt ein Klick-Retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:08:19 +02:00
Hitonabi 77dfac79ee selbst-inventur: Diff-Filter warf alle Bullet-Zeilen weg (immer "0 Aenderungen")
Steckbrief-Zeilen sind Bullets ("- ..."), im Diff also "+- "/"-- " — der alte
Filter ^[+-][^+-] (gedacht gegen +++/----Kopfzeilen) verschluckte damit jede
echte Aenderung. Jetzt werden nur die Datei-Kopfzeilen ausgefiltert.
Beweis 16.07.: realer Nacht-Diff hatte 7 Aenderungen, Meldung sagte 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:04:58 +02:00
Hitonabi fa22435d7c tabu-pfade-guard: V16 — relative Pfade bei riskantem cwd blocken (Vorfall 16.07.)
Nacht-Worker t_7a05992a ersetzte 03:59 den Live-Checkout mit cwd=$HOME und
RELATIVEN Pfaden (rm -rf mission-control-v2 && git clone ...) — der Guard
prueft beim terminal-Tool nur den Kommandotext auf home-verankerte Formen
und liess das durch (.venv weg -> Gateway 203/EXEC-Crash-Loop).

Drei Stopfen:
(1) cwd IN einem Tabu-Pfad -> jede Schreib-Operation geblockt (Lesen frei).
(2) git clone/init mit Ziel direkt in $HOME geblockt (Worker klonen im
    Task-Workspace); explizites Ziel ausserhalb $HOME bleibt erlaubt.
(3) cwd == Vorfahr eines Tabu-Pfads -> relative Nennung des Tabu-Ziels
    zaehlt als Treffer; Lookarounds lassen den Workspace-Klon-Pfad
    (.hermes/kanban/workspaces/<id>/mission-control-v2) weiter durch.
Nebenbefund zu: clone fehlte in der git-Schreibliste (a) — auch
git clone URL ~/mission-control-v2 war vorher ungeblockt.

Lokal bewiesen: 35/35 Payload-Faelle (Vorfalls-Kommando geblockt,
Workspace-Arbeit/Lesen frei, Alt-Verhalten regressionsfrei).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:02:35 +02:00
Auftragsbuch 4df6a3ecf9 Auftragsbuch: 'wartung/parse-mc2-timeout-user-journal' angenommen (Ein-Klick-Gate) 2026-07-16 11:01:48 +02:00
werkstatt d697f3cc0d fix: parse_mc2_timeout.py auf User-Journal umstellen (--user) 2026-07-16 11:00:33 +02:00
Auftragsbuch fd241aca51 Auftragsbuch: 'wartung/parse-mc2-timeout' angenommen (Ein-Klick-Gate) 2026-07-16 10:39:41 +02:00
werkstatt d2d726c105 wartung: Skript zum Parsen von systemd-Timeout-Fehlern (parse_mc2_timeout.py)
Einfaches Python-Skript, das journalctl nach 'State stop-sigterm timed out' durchsucht und Ergebnisse mit ISO-Zeitstempel in ~/logs/mc2-timeout.log schreibt. Bereitet Berechtigungsfehler sauber auf.
2026-07-16 04:08:10 +02:00
Hitonabi 71192b3db0 Zeitachse (24h-Historie), Verbinden-Autofill, Modelltausch abgesichert
Paket 1 - Metrik-Historie mit Zeitachse (User: 'WANN war Last?'):
- services/metrics_history.py: 10s-Sampler (CPU/RAM/GPU/Disk + Token-Totale) in
  24h-Ringpuffer (aelteres faellt automatisch raus, nichts waechst unbegrenzt),
  persistiert alle 5 min -> uebersteht Deploys; GET /api/system/history?minutes
  mit Downsampling auf ~300 Punkte; Lifespan-Task in app.py
- Cockpit-Karten System-Status + Token-Durchsatz: Bereichs-Schalter Live/1h/24h,
  sichtbare Zeitachse (HH:MM:SS bei kurzen, HH:MM bei langen Fenstern), Tooltip
  zeigt Uhrzeit; Token-Raten in 1h/24h aus Totale-Deltas

Paket 2 - Verbinden selbsterklaerend:
- Box-IP vorbefuellt aus window.location.hostname (Dev-Fallback bleibt)
- MCP-Scriptpfad-Feld aus der Kopfzeile in die 'Gedaechtnis anbinden'-Karte
  verschoben, mit Erklaerung WANN man es braucht

Paket 3 - Modelltausch-Loecher gestopft (Review 16.07.):
- install: Registrierung OHNE Alias-Umzug; Rolle wird erst NACH erfolgreichem
  Download uebernommen (on_done) - hermes dabei durch den warm-bewussten
  set_agent_brain-Flow statt rohem Alias-Move (Lucy waere sonst bis Download-
  Ende tot gewesen); Re-Install erhaelt bestehende Aliase
- set_role_alias: lebenswichtige Aliase (hermes/embed) des Ziel-Modells
  ueberleben jeden Rollen-Klick (Qwen3.6 haelt live hermes+fast!)
- Rollen-Endpoint verweigert Rollen-Wechsel am Halter geschuetzter Aliase
  mit klarer Anleitung; Werkbank sperrt die Rollen-Chips sichtbar

Verifiziert: py_compile + Sampler-Smoke (2 Punkte, Persist ok) + Alias-Logik-
Unittest (3 Faelle) + Browser (Zeitachse tickt, Schalter, Leerzustand, Verbinden).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 01:08:03 +02:00
Hitonabi 26224804ed Tab-Review-Fixes: toter Gedaechtnis-Knopf, Konsole-Restart, Cockpit-Dopplung
- Gedaechtnis: 'Im Terminal starten' navigierte zu nicht existenter View 'terminal'
  -> 'konsole'; interne Duplikate (AUTO-Set, CAT_LABEL_MAP) entfernt
- Konsole: box-console jetzt in Dienste-Liste + Restart-Allowlist; Offline-Overlay
  bekommt Ein-Klick-'Dienst neu starten' statt SSH-Anweisung
- Cockpit Werkzeuge: Doppel-Eintrag ('Box-Shell' + 'Interaktives Terminal', beide
  -> Konsole) zu EINEM Eintrag 'Konsole (Box-Shell)' zusammengelegt
- Auftragsbuch: 'Nichts zu entscheiden'-Banner beruecksichtigt blockierte
  Ideen-Karten; Abschnitt heisst neutral 'Patch-Vorschlaege'
- Hermes-Diagnose: Klickweg (Dienste-Schublade oeffnen) statt veraltetem
  Drawer-Namen + systemctl-Kommandos
- Entdecken: Upgrade-Knopf uebernimmt fremde Quant nicht mehr blind
- Skills: Status-Badge zeigt Klartext (abgeschaltet/defekt/...) statt rohem State

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:42:04 +02:00
Hitonabi bc60f9254b Verbinden-Tab: eine Geschichte statt Lanes-vs-Modelle-Widerspruch
- Hero 'Leitung 1' zeigte 'Lanes chat / coding', alle Snippets nutzen aber die
  ECHTEN Modelle (Verdikt 09.07.: IDE-Welt = coder/heavy/vision, kein Lane-Routing)
  -> Zeile zeigt jetzt die Modelle; Fusstext erklaert die Lanes als Zusatz
- Kilo-Note entwirrt: 'Ask/Debug -> Lane chat' als virtuelles Modell benannt
- Anleitung Par.10 (IDE-Tools) auf dieselbe Story (coder/heavy/vision statt chat/coding)
- connect.py: stale '_gw'-Kommentar (MC2 serviert /v1 selbst) auf UMBAU-v3-Stand;
  check_health nutzt HERMES_BUILTIN_UI_UPSTREAM statt hartem 127.0.0.1:9119

Live geprueft vor dem Fix: Lanes chat/coding/auto funktionieren (200), stehen aber
nicht in /v1/models; /v1/messages weiterhin 404 (Claude-Code-Hinweis korrekt);
hermes-ui 200 + Token-Datei existiert (Anleitung stimmt).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:32:12 +02:00
Hitonabi 0c69c491e5 Single Source of Truth: Frontend/Backend-Ungereimtheiten ausgeraeumt (Review 15.07.)
Backend (Wahrheit reparieren):
- gateway_reachable() prueft jetzt den ECHTEN mc2-gateway (:9010 via V1_UPSTREAM)
  statt nur die Engine - toter Denkpfad war in Health/Cockpit unsichtbar
- /api/system/services vollstaendig: + LLM-Gateway, + Steuerpult, + Waechter
  (mc2-steward via is-active); scope-Feld (user/system); mc2-gateway/mc2-steward
  in die Restart-Allowlist
- Connect-Health Leitung 1 prueft den Client-Pfad (V1_UPSTREAM statt llama-swap)
- agent_status liefert pc_executor_url (UI zeigte hartcodierte IP/Ports)
- SSE-Endlosschleife gefixt: /api/auftragsbuch schrieb announce-branches bei JEDEM
  Aufruf neu -> mtime-Bump -> invalidate -> Refetch im 3-s-Takt je Client;
  jetzt nur noch bei echter Aenderung

Frontend (ein Datenweg):
- SystemDrawer komplett auf React-Query-Hooks (vorher eigenes 3-s-setInterval auf
  dieselben Endpunkte inkl. teurem Updates-Check); Dienste-Liste = Backend-Antwort
  (SERVICES-UI-Kopie geloescht); useDialog statt Eigenbau; fmtBytes statt Duplikat
- useLucyHealth: Dienst-Matching per systemd-unit statt Namensfragment; Gateway-
  Reparatur zielt auf mc2-gateway; neuer Waechter-Check; Backend-tot => ehrliches
  rotes Verdikt statt eingefrorener letzter Stand (auch Sidebar)
- Toter Code raus: views/models/Cockpit.tsx (908 Z.) + RoleAssignModal + Placeholder;
  LaneEditor (Routing-Policy) + WarmSetManager damit ZURUECK im UI als Tab
  "Routing & Warm-Set" im Modell-Manager
- Cockpit: blockierte Ideen-Karten erscheinen in "Braucht dich" + Kachel-Hint;
  Verbinden-Kachel zeigt 3 Leitungen live statt hartem "Zed"
- Maschinenraum: ehrliches "frei" (reale Belegung) + eigenes KV-Cache-Segment
- AgentView: Ports/PC-Adresse aus der API; Guide lehrt Lanes chat/coding
- queries.ts: useModels nutzt SSE-relax; Chronik-Limit im Query-Key;
  useJobs/useZeitmaschine mit enabled-Schalter

Verifiziert: tsc+vite gruen, Backend-Smoke beide Gateway-Modi, UI live gegen die
Box (Cockpit/Werkbank/Routing-Tab/Drawer rendern mit Echtdaten).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 22:27:32 +02:00
Auftragsbuch b36561990e Auftragsbuch: 'doku/sofort-paket-erledigt' angenommen (Ein-Klick-Gate) 2026-07-15 22:08:21 +02:00
Hitonabi 41b0edba53 UMBAUPLAN: P1 KOMPLETT - Stufe 2 gefahren + Neustart-Beweis bestanden
Cutover 15.07. 21:26 (alle 14 Hermes-base_urls auf :9010, Backups).
Abschlussbeweis 21:27: MC2-Restart mitten im Betrieb, Denkpfad :9010
antwortete durchgehend HTTP 200 - null Aussetzer. Reine Doku.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 21:28:18 +02:00
Auftragsbuch b7e6cf4acc Auftragsbuch: 'doku/sofort-paket-erledigt' angenommen (Ein-Klick-Gate) 2026-07-15 19:45:45 +02:00
Auftragsbuch f271b04754 Auftragsbuch: 'feature/pydantic-response-models' angenommen (Ein-Klick-Gate) 2026-07-15 19:44:20 +02:00
Hitonabi 78bff28acf UMBAUPLAN: P1/P2-Status auf AKTIV getrued (beide angenommen+verifiziert)
Fahrplan-Punkte 1-3 als erledigt markiert (inkl. Bild-Weiche-Fix d2398b3
und 256er-Testbild-Lehre); einzig offener Schritt ist Punkt 4 = Stufe 2
(gateway-cutover.sh). Reine Doku - Bagatell-Klasse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:43:22 +02:00
Hitonabi ff6ef3eb1f UMBAUPLAN: Sofort-Paket (Abschnitt 3) als komplett erledigt markiert
Alle vier Punkte live verifiziert (15.07. abends): Bild-Weiche-Karte
angenommen+E2E, t_8231d6b8 abgeschlossen, sudo-Runde durch (v1-Unit/opt
restlos weg, Root-Warmup-Kopie byte-identisch mit Repo), Briefing-Cron
auf unendlich. Reine Doku — Bagatell-Klasse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:42:09 +02:00
Hitonabi 21ea7e1dd3 backend: Pydantic response_models für 5 GET-Endpoints hinzufügen
- health.py: HealthResponse
- eigenleben.py: EigenlebenOverviewResponse + SkillTextResponse
- chronik.py: ChronikResponse + ChronikItem
- wissen.py: WissenListResponse + WissenFileResponse
- reminders.py: ReminderListResponse + ReminderOut

py_compile bestanden, /docs zeigt neue Schemata.
Response-Payloads unverändert — nur Typisierung hinzugefügt.
2026-07-15 19:33:39 +02:00
Hitonabi e91f6b7ba8 SSE-Eventstrom versoehnt: getesteter Kern + Modell-Quelle aus der Werkstatt-Karte
Die angenommene Karte feature/sse-backend-v1 ersetzte die bereits E2E-
getestete Hand-Implementierung mit drei harten Fehlern: 'type:' statt
'event:' (ungueltiges SSE-Framing - EventSource-Listener feuern NIE),
globaler Snapshot ueber alle Clients (verschluckt Events), Ideen-Zaehler
gegen nicht existierenden Endpunkt :9010/v1/ideen. Dazu doppelter
Router-Mount in app.py (Merge-Folge).

Zurueck auf den bewiesenen Kern (3s-Fingerprints, Basislinie je
Verbindung, is_disconnected, korrektes Framing) + die GUTE Idee der
Karte uebernommen: Running-Set der Modelle als Quelle (Laden/Entladen
invalidiert die Modelle-Ansicht). System-/Token-Metriken bleiben bewusst
beim Polling (aendern sich jede Sekunde = invalidate-Laerm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:30:27 +02:00
Auftragsbuch d0a967e765 Auftragsbuch: 'feature/sse-backend-v1' angenommen (Ein-Klick-Gate) 2026-07-15 16:10:08 +02:00
Hitonabi 87670dd3a6 self-smoke Check 5: Bild-Weiche v2 taeglich aktiv durchspielen
Der Pfad (Bild an coder -> Vision beschreibt -> Coder antwortet) brach am
15.07. zweimal still: erst als nie verdrahteter Patch in der Hermes-Quelle,
dann am gepoolten Keep-Alive-Socket nach Modell-Swap (:9010). 256x256-Rot
als Testbild - Winzbilder liefern VL-Halluzinationen (Kapuzinerkresse).
Nebeneffekt: waermt coder+vision morgens vor. Vorschlag der P2-Session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:58:52 +02:00
werkstatt 41e569cf99 backend: SSE-Eventstrom /api/events (UMBAU v3 P3a)
- Neuer Router routers/events.py mit EventSource-Endpunkt\n- Heartbeat alle 25s, invalidate-Event bei Status-Änderung\n- Keys: system-status, models, agent-status, ideen, token-stats
2026-07-15 15:38:32 +02:00
Hitonabi d2398b3624 Bild-Weiche v2: frischer Client + Retry fuer den Vision-Unteraufruf
Befund :9010 (Journal): httpx.ReadTimeout nach Sekunden trotz timeout=240 -
der gepoolte Keep-Alive-Client reicht Sockets wieder aus, die llama-swap
beim Modell-Swap gekappt hat. Der Beschreibungs-Unteraufruf nutzt jetzt
einen eigenen Kurzzeit-Client und versucht es einmal erneut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:30:36 +02:00
Hitonabi 202e97333a Bild-Weiche v2: stummen Fallback-except durch Logging ersetzen (Diagnose :9010)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:22:46 +02:00
345 changed files with 13238 additions and 3448 deletions
+4
View File
@@ -11,6 +11,10 @@
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
deploy/agent-hooks/*.py text eol=lf
deploy/*.py text eol=lf
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
deploy/**/*.py text eol=lf
# Windows-Batch-Wrapper bleiben CRLF.
*.cmd text eol=crlf
+53
View File
@@ -0,0 +1,53 @@
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
#
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
#
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
name: Ampel
on: [push, pull_request]
jobs:
ampel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
shell: bash
run: |
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
set -u +e
rot=0
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
ruff check . || rot=1
echo "== Python: Import/Syntax (compileall) =="
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
if [ -f frontend/package.json ]; then
if [ ! -f frontend/package-lock.json ]; then
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
rot=1
else
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
fi
fi
if [ $rot -ne 0 ]; then
echo "❌ AMPEL ROT — nichts heißt fertig', solange das rot ist."
else
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
fi
exit $rot
+3
View File
@@ -20,3 +20,6 @@ frontend/dist/avatar.vrm
box_recon*
gemma_swap*
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
frontend/tsconfig.tsbuildinfo
+128 -50
View File
@@ -1,72 +1,150 @@
# Mission Control 2.0
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box**
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren**
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die
„Werkstatt"), nicht mehr manuelle Releases.
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
## Die drei Bahnen
## Status: **live + eingefroren (MC2 Final)**
| Bahn | Was sie tut | Wo |
|---|---|---|
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
| **Coding** | Zed + OpenCode am Tag, `opencode-lauf.sh` in der Nacht — **ein Regelwerk, zwei Auslöser** | Governor `:8100` |
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch
ohne Betreuung monatelang weiter):
Lucy holt ihr Hirn über MC2, spricht aber nie durch den Governor — ein Gespräch ist keine
Bau-Sitzung und wird nie unterbrochen.
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
Branch → Gate → Deploy selbst).
## Ports & Dienste
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`.
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md),
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
| Port | Dienst | Erreichbar |
|---|---|---|
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
| `8100` | **Governor** — Token-Wächter vor dem Gateway | LAN |
| `8080` | **llama-swap** — Modell-Router | LAN |
| `9010` | `mc2-gateway``/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` · `governor` ·
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
## Der Weg einer Idee
```
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Zed bauen → Ampel → main
└── oder nachts: Hermes-Cron → opencode-lauf.sh → dieselben Regeln
```
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
Wächtern geboren**:
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Das OpenCode-Plugin
führt sie nach jeder Etappe aus und gibt rote Tests dem Agenten **sofort** zurück, statt sie
erst der CI zu zeigen. Keine `VERIFY`-Datei = Prüf-Tor aus.
## Modelle & Rollen
Gemessen auf der Box (25.07.2026, Vulkan, Q4):
| Rolle | Modell | Tempo | Aufgabe |
|---|---|---|---|
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft |
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6 t/s** | Lucys Hirn **und** der Sucher-Subagent. Immer warm |
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen) |
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen |
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30). **Nie tagsüber** — verdrängt das warme Set |
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
## Qualitäts-Tore
1. **Werkzeug-Zaun** ([`deploy/opencode/plugin/`](deploy/opencode/plugin/)) — blockt `git push`,
`rm -rf`, `sudo`, `curl | sh` und Fremd-Hosts im Agentenlauf.
2. **Prüf-Tor**`VERIFY` nach jeder Etappe; rot → der Agent repariert selbst weiter (max. 3 Runden).
3. **Governor** ([`deploy/governor/`](deploy/governor/)) — zählt Tokens ehrlich (aus den echten
`usage.prompt_tokens` jeder Antwort, selbstkalibrierend). Bei ~45.000: Savepoint schreiben und
Sitzung sauber beenden. Bei ~50.000: harter Riegel. Sichtbar als Kachel im Cockpit.
4. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft bei jedem Push.
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
Kontext-Komprimierung löscht still die Regeln mit.
## Selbsterhaltung
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
## API (Auswahl)
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
memory/* · agent/* · **governor** · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
## Entwickeln
**Backend:**
```bash
# Backend
cd backend
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
.venv/Scripts/python -m uvicorn app:app --port 9000
# Frontend (Dev, proxyt /api → :9000)
cd frontend && npm install && npm run dev # http://localhost:5173
# Frontend (Build → wird vom Backend ausgeliefert)
cd frontend && npm run build # → frontend/dist
```
**Frontend (Dev, proxyt /api → :9000):**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
**Frontend (Build → wird vom Backend ausgeliefert):**
```bash
cd frontend && npm run build # → frontend/dist
```
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
## Env-Vars (Auswahl)
| Variable | Default | Zweck |
|---|---|---|
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
| `MC_PORT` | `9000` | MC-Backend-Port |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
| `MC_V1_UPSTREAM` | | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
| `MC_GOVERNOR_URL` | `http://127.0.0.1:8100` | Token-Wächter für die Cockpit-Kachel |
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
Governor-eigene Schalter (`GOV_THRESHOLD`, `GOV_HARD_CEILING`, `GOV_EXEMPT_MODELS`, …):
[`deploy/governor/README.md`](deploy/governor/README.md).
## Weiterlesen
| Dokument | Inhalt |
|---|---|
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
| [`deploy/opencode/README.md`](deploy/opencode/README.md) | Coding-Bahn: Mannschaft, Verteilung, Fallen |
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
+12 -5
View File
@@ -9,17 +9,16 @@ Server (proxyt /api hierher), daher CORS für localhost offen.
import asyncio
import logging
import os
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Awaitable, Callable
from typing import Any
import httpx
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
from routers import (
agent,
auftragsbuch,
@@ -29,6 +28,7 @@ from routers import (
eigenleben,
events,
gateway_proxy,
governor,
health,
hermes_ui,
ideen,
@@ -42,8 +42,9 @@ from routers import (
zeitmaschine,
)
from routers import reminders as reminders_router
from services import ketten_digest, metrics_history, reminders, sentry, warmer
from services import memory as memory_svc
from services import reminders, sentry, warmer
from starlette.requests import Request
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
# für alle Module (logging.getLogger(__name__)).
@@ -68,6 +69,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if sentry.ENABLED:
tasks.append(asyncio.create_task(sentry.sentry_loop()))
tasks.append(asyncio.create_task(reminders.reminders_loop()))
if ketten_digest.ENABLED:
# Werkstatt-Status aufs Handy: Fragen sofort, Meilensteine sofort, sonst 60-min-Puls.
tasks.append(asyncio.create_task(ketten_digest.digest_loop()))
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
if memory_svc.AUTO_DEDUPE_ENABLED:
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
log.info(
@@ -136,6 +142,7 @@ app.include_router(maintenance.router)
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
app.include_router(governor.router) # Token-Wächter (:8100) — Zählerstand fürs Cockpit
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
+2 -3
View File
@@ -15,13 +15,12 @@ durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import httpx
from fastapi import FastAPI, Request
from config import LLAMA_SWAP_URL, VERSION
from fastapi import FastAPI, Request
from routers import gateway_proxy
logging.basicConfig(
+2 -1
View File
@@ -4,8 +4,9 @@ from pathlib import Path
# Add backend directory to sys.path so we can import services
sys.path.append(str(Path(__file__).resolve().parent))
from services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
from config import CONFIG_PATH
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
def migrate():
print(f"Reading config from {CONFIG_PATH}...")
+11
View File
@@ -55,6 +55,17 @@
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
},
{
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
},
{
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
}
]
}
+1 -4
View File
@@ -1,10 +1,7 @@
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from fastapi import HTTPException
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
router = APIRouter(prefix="/api")
-1
View File
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import auftragsbuch
router = APIRouter(prefix="/api")
+15 -2
View File
@@ -4,12 +4,25 @@ Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — a
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from fastapi import APIRouter
from pydantic import BaseModel
from services import announce
router = APIRouter(prefix="/api")
@router.get("/chronik")
class ChronikItem(BaseModel):
id: int
ts: float
subject: str
text: str
source: str
priority: str
class ChronikResponse(BaseModel):
items: list[ChronikItem]
@router.get("/chronik", response_model=ChronikResponse)
def chronik(limit: int = 150) -> dict:
return {"items": announce.list_recent(limit)}
-1
View File
@@ -1,7 +1,6 @@
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
from fastapi import APIRouter
from services.connect import DEFAULT_HOST, build_snippets, check_health
router = APIRouter(prefix="/api")
+2 -3
View File
@@ -14,11 +14,10 @@ import logging
import httpx
import websockets
from config import BOX_CONSOLE_UPSTREAM
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import Response
from config import BOX_CONSOLE_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
@@ -28,7 +27,7 @@ def _register(base: str, upstream: str) -> None:
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
@router.websocket(f"/{base}/ws")
async def _proxy_ws(ws: WebSocket) -> None: # noqa: ANN001 — Closure je base
async def _proxy_ws(ws: WebSocket) -> None:
await ws.accept(subprotocol="tty")
try:
async with websockets.connect(
+33 -4
View File
@@ -1,13 +1,42 @@
"""Eigenleben-Endpoints — „Von allein"-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
"""Eigenleben-Endpoints — „Von allein-Ansicht (Skills + Vorschlags-Bilanz), read-only."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import eigenleben
router = APIRouter(prefix="/api")
@router.get("/eigenleben")
class SkillItem(BaseModel):
id: str
name: str
kategorie: str | None
aktiv: bool | None
beschreibung: str
version: str
autor: str
herkunft: str
herkunft_label: str
genutzt: int
zuletzt_genutzt: str | None
erstellt: str | None
status: str
geaendert: float
class EigenlebenOverviewResponse(BaseModel):
available: bool
skills: list[SkillItem]
selbst_erstellt: int
bilanz: dict
class SkillTextResponse(BaseModel):
id: str
text: str
@router.get("/eigenleben", response_model=EigenlebenOverviewResponse)
def get_overview() -> dict:
return eigenleben.overview()
@@ -17,7 +46,7 @@ def get_overview() -> dict:
# fällt in den SPA-Fallback (index.html, Status 200) und die UI zeigt „Konnte den
# Skill-Text nicht laden". Die Pfad-Validierung (max. eine Ebene, keine . /..) macht
# services.eigenleben.skill_text.
@router.get("/eigenleben/skill/{skill_id:path}")
@router.get("/eigenleben/skill/{skill_id:path}", response_model=SkillTextResponse)
def get_skill(skill_id: str) -> dict:
text = eigenleben.skill_text(skill_id)
if text is None:
+25 -14
View File
@@ -1,19 +1,25 @@
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
billige Fingerabdrücke der ereignishaften Quellen (Briefkasten/Chronik, Ideen-Queue,
Auftragsbuch, Erinnerungen) und schickt NUR bei Änderung ein `invalidate`-Event mit
den React-Query-Keys. Die Wahrheit bleibt in den bestehenden Endpunkten — der Strom
ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die Caches,
entspannt die Fallback-Poller ×5, solange der Strom steht; reißt er ab, reconnectet
EventSource selbst und bis dahin pollt die UI wie bisher). Live-Metriken (CPU/Token-
Graphen) laufen bewusst NICHT hierüber — die ändern sich jede Sekunde, da ist Polling
das richtige Werkzeug.
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
Metriken (ändern sich jede Sekunde da ist Polling das richtige Werkzeug und ein
invalidate-Event nur Lärm).
Handgebaut 15.07. abends (User-Entscheid: „ihr brecht die Jobs ab, du baust es ein
letztes Mal manuell") — die autonomen P3-Worker bissen sich an der Aufgabe fest.
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
Sekunden), Modell-Quelle aus der Karte übernommen.
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
selbst und bis dahin pollt die UI wie bisher).
"""
import asyncio
@@ -21,11 +27,10 @@ import json
import logging
from pathlib import Path
from config import MODELS_DIR
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from config import MODELS_DIR
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
@@ -56,6 +61,11 @@ def _fingerprints() -> dict[str, object]:
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
except Exception:
pass
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
from services import llamaswap
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
except Exception:
pass
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
@@ -66,7 +76,8 @@ def _fingerprints() -> dict[str, object]:
@router.get("/events")
async def events(request: Request) -> StreamingResponse:
async def strom():
# Der Client hat beim Verbinden frisch geladen — Basislinie ohne Event setzen.
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen)
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
alt = _fingerprints()
yield ": verbunden\n\n"
seit_ping = 0.0
+1 -2
View File
@@ -11,11 +11,10 @@ entfernen → app.py bindet wieder den lokalen Gateway ein.
import logging
from config import V1_UPSTREAM
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from config import V1_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter(prefix="/v1")
+57 -13
View File
@@ -1,13 +1,16 @@
import logging
import os
import httpx
from config import LLAMA_SWAP_URL
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from config import LLAMA_SWAP_URL
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
from services.routing_policy import load_policy
log = logging.getLogger(__name__)
router = APIRouter(prefix="/v1")
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
@@ -28,6 +31,38 @@ _BILD_BESCHREIB_PROMPT = os.environ.get(
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
def _apply_no_think_marker(body: dict) -> None:
"""Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
(sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
if not _NO_THINK_MARKER:
return
found = False
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str) and _NO_THINK_MARKER in c:
m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
found = True
elif isinstance(c, list):
for part in c:
if (isinstance(part, dict) and isinstance(part.get("text"), str)
and _NO_THINK_MARKER in part["text"]):
part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
found = True
if found and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
def _ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
@@ -58,15 +93,23 @@ async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str)
"messages": [{"role": "user",
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
}
try:
# Grosszuegiger Timeout: VL-30B ist on-demand (Kaltladen ~30 s) + Beschreibung ~25 s.
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage, timeout=240.0)
text = ""
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if not text.strip():
return False
except Exception:
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
text = ""
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=240.0) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if text.strip():
break
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s%.200s",
versuch, r.status_code, r.text)
except Exception:
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
if not text.strip():
return False
msg["content"][idx] = {"type": "text", "text": (
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
@@ -145,6 +188,7 @@ async def models(request: Request):
async def _proxy(path: str, request: Request):
body = await request.json()
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
requested = str(body.get("model") or "auto")
if requested.lower() in ("auto", "chat", "coding"):
lane = requested.lower()
@@ -193,7 +237,7 @@ async def _proxy(path: str, request: Request):
async def gen():
try:
async for chunk in r.aiter_raw():
record_stream_chunk(chunk, alias)
record_stream_chunk(chunk, alias, body.get("max_tokens"))
yield chunk
finally:
await r.aclose()
@@ -205,7 +249,7 @@ async def _proxy(path: str, request: Request):
record_usage(resp_json.get("usage"), alias)
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
for c in resp_json.get("choices") or []):
warn_truncation(alias)
warn_truncation(alias, body.get("max_tokens"))
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
+40
View File
@@ -0,0 +1,40 @@
"""Governor — Fenster auf den Token-Waechter (:8100).
Der Governor ist ein eigenstaendiger, absichtlich winziger Proxy ohne Datenbank: er
sitzt zwischen den Coding-Agenten und diesem Gateway, zaehlt ehrlich mit (echte
`usage.prompt_tokens` aus jeder Antwort) und zieht bei ueberlangen Sitzungen die
Notbremse. Hier wird nichts Neues erhoben — nur sein Status-Endpunkt gleichursprünglich
fuer die Oberflaeche verfuegbar gemacht, damit das Frontend nicht per CORS auf einen
zweiten Port ausweichen muss.
Faellt der Governor aus, liefert dieser Router `ok: false` statt eines Fehlers: die
Kachel zeigt dann „nicht erreichbar" und das Cockpit bleibt heil.
"""
import os
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/api")
GOVERNOR_URL = os.environ.get("MC_GOVERNOR_URL", "http://127.0.0.1:8100")
@router.get("/governor")
async def governor_status() -> dict:
"""Momentaufnahme des Token-Waechters. Nie werfen — die Kachel darf nie das Cockpit reissen."""
try:
async with httpx.AsyncClient(timeout=3.0) as c:
r = await c.get(f"{GOVERNOR_URL}/governor/status")
r.raise_for_status()
data = r.json()
data["reachable"] = True
return data
except Exception as exc:
return {
"ok": False,
"reachable": False,
"error": f"{type(exc).__name__}: {exc}",
"url": GOVERNOR_URL,
}
+11 -3
View File
@@ -1,14 +1,22 @@
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
from fastapi import APIRouter
from config import VERSION
from fastapi import APIRouter
from pydantic import BaseModel
from services import gateway, llamaswap
router = APIRouter(prefix="/api")
@router.get("/health")
class HealthResponse(BaseModel):
status: str
version: str
engine_reachable: bool
gateway_reachable: bool
brain: dict
@router.get("/health", response_model=HealthResponse)
def health() -> dict:
return {
"status": "ok",
+1 -2
View File
@@ -22,11 +22,10 @@ import re
import httpx
import websockets
from config import HERMES_BUILTIN_UI_UPSTREAM
from fastapi import APIRouter, Request, WebSocket
from starlette.responses import RedirectResponse, Response
from config import HERMES_BUILTIN_UI_UPSTREAM
log = logging.getLogger(__name__)
router = APIRouter()
+94 -2
View File
@@ -3,7 +3,6 @@ LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuc
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import ideen
router = APIRouter(prefix="/api")
@@ -12,6 +11,10 @@ router = APIRouter(prefix="/api")
class IdeeIn(BaseModel):
titel: str
notiz: str = ""
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Zed
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
class TaskIn(BaseModel):
@@ -23,6 +26,15 @@ class AntwortIn(BaseModel):
antwort: str
class ArchivAlleIn(BaseModel):
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
projekt: str = ""
class EinordnenIn(BaseModel):
text: str
@router.get("/ideen")
def list_queue() -> dict:
return ideen.list_queue()
@@ -30,7 +42,8 @@ def list_queue() -> dict:
@router.post("/ideen")
def add_idea(body: IdeeIn) -> dict:
res = ideen.add_idea(body.titel, body.notiz)
res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe,
ziel=body.ziel, art=body.art)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
return res
@@ -44,6 +57,64 @@ def answer(body: AntwortIn) -> dict:
return res
class PrioIn(BaseModel):
id: str
richtung: str # hoch | runter
@router.post("/ideen/stopp")
def stop(body: TaskIn) -> dict:
res = ideen.stop_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
return res
@router.post("/ideen/retry")
def retry(body: TaskIn) -> dict:
res = ideen.retry_task(body.id)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
return res
@router.post("/ideen/prio")
def prio(body: PrioIn) -> dict:
res = ideen.prio_task(body.id, body.richtung)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
return res
class WeiterIn(BaseModel):
id: str
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
@router.post("/ideen/weiterfuehren")
def weiterfuehren(body: WeiterIn) -> dict:
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
return res
class NachschaerfenIn(BaseModel):
id: str
hinweis: str
@router.post("/ideen/nachschaerfen")
def nachschaerfen(body: NachschaerfenIn) -> dict:
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden."))
return res
@router.post("/ideen/archivieren")
def archive(body: TaskIn) -> dict:
res = ideen.archive_task(body.id)
@@ -52,6 +123,22 @@ def archive(body: TaskIn) -> dict:
return res
@router.post("/ideen/einordnen")
def einordnen(body: EinordnenIn) -> dict:
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
und die Oberfläche behält ihre Voreinstellung."""
return ideen.einordnen(body.text)
@router.post("/ideen/archivieren-alle")
def archive_alle(body: ArchivAlleIn) -> dict:
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
res = ideen.archive_done(body.projekt)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
return res
@router.get("/ideen/{id}/log")
def get_log(id: str) -> dict:
return ideen.log_of(id)
@@ -60,3 +147,8 @@ def get_log(id: str) -> dict:
@router.get("/ideen/{id}/ergebnis")
def get_ergebnis(id: str) -> dict:
return ideen.ergebnis_of(id)
@router.get("/ideen/{id}/konzept")
def get_konzept(id: str) -> dict:
return ideen.konzept_of(id)
+1 -2
View File
@@ -1,8 +1,7 @@
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
from fastapi import APIRouter, HTTPException, Header
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
from services import maintenance
router = APIRouter(prefix="/api")
-1
View File
@@ -4,7 +4,6 @@ import time
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import memory
from services.voice_metrics import park, record_stage
+33 -8
View File
@@ -1,10 +1,9 @@
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
import psutil
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from config import HF_DOWNLOAD_ENV, MODELS_DIR
from services import budget, discover, hf, jobengine, llamaswap
from services.fit import evaluate_fit, max_ctx_for
@@ -114,24 +113,41 @@ def install(req: InstallReq) -> dict:
# SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein).
ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"]
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
# Sofort registrieren (robust gegen -watch-config) — aber OHNE den Rollen-Alias
# umzuhängen: der zeigte sonst minutenlang auf eine noch ladende Datei (Lane kalt;
# bei role=hermes wäre Lucy bis Download-Ende tot gewesen — Review 16.07.).
try:
model_id = llamaswap.register_model(
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja)
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja,
set_alias=False)
except PermissionError as exc:
raise HTTPException(500, str(exc))
# Rolle erst NACH erfolgreichem Download übernehmen. hermes geht dabei durch den
# warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) —
# der rohe Alias-Move hatte diesen Flow bisher umgangen.
role = (req.role or "").strip().lower()
def _apply_role() -> None:
if role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
else:
llamaswap.set_role(model_id, role)
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
args = [hf.hf_bin(), "download", repo]
for f in info["files"]:
args.append(f)
args.extend(info["files"])
if info["mmproj"]:
args.append(info["mmproj"])
args += ["--local-dir", str(target)]
env = dict(HF_DOWNLOAD_ENV)
if req.hf_token:
env["HF_TOKEN"] = req.hf_token
job_id = jobengine.start_job(args, f"download {req.repo}", env=env)
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
on_done=_apply_role if role else None)
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
"total_bytes": info["total_bytes"], "files": len(info["files"])}
@@ -161,9 +177,18 @@ def recommend_role(role: str) -> dict:
@router.post("/models/{model_id}/role")
def set_model_role(model_id: str, body: RoleReq) -> dict:
new_role = (body.role or "").strip().lower()
# Guard: Hält das Modell einen LEBENSWICHTIGEN Alias (hermes/embed), darf die Rolle
# hier nicht weggeklickt werden — sonst verliert Lucy Hirn/Gedächtnis mit einem Klick.
# Weg: die geschützte Rolle zuerst einem ANDEREN Modell zuweisen (Alias zieht um).
prot = llamaswap.protected_alias_of(model_id)
if prot and new_role != prot:
raise HTTPException(400,
f"Dieses Modell hält die lebenswichtige Rolle '{prot}'. Weise '{prot}' zuerst "
f"einem anderen Modell zu — danach lässt sich die Rolle hier ändern.")
# Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe +
# ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes.
if (body.role or "").strip().lower() == "hermes":
if new_role == "hermes":
from services.agent import set_agent_brain
res = set_agent_brain(model_id)
if not res.get("ok"):
+14 -2
View File
@@ -4,7 +4,6 @@ LAN-only wie alle MC2-Endpoints."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import reminders
router = APIRouter(prefix="/api")
@@ -16,7 +15,20 @@ class ReminderIn(BaseModel):
repeat: str = "" # '' einmalig | daily | weekdays | weekly
@router.get("/reminders")
class ReminderOut(BaseModel):
id: int
text: str
next_ts: float
repeat: str
created_ts: float
when_local: str
class ReminderListResponse(BaseModel):
items: list[ReminderOut]
@router.get("/reminders", response_model=ReminderListResponse)
def list_reminders() -> dict:
return {"items": reminders.list_all()}
-1
View File
@@ -2,7 +2,6 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import gateway
from services.routing_policy import policy_meta, save_policy
+59 -14
View File
@@ -9,12 +9,10 @@ import logging
import os
import subprocess
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
from fastapi import APIRouter
from pydantic import BaseModel
import httpx
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
from services import backup as backup_svc
from services import maintenance
from services.agent import agent_status
@@ -37,6 +35,13 @@ def status() -> dict:
return system_status()
@router.get("/system/history")
def history(minutes: int = 60) -> dict:
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
from services import metrics_history
return metrics_history.history(minutes)
def _mem0_reachable() -> bool:
try:
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
@@ -51,20 +56,60 @@ def _voice_reachable() -> bool:
return False
def _user_unit_active(unit: str) -> bool:
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
Auf Windows/Dev schlägt das harmlos fehl (False)."""
if os.name != "posix":
return False
try:
r = subprocess.run(["systemctl", "--user", "is-active", unit],
capture_output=True, text=True, timeout=3)
return r.stdout.strip() == "active"
except Exception:
return False
@router.get("/system/services")
def services() -> dict:
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
"""Aggregierte Erreichbarkeit aller Stack-Dienste die EINE Quelle für Cockpit-Kachel,
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
a = agent_status()
gw_url = f"{GATEWAY_URL}/v1"
rows = [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
"ok": engine_reachable(), "scope": "system"},
]
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
if V1_UPSTREAM:
rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
"ok": gateway_reachable(), "scope": "user"})
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
"ok": True, "scope": "user"})
else:
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
"ok": gateway_reachable(), "scope": "user"})
rows += [
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL,
"ok": a["gateway_reachable"], "scope": "user"},
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
"ok": a["hermes_ui_reachable"], "scope": "user"},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL,
"ok": _mem0_reachable(), "scope": "user"},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
"ok": _voice_reachable(), "scope": "user"},
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
]
return {
"services": [
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
{"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()},
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"]},
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
],
"services": rows,
"links": {
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
"gateway": gw_url,
@@ -88,7 +133,7 @@ def _run(cmd: list[str], cwd: str | None = None) -> dict:
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
return {"ok": p.returncode == 0, "code": p.returncode,
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
except Exception as exc:
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
+7 -7
View File
@@ -11,14 +11,16 @@ LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
import logging
import os
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
import time
import httpx
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
from services import announce
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
Timer,
@@ -29,8 +31,6 @@ from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (inter
record_stage,
)
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
import sys as _sys
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
if _GUARD_DIR not in _sys.path:
_sys.path.insert(0, _GUARD_DIR)
@@ -155,7 +155,7 @@ def voice_health() -> dict:
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
out["sidecar"] = r.status_code == 200
out["detail"] = r.json() if r.status_code == 200 else None
except Exception as exc: # noqa: BLE001
except Exception as exc:
out["error"] = str(exc)
return out
@@ -180,7 +180,7 @@ def voice_voices() -> dict:
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
except Exception as exc:
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
@@ -245,7 +245,7 @@ def voice_get_reference() -> dict:
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
r.raise_for_status()
return r.json()
except Exception as exc: # noqa: BLE001
except Exception as exc:
return {"active": False, "error": str(exc)}
+23 -2
View File
@@ -7,6 +7,7 @@ import time
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api")
@@ -30,7 +31,27 @@ def _title_of(p: Path) -> str:
return p.stem
@router.get("/wissen")
class VaultFile(BaseModel):
path: str
name: str
dir: str
title: str
mtime: float
neu: bool
class WissenListResponse(BaseModel):
available: bool
files: list[VaultFile]
class WissenFileResponse(BaseModel):
path: str
content: str
mtime: float
@router.get("/wissen", response_model=WissenListResponse)
def list_vault() -> dict:
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
if not _available():
@@ -54,7 +75,7 @@ def list_vault() -> dict:
return {"available": True, "files": files}
@router.get("/wissen/datei")
@router.get("/wissen/datei", response_model=WissenFileResponse)
def read_file(pfad: str) -> dict:
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
if not _available():
-1
View File
@@ -13,7 +13,6 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import backup as backup_svc
router = APIRouter(prefix="/api")
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""
Parse systemd timeout errors for mission-control-2.service from journalctl.
Captures lines containing "State 'stop-sigterm' timed out" and appends them
to ~/logs/mc2-timeout.log with ISO timestamps.
Permission errors are handled gracefully.
Uses user journal (systemctl --user) to query logs.
"""
import subprocess
from datetime import datetime, timezone
from pathlib import Path
LOG_DIR = Path.home() / "logs"
LOG_FILE = LOG_DIR / "mc2-timeout.log"
SERVICE_NAME = "mission-control-2.service"
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
JOURNALCTL_LIMIT = 1000
def ensure_log_dir() -> None:
"""Create log directory if it doesn't exist."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
def get_timeout_entries() -> list[str]:
"""Run journalctl --user and return lines matching the timeout pattern."""
cmd = [
"journalctl",
"--user",
"-u", SERVICE_NAME,
"--no-pager",
"-n", str(JOURNALCTL_LIMIT),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
print("journalctl timed out")
return []
except PermissionError:
print("Permission denied: journalctl requires access to user logs")
return []
if result.returncode != 0:
if "Permission denied" in result.stderr:
print("Permission denied: journalctl requires access to user logs")
else:
print(f"journalctl failed: {result.stderr.strip()}")
return []
return [
line
for line in result.stdout.splitlines()
if SEARCH_PATTERN in line
]
def write_to_log(entries: list[str]) -> int:
"""Append entries to log file with ISO timestamps. Returns count written."""
ensure_log_dir()
timestamp = datetime.now(timezone.utc).isoformat()
written = 0
with LOG_FILE.open("a", encoding="utf-8") as f:
for entry in entries:
f.write(f"[{timestamp}] {entry}\n")
written += 1
return written
def main() -> None:
entries = get_timeout_entries()
if not entries:
print("No timeout entries found.")
return
count = write_to_log(entries)
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
if __name__ == "__main__":
main()
+13 -7
View File
@@ -9,11 +9,15 @@ import os
import re
import httpx
import psutil
from config import (BOX_CONSOLE_UPSTREAM,
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
from config import (
BOX_CONSOLE_PATH,
BOX_CONSOLE_UPSTREAM,
HERMES_API_URL,
HERMES_BUILTIN_UI_PATH,
HERMES_BUILTIN_UI_UPSTREAM,
HERMES_HOME,
PC_EXECUTOR_URL,
)
log = logging.getLogger(__name__)
@@ -161,6 +165,8 @@ def agent_status() -> dict:
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
"mcp_server_count": _count_enabled_mcp_servers(),
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
"pc_executor_url": PC_EXECUTOR_URL,
}
@@ -192,7 +198,7 @@ def set_agent_brain(model_id: str) -> dict:
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
from services.llamaswap import set_ttl, DEFAULT_TTL
from services.llamaswap import DEFAULT_TTL, set_ttl
set_ttl(model_id, 0)
if old:
set_ttl(old, DEFAULT_TTL)
@@ -251,7 +257,7 @@ def update_brain_model(new_model: str) -> bool:
# Restart the user-space service to apply changes
try:
import services.maintenance as maintenance
from services import maintenance
maintenance.restart_service("hermes-gateway")
except Exception:
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
-1
View File
@@ -20,7 +20,6 @@ import time
from pathlib import Path
import httpx
from config import MODELS_DIR
log = logging.getLogger(__name__)
+6 -2
View File
@@ -258,8 +258,12 @@ def list_proposals() -> dict:
except Exception:
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
# Alle aktuellen Branches als gemeldet speichern
_save_announce_branches(aktuelle_branches)
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
if aktuelle_branches != gemeldet:
_save_announce_branches(aktuelle_branches)
kandidaten = list_skill_kandidaten()
open_count = sum(1 for i in items
+1 -1
View File
@@ -51,7 +51,7 @@ def backup_now() -> dict:
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
if r.returncode != 0:
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
except Exception as exc: # noqa: BLE001
except Exception as exc:
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
latest = _latest()
+12 -10
View File
@@ -10,8 +10,7 @@ die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
import json
import httpx
from config import LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT, V1_UPSTREAM
DEFAULT_HOST = "192.168.178.151"
@@ -36,7 +35,8 @@ IDE_MODELS = [
def _gw(host: str) -> str:
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit).
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
return f"http://{host}:{PORT}/v1"
@@ -113,7 +113,8 @@ def build_snippets(host: str = DEFAULT_HOST,
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · Ask/Debug→chat."},
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
},
@@ -125,18 +126,19 @@ def build_snippets(host: str = DEFAULT_HOST,
def check_health() -> dict:
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
Leitung 1 = Gateway/Engine (llama-swap), Leitung 2 = Gedächtnis-Sidecar (Mem0),
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
sonst llama-swap direkt), Leitung 2 = Gedächtnis-Sidecar (Mem0),
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get(f"{LLAMA_SWAP_URL}/v1/models")
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
if r.status_code == 200:
n = len(r.json().get("data", []))
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
else:
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
except Exception: # noqa: BLE001
except Exception:
pass
memory = {"ok": False, "detail": "nicht erreichbar"}
@@ -145,16 +147,16 @@ def check_health() -> dict:
r = c.get(f"{MEM0_SERVICE_URL}/health")
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001
except Exception:
pass
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
try:
with httpx.Client(timeout=3.0) as c:
r = c.get("http://127.0.0.1:9119/api/status")
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
else {"ok": False, "detail": f"HTTP {r.status_code}"})
except Exception: # noqa: BLE001
except Exception:
pass
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
+2 -3
View File
@@ -9,16 +9,15 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import logging
import math
import os
import time
from datetime import datetime
import httpx
import logging
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services import catalog
from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
+16 -6
View File
@@ -1,10 +1,12 @@
"""
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
Routing-Gateway-Status. Seit UMBAU v3 P1 bedient der EIGENSTÄNDIGE mc2-gateway-
Prozess den /v1-Pfad (MC_V1_UPSTREAM gesetzt, MC2 reicht nur roh durch); ohne
V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
"""
from config import PORT
import httpx
from config import PORT, V1_UPSTREAM
from services.llamaswap import engine_reachable
from services.routing_policy import load_policy
@@ -13,7 +15,7 @@ def routing_summary() -> dict:
p = load_policy()
coding_default = p["coder_lite"] or p["coder"]
return {
"mode": "builtin",
"mode": "gateway (eigener Prozess)" if V1_UPSTREAM else "builtin",
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
"lanes": [
@@ -42,5 +44,13 @@ def routing_summary() -> dict:
def gateway_reachable() -> bool:
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft.
"""Erreichbarkeit des ECHTEN Denkpfads. Mit V1_UPSTREAM ist das der eigenständige
mc2-gateway-Prozess (:9010) — vorher meldete diese Funktion nur die Engine, d. h.
ein toter Gateway blieb in Health/Diensten/Cockpit unsichtbar (Review 15.07.)."""
if V1_UPSTREAM:
try:
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
except Exception:
return False
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
return engine_reachable()
+11 -3
View File
@@ -22,8 +22,16 @@ log = logging.getLogger(__name__)
_trunc_last: dict[str, float] = {}
_TRUNC_EVERY = 600.0 # s
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
_PING_MAXTOK = 16
def warn_truncation(model: str) -> None:
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
return
now = time.time()
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
return
@@ -50,11 +58,11 @@ def record_usage(usage: dict | None, model: str) -> None:
increment_tokens(prompt, completion, model=model)
def record_stream_chunk(chunk: bytes, model: str) -> None:
def record_stream_chunk(chunk: bytes, model: str, max_tokens: int | None = None) -> None:
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
if b'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
warn_truncation(model)
warn_truncation(model, max_tokens)
if b'"usage"' not in chunk:
return
text = chunk.decode("utf-8", errors="ignore")
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -90,7 +90,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
log_str = "\n".join(job["log"])
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
job["sudo_failed"] = True
except Exception as exc: # noqa: BLE001
except Exception as exc:
_append_log(job, f"[mc] Fehler: {exc}")
job["state"] = "failed"
job["returncode"] = -1
@@ -101,7 +101,7 @@ def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_passwor
if cb and job["state"] == "done":
try:
cb()
except Exception as exc: # noqa: BLE001
except Exception as exc:
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
@@ -136,7 +136,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
j["rate_bps"] = rate
j["eta_s"] = int((total_bytes - cur) / rate)
prev_t, prev_b = now, cur
except Exception: # noqa: BLE001
except Exception:
pass
time.sleep(1.0)
j = JOBS.get(job_id)
@@ -182,7 +182,7 @@ def cancel_job(job_id: str) -> bool:
if proc is not None:
try:
proc.terminate()
except Exception: # noqa: BLE001
except Exception:
pass
else:
job["state"] = "canceled"
+190
View File
@@ -0,0 +1,190 @@
"""
Ketten-Digest — Telegram-Status für lange Aufgaben-Ketten (User-Wunsch 20.07.2026).
Wenn die Werkstatt eine Karten-Familie abarbeitet (z. B. „Homelab-Dashboard 2.0" mit
19 verketteten Karten), soll der Commander nicht raten müssen: dieser Wächter liest
die Queue-Sicht (services/ideen.py, inkl. Ketten-Anreicherung) und meldet per Telegram
• SOFORT, wenn eine Karte hängt und eine Frage an den Commander hat (blocked) —
einmal pro Karte, mit der Frage im Wortlaut,
• bei MEILENSTEINEN (Projekt komplett fertig) sofort,
• sonst höchstens einmal pro PULS_SEKUNDEN (Default 60 min) einen Zwischenstand
je aktivem Projekt („5/19 fertig · läuft: … · als Nächstes: …").
Taktung war explizite User-Wahl („Meilensteine + 60-min-Puls, Fragen immer sofort").
Zustand (was wurde wann gemeldet) liegt als JSON neben den Modellen und übersteht
Neustarts — sonst käme nach jedem Deploy ein Duplikat-Schwall. Auf Windows (Dev) No-op.
"""
import asyncio
import json
import logging
import os
import time
import zlib
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
ENABLED = os.name == "posix" and os.environ.get("MC_KETTEN_DIGEST", "1") != "0"
INTERVAL = int(os.environ.get("MC_KETTEN_DIGEST_INTERVAL", "300")) # Prüf-Tick: 5 min
PULS_SEKUNDEN = int(os.environ.get("MC_KETTEN_DIGEST_PULS", "3600")) # Zwischenstand: 60 min
# Hänger-Alarm: laufende Karte ohne Lebenszeichen (Heartbeat) länger als diese Schwelle
# → sofortige Telegram-Warnung (einmal pro Karten-Lauf). Live-Fall 20.07.: Worker wartete
# 45 min auf einen selbst gestarteten uvicorn — „pid_alive" hielt den Claim ewig frisch.
HANG_SEKUNDEN = int(os.environ.get("MC_KETTEN_HANG", "900"))
KANBAN_LOGS = Path(os.environ.get("MC_KANBAN_LOGS", "~/.hermes/kanban/logs")).expanduser()
STATE_PATH = Path(os.environ.get("MC_KETTEN_DIGEST_STATE", str(MODELS_DIR / "mc2-ketten-digest.json")))
def _kontext_druck(task_id: str) -> int:
"""Wie oft der Worker zuletzt den Kontext verdichten musste (Log-Marker) —
mehrfaches „Compacting context" heißt: die Aufgabe sprengt das Fenster."""
try:
p = KANBAN_LOGS / f"{task_id}.log"
with p.open("rb") as f:
f.seek(max(0, p.stat().st_size - 200_000))
tail = f.read().decode("utf-8", errors="replace")
return tail.count("Compacting context")
except Exception:
return 0
def _load_state() -> dict:
try:
d = json.loads(STATE_PATH.read_text(encoding="utf-8"))
return d if isinstance(d, dict) else {}
except Exception:
return {}
def _save_state(state: dict) -> None:
try:
tmp = STATE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATE_PATH)
except OSError:
log.warning("ketten-digest: Zustand %s nicht schreibbar", STATE_PATH, exc_info=True)
def _melden(subject: str, text: str) -> None:
"""Aufs Handy UND in den Briefkasten (silent — Lucy muss den Status nicht sprechen)."""
from services import announce
announce.notify_telegram(subject, text)
try:
announce.add(text, subject, "ketten-digest", "silent")
except Exception:
pass
def _tick() -> None:
from services import ideen
data = ideen.list_queue()
if not data.get("available"):
return
items = data.get("items") or []
projekte = data.get("projekte") or []
state = _load_state()
gemeldete_fragen: dict = state.setdefault("fragen", {})
projekt_state: dict = state.setdefault("projekte", {})
dirty = False
now = int(time.time())
# 1) Hängende Karten mit Frage → sofort, einmal pro Karte. Der Schlüssel enthält den
# Fragen-Text-Hash: hängt dieselbe Karte später mit NEUER Frage, wird wieder gemeldet.
for it in items:
if it.get("status") != "blocked":
continue
frage = (it.get("frage") or "").strip()
key = f"{it['id']}:{zlib.crc32(frage.encode('utf-8')):x}"
if key in gemeldete_fragen:
continue
text = (f"Aufgabe hängt und wartet auf dich: „{(it.get('titel') or '')[:120]}\n"
+ (f"Frage: {frage[:500]}\n" if frage else "")
+ "Antworten geht im Auftragsbuch (Zentrale) — die Box macht dann weiter.")
_melden("[Werkstatt]", text)
gemeldete_fragen[key] = now
dirty = True
# 1b) Hänger-Alarm: laufende Karte ohne Lebenszeichen → einmal pro Karten-Lauf warnen.
gemeldete_haenger: dict = state.setdefault("haenger", {})
for it in items:
if it.get("status") != "running":
continue
puls = it.get("puls_alter")
if puls is None or puls < HANG_SEKUNDEN:
continue
key = f"{it['id']}:{it.get('gestartet') or 0}"
if key in gemeldete_haenger:
continue
minuten = int(puls / 60)
text = (f"Karte scheint zu hängen: „{(it.get('titel') or '')[:120]}“ — "
f"seit {minuten} min kein Lebenszeichen vom Worker.")
druck = _kontext_druck(it["id"])
if druck >= 2:
text += (f"\nDer Worker musste {druck}× den Kontext verdichten — "
"die Aufgabe ist womöglich zu groß geschnitten.")
text += "\nIn der Zentrale: „Neuer Versuch“ startet sie frisch, „Stopp“ hält sie an."
_melden("[Werkstatt]", text)
gemeldete_haenger[key] = now
dirty = True
if len(gemeldete_haenger) > 200:
for k in sorted(gemeldete_haenger, key=gemeldete_haenger.get)[:100]:
gemeldete_haenger.pop(k, None)
dirty = True
# Fragen-Gedächtnis klein halten (Karten verschwinden irgendwann ins Archiv).
if len(gemeldete_fragen) > 200:
for k in sorted(gemeldete_fragen, key=gemeldete_fragen.get)[:100]:
gemeldete_fragen.pop(k, None)
dirty = True
# 2) Projekt-Status: Abschluss sofort, sonst gedrosselter Puls solange gearbeitet wird.
for p in projekte:
ps = projekt_state.setdefault(p["key"], {})
fertig, gesamt = p.get("fertig", 0), p.get("gesamt", 0)
if gesamt > 0 and fertig >= gesamt and not ps.get("abschluss_gemeldet"):
_melden("[Werkstatt]", f"Projekt fertig: „{p['titel']}“ — alle {gesamt} Karten erledigt. "
"Ergebnisse liegen im Auftragsbuch.")
ps.update(abschluss_gemeldet=True, letzter_puls=now, letzter_stand=fertig)
dirty = True
continue
aktiv = p.get("laeuft", 0) > 0
if not aktiv:
continue
if now - int(ps.get("letzter_puls") or 0) < PULS_SEKUNDEN:
continue
laufende = [i for i in items if i.get("projekt") == p["key"] and i["status"] == "running"]
naechste = [i for i in items if i.get("projekt") == p["key"]
and i["status"] in ("ready", "todo") and not i.get("wartet_auf")]
zeilen = [f"Werkstatt-Status „{p['titel']}“: {fertig}/{gesamt} fertig."]
for l in laufende[:2]:
note = f"{l['notiz']}" if l.get("notiz") else ""
zeilen.append(f"Läuft: {l['titel'][:90]}{note}")
if naechste:
zeilen.append(f"Als Nächstes: {naechste[0]['titel'][:90]}")
haengt = p.get("haengt", 0)
if haengt:
zeilen.append(f"{haengt} Karte(n) warten auf deine Antwort im Auftragsbuch.")
_melden("[Werkstatt]", "\n".join(zeilen))
ps.update(letzter_puls=now, letzter_stand=fertig)
dirty = True
if dirty:
_save_state(state)
async def digest_loop() -> None:
"""Hintergrund-Task im MC2-Lifespan (Muster: reminders_loop)."""
log.info("ketten-digest: aktiv (Tick %ss, Puls %ss)", INTERVAL, PULS_SEKUNDEN)
while True:
try:
await asyncio.to_thread(_tick)
except Exception:
log.debug("ketten-digest: Tick fehlgeschlagen", exc_info=True)
await asyncio.sleep(INTERVAL)
+53 -17
View File
@@ -11,12 +11,17 @@ import os
import re
import httpx
from ruamel.yaml.scalarstring import LiteralScalarString
from config import (
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
CMD_TEMPLATE,
CONFIG_PATH,
DEFAULT_TTL,
DRAFTS_DIR,
LLAMA_SWAP_URL,
SPEC_DRAFT_MODEL_PATH,
SPEC_DRAFT_N_MAX,
SPEC_TYPE,
)
from ruamel.yaml.scalarstring import LiteralScalarString
log = logging.getLogger(__name__)
@@ -143,19 +148,36 @@ def model_id_from_path(model_path: str) -> str:
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
d = os.path.basename(os.path.dirname(model_path))
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
if not name:
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
fn = re.sub(r"-\d+-of-\d+$", "", fn)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
return name or "modell"
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
PROTECTED_ALIASES = {"hermes", "embed"}
def protected_alias_of(model_id: str) -> str | None:
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
spec = (read_config().get("models") or {}).get(model_id)
if isinstance(spec, dict):
for a in spec.get("aliases") or []:
if str(a).lower() in PROTECTED_ALIASES:
return str(a).lower()
return None
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
"""Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
models = cfg.get("models") or {}
role = (role or "").strip().lower()
if role:
@@ -169,8 +191,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
spec.pop("aliases", None)
spec = models.get(model_id)
if isinstance(spec, dict):
if role and role != model_id.lower():
spec["aliases"] = [role]
keep = [a for a in (spec.get("aliases") or [])
if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
new = keep + ([role] if role and role != model_id.lower() else [])
if new:
spec["aliases"] = new
else:
spec.pop("aliases", None)
@@ -202,7 +227,7 @@ def write_config(cfg: dict) -> None:
try:
from services import warmer
warmer.nudge()
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
except Exception:
pass
except PermissionError as exc:
raise PermissionError(
@@ -214,9 +239,12 @@ def write_config(cfg: dict) -> None:
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False) -> str:
jinja: bool = False, set_alias: bool = True) -> str:
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
minutenlang auf eine Datei, die noch gar nicht existiert)."""
cfg = read_config()
model_id = model_id_from_path(model_path)
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
@@ -242,11 +270,19 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
cmd += spec_draft_flags(model_path)
cfg.setdefault("models", {})[model_id] = {
# Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
old_aliases = (cfg.get("models") or {}).get(model_id, {})
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
entry: dict = {
"cmd": LiteralScalarString(cmd + "\n"),
"ttl": ttl if ttl is not None else DEFAULT_TTL,
}
set_role_alias(cfg, model_id, role)
if old_aliases:
entry["aliases"] = old_aliases
cfg.setdefault("models", {})[model_id] = entry
if set_alias:
set_role_alias(cfg, model_id, role)
write_config(cfg)
return model_id
+11 -10
View File
@@ -19,7 +19,8 @@ from services import catalog, discover, jobengine, llamaswap, system
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
SYSTEM_SERVICES = {"llama-swap"}
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
@@ -34,7 +35,7 @@ _engine_cache = {"ts": 0.0, "avail": False}
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I)
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
def _latest_engine_asset_release() -> dict | None:
@@ -288,7 +289,7 @@ def model_upgrades() -> list[dict]:
continue
base = rec.split("/")[-1].lower()
stem = base[:-5] if base.endswith("-gguf") else base
stem = base.removesuffix("-gguf")
if base in cmds or (stem and stem in cmds):
continue # schon installiert
out.append({"role": role, "title": c["title"], "repo": rec})
@@ -342,7 +343,7 @@ def _os_held_back() -> list[dict]:
held.append({"name": name, "reason": reason})
elif reason: # nicht eingerückt → Abschnitt zu Ende
reason = None
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
except Exception:
pass
held.sort(key=lambda p: p["name"])
return held
@@ -367,7 +368,7 @@ def os_update_details() -> dict:
out_pkgs.sort(key=lambda p: p["name"])
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
"held_back": _os_held_back()}
except Exception as exc: # noqa: BLE001
except Exception as exc:
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
@@ -392,7 +393,7 @@ def engine_update_details() -> dict:
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
except Exception as exc:
info["error"] = str(exc)
return info
@@ -417,7 +418,7 @@ def swap_update_details() -> dict:
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
"verteilt und Sprachmodelle heiß nachlädt).")
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
except Exception as exc: # noqa: BLE001
except Exception as exc:
info["error"] = str(exc)
return info
@@ -495,7 +496,7 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
if text:
cache.update(key=key, data=data)
return data
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
except Exception as exc:
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
"action_needed": None, "action_text": ""}
@@ -533,7 +534,7 @@ def hermes_update_details() -> dict:
info["behind"] = len(commits)
if commits:
info.update(_summarize_hermes_commits(commits))
except Exception as exc: # noqa: BLE001
except Exception as exc:
info["error"] = str(exc)
return info
@@ -574,7 +575,7 @@ def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
except Exception as exc: # noqa: BLE001
except Exception as exc:
return {"ok": False, "out": "", "err": str(exc)}
-1
View File
@@ -19,7 +19,6 @@ import re
from difflib import SequenceMatcher
import httpx
from config import MEM0_SERVICE_URL
log = logging.getLogger(__name__)
+128
View File
@@ -0,0 +1,128 @@
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
Laden fliegt alles raus, was älter als 24 h ist.
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
Lücke pro Deploy sind egal.
"""
import asyncio
import json
import logging
import os
import time
from collections import deque
from config import MODELS_DIR
log = logging.getLogger(__name__)
SAMPLE_S = 10 # Abtast-Takt
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
FLUSH_S = 300 # höchstens alle 5 min auf Platte
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
_points: deque = deque(maxlen=MAXLEN)
_loaded = False
_last_flush = 0.0
def _load() -> None:
global _loaded
if _loaded:
return
_loaded = True
try:
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
cutoff = time.time() - RETENTION_S
for p in raw if isinstance(raw, list) else []:
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
_points.append(p)
if _points:
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
except FileNotFoundError:
pass
except Exception:
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
def _flush() -> None:
global _last_flush
_last_flush = time.time()
try:
tmp = HISTORY_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
tmp.replace(HISTORY_PATH)
except OSError:
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
def _sample() -> None:
import psutil
from services.system import _gpu_sysfs
from services.token_stats import get_stats
vm = psutil.virtual_memory()
disk = None
try:
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
disk = du.percent
except Exception:
pass
gpu = None
try:
g = _gpu_sysfs()
gpu = g.get("busy_percent") if g else None
except Exception:
pass
tp = tc = None
try:
ts = get_stats()
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
except Exception:
pass
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
async def sampler_loop() -> None:
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
_load()
while True:
try:
await asyncio.to_thread(_sample)
except Exception:
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
if time.time() - _last_flush >= FLUSH_S:
try:
await asyncio.to_thread(_flush)
except Exception:
pass
await asyncio.sleep(SAMPLE_S)
def history(minutes: int = 60) -> dict:
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
_load()
minutes = max(1, min(int(minutes), RETENTION_S // 60))
cutoff = time.time() - minutes * 60
pts = [p for p in _points if p[0] >= cutoff]
stride = max(1, len(pts) // MAX_RETURN_POINTS)
pts = pts[::stride]
return {
"sample_s": SAMPLE_S * stride,
"points": [
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
for p in pts
],
}
+1
View File
@@ -24,6 +24,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo
from config import MODELS_DIR
from services import announce
log = logging.getLogger(__name__)
+2 -2
View File
@@ -20,8 +20,8 @@ import time
import httpx
import psutil
from config import HERMES_API_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
from services import announce, llamaswap
log = logging.getLogger(__name__)
@@ -100,7 +100,7 @@ if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
class _Watch:
__slots__ = ("fails", "alerted", "alert_ts")
__slots__ = ("alert_ts", "alerted", "fails")
def __init__(self) -> None:
self.fails = 0 # Fehl-Ticks in Folge
-1
View File
@@ -12,7 +12,6 @@ import subprocess
import threading
import psutil
from config import MODELS_DIR
-1
View File
@@ -11,7 +11,6 @@ import json
import logging
import threading
import time
from pathlib import Path
from config import HERMES_HOME
+12 -2
View File
@@ -24,7 +24,6 @@ import os
import subprocess
import httpx
from config import LLAMA_SWAP_URL
log = logging.getLogger(__name__)
@@ -74,7 +73,18 @@ def _run_warmup() -> bool:
def _warmset_members() -> set[str]:
"""Soll-Warm-Set = Mitglieder aller ko-residenten Gruppen (swap:false bzw. persist/persistent)."""
"""Soll-Warm-Set. `MC_WARMSET` (leerzeichengetrennt) schlägt die Gruppen-Ableitung.
Warum ein Override (26.07.2026): Seit die ko-residente Gruppe auch Modelle MIT TTL
enthält (Coder 90 min, Kritiker 30 min), ist „alle Gruppen-Mitglieder" das falsche Ziel.
Der Wächter würde gegen die TTLs arbeiten und nachts 62 GB wieder hochladen, die
absichtlich freigegeben wurden. Ko-Residenz („dürfen gleichzeitig liegen") und
Warm-Pflicht („müssen immer liegen") sind zwei verschiedene Aussagen — die Gruppe kann
nur die erste ausdrücken.
"""
explizit = os.environ.get("MC_WARMSET", "").split()
if explizit:
return set(explizit)
try:
from services import llamaswap
groups = llamaswap.list_groups() or {}
+1 -1
View File
@@ -154,7 +154,7 @@ async def volume_control(req: dict):
async def open_target(req: dict):
target = req.get("target", "")
try:
if target.startswith("http://") or target.startswith("https://"):
if target.startswith(("http://", "https://")):
webbrowser.open(target)
else:
os.startfile(target)
@@ -0,0 +1,76 @@
---
name: projekt-uebernehmen
description: Ein von der AI-Box vorbereitetes Projekt in Zed uebernehmen und starten - Konzept und Roadmap einlesen, die offenen Punkte mit dem Commander klaeren, dann Etappe 1 planen und bauen. Nutze diesen Skill beim ERSTEN Chat in einem Projekt-Ordner, der KONZEPT.md und ROADMAP.md enthaelt, aber noch keinen (oder kaum) eigenen Code - also bei jedem frisch vorbereiteten Repo aus dem Auftragsbuch.
metadata:
quelle: mission-control-2/client/ide-skills
---
# Vorbereitetes Projekt übernehmen
Die Box hat gedacht, du baust. Das Konzept in diesem Ordner ist **bereits gehärtet** — mehrere
Fach-Rollen und ein Advocatus Diaboli haben es auf der Box durchgearbeitet. Deine Aufgabe ist
nicht, es neu zu denken, sondern es umzusetzen.
**Zwei Modelle, ein Ablauf:** Stufe 1 und 2 (einlesen, nachfragen, planen) gehören in den
**Planer**, Stufe 3 (bauen) in den **Coder**. Du kannst nicht selbst umschalten — wenn der Plan
freigegeben ist, bitte den Commander ausdrücklich darum, oben im Modellwähler auf Coder zu
wechseln. Läufst du schon im Coder, ist das auch in Ordnung: dann gilt trotzdem erst lesen,
fragen, Plan zeigen — und **keine Datei vor seinem „los"**.
Drei Stufen, in dieser Reihenfolge. **Vor Stufe 3 wird keine Datei angefasst.**
## 1. Einlesen — still, ohne Rückfragen
Lies, was da ist: `KONZEPT.md`, `ROADMAP.md`, `AGENTS.md`, `SAVEPOINT.md`, `README.md`.
Verschaff dir den Ist-Zustand: `git log --oneline -5` und den Dateibaum.
Zwei Abbruch-Fälle — prüfe sie ZUERST:
- **Es gibt schon Code und Historie.** Dann ist das keine Übernahme, sondern Fortsetzung:
arbeite nach `SAVEPOINT.md` weiter und sag in zwei Sätzen, wo ihr steht. Dieser Skill ist
hier fertig.
- **Nur `KONZEPT.md` + `README.md`, keine `ROADMAP.md`.** Das ist ein *geprüfte-Idee*-Repo, kein
startklares Projekt — die Box legt bei „Idee prüfen" bewusst kein Gerüst an. Sag dem Commander:
*„Das ist bisher nur ein durchdachtes Konzept, noch kein Projekt. Im Auftragsbuch gibt es auf
der Karte den Knopf Gefällt mir — Projekt daraus machen'; danach hat das Repo eine Roadmap und
ich kann loslegen."* Dann **STOPP**, nichts bauen.
Sonst: fasse in **höchstens 8 Zeilen** zusammen — was gebaut wird, für wen, welche Technik,
und was laut Roadmap **Etappe 1** ist. Alltagssprache, keine Fachbegriffe ohne Übersetzung.
## 2. Fragen stellen — der eigentliche Wert dieses Schritts
Stell **3 bis 6 Fragen**. Nicht mehr, und nur solche, die den Start wirklich blockieren.
- **Nichts fragen, was in den Dokumenten steht.** Wer das Konzept nicht gelesen hat, merkt man
genau hier.
- Jede Frage: eine Zeile, Alltagssprache, **mit deiner Empfehlung** („ich würde X nehmen, weil …").
Der Commander ist kein Entwickler — frag nach Zielen, Vorlieben und Gegebenheiten, nie nach
Bau-Prinzipien oder Architektur-Geschmack. Das ist dein Job.
- Die üblichen echten Lücken: Wo läuft es am Ende? Welche Zugänge/Geräte/Pfade brauchst du von
ihm? Was ist in Etappe 1 ausdrücklich **nicht** drin? Woran merkt ihr beide, dass Etappe 1
fertig ist?
- Der Abschnitt „offene Punkte" / „Risiken" im Konzept ist deine Fundgrube — genau dafür steht er da.
Dann **warten**. Keine Annahmen, kein Vorpreschen, kein „ich fange schon mal an".
## 3. Plan → „los" → bauen
1. **Etappe-1-Plan** vorlegen: Ziel · Vorgehen in kleinen Schritten · Technik und warum ·
Fertig-Kriterien · Risiken. Etappe 1 muss **für sich lauffähig** sein — eine halbe Baustelle ist
kein Meilenstein.
2. **STOPP**, auf sein „los" warten. Danach der Satz, den er hören muss: *„Bitte oben im
Modellwähler auf **Coder** umschalten, dann lege ich los."* Umschalten kannst du nicht selbst.
3. Bauen: kleine Schritte, ein Sicherungspunkt (Commit mit klarem Namen) je Meilenstein und vor
riskanten Änderungen. Am Ende **live testen** — beweisen statt behaupten.
4. Nachführen: `SAVEPOINT.md` (Stand, nächster Schritt, offene Fragen) und die erledigte Etappe in
`ROADMAP.md` abhaken. Das ist das Gedächtnis für den nächsten Chat.
## Merksätze
- **Du denkst das Konzept nicht neu.** Findest du einen echten Widerspruch oder eine Lücke:
benenne sie und frag — bau nicht still etwas anderes.
- **Nichts bauen, was nicht in Etappe 1 steht.** Ideen für später gehören in die Roadmap, nicht
in den Code.
- Geheimnisse (Schlüssel, Passwörter, Tokens) nie in den Code — `.env` plus `.gitignore`.
- Klemmt dieselbe Sache zweimal: anhalten, sichern, erklären, fragen.
@@ -0,0 +1,27 @@
# Security Memo: Gitea Registrierung deaktivieren
## Status
**Aktuell:** Registrierung ist OFFEN (Standard-Gitea-Setup)
**Ziel:** `DISABLE_REGISTRATION = true` in `[security]` Abschnitt der app.ini setzen
## Risiko
- **DDNS-Endpunkt** (`git.tobisniceshomelab.ddnsfree.com`) ist öffentlich erreichbar
- **Version im Footer** (1.26.2) identifizierbar — potenziell anfällige Version
- Jeder kann sich ohne Genehmigung registrieren → potenzielle Angreifer/Spammer
## Empfohlene Maßnahme
1. Auf dem Gitea-Server (Proxmox LXC `192.168.178.153`) in `/etc/gitea/app.ini` im Abschnitt `[security]` die Zeile `DISABLE_REGISTRATION = true` hinzufügen
2. Gitea neu starten: `sudo systemctl restart gitea` (oder wie auch der Service heißt)
## Commander-Approval notwendig
- ⚠️ Diese Änderung ist **nur im laufenden Betrieb** auf dem Gitea-Server vorzunehmen
- **Branch `wartung/gitea-disable-registration`** enthält eine Beispielkonfiguration und Dokumentation
- **Bitte bestätige mit "JA", dass du die Änderung freigibst**, damit sie auf dem Server umgesetzt werden kann
## Hinweis
Ohne Commander-Ja: keine Änderung an der Gitea-Instanz vornehmen. Die Sicherheitslücke bleibt bestehen, bis die Freigabe erfolgt.
---
*Erstellt am: 2026-07-17*
*Branch: `wartung/gitea-disable-registration`*
+4 -1
View File
@@ -29,10 +29,13 @@ hint=""
ctx="$(cat <<EOF
[BOX-ORIENTIERUNG — du bist ein Hintergrund-Worker auf der lokalen AI-Box (Linux, Zeit UTC / lokal Europe/Berlin, locale de_DE), NICHT Lucy und kein Dev-Laptop. Du arbeitest EINE Kanban-Aufgabe ab.]
Die Modelle / das "Hirn" laufen ausschliesslich ueber den Router llama-swap auf 127.0.0.1:8080 (startet je Modell einen llama-server) bzw. das OpenAI-kompatible Gateway von MC2 auf 127.0.0.1:9001/v1 — es gibt KEINEN anderen Modell-Server, also nicht danach suchen. Weitere Dienste: hermes-gateway :8642, mem0-Gedaechtnis :8765. Der MC2-Live-Checkout ~/mission-control-v2 ist nur zum LESEN da (Schreiben/committen TABU — Code-Arbeit immer im frischen Klon deines Task-Workspaces). Grenzen (was gehoert wohin): MC2 = Steuerpult (verwalten/klicken/zusehen, KEIN Chat-UI), Lucy = Chat & Stimme (eigenes Repo), Hermes-Quelle ist TABU (Agenten-Verhalten nur ueber Config/SOUL/Skill/Hook/MCP aendern). Feste Entscheide/Verbote + volle Grenzen-Landkarte: ~/mission-control-v2/docs/wissen/ (GRENZEN/VERDIKTE/FALLEN/STACK) + ~/wissens-vault/eigenbau-landkarte.md.${hint}
TRIAGE (19.07. — Drei-Bahnen-Regel): Ordne deine Karte ZUERST einer Bahn zu und bleib darin: Denken (Konzept/Plan), Bauen (Code schreiben/aendern), Betrieb (SSH/Deploy/Dienste). Fuer Betrieb-Arbeit lade IMMER zuerst den Skill betrieb-playbook (Diagnose-Checklisten; max 3 Anlaeufe pro Hindernis, dann kanban_block mit exaktem Fehlertext — die No-Progress-Bremse erzwingt das mechanisch).
PROTOKOLL-PFLICHT (15.07.): Beende deine Aufgabe IMMER mit dem Tool-Aufruf kanban_complete (fertig) oder kanban_block (Frage/Sackgasse). Eine Text-Zusammenfassung OHNE diesen Aufruf zaehlt als Absturz (Protokollverstoss) und blockiert die Karte — genau so ging am 14.07. eine fertige Arbeit verloren.
REPO-WEGWEISER mission-control-v2 (NICHT raten/suchen — so liegt es): backend/app.py = Einstieg + Router-Mounting · backend/routers/*.py = REST-Endpunkte · backend/services/*.py = Logik · backend/gateway_app.py = /v1-Gateway-Prozess (:9010) · frontend/src/views/*.tsx = Ansichten · frontend/src/lib/api.ts = Typen+Fetch · frontend/src/lib/queries.ts = TanStack-Query-Hooks (Polling) · frontend/src/nav.ts = Sidebar · deploy/ = Skripte/Units/Hooks/Skills · Projekt-Regeln: AGENTS.md (Wurzel) + docs/wissen/.
FRONTEND-BAUEN (seit 15.07. abends): Node LTS liegt sudo-frei unter ~/.local (node/npm im Worker-PATH). Bei Frontend-Aenderungen baust DU das dist MIT: cd frontend && npm ci --no-audit --no-fund && npm run build — und committest frontend/dist im selben Branch (die Box zeigt sonst nach Annahme den alten Stand; Bau-Beweis 15.07.: kompletter Build in ~4 s).
KONTEXT-BUDGET & ETAPPEN-REGEL (15.07. — vier Worker starben an finish_reason=length): Dein Kontext ist ENDLICH. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte; grosse Dateien nur in Ausschnitten. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage.
WO WAS LAEUFT (20.07. — zwei getrennte Maschinen, NICHT verwechseln): Du laeufst auf der AI-Box (192.168.178.151) — das ist die WERKBANK (Agent/Lucy/MC2/Modelle), NICHT das Ziel-System fuer Projekt-Deployments. Der Proxmox-Host 'pve' (192.168.178.108) ist ein SEPARATER Rechner; dort laufen die LXC-Container. Von hier per 'ssh pve' erreichbar (root, Key), Steuerung mit 'pct'. Welche Container es gerade gibt, schaust du bei Bedarf mit 'ssh pve pct list' nach — nicht raten, nicht auswendig annehmen. Soll etwas dauerhaft laufen, gehoert es in einen LXC auf pve, nicht auf die Box.
REPO FEHLT? (20.07.): Soll dein Ergebnis in ein Gitea-Repo, das noch NICHT existiert (eigenes/neues Projekt), dann lege es SELBST an — NICHT den Commander fragen, NICHT git init: bash ~/.hermes/scripts/gitea-repo-create.sh <name> "<kurze Beschreibung>" (immer privat, letzte Ausgabezeile = CLONE <url>), dann voll klonen und darin arbeiten. NUR mission-control-v2 und das Lucy-Repo NIE neu anlegen/veraendern.
KONTEXT-BUDGET & ETAPPEN-REGEL (15.07./20.07. — Worker starben an finish_reason=length UND an vollgelaufenem Kontext): Dein Kontext ist ENDLICH und fuellt sich vor allem durch GROSSE Datei- und Terminal-Ausgaben. Lies NIE eine grosse Datei am Stueck — nutze grep und Zeilenbereiche (read mit offset/limit); Datei-Reads sind auf 50k Zeichen gedeckelt, aber auch viele mittlere summieren sich. Lies gezielt ueber den Wegweiser statt ls-Ketten und Volltexte. Merkst du, dass die Aufgabe mehr als ~5 Dateien oder mehrere Baustellen umfasst: NICHT durchziehen — schneide Etappen (eine Etappe = ein Worker-Lauf): lege Teil-Karten an (kanban_create, falls verfuegbar) und schliesse deine Karte per kanban_complete mit dem Etappen-Plan ab; alternativ kanban_block mit dem Etappen-Vorschlag als Frage.
EOF
)"
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""No-Progress-Bremse (Phase 2 des Drei-Welten-Plans, 19.07.2026).
Generische, turn-UEBERGREIFENDE Schleifenbremse — ergaenzt Hermes' native
tool_loop_guardrails, die (a) pro Turn zaehlen und (b) nur als Fehler
klassifizierte Ergebnisse sehen. Die teuer gelernte Luecke (SSH-Marathon 18.07.,
36 min / 75 Schleifen): ein Befehl laeuft "erfolgreich" durch, die AUSGABE sagt
aber immer dasselbe ("Permission denied") — der Agent variiert Befehle gegen
eine unsichtbare Wand, ueber Turn-Grenzen hinweg, und nichts stoppt ihn.
Prinzip (KEINE hartkodierten Fehler-Strings — Leitplanke des Commanders:
"wenn sich was aendert, muss es das SELBST erkennen"):
post_tool_call → Ergebnis normalisieren (Zahlen/Hex-IDs raus) → Muster-Hash.
Gleicher Hash beim gleichen Tool in Folge = Zaehler hoch.
Unbekannte Muster landen in neue-signaturen.jsonl —
Futter fuer Traum/Radar, um daraus Playbooks zu verdichten.
pre_tool_call → Zaehler >= 3 fuer dieses Tool: EINMAL mechanisch blocken
(Diagnose-Anweisung statt naechster Blindversuch), Zaehler
zuruecksetzen. Der Folgeversuch ist frei; laeuft er wieder
3x gegen dasselbe Muster, blockt es erneut.
Gezaehlt wird nur, wo Wiederholung wirklich Stillstand heisst: terminal IMMER
(dort verstecken sich die "erfolgreichen" Fehler), andere Tools nur bei
status=error. Lese-Tools deckt die native idempotent-Bremse ab.
Aufruf (hooks in config.yaml): `no-progress-bremse.py pre` bzw. `... post`.
State: ~/.hermes/state/no-progress/<session>.json (48 h Selbst-Aufraeumen).
Fail-open: jeder eigene Fehler -> {} (die Bremse darf nie selbst zur Wand werden).
"""
import hashlib
import json
import os
import re
import sys
import time
STATE_DIR = os.path.expanduser("~/.hermes/state/no-progress")
SIG_LOG = os.path.join(STATE_DIR, "neue-signaturen.jsonl")
SEEN_FILE = os.path.join(STATE_DIR, "seen-sigs.json")
THRESHOLD = 3
STATE_TTL = 48 * 3600
BLOCK_REASON = (
"NO-PROGRESS-BREMSE: '{tool}' hat {n}x in Folge dasselbe Ergebnismuster geliefert — "
"das ist Stillstand, egal wie sehr die Befehle variieren. STOPP. "
"WICHTIG: Dein Befehl wurde NICHT ausgefuehrt. Das hier ist eine Bremse, KEIN Ergebnis — "
"schliesse daraus NICHTS ueber die Existenz oder den Zustand von Dateien, Verzeichnissen "
"oder Diensten (Vorfall 21.07.2026: ein Worker hielt diese Meldung fuer 'Verzeichnis nicht "
"vorhanden' und baute eine halbe Stunde Arbeit neu). "
"1) Benenne das Hindernis in EINEM Satz (was genau meldet das Ergebnis?). "
"2) Diagnose statt Variation — bei SSH/Zugriff ZUERST: Schluessel-Passphrase pruefen "
"(`ssh-keygen -y -f <key> </dev/null` — stilles 'Permission denied' = verschluesselter Key), "
"dann Gegenseite (Auth-Log, Dienststatus, Erreichbarkeit) LESEN statt raten. "
"3) Bleibt es unklar: Aufgabe als blocked/Karte mit dem EXAKTEN Ergebnistext an den "
"Commander uebergeben. Dein naechster Versuch ist frei — nutze ihn erst NACH der Diagnose."
)
# Rein LESENDE Befehle nie bremsen (21.07.2026). Die Bremse verlangt selbst „Diagnose
# statt Variation" — genau die besteht aus vielen kurzen Schau-Befehlen (ls/cat/git log),
# deren Ergebnisse sich stark aehneln. Die Signatur hielt das fuer Stillstand und blockte
# den naechsten Blick; der Worker las die Block-Meldung als „da ist nichts" und baute
# alles neu (Karte t_d26c3203, eine halbe Stunde Arbeit weggeworfen). Geschaut wird also
# ungebremst — gebremst wird nur, wer immer wieder dasselbe TUT.
# Bewusst strukturell (Befehls-Art), nicht per Fehlertext-Liste: die Bremse soll generisch
# bleiben und Neues selbst erkennen.
_LESEND = {"ls", "cat", "head", "tail", "wc", "stat", "find", "grep", "rg", "file",
"du", "df", "echo", "pwd", "tree", "readlink", "basename", "dirname", "which"}
_GIT_LESEND = {"status", "log", "show", "diff", "ls-remote", "ls-files", "rev-parse",
"merge-base", "describe"}
def _nur_lesend(cmd: str) -> bool:
"""True, wenn der Befehl ausschliesslich schaut — kein Schreiben, kein Eingriff."""
cmd = (cmd or "").strip()
if not cmd or ">" in cmd: # jede Umleitung = schreiben
return False
for teil in re.split(r"&&|\|\||;|\|", cmd):
worte = teil.split()
i = 0
while i < len(worte) and "=" in worte[i] and not worte[i].startswith("-"):
i += 1 # VAR=wert-Praefixe ueberspringen
if i >= len(worte):
return False
verb = os.path.basename(worte[i])
if verb == "git":
rest = [w for w in worte[i + 1:] if not w.startswith("-")]
# `git log …` oder `git -C <pfad> log …` → erstes oder zweites Wort zaehlt
if not (rest[:1] and rest[0] in _GIT_LESEND) and \
not (rest[1:2] and rest[1] in _GIT_LESEND):
return False
elif verb not in _LESEND:
return False
return True
_NUM_RE = re.compile(r"\d+")
_HEX_RE = re.compile(r"\b[0-9a-f]{8,}\b")
_WS_RE = re.compile(r"\s+")
def _norm_sig(tool: str, text: str) -> str:
t = (text or "")[:2000].lower()
t = _HEX_RE.sub("#", _NUM_RE.sub("#", t))
t = _WS_RE.sub(" ", t).strip()[:400]
return hashlib.sha1(f"{tool}|{t}".encode()).hexdigest()[:16]
def _session_key(p: dict) -> str:
sid = p.get("session_id") or p.get("parent_session_id") or ""
if sid:
return re.sub(r"[^A-Za-z0-9_.-]", "_", str(sid))[:80]
return "cwd-" + hashlib.sha1(os.getcwd().encode()).hexdigest()[:12]
def _prune() -> None:
now = time.time()
for f in os.listdir(STATE_DIR):
fp = os.path.join(STATE_DIR, f)
if f.endswith(".json") and f != os.path.basename(SEEN_FILE):
if now - os.path.getmtime(fp) > STATE_TTL:
os.remove(fp)
def _load(path: str) -> dict:
try:
with open(path, encoding="utf-8") as fh:
return json.load(fh)
except Exception:
return {}
def _save(path: str, data: dict) -> None:
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(data, fh)
os.replace(tmp, path)
def main() -> None:
mode = sys.argv[1] if len(sys.argv) > 1 else "post"
payload = json.load(sys.stdin)
tool = str(payload.get("tool_name") or "")
if not tool:
print("{}")
return
# Delegations-Ausnahme (20.07.2026): worker.sh / fremdblick.sh sind der
# Fliessband-/Orchestrator-Motor — konzept-fliessband ruft sie pro Runde/Rolle
# WIEDERHOLT auf (verschiedene Prompts, aehnliche Prosa-Ergebnisse). Die Bremse
# bildet ihre Signatur aus dem Ergebnis-Text → sie hielt diese legitimen
# Mehrrunden faelschlich fuer eine Schleife und blockte (Pomodoro-IDE-Test lief
# 19 min dagegen an). Diese Helfer haben ihr EIGENES Runden-Limit (max 3) →
# von der generischen Bremse ausnehmen.
_ti = payload.get("tool_input")
_cmd = _ti.get("command") if isinstance(_ti, dict) else (_ti if isinstance(_ti, str) else "")
if tool == "terminal" and ("worker.sh" in str(_cmd) or "fremdblick.sh" in str(_cmd)):
print("{}")
return
# Schauen ist keine Schleife (siehe _nur_lesend): weder zaehlen noch blocken.
if tool == "terminal" and _nur_lesend(str(_cmd)):
print("{}")
return
os.makedirs(STATE_DIR, exist_ok=True)
sfile = os.path.join(STATE_DIR, _session_key(payload) + ".json")
state = _load(sfile)
tools = state.setdefault("tools", {})
if mode == "post":
status = str(payload.get("status") or "ok")
# terminal immer beobachten (dort tarnen sich Fehler als Erfolg),
# sonst nur echte Fehler — Lese-Tools regelt die native Bremse.
if tool != "terminal" and status != "error":
print("{}")
return
sig = _norm_sig(tool, str(payload.get("result") or ""))
entry = tools.get(tool) or {}
entry["count"] = entry.get("count", 0) + 1 if entry.get("sig") == sig else 1
entry["sig"] = sig
tools[tool] = entry
_save(sfile, state)
_prune()
seen = _load(SEEN_FILE)
sigs = seen.setdefault("sigs", [])
if sig not in sigs and status == "error" or (tool == "terminal" and sig not in sigs and entry["count"] >= 2):
sigs.append(sig)
del sigs[:-500]
_save(SEEN_FILE, seen)
sample = _WS_RE.sub(" ", str(payload.get("result") or ""))[:200]
with open(SIG_LOG, "a", encoding="utf-8") as fh:
fh.write(json.dumps({"ts": int(time.time()), "tool": tool,
"sig": sig, "sample": sample}, ensure_ascii=False) + "\n")
print("{}")
return
# mode == "pre"
entry = tools.get(tool) or {}
if entry.get("count", 0) >= THRESHOLD:
entry["count"] = 0
tools[tool] = entry
_save(sfile, state)
print(json.dumps({"decision": "block",
"reason": BLOCK_REASON.format(tool=tool, n=THRESHOLD)},
ensure_ascii=False))
return
print("{}")
if __name__ == "__main__":
try:
main()
except Exception:
print("{}")
+65 -13
View File
@@ -12,13 +12,18 @@ Task-Scope waere hier sogar gefaehrlich: Worker tragen als task_id den Session-Z
(z.B. 20260713_224206_267304), NICHT die Kanban-t_-ID — ein "nur t_"-Check liesse also alle
Worker-Schreibzugriffe durch (Bug 13.07., so entdeckt).
WICHTIG: Gesperrt wird NUR der home-verankerte Live-Checkout ($HOME/mission-control-v2), NIE
der blosse Name "mission-control-v2" (der Workspace-Klon enthaelt ihn) — sonst waere die
legitime Worker-Arbeit blockiert. Lesen ist erlaubt; geblockt werden nur Schreib-Operationen.
WICHTIG: Gesperrt wird der home-verankerte Live-Checkout ($HOME/mission-control-v2); der
blosse Name "mission-control-v2" zaehlt NUR, wenn er vom cwd aus in einen Tabu-Pfad aufloest
(V16 unten) — der Workspace-Klon (~/.hermes/kanban/workspaces/<id>/mission-control-v2) bleibt
frei, sonst waere die legitime Worker-Arbeit blockiert. Lesen ist erlaubt; geblockt werden
nur Schreib-Operationen.
Ausgabe {"action":"block","message":...} = Tool geblockt; {} = durchlassen.
"""
import sys, json, os, re
import json
import os
import re
import sys
def out(obj):
@@ -78,6 +83,8 @@ if tool == "terminal":
cmd = ti.get("command") or ""
if not cmd:
out({})
cwd_raw = payload.get("cwd") or ""
cwd = os.path.realpath(os.path.expanduser(cwd_raw)) if cwd_raw else ""
# R5 (Review 15.07.): `git init` im Kanban-Workspace hart blocken. Drei Naechte in
# Folge bauten Worker git-init-Orphans ohne gemeinsamen Ursprung mit main
# (cleanse-skill-index 12.07., skill-kanten-generator 13.07., blogwatcher-logging
@@ -86,13 +93,50 @@ if tool == "terminal":
# klonen (SOUL/GRENZEN). Scope: nur Kanban-Worker (Lucy/CLI duerfen z. B. fuer
# projekt-start neue Repos initialisieren).
ws_dir = os.path.join(home, ".hermes", "kanban", "workspaces")
if (payload.get("cwd") or "").startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
if cwd_raw.startswith(ws_dir) and re.search(r"(?:^|[^\w])git\s+init\b", cmd):
out({"action": "block", "message": (
"TABU (R5): `git init` ist im Worker-Workspace gesperrt — Orphan-Branches ohne "
"gemeinsamen Ursprung mit main sind bei der Annahme technisch tot (Vorfaelle "
"12.14.07.). Richtiger Weg: VOLL klonen, z. B. git clone "
"https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2 "
"http://192.168.178.153:3000/Hitonabi/mission-control-v2 "
"(Credentials liegen in ~/.git-credentials), dann Branch von origin/main abzweigen.")})
# Schreib-Subkommandos von git (V16: + clone — `git clone URL ~/mission-control-v2`
# rutschte vorher an (a) UND (d) vorbei).
git_write = (
r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*"
r"(commit|push|reset|checkout|switch|merge|add|rm|mv|clean|stash|rebase|apply|restore|init|clone|remote|config)\b"
)
# V16 (Vorfall 16.07.2026): Werkstatt-Worker t_7a05992a ersetzte um 03:59 den Live-
# Checkout mit cwd=$HOME und RELATIVEN Pfaden (`rm -rf mission-control-v2 && git
# clone ...`) — keine home-verankerte Tabu-Form im Kommandotext, der Guard sah nur
# Strings und liess durch (.venv weg -> Gateway im 203/EXEC-Crash-Loop). Drei Stopfen:
# (1) cwd liegt IN einem Tabu-Pfad -> jede Schreib-Operation blocken (relative Ziele
# treffen zwangslaeufig den Tabu-Baum). Lesen bleibt erlaubt.
if cwd and under_tabu(cwd):
if (re.search(git_write, cmd) or re.search(r"(?:^|[^\w])sed\s+-i", cmd)
or re.search(r"(?:^|[^\w])(rm|rmdir|mv|cp|truncate|dd|chmod|chown|ln|install|mkdir|touch)\s", cmd)
or re.search(r">>?\s*(?![/&])", cmd)
or re.search(r"(?:^|[^\w])tee\s+(?:-a\s+)?(?!/)", cmd)):
block()
# (2) cwd == $HOME -> `git clone`/`git init`, deren Ziel direkt in $HOME landet,
# blocken (exakt der Vorfalls-Hergang). Worker klonen im Task-Workspace; ein
# explizites Ziel AUSSERHALB von $HOME (z.B. Unterordner) bleibt erlaubt.
m = re.search(r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*(?:clone|init)\b([^|&;]*)", cmd)
if cwd == home and m:
toks = m.group(1).split()
dest = toks[-1] if toks else ""
# kein brauchbares Ziel-Argument (leer, Option oder URL) -> implizit direkt in $HOME
implicit = (not dest or dest.startswith("-")
or re.search(r"://|^git@|^[\w.-]+@[\w.-]+:|\.git$", dest))
if (implicit or under_tabu(os.path.join(home, dest))
or os.path.dirname(os.path.realpath(os.path.join(home, dest))) == home):
out({"action": "block", "message": (
"TABU (V16): `git clone`/`git init` direkt in $HOME ist gesperrt — so wurde "
"am 16.07. der Live-Checkout ~/mission-control-v2 durch einen frischen Klon "
"ersetzt (.venv weg, Gateway tot). Klone/initialisiere in deinem Task-"
"Workspace (~/.hermes/kanban/workspaces/<task>/...) oder gib ein Ziel "
"ausserhalb von $HOME an.")})
# Home-verankerte Tabu-Formen. Der Workspace-Klon
# ($HOME/.hermes/kanban/.../mission-control-v2) enthaelt KEINE davon als Teilstring.
tabu_forms = [
@@ -100,16 +144,24 @@ if tool == "terminal":
SRC, "~/.hermes/hermes-agent", "$HOME/.hermes/hermes-agent", "${HOME}/.hermes/hermes-agent",
SYSD, "~/.config/systemd/user", "$HOME/.config/systemd/user", "${HOME}/.config/systemd/user",
]
tabu_alt = "(?:" + "|".join(re.escape(t) for t in tabu_forms) + ")"
# (3) cwd == Vorfahr eines Tabu-Pfads (z.B. $HOME) -> auch die RELATIVE Nennung des
# Tabu-Ziels (mission-control-v2, .hermes/hermes-agent, .config/systemd/user)
# zaehlt als Treffer fuer die Schreib-Checks unten. Die Lookarounds stellen
# sicher, dass der Name ein eigenes Argument beginnt — als Teil eines laengeren
# Pfades (z.B. .hermes/kanban/workspaces/<id>/mission-control-v2) matcht er
# NICHT, die legitime Workspace-Arbeit bleibt frei.
rel_pats = []
if cwd:
for t in (LIVE, SRC, SYSD):
if t.startswith(cwd + os.sep):
rel = os.path.relpath(t, cwd).replace(os.sep, "/")
rel_pats.append(r"(?<![\w./@-])(?:\./)?" + re.escape(rel) + r"(?![\w.-])")
tabu_alt = "(?:" + "|".join([re.escape(t) for t in tabu_forms] + rel_pats) + ")"
if not re.search(tabu_alt, cmd):
out({}) # kein home-verankerter Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
out({}) # kein Tabu-Pfad referenziert -> Workspace-Arbeit, durchlassen
# (a) git-Schreib-Subkommando (Repo-Kontext ist der referenzierte Tabu-Pfad).
if re.search(
r"(?:^|[^\w])git(?:\s+-C\s+\S+)?\s+(?:-[\w-]+\s+)*"
r"(commit|push|reset|checkout|switch|merge|add|rm|mv|clean|stash|rebase|apply|restore|init|remote|config)\b",
cmd,
):
if re.search(git_write, cmd):
block()
# (b) sed -i, das einen Tabu-Pfad anfasst.
if re.search(r"(?:^|[^\w])sed\s+-i\S*\s+[^|&;]*" + tabu_alt, cmd):
+76
View File
@@ -0,0 +1,76 @@
# Universelle CI-Ampel — wird bei der Repo-Anlage automatisch eingepflanzt
# (gitea-repo-create.sh, Wasserdicht-Runde 22.07.2026) und läuft auf dem
# Gitea-Actions-Runner (arcane-VM).
# GRUNDSATZ: Rot ist ein Ergebnis („nicht bewiesen"), kein Ärgernis.
# • Reines Doku-Repo (noch kein Code) → GRÜN mit Vermerk.
# • Code ohne Tests → ROT. Tests sind Pflicht, kein Deko.
# Der Workflow erkennt selbst, was das Projekt ist (Python / Node / beides).
name: Ampel
on: [push, pull_request]
jobs:
ampel:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
shell: bash
run: |
# Der Runner startet bash mit -e — das schalten wir ab: die Ampel wertet
# JEDEN Fehler selbst (rot=1) und liefert am Ende EIN Klartext-Urteil.
# (Lehre Lauf #5: ungeschütztes pytest unter -e schnitt den Bericht ab.)
set -u +e
rot=0
py_datei=$(find . -name '*.py' -not -path './.git/*' -not -path '*/node_modules/*' -print -quit)
pkg_dateien=$(find . -name package.json -not -path '*/node_modules/*' -not -path './.git/*')
if [ -z "$py_datei" ] && [ -z "$pkg_dateien" ]; then
echo "✅ Doku-Repo (noch kein Code) — Ampel GRÜN mit Vermerk."
exit 0
fi
if [ -n "$py_datei" ]; then
echo "== Python erkannt =="
{ python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate; } || { echo "❌ Python-Setup kaputt (venv)"; exit 1; }
pip install -q ruff pytest || { echo "❌ Werkzeug-Installation kaputt"; exit 1; }
echo "-- Ruff (Linter: toter Code, kaputte Imports, Schlampereien)"
ruff check . || rot=1
echo "-- Abhängigkeiten installierbar? (halluzinierte Pakete fliegen hier auf)"
while IFS= read -r req; do
[ -n "$req" ] || continue
pip install -q -r "$req" || { echo "❌ $req nicht installierbar"; rot=1; }
done < <(find . -name 'requirements*.txt' -not -path '*/node_modules/*' -not -path './.git/*')
echo "-- Importierbar? (kaputte Modul-Struktur fliegt hier auf)"
python -m compileall -q . || rot=1
echo "-- Pytest (keine Tests gefunden = ROT)"
ec=0; pytest -q || ec=$?
if [ $ec -eq 5 ]; then
echo "❌ KEINE TESTS GEFUNDEN — Tests sind Pflicht, kein Deko (AGENTS.md)."
rot=1
elif [ $ec -ne 0 ]; then
rot=1
fi
fi
if [ -n "$pkg_dateien" ]; then
echo "== Node/TypeScript erkannt =="
while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
d=$(dirname "$pkg")
echo "-- $d"
if [ ! -f "$d/package-lock.json" ]; then
echo "❌ $d: kein package-lock.json — Build nicht reproduzierbar."
echo " Fix: dort 'npm install --package-lock-only' ausführen und committen."
rot=1; continue
fi
(cd "$d" && npm ci --no-audit --no-fund) || { rot=1; continue; }
if grep -q '"build"' "$pkg"; then (cd "$d" && npm run build) || rot=1; fi
if grep -q '"test"' "$pkg"; then (cd "$d" && npm test --silent) || rot=1; fi
done <<< "$pkg_dateien"
fi
if [ $rot -ne 0 ]; then
echo "❌ AMPEL ROT — nichts heißt fertig', solange das rot ist."
fi
exit $rot
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Ampel-Wächter (22.07.2026, Wasserdicht-Runde): meldet ROTE CI-Läufe aller
# Gitea-Repos per Telegram. Läuft als no-agent-Cron (stdout leer = still,
# klassisches Watchdog-Muster). Nur NEUE Fehlschläge werden gemeldet —
# Status-Merkliste unter ~/.hermes/state/ampel-waechter-gesehen.txt.
set -uo pipefail
STATE="$HOME/.hermes/state/ampel-waechter-gesehen.txt"
mkdir -p "$(dirname "$STATE")"; touch "$STATE"
TOKEN=$(grep -m1 'git\.tobisniceshomelab' "$HOME/.git-credentials" 2>/dev/null | sed -E 's#https://[^:]+:([^@]+)@.*#\1#')
[ -n "$TOKEN" ] || exit 0
python3 - "$TOKEN" "$STATE" <<'PY'
import json, sys, urllib.request
token, state_pfad = sys.argv[1], sys.argv[2]
# API intern (die DDNS-Domain ist nachts wegen Zwangstrennung oft tot, 24.07.2026);
# WEB bleibt die Domain — Telegram-Links muessen auch von unterwegs klickbar sein.
BASE = "http://192.168.178.153:3000/api/v1"
WEB = "https://git.tobisniceshomelab.ddnsfree.com"
def api(pfad):
req = urllib.request.Request(BASE + pfad, headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=20) as r:
return json.load(r)
try:
gesehen = set(open(state_pfad).read().split())
except OSError:
gesehen = set()
neu = []
try:
# /repos/search statt /user/repos: das Token hat nur write:repository-Scope,
# /user/repos verlangt read:user -> 403. Der stille exit 0 hat diesen Defekt
# seit Inbetriebnahme versteckt (Fund 24.07.2026, Merkliste blieb leer).
repos = api("/repos/search?limit=50&private=true").get("data", [])
except Exception as e:
print(f"ampel-waechter: Repo-Liste fehlgeschlagen: {e}", file=sys.stderr)
sys.exit(0)
for repo in repos:
full = repo.get("full_name", "")
if not full or repo.get("archived"):
continue
try:
d = api(f"/repos/{full}/actions/tasks?limit=1")
except Exception:
continue
rows = d.get("workflow_runs", d if isinstance(d, list) else d.get("entries", []))
for t in rows[:1]:
kennung = f"{full}#{t.get('id')}"
status = t.get("status")
if status == "failure" and kennung not in gesehen:
# Status-Lügen-Wache (Fund 22.07.): alter Runner meldete Abschluss nicht,
# Gitea zombie-markierte GRÜNE Jobs als failure. Das Job-LOG ist die
# Wahrheit — steht dort „Job succeeded", ist es KEIN Alarm.
try:
req = urllib.request.Request(
f"{BASE}/repos/{full}/actions/jobs/{t.get('id')}/logs",
headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=20) as r:
log_ende = r.read().decode(errors="replace")[-4000:]
wirklich_rot = "Job succeeded" not in log_ende
except Exception:
wirklich_rot = True
if wirklich_rot:
neu.append((full, t.get("head_sha", "")[:7], t.get("name", "")))
# Nur ABGESCHLOSSENE Läufe merken — ein 'running', das später rot wird,
# darf nicht schon als gesehen gelten.
if status in ("failure", "success", "cancelled", "skipped"):
gesehen.add(kennung)
if neu:
print("🔴 CI-AMPEL ROT:")
for full, sha, name in neu:
print(f"• {full} @ {sha} ({name})")
print(f" {WEB}/{full}/actions")
print("Nichts aus diesen Ständen ist fertig', solange die Ampel rot ist.")
with open(state_pfad, "w") as f:
f.write("\n".join(sorted(gesehen)))
PY
+14
View File
@@ -6,6 +6,20 @@ LEBENDEN Box (Dienste, Modelle, Latenzen, Logs, Ressourcen). Keine Persona, kein
**Dein Ergebnis ist ein Mess-/Diagnose-BERICHT mit belegten Zahlen — du misst und berichtest,
du reparierst nicht.**
## Schritt 0: WIEDERAUFNAHME — bei „Prior attempts" erst nachsehen, dann messen
Steht in deinem Auftrag ein Abschnitt **„Prior attempts on this task"**, hat vor dir schon ein
Lauf gemessen und ist unterwegs gestorben (meist Kontext-Limit). **Dein Workspace ist derselbe.**
Also zuerst: `cat FORTSCHRITT.md` und `ls -la` im Workspace — liegen dort schon Messwerte, Logs,
Bench-Ausgaben? **Vorhandene Zahlen misst du NICHT neu.** Das ist hier keine Bequemlichkeit,
sondern Pflicht: ein wiederholter Bench lädt 70-GB-Modelle, gefährdet Lucys Warm-Set und kann
einen OOM auslösen (Regel 5). Fehlt nur noch die Auswertung, wertest du aus und schließt ab.
Nur was nachweislich fehlt oder unbrauchbar ist, wird neu gemessen.
**Fortschritt hinterlassen:** jede fertige Messung sofort als Zeile in `FORTSCHRITT.md` im
Workspace (was gemessen, welche Zahl, in welcher Datei) — und die Rohausgabe in eine Datei
daneben. Eine Messung, die nur im Kontext steht, ist beim nächsten Lauf verloren.
## Eiserne Regeln (nicht verhandelbar)
1. **MESSEN, NICHT ÄNDERN.** Du fasst das Live-System NICHT an: kein `systemctl restart`, kein
Config-Edit, kein Deploy/Merge/Push, kein Laden/Entladen von Modellen, das das Warm-Set
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# curator-archive-watchdog.sh
# Archiviert Skills nach 14 Tagen Inaktivität (nächster Durchlauf um 3:00 Uhr)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_SCRIPT="$SCRIPT_DIR/curator_archive_logic.py"
# Python-Skript ausführen
python3 "$PYTHON_SCRIPT"
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# curator-idle-watchdog.sh
# Prüft den Curator-Idle-Status und triggert Reaktivierung nach 14 Tagen
set -euo pipefail
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
LOG_FILE="$LOG_DIR/idle-watchdog-$(date +%Y%m%d).log"
THRESHOLD_DAYS=14
mkdir -p "$LOG_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# Curator-Status auslesen
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
log "FEHLER: hermes curator status fehlgeschlagen"
exit 1
}
# Prüfen, ob Curator ENABLED ist
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
exit 0
fi
# Letzten Lauf extrahieren (Format: "last run: 17d ago")
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
if [[ -z "$LAST_RUN_LINE" ]]; then
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
exit 1
fi
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
# Prüfen, ob "d ago" vorhanden ist
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
else
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen) → überspringe"
exit 0
fi
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
# Threshold prüfen
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage) → kein Trigger nötig"
exit 0
fi
# Reaktivierung triggern
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS) → triggere 'hermes curator run'"
if hermes curator run 2>&1 | tee -a "$LOG_FILE"; then
log "ERFOLG: Curator-Reaktivierung gestartet"
else
log "FEHLER: Curator-Reaktivierung fehlgeschlagen"
exit 1
fi
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# curator-monitor.sh
# Prüft den Curator-Idle-Status mit 7-Tage-Schwellenwert.
# GIBT AUS: PASS oder FAIL mit Detaillierung.
# EXIT 0 = OK (Curator aktiv innerhalb von 7 Tagen)
# EXIT 1 = FAIL (Curator >7 Tage idle) Monitoring-Skript erkannt Problem
#
# --dry-run DAYS Simuliert eine >DAYS-alte Curator-Auszeit (für Tests)
set -euo pipefail
LOG_DIR="/home/hitonabi/.hermes/logs/curator"
LOG_FILE="$LOG_DIR/monitor-$(date +%Y%m%d).log"
THRESHOLD_DAYS=7
mkdir -p "$LOG_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# --- Argumente parsen ---
DRY_RUN=false
DRY_RUN_DAYS=""
for arg in "$@"; do
case "$arg" in
--dry-run)
DRY_RUN=true
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
DRY_RUN_DAYS="$2"
shift
else
DRY_RUN_DAYS=8 # Default-Testwert
fi
;;
esac
done
# --- LIVE-PRÜFUNG oder DRY-RUN ---
if [[ "$DRY_RUN" == "true" ]]; then
log "DRY-RUN: Simuliere Curator-Auszeit von $DRY_RUN_DAYS Tagen"
DAYS_SINCE_LAST_RUN="$DRY_RUN_DAYS"
else
# Live-Status abfragen
STATUS_OUTPUT=$(hermes curator status 2>&1) || {
log "FEHLER: hermes curator status fehlgeschlagen"
echo "FAIL: hermes curator status fehlgeschlagen"
exit 1
}
# Prüfen, ob Curator ENABLED ist
if ! echo "$STATUS_OUTPUT" | grep -q "^curator: ENABLED$"; then
log "INFO: Curator ist nicht ENABLED → überspringe Prüfung"
echo "PASS: Curator ist nicht ENABLED (keine Aktivität nötig)"
exit 0
fi
# Letzten Lauf extrahieren
LAST_RUN_LINE=$(echo "$STATUS_OUTPUT" | grep "^ last run:" | head -1)
if [[ -z "$LAST_RUN_LINE" ]]; then
log "FEHLER: Konnte 'last run' nicht aus Status extrahieren"
echo "FAIL: Konnte 'last run' nicht extrahieren"
exit 1
fi
LAST_RUN_TEXT=$(echo "$LAST_RUN_LINE" | sed 's/^ last run: *//')
log "Letzter Curator-Lauf: $LAST_RUN_TEXT"
# Tage extrahieren
if [[ "$LAST_RUN_TEXT" =~ ([0-9]+)d\ ago ]]; then
DAYS_SINCE_LAST_RUN="${BASH_REMATCH[1]}"
else
log "INFO: Kein 'd ago' gefunden (möglicherweise <1d oder nie gelaufen)"
echo "PASS: Curator ist aktuell aktiv oder <1 Tag idle"
exit 0
fi
fi
log "Tage seit letztem Lauf: $DAYS_SINCE_LAST_RUN"
# --- MONITORING-LOGIK (keine Reaktivierung!) ---
if (( DAYS_SINCE_LAST_RUN < THRESHOLD_DAYS )); then
log "INFO: Curator ist innerhalb des erlaubten Intervalls ($DAYS_SINCE_LAST_RUN < $THRESHOLD_DAYS Tage)"
echo "PASS: Curator ist aktiv ($DAYS_SINCE_LAST_RUN Tage idle)"
exit 0
else
log "WARNUNG: Curator ist für $DAYS_SINCE_LAST_RUN Tage idle (>= $THRESHOLD_DAYS)"
echo "FAIL: Curator-Cron ist für $DAYS_SINCE_LAST_RUN Tage stumm (>= $THRESHOLD_DAYS-Tage-Schwellenwert)"
exit 1
fi
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
Curator Auto-Archivierung: Archiviert Skills nach 14 Tagen Inaktivität.
Liest `.curator_state` und `.usage.json` aus ~/.hermes/skills/, prüft
auf Inaktivität (>14 Tage last_used_at), schließt aus (pinned, system)
und verschiebt in .archive/<Kategorie>/.
"""
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
CURATOR_STATE_FILE = HERMES_SKILLS_DIR / ".curator_state"
USAGE_JSON_FILE = HERMES_SKILLS_DIR / ".usage.json"
ARCHIVE_DIR = HERMES_SKILLS_DIR / ".archive"
LOG_DIR = Path.home() / ".hermes" / "logs" / "curator"
def load_json(path: Path) -> dict:
"""JSON-Datei laden."""
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def save_json(path: Path, data: dict) -> None:
"""JSON-Datei speichern (mit Pretty-Print)."""
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
def is_stale(skill_usage: dict, threshold_days: int = 14) -> bool:
"""Prüft, ob ein Skill älter als threshold_days ist."""
last_used = skill_usage.get("last_used_at")
if not last_used:
return False
# ISO-Format mit optionaler Zeitzone
last_used_str = last_used.replace("Z", "+00:00")
try:
last_used_dt = datetime.fromisoformat(last_used_str)
except ValueError:
return False
now = datetime.now(timezone.utc)
delta = now - last_used_dt
return delta.days >= threshold_days
def is_excluded(skill_name: str, excluded: list) -> bool:
"""Prüft, ob ein Skill ausgeschlossen ist."""
return skill_name in excluded
def is_pinned(skill_usage: dict) -> bool:
"""Prüft, ob ein Skill angepinnt ist."""
return skill_usage.get("pinned", False) is True
def get_skill_category(skill_name: str) -> str:
"""
Ermittelt die Kategorie eines Skills basierend auf dem Verzeichnisnamen.
Gibt 'unknown' zurück, wenn keine Kategorie gefunden wird.
"""
# Versuche, die Kategorie aus dem Verzeichnis zu ermitteln
# z.B. 'betrieb-playbook' → 'betrieb-playbook' (direkt unter skills/)
# 'apple' (in .archive/apple/) → 'apple'
# Falls der Skill direkt in skills/ liegt ohne Unterverzeichnis:
return "unknown"
def ensure_archive_category(category: str) -> Path:
"""Erstellt das Archiv-Verzeichnis für eine Kategorie, falls nötig."""
category_path = ARCHIVE_DIR / category
category_path.mkdir(parents=True, exist_ok=True)
return category_path
def move_skill_to_archive(skill_name: str, category: str) -> Path:
"""
Verschiebt ein Skill-Verzeichnis in das Archiv.
Gibt den neuen Pfad zurück.
"""
source = HERMES_SKILLS_DIR / skill_name
target_category = ensure_archive_category(category)
target = target_category / skill_name
if source.exists():
shutil.move(str(source), str(target))
return target
def archive_skill(skill_name: str, usage_data: dict) -> dict:
"""
Archiviert einen Skill:
- Setzt state auf 'archived'
- Setzt archived_at auf aktuelle Zeit
- Verschiebt das Verzeichnis nach .archive/
Gibt das aktualisierte Usage-Dict zurück.
"""
usage_data[skill_name]["state"] = "archived"
usage_data[skill_name]["archived_at"] = datetime.now(timezone.utc).isoformat()
return usage_data
def log_archive_action(skill_name: str, category: str, success: bool, message: str) -> None:
"""Protokolliert eine Archivierungsaktion."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_file = LOG_DIR / f"archive-{datetime.now(timezone.utc).date().isoformat()}.log"
timestamp = datetime.now(timezone.utc).isoformat()
status = "SUCCESS" if success else "FAILED"
log_line = f"[{timestamp}] {status} | {skill_name} ({category}) | {message}\n"
with open(log_file, "a", encoding="utf-8") as f:
f.write(log_line)
def main() -> None:
"""Hauptfunktion: Archiviert stale Skills."""
# 1. Config laden
if not CURATOR_STATE_FILE.exists():
print(f"Fehler: {CURATOR_STATE_FILE} nicht gefunden.")
return
curator_state = load_json(CURATOR_STATE_FILE)
archivierung = curator_state.get("archivierung", {})
if not archivierung.get("enabled", False):
print("Archivierung ist nicht aktiviert.")
return
threshold_days = archivierung.get("threshold_days", 14)
excluded_skills = archivierung.get("excluded_skills", [])
# 2. Usage-Daten laden
if not USAGE_JSON_FILE.exists():
print(f"Fehler: {USAGE_JSON_FILE} nicht gefunden.")
return
usage_data = load_json(USAGE_JSON_FILE)
# 3. Skills prüfen
archived_count = 0
skipped_count = 0
for skill_name, skill_usage in usage_data.items():
# Ausschlusskriterien prüfen
if is_excluded(skill_name, excluded_skills):
print(f"Übersprungen (Exkludiert): {skill_name}")
skipped_count += 1
continue
if is_pinned(skill_usage):
print(f"Übersprungen (Gepinnt): {skill_name}")
skipped_count += 1
continue
# Inaktivität prüfen
if not is_stale(skill_usage, threshold_days):
print(f"Übersprungen (Aktiv): {skill_name}")
skipped_count += 1
continue
# Archivierung durchführen
category = get_skill_category(skill_name) or "unknown"
print(f"Archiviere: {skill_name}{category}")
try:
# Usage aktualisieren
usage_data = archive_skill(skill_name, usage_data)
# Verzeichnis verschieben
move_skill_to_archive(skill_name, category)
# Erfolg protokollieren
log_archive_action(skill_name, category, True, "Skill archiviert")
archived_count += 1
except Exception as e:
log_archive_action(skill_name, category, False, str(e))
print(f"Fehler beim Archivieren von {skill_name}: {e}")
# 4. Usage-Daten speichern
save_json(USAGE_JSON_FILE, usage_data)
# 5. Archivierungs-Zeitstempel aktualisieren
curator_state["archivierung"]["last_archive_run_at"] = datetime.now(timezone.utc).isoformat()
save_json(CURATOR_STATE_FILE, curator_state)
print(f"\nArchivierung abgeschlossen: {archived_count} archiviert, {skipped_count} übersprungen.")
if __name__ == "__main__":
main()
+56 -2
View File
@@ -89,6 +89,9 @@ cp "$SRC/deploy/mc2-selfsmoke.timer" "$HOME/.config/systemd/user/mc2-selfsmoke.t
# Bagatell-Annahme (Ideen-Queue, 10.07.2026): reine Doku-Vorschläge nachts ohne Klick einspielen.
cp "$SRC/deploy/mc2-bagatell.service" "$HOME/.config/systemd/user/mc2-bagatell.service"
cp "$SRC/deploy/mc2-bagatell.timer" "$HOME/.config/systemd/user/mc2-bagatell.timer"
# Morgen-Digest (Nächtliche Telegram-Zusammenfassung, 26.07.2026): fasst alle Meldungen zwischen 0 und 7 Uhr zusammen.
cp "$SRC/deploy/mc2-morgen-digest.service" "$HOME/.config/systemd/user/mc2-morgen-digest.service"
cp "$SRC/deploy/mc2-morgen-digest.timer" "$HOME/.config/systemd/user/mc2-morgen-digest.timer"
# Evolution-Radar-Feed (Autonomie E4): Hermes-cron speist ihn als Prompt ein.
# Cron-Job selbst wird EINMALIG registriert (hermes cron create, siehe docs/RUNBOOK.md).
mkdir -p "$HOME/.hermes/scripts"
@@ -105,6 +108,8 @@ cp "$SRC/deploy/fremdblick.sh" "$HOME/.hermes/scripts/fremdblick.sh"
cp "$SRC/deploy/worker.sh" "$HOME/.hermes/scripts/worker.sh"
# Chef-Gutachter-Cron (Orchestrator-Finale): gpt-oss urteilt nachts über die autonome Arbeit.
cp "$SRC/deploy/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh"
# Wrapper für nächtliche Crons, um Telegram-Spam in den Morgen-Digest zu leiten.
cp "$SRC/deploy/night-cron-wrapper.sh" "$HOME/.hermes/scripts/night-cron-wrapper.sh"
# Release-Radar-Cron (10.07.2026): hält neue hermes-agent-Releases wöchentlich gegen die
# Eigenbau-Landkarte im Wissens-Vault ("mehr Hermes nativ, weniger Eigenkreationen").
cp "$SRC/deploy/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh"
@@ -115,6 +120,24 @@ cp "$SRC/deploy/idle-radar-feed.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh"
# eigene Aenderungen; der Karten-Gutachter stempelt jede Karte mit einer Empfehlung.
cp "$SRC/deploy/selbst-inventur.sh" "$HOME/.hermes/scripts/selbst-inventur.sh"
cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
# Trend-Radar (17.07.2026, Sa 05:15): Live-Puls aller Rollen + Engine-A/B + Watch-Liste +
# HF-Kandidaten-Suche → max. 1 Prüfstand-Kandidat/Woche. Prüfstand (So 01:00, --no-agent):
# volles Programm (Coding/Agent/Recherche/Deutsch/Vision) gegen die Amtsinhaber-Baseline;
# Harness + Suiten liegen im Repo ($SRC/deploy/pruefstand/), nur der Runner wird kopiert.
cp "$SRC/deploy/trend-radar-feed.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh"
cp "$SRC/deploy/pruefstand/pruefstand.sh" "$HOME/.hermes/scripts/pruefstand.sh"
# Restore-Probe + Venv-Audit (17.07.2026, monatliche --no-agent-Crons): echte
# Wiederherstellungs-Drills (PBS-Canary + Tarball) und CVE-/Outdated-Audit der
# Python-Nebendienst-venvs (mem0, voice) — melden nur, ändern nichts.
cp "$SRC/deploy/restore-probe.sh" "$HOME/.hermes/scripts/restore-probe.sh"
cp "$SRC/deploy/venv-audit.sh" "$HOME/.hermes/scripts/venv-audit.sh"
# mem0-Konsolidierung (monatlich am 3., 19.07.2026): legt aehnliche Fakten zusammen,
# loest Widersprueche (neuester gewinnt) — mem0 v2 ist ADD-only und tut das nie selbst.
cp "$SRC/deploy/mem0-konsolidierung.py" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
cp "$SRC/deploy/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh"
# Gitea-Repo-Anlage (17.07.2026): schließt Lucys „Projekte selbst starten"-Lücke —
# legt PRIVATE Projekt-Repos an (Token aus ~/.git-credentials, braucht write:user-Scope).
cp "$SRC/deploy/gitea-repo-create.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh"
# Werkstatt-Kanban-Profil (Ideen-Queue, 10.07.2026): Leitplanken des Worker-Profils nachziehen
# (Profil selbst wurde einmalig per `hermes profile create werkstatt --clone` angelegt).
[ -d "$HOME/.hermes/profiles/werkstatt" ] && cp "$SRC/deploy/werkstatt-SOUL.md" "$HOME/.hermes/profiles/werkstatt/SOUL.md"
@@ -122,7 +145,12 @@ cp "$SRC/deploy/karten-gutachter.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
# statt Lucy-default. Profil selbst wird einmalig per `hermes profile create betrieb
# --clone-from werkstatt` angelegt (model coder→hermes, Description fürs Specifier-Routing).
[ -d "$HOME/.hermes/profiles/betrieb" ] && cp "$SRC/deploy/betrieb-SOUL.md" "$HOME/.hermes/profiles/betrieb/SOUL.md"
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh"
# IDE-Vorbereiter (20.07.2026): haertet ein Konzept (konzept-fliessband), legt das Repo an und
# fuellt es NUR mit Doku — der Commander baut dann in Zed. Sonderfall „IDEE PRUEFEN": nur Konzept,
# kein Geruest, kein Bau-Druck. Profil einmalig per `hermes profile create projektstart
# --clone-from werkstatt` angelegt (model coder→hermes, damit heavy warm bleibt).
[ -d "$HOME/.hermes/profiles/projektstart" ] && cp "$SRC/deploy/projektstart-SOUL.md" "$HOME/.hermes/profiles/projektstart/SOUL.md"
chmod +x "$HOME/.hermes/scripts/fremdblick.sh" "$HOME/.hermes/scripts/dreaming-feed.sh" "$HOME/.hermes/scripts/worker.sh" "$HOME/.hermes/scripts/chef-gutachter-feed.sh" "$HOME/.hermes/scripts/hermes-release-radar-feed.sh" "$HOME/.hermes/scripts/radar-selbstkritik-wrapper.sh" "$HOME/.hermes/scripts/idle-radar-feed.sh" "$HOME/.hermes/scripts/selbst-inventur.sh" "$HOME/.hermes/scripts/karten-gutachter.sh" "$HOME/.hermes/scripts/trend-radar-feed.sh" "$HOME/.hermes/scripts/pruefstand.sh" "$HOME/.hermes/scripts/restore-probe.sh" "$HOME/.hermes/scripts/venv-audit.sh" "$HOME/.hermes/scripts/gitea-repo-create.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.sh" "$HOME/.hermes/scripts/mem0-konsolidierung.py"
# Werkstatt-Skill (Autonomie E6): Selbstwartungs-Kreislauf für Hermes.
mkdir -p "$HOME/.hermes/skills/wartung"
cp "$SRC/deploy/skills/wartung/SKILL.md" "$HOME/.hermes/skills/wartung/SKILL.md"
@@ -146,6 +174,19 @@ cp "$SRC/deploy/skills/projekt-start/SKILL.md" "$HOME/.hermes/skills/projekt-sta
# Output = Konzept-Dokument (~/konzepte/<slug>-konzept.md) → Vorlage fuer projekt-start in Hermes Desktop.
mkdir -p "$HOME/.hermes/skills/konzept-fliessband"
cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$HOME/.hermes/skills/konzept-fliessband/SKILL.md"
# ...auch in Profile spiegeln, die den Skill schon haben (projektstart = IDE-Vorbereiter
# nutzt ihn als Motor; Profile teilen KEINE Skills, Lehre 18.07.). Nur vorhandene updaten.
for _psd in "$HOME"/.hermes/profiles/*/skills/konzept-fliessband; do
[ -d "$_psd" ] && cp "$SRC/deploy/skills/konzept-fliessband/SKILL.md" "$_psd/SKILL.md"
done
# Betrieb-Playbook (Phase 2 Drei-Welten-Plan, 19.07.2026): Diagnose-Checklisten der
# Betrieb-Bahn (SSH/Deploy) — Gegenstueck zur No-Progress-Bremse. Auch in die Profile,
# damit betrieb-Worker es laden koennen (Profile teilen KEINE Skills, Lehre 18.07.).
for _sd in "$HOME/.hermes/skills" "$HOME"/.hermes/profiles/*/skills; do
[ -d "$(dirname "$_sd")" ] || continue
mkdir -p "$_sd/betrieb-playbook"
cp "$SRC/deploy/skills/betrieb-playbook/SKILL.md" "$_sd/betrieb-playbook/SKILL.md"
done
# Agent-Hooks (Faden 5, 13.07.2026): Skripte frisch halten (reproduzierbar, ueberlebt
# Box-Neuaufbau). box-steckbrief-inject.sh = Box-Selbstwissen in JEDEN Kanban-Worker
# (pre_llm_call, scoped auf task_id → Lucy-Chat unberuehrt); pre-verify-gates.sh = das
@@ -155,7 +196,7 @@ mkdir -p "$HOME/.hermes/agent-hooks"
# Nur kopieren, wenn der Inhalt sich WIRKLICH geaendert hat — ein blindes cp aendert die
# mtime auch bei identischem Inhalt und loest sonst bei JEDEM Deploy die harmlose
# "script modified since approval"-Warnung von `hermes hooks doctor` aus.
for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py; do
for _h in box-steckbrief-inject.sh pre-verify-gates.sh tabu-pfade-guard.py no-progress-bremse.py; do
_src="$SRC/deploy/agent-hooks/$_h"; _dst="$HOME/.hermes/agent-hooks/$_h"
cmp -s "$_src" "$_dst" 2>/dev/null || { cp "$_src" "$_dst"; chmod +x "$_dst"; }
done
@@ -165,6 +206,18 @@ done
for _prof in "$HOME"/.hermes/profiles/*/config.yaml; do
[ -f "$_prof" ] && python3 "$SRC/deploy/ensure-profile-hooks.py" "$_prof" || true
done
# Hermes-Runtime-Patch-Traeger (20.07.2026): MC2-eigene Fixes im Hermes-Quellcode
# (needs_input-wartet-auf-Commander, Decompose-erst-bei-Startreife, Orphan-Guard,
# Projekt-Kontext-Vererbung, Etappen-Auto-Kette) idempotent (re-)eintragen — so
# ueberleben sie ein `hermes update`, das den Quellbaum sonst zuruecksetzt.
python3 "$SRC/deploy/hermes-patches/apply.py"; _patch_rc=$?
if [ "$_patch_rc" -eq 10 ]; then
# Patches haben den Quellcode geaendert → Gateway muss die neue Laufzeit laden.
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
echo "[deploy] Hermes-Runtime-Patches (neu) angewandt → hermes-gateway neu gestartet."
elif [ "$_patch_rc" -ne 0 ]; then
echo "[deploy] ‼ WARNUNG: hermes-patches/apply.py meldete Fehler — ein Patch passt nicht mehr (Update hat den Kontext geaendert?). Fixes pruefen!"
fi
# Eingebaute Hermes-Web-GUI (`hermes serve`) für die Einbettung in MC2 (routers/hermes_ui.py →
# same-origin /hermes-ui/) vorbereiten: das SPA-Bundle mit Vite-base=/hermes-ui/ (neu) bauen, sonst
# kollidieren seine absoluten Pfade (/assets, /api) mit MC2s eigenen. Läuft auch nach jedem
@@ -200,6 +253,7 @@ systemctl --user enable --now mc2-backup.timer >/dev/null 2>&1 || true
# Router/Engine/Hermes dürfen So 04:30 automatisch (Fangnetz Backup→Postcheck→Rollback+Pin).
systemctl --user enable --now mc2-selfsmoke.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-bagatell.timer >/dev/null 2>&1 || true
systemctl --user enable --now mc2-morgen-digest.timer >/dev/null 2>&1 || true
loginctl enable-linger "$USER" >/dev/null 2>&1 || true
# Mem0-Sidecar VOR dem Backend (re)starten, damit /api/memory sofort bedient wird.
[ -x "$HOME/.mem0/venv/bin/python" ] && systemctl --user restart mem0-service 2>/dev/null || true
+11 -1
View File
@@ -32,7 +32,17 @@ Sessions, Skills, Gedächtnis, wiederkehrende Muster.
das akkumulierte Projektwissen.
- Skill-Kandidat (nur wenn sich klar einer aufdrängt): wenn aus einem ERFOLGREICHEN,
wiederholten Handgriff ein wiederverwendbarer Skill würde (Voyager-Prinzip), beschreibe
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **Entwirf ihn gleich
ihn als Entwurf unter `~/wissens-vault/skill-kandidaten/<slug>.md`. **ABER ZUERST prüfen:**
```
ls ~/wissens-vault/skill-kandidaten/*.md ~/wissens-vault/skill-kandidaten/beauftragt/*.md 2>/dev/null
ls ~/.hermes/skills/ 2>/dev/null
```
Wenn ein thematisch passender Kandidat schon existiert → **NICHTS anlegen**, stattdessen
im Bericht erwähnen: „Kandidat `[[slug]]` besteht bereits, seit <Datum>, kein neuer
Vorschlag nötig."
**WICHTIG:** Prüfe auch, ob es bereits einen fertig gebauten Skill gibt.
Wenn ja: kein Kandidat nötig, der Skill existiert bereits.
**Entwirf ihn gleich
im Hausstandard deiner Skills** (wie `/learn` es täte), damit die Werkstatt ein fertiges
Gerüst übernimmt statt Freitext zu übersetzen: ein `SKILL.md`-Block mit Frontmatter
(`name` = slug, `description` ≤ 60 Zeichen, `version: 1.0.0`), dann die Abschnitte
+47 -20
View File
@@ -1,19 +1,24 @@
#!/usr/bin/env python3
"""Stellt sicher, dass ein Worker-Profil die Agent-Hooks kennt (Faden 8, 13.07.2026).
"""Stellt sicher, dass ein Worker-Profil die Agent-Hooks kennt (Faden 8, 13.07.2026;
erweitert 19.07.2026 um die No-Progress-Bremse und Mehrfach-Eintraege pro Event).
Kanban-Worker laufen unter IHREM Profil (HERMES_HOME=~/.hermes/profiles/<name>) und lesen
dessen config.yaml — NICHT die Top-Level ~/.hermes/config.yaml. Die worker-relevanten Hooks
(box-steckbrief-inject = Box-Wissen, tabu-pfade-guard = Schreibschutz) muessen also in JEDER
Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie (Bug-Fund 13.07.).
muessen also in JEDER Worker-Profil-config.yaml stehen, sonst feuern sie fuer Worker nie
(Bug-Fund 13.07.).
Idempotent + validiert + Backup. Setzt einen bestehenden `pre_verify`-Hook-Block als Anker
voraus (haben alle von default/werkstatt geklonten Profile); fehlt er, wird sauber
uebersprungen (exit 0), damit deploy.sh nicht bricht. Arg: Pfad zur Profil-config.yaml.
WICHTIG (Falle, 19.07.): Ein Event darf nur EINMAL als YAML-Key existieren. Existiert
`pre_tool_call:` bereits (tabu-pfade-guard), wird ein weiterer Eintrag IN den Block
eingefuegt — ein zweiter `pre_tool_call:`-Key wuerde beim YAML-Laden den ersten
verschlucken und den Tabu-Guard lautlos deaktivieren.
Idempotent + validiert + Backup. Anker fuer neue Event-Bloecke ist der bestehende
`pre_verify`-Block. Arg: Pfad zur Profil-config.yaml.
"""
import sys
import os
import datetime
import os
import shutil
import sys
try:
import yaml
@@ -22,10 +27,13 @@ except Exception:
sys.exit(0)
AH = os.path.expanduser("~/.hermes/agent-hooks")
WANT = {
"pre_llm_call": f"{AH}/box-steckbrief-inject.sh",
"pre_tool_call": f"{AH}/tabu-pfade-guard.py",
}
# Reihenfolge zaehlt: tabu-pfade-guard MUSS vor der Bremse stehen (Schutz vor Komfort).
WANT = [
("pre_llm_call", f"{AH}/box-steckbrief-inject.sh", 10),
("pre_tool_call", f"{AH}/tabu-pfade-guard.py", 10),
("pre_tool_call", f"{AH}/no-progress-bremse.py pre", 10),
("post_tool_call", f"{AH}/no-progress-bremse.py post", 10),
]
if len(sys.argv) < 2:
print(" usage: ensure-profile-hooks.py <config.yaml>")
@@ -44,22 +52,41 @@ if txt.count(anchor) != 1:
print(f" [{os.path.basename(os.path.dirname(p))}] kein eindeutiger pre_verify-Anker — SKIP")
sys.exit(0)
add = ""
for event, cmd in WANT.items():
if cmd not in txt:
add += f" {event}:\n - command: {cmd}\n timeout: 10\n"
new = txt
changed = False
for event, cmd, timeout in WANT:
if cmd in new:
continue
entry = f" - command: {cmd}\n timeout: {timeout}\n"
ev_line = f" {event}:\n"
if ev_line in new:
# Eintrag ANS ENDE des bestehenden Event-Blocks: direkt nach dem Key einfuegen
# waere auch ok, aber hinter dem letzten Eintrag haelt die gewollte Reihenfolge.
idx = new.index(ev_line) + len(ev_line)
end = idx
for line in new[idx:].splitlines(keepends=True):
if line.startswith((" - ", " ")):
end += len(line)
else:
break
new = new[:end] + entry + new[end:]
else:
new = new.replace(anchor, anchor + ev_line + entry, 1)
changed = True
if not add:
if not changed:
print(f" [{os.path.basename(os.path.dirname(p))}] Hooks schon registriert.")
sys.exit(0)
new = txt.replace(anchor, anchor + add, 1)
try:
d = yaml.safe_load(new)
h = d["hooks"]
for event, cmd in WANT.items():
assert any(cmd in x.get("command", "") for x in h.get(event, []))
for event, cmd, _t in WANT:
assert any(cmd == x.get("command", "") for x in h.get(event, [])), f"{event}: {cmd} fehlt"
assert any("pre-verify-gates.sh" in x.get("command", "") for x in h["pre_verify"])
# Tabu-Guard muss VOR der Bremse stehen (beide in pre_tool_call).
cmds = [x.get("command", "") for x in h["pre_tool_call"]]
assert cmds.index(f"{AH}/tabu-pfade-guard.py") < cmds.index(f"{AH}/no-progress-bremse.py pre")
except Exception as exc:
print(f" [{p}] Validierung fehlgeschlagen ({exc}) — config UNVERAENDERT.")
sys.exit(0)
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -e
# Alte Crons löschen
hermes cron remove traum || true
hermes cron remove chef-gutachter || true
hermes cron remove release-radar || true
hermes cron remove idle-radar || true
hermes cron remove selbst-inventur || true
hermes cron remove karten-gutachter || true
hermes cron remove pruefstand || true
hermes cron remove trend-radar || true
# Neue Crons registrieren mit Wrapper und --deliver origin.
# Dadurch schweigt Hermes cron selbst, und der Wrapper nutzt notify.sh für den Morgen-Digest.
hermes cron create --name traum --deliver origin "15 3 * * *" "Traum dreaming-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name chef-gutachter --deliver origin "30 4 * * *" "Chef-Gutachter chef-gutachter-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name release-radar --deliver origin "15 5 * * 1" "Release-Radar hermes-release-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name idle-radar --deliver origin "45 3 * * *" "Idle-Radar idle-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name selbst-inventur --deliver origin "35 3 * * *" "Selbst-Inventur selbst-inventur.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name karten-gutachter --deliver origin "0 4 * * *" "Karten-Gutachter karten-gutachter.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name pruefstand --deliver origin "0 2 * * *" "Prüfstand pruefstand.sh" --script night-cron-wrapper.sh --no-agent
hermes cron create --name trend-radar --deliver origin "15 5 * * 6" "Trend-Radar trend-radar-feed.sh" --script night-cron-wrapper.sh --no-agent
echo "Crons erfolgreich gefixt!"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# gitea-pr (24.07.2026): PR auf Gitea per REST-API anlegen — der EINZIGE Weg für
# Worker, einen Pull-Request zu erstellen. Kein Web-Login, kein Passwort: das
# HTTP-Token aus ~/.git-credentials reicht (Vorfall 23.07.: Wartung-Worker bat
# um das Gitea-Passwort für die Web-GUI und blockte — genau das nie wieder).
# Nutzung: gitea-pr <repo> <head-branch> "<titel>" ["<body>"] [base=main]
set -euo pipefail
REPO="${1:?Nutzung: gitea-pr <repo> <head-branch> \"<titel>\" [\"<body>\"] [base]}"
HEAD="${2:?head-branch fehlt}"
TITLE="${3:?titel fehlt}"
BODY="${4:-}"
BASE="${5:-main}"
OWNER="Hitonabi"
# Intern statt DDNS-Domain: die ist nachts (Zwangstrennung) oft nicht erreichbar.
HOST="http://192.168.178.153:3000"
creds="$(printf 'protocol=http\nhost=192.168.178.153:3000\n\n' | git credential fill)"
username="$(sed -n 's/^username=//p' <<<"$creds")"
password="$(sed -n 's/^password=//p' <<<"$creds")"
[ -n "$password" ] || { echo "FEHLER: kein Token in ~/.git-credentials gefunden" >&2; exit 1; }
payload="$(TITLE="$TITLE" BODY="$BODY" HEAD="$HEAD" BASE="$BASE" python3 -c '
import json, os
print(json.dumps({"title": os.environ["TITLE"], "body": os.environ["BODY"],
"head": os.environ["HEAD"], "base": os.environ["BASE"]}))')"
antwort="$(curl -sS -w '\n%{http_code}' -X POST \
-u "$username:$password" -H "Content-Type: application/json" \
-d "$payload" "$HOST/api/v1/repos/$OWNER/$REPO/pulls")"
status="${antwort##*$'\n'}"
koerper="${antwort%$'\n'*}"
if [ "$status" = "201" ]; then
printf '%s' "$koerper" | python3 -c 'import json,sys; pr=json.load(sys.stdin); print("PR angelegt:", pr.get("html_url", pr.get("url", "?")))'
else
echo "FEHLER: Gitea-API antwortete $status" >&2
printf '%s\n' "$koerper" | head -c 600 >&2
exit 1
fi
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Gitea-Repo-Anlage für Lucy (17.07.2026): schließt die Lücke im „Projekte selbst
# starten"-Weg — bislang konnte die Werkstatt nur Branches in BESTEHENDE Repos
# proposen, aber kein neues Projekt-Repo anlegen.
#
# Nutzung: gitea-repo-create.sh <name> ["Beschreibung"]
# Ergebnis (stdout, letzte Zeile): CLONE <https-clone-url> → Aufrufer kann direkt klonen.
#
# HARTE Sicherheits-Leitplanken (Gitea ist öffentlich erreichbar!):
# • Repos werden IMMER PRIVAT angelegt (kein öffentliches Repo aus Versehen).
# • Token wird aus ~/.git-credentials gelesen, NIE ausgegeben/geloggt.
# • Jede Anlage wird protokolliert (Audit-Spur) + still an die Chronik gespiegelt.
# Braucht ein Token mit Scope `write:user` (zusätzlich zu `write:repository`); ohne das
# meldet Gitea 403 — das Skript sagt dann genau, was zu tun ist (Token in Gitea neu
# generieren), statt kryptisch zu scheitern.
set -uo pipefail
NAME="${1:-}"
BESCHREIBUNG="${2:-Von Lucy angelegtes Projekt-Repo.}"
# Worker-Subprozesse laufen evtl. mit abweichendem $HOME (Profil-Home) — das dedizierte
# Anlage-Token (write:user) UND .git-credentials liegen aber nur im ECHTEN User-Home.
# Darum den echten Home aus /etc/passwd aufloesen, sonst faellt das Skript aufs falsche
# Token zurueck (Fan-out-Nachlese 20.07.: projektstart-Worker blockte an genau dieser
# Stelle mit "Token hat nur write:repository").
REALHOME="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f6)"; REALHOME="${REALHOME:-$HOME}"
CRED="$REALHOME/.git-credentials"
# Dediziertes Anlage-Token (Scope write:user) — GETRENNT vom Push-Token in
# ~/.git-credentials, damit ein Überschreiben nie Push/PR bricht. Priorität:
# ENV GITEA_CREATE_TOKEN → <echter-Home>/.config/gitea/create-token → Fallback .git-credentials.
CREATE_TOKEN_FILE="$REALHOME/.config/gitea/create-token"
LOG="$REALHOME/.hermes/state/gitea-repos-created.log"
if [ -z "$NAME" ]; then
echo "Nutzung: gitea-repo-create.sh <name> [\"Beschreibung\"]" >&2
exit 2
fi
# Repo-Name defensiv prüfen (Gitea erlaubt Buchstaben/Ziffern/-/_/.)
if ! printf '%s' "$NAME" | grep -qE '^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$'; then
echo "Ungültiger Repo-Name '$NAME' (erlaubt: Buchstaben/Ziffern/.-_, Beginn alphanumerisch)." >&2
exit 2
fi
# User + Host sind NICHT geheim: aus .git-credentials ableiten, sonst Standardwerte.
LINE="$(grep -m1 'git\.tobisniceshomelab\.ddnsfree\.com' "$CRED" 2>/dev/null || head -1 "$CRED" 2>/dev/null || true)"
USER="$(printf '%s' "$LINE" | sed -nE 's#https://([^:]+):.*#\1#p')"; USER="${USER:-Hitonabi}"
HOST="$(printf '%s' "$LINE" | sed -nE 's#https://[^@]+@([^/]+).*#\1#p')"; HOST="${HOST:-git.tobisniceshomelab.ddnsfree.com}"
BASE="https://${HOST}"
# Anlage-Token bestimmen (dediziert bevorzugt, damit Push-Token unberührt bleibt)
if [ -n "${GITEA_CREATE_TOKEN:-}" ]; then
TOKEN="$GITEA_CREATE_TOKEN"
elif [ -r "$CREATE_TOKEN_FILE" ]; then
TOKEN="$(tr -d '[:space:]' < "$CREATE_TOKEN_FILE")"
else
TOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"
fi
if [ -z "$TOKEN" ]; then
echo "Kein Gitea-Token gefunden (weder \$GITEA_CREATE_TOKEN noch $CREATE_TOKEN_FILE noch ~/.git-credentials)." >&2
exit 1
fi
RESP="$(mktemp)"; trap 'rm -f "$RESP"' EXIT
CODE="$(curl -s -o "$RESP" -w '%{http_code}' -X POST "$BASE/api/v1/user/repos" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"description":sys.argv[2],"private":True,"auto_init":True,"default_branch":"main"}))' "$NAME" "$BESCHREIBUNG")")"
case "$CODE" in
201)
CLONE="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("clone_url",""))' "$RESP" 2>/dev/null)"
FULL="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("full_name",""))' "$RESP" 2>/dev/null)"
mkdir -p "$(dirname "$LOG")"
echo "$(date '+%Y-%m-%d %H:%M') angelegt (privat): $FULL" >> "$LOG"
# Stiller Chronik-Spiegel (Audit-Spur, wie die Radar-Meldungen)
curl -sf -m 5 -X POST "${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}" \
-H 'Content-Type: application/json' \
--data "$(python3 -c 'import json,sys; print(json.dumps({"text":"Neues PRIVATES Projekt-Repo angelegt: "+sys.argv[1],"subject":"Gitea","source":"gitea-repo-create","priority":"silent"}))' "$FULL")" \
>/dev/null 2>&1 || true
# CI-Ampel einpflanzen (Wasserdicht-Runde 22.07.): JEDES neue Repo wird mit der
# universellen Actions-Ampel geboren (deploy/ampel-ci.yml). Defensiv: fehlt die
# Vorlage oder scheitert der Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
# WICHTIG: Contents-API braucht write:repository → PUSH-Token nutzen (das
# dedizierte Anlage-Token hat u.U. nur write:user).
PUSHTOKEN="$(printf '%s' "$LINE" | sed -nE 's#https://[^:]+:([^@]+)@.*#\1#p')"; PUSHTOKEN="${PUSHTOKEN:-$TOKEN}"
# saat <lokale-vorlage> <pfad-im-repo> <commit-nachricht> <klartext-name>
saat () {
local QUELLE="$1" ZIEL="$2" MSG="$3" NAME="$4" CODE
if [ ! -r "$QUELLE" ]; then
echo "WARNUNG: Vorlage fehlt ($QUELLE) — Repo ohne $NAME angelegt." >&2
return
fi
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/repos/$FULL/contents/$ZIEL" \
-H "Authorization: token $PUSHTOKEN" -H "Content-Type: application/json" \
--data "$(python3 - "$QUELLE" "$MSG" <<'PY'
import base64, json, sys
inhalt = open(sys.argv[1], "rb").read()
print(json.dumps({"content": base64.b64encode(inhalt).decode(), "message": sys.argv[2]}))
PY
)")"
if [ "$CODE" = "201" ]; then
echo "$NAME eingepflanzt ($ZIEL)."
else
echo "WARNUNG: $NAME-Seed antwortete HTTP $CODE (Repo ist trotzdem da)." >&2
fi
}
# JEDES neue Repo wird mit beiden Wächtern geboren:
# ci.yml = die AUSSEN-Prüfung (Gitea Actions nach dem Push, Wasserdicht-Runde 22.07.)
# VERIFY = die INNEN-Prüfung (das OpenCode-Plugin führt sie nach jeder Etappe aus und
# gibt rote Tests dem Agenten sofort zurück, statt sie erst der CI zu zeigen)
# Defensiv: scheitert ein Seed, wird die Anlage NICHT abgebrochen, nur gewarnt.
saat "$REALHOME/mission-control-v2/deploy/ampel-ci.yml" ".gitea/workflows/ci.yml" \
"CI-Ampel (automatisch bei Repo-Anlage eingepflanzt)" "CI-Ampel"
saat "$REALHOME/mission-control-v2/deploy/opencode/VERIFY.template" "VERIFY" \
"Pruef-Tor (automatisch bei Repo-Anlage eingepflanzt)" "Pruef-Tor"
echo "Repo '$FULL' angelegt (privat, main initialisiert)."
echo "CLONE ${CLONE}"
exit 0 ;;
403)
if grep -q 'write:user' "$RESP"; then
echo "FEHLT: Das benutzte Gitea-Token darf keine Repos anlegen (Scope 'write:user' fehlt)." >&2
echo "So wird es freigeschaltet (einmalig, durch den Commander):" >&2
echo " 1) In Gitea ein Token mit Scope 'write:user' erzeugen (nur DAS reicht fürs Anlegen)." >&2
echo " 2) Den Token-Wert in ~/.config/gitea/create-token legen (chmod 600) —" >&2
echo " getrennt vom Push-Token, das in ~/.git-credentials unberührt bleibt." >&2
else
echo "403 von Gitea:" >&2; head -c 300 "$RESP" >&2; echo >&2
fi
exit 3 ;;
409)
echo "Ein Repo namens '$NAME' existiert bereits." >&2
exit 4 ;;
*)
echo "Gitea antwortete mit HTTP $CODE:" >&2; head -c 300 "$RESP" >&2; echo >&2
exit 1 ;;
esac
+65
View File
@@ -0,0 +1,65 @@
# Gitea app.ini - Beispielkonfiguration mit DISABLE_REGISTRATION
# Speichere diese Datei auf dem Server unter /etc/gitea/app.ini
# und setze DISABLE_REGISTRATION=true im Abschnitt [security]
APP_NAME = TobisNicesGitTea
RUN_USER = git
RUN_MODE = prod
[server]
DOMAIN = git.tobisniceshomelab.ddnsfree.com
HTTP_PORT = 3000
APP_DATA_PATH = /var/lib/gitea
PROTOCOL = http
ROOT_URL = https://git.tobisniceshomelab.ddnsfree.com/
DISABLE_SSH = false
SSH_PORT = 22
LFS_START_SERVER = true
LFS_CONTENT_PATH = /var/lib/gitea/data/lfs
OFFLINE_MODE = false
[database]
DB_TYPE = sqlite3
PATH = /var/lib/gitea/gitea.db
MAX_OPEN_CONNS = 10
MAX_IDLE_CONNS = 5
MAX_CONN_LIFETIME = 30m
DSN = /var/lib/gitea/gitea.db
[security]
INSTALL_LOCK = true
SECRET_KEY = CHANGE_THIS_TO_A_RANDOM_SECRET_KEY
PASSWORD_HASH_ALGO = pbkdf2
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
[service]
ENABLE_CAPTCHA = false
ENABLE_REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTER_API = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.example.org
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[session]
PROVIDER = file
COOKIE_NAME = gitea sessionId
COOKIE_SECURE = false
[mailer]
ENABLED = false
HOST =
FROM = gitea@example.com
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
[security]
DISABLE_REGISTRATION = true
+33
View File
@@ -0,0 +1,33 @@
# Arbeitsregeln (Aider liest diese Datei als schreibgeschützten Kontext)
Diese Regeln gelten für JEDE Sitzung. Sie sind der Kern der Session-Hygiene:
Wissen lebt in `SAVEPOINT.md` und in der git-Historie, NICHT im Chat-Verlauf.
## 1. Zu Beginn: erst SAVEPOINT.md lesen
Bevor du irgendetwas tust, lies `SAVEPOINT.md` vollständig. Es ist die Wahrheit über
den aktuellen Stand. Richte dich danach — nicht nach Annahmen. Erfinde keinen Kontext,
der nicht in `SAVEPOINT.md`, im Code oder in der git-Historie steht. Wenn etwas unklar
ist, sag es, statt es zu erfinden.
## 2. Nach jedem sinnvollen Schritt: SAVEPOINT.md aktualisieren
Sobald du eine sinnvolle Änderung abgeschlossen hast (eine Funktion, ein Fix, ein
Testlauf), aktualisiere `SAVEPOINT.md`. Halte es kurz und ehrlich. Struktur:
- **Ziel** — was insgesamt gebaut werden soll (ein bis zwei Sätze).
- **Erledigt** — was jetzt wirklich funktioniert (nur Bewiesenes; keine Fassade).
- **Nächster Schritt** — die genau eine Sache, die als Nächstes zu tun ist.
- **Offene Fragen** — Entscheidungen, die noch anstehen.
- **Stolpersteine** — alles, worüber eine frische Sitzung sonst stolpern würde.
- **Dateien** — die wichtigsten Dateien und was sie enthalten.
Schreibe es so, dass eine frische Sitzung OHNE jede Erinnerung allein aus `SAVEPOINT.md`
plus git sauber weitermachen kann. Das ist der Test: kein verstecktes Wissen im Chat.
## 3. Kleine, überprüfbare Schritte
Ändere wenig pro Runde. Behaupte nichts als fertig, was du nicht geprüft hast. Wenn ein
Test existiert, nenne sein Ergebnis. Wenn du unsicher bist, prüfe, statt zu raten.
## 4. Wenn der Governor das Sitzungs-Limit meldet
Erscheint eine Nachricht mit `[GOVERNOR — SITZUNGS-LIMIT ERREICHT]`, dann beginne KEINE
neuen Code-Änderungen mehr. Finalisiere nur `SAVEPOINT.md` (Stand vollständig, nächster
Schritt präzise) und weise den Nutzer an, eine frische Sitzung zu starten. Sonst nichts.
+134
View File
@@ -0,0 +1,134 @@
# Governor — Phase 0
Dünner, zustandsloser Token-Wächter-Proxy zwischen einem Off-the-shelf-Coding-Agenten
(**Aider**) und dem lokalen Modell-Endpoint (llama-swap `:8080`). Er erzwingt
**Session-Hygiene per hartem Schnitt statt Auto-Compaction**: wenn die Anfrage (= ganze
Sitzungshistorie, die jede Runde mitkommt) eine Schwelle übersteigt, schiebt er eine
Anweisung ein, `SAVEPOINT.md` zu finalisieren und zu stoppen — damit Wissen in
`SAVEPOINT.md` + git lebt, nicht im degradierenden Chat-Kontext.
Das ist **Phase 0** des Ablöse-Plans „Lucy IDE-Modus + Governor": den Kern beweisen,
**ohne** eine Zeile Lucy-Code. Kein bespoke Editor, kein Aider-Fork, kein Modelltausch.
## Bausteine (dieses Verzeichnis)
| Datei | Zweck |
|---|---|
| `governor.py` | Der Proxy. Nur Standardbibliothek (läuft mit System-`python3`), zustandslos. |
| `CONVENTIONS.md` | Aiders schreibgeschützte Arbeitsregeln: SAVEPOINT.md zuerst lesen, laufend pflegen, keine Fassade. |
| `SAVEPOINT.template.md` | Anfangs-Savepoint für ein frisches Projekt. |
| `driver.py` | Treibt eine akkumulierende Aider-Sitzung über die Scripting-API (eine Zeile = eine Runde). |
| `gov-ctl.sh` | Governor sauber starten/stoppen/status (detached via `setsid`). |
| `run-driver.sh` | `run-driver.sh <msgs> [repo]` — Aider-Sitzung durch den Governor. |
| `reset-repo.sh` | Wegwerf-Test-Repo frisch aufsetzen. |
| `hardstop-test.sh` | Direkte curls für die drei Pfade (passthrough / soft / hart). |
| `msgs-*.txt` | Nachrichtenskripte für die Testläufe. |
## Verhalten des Governors
Pro `/v1/chat/completions`-Anfrage schätzt er die Tokenzahl (`Zeichen / GOV_CHARS_PER_TOKEN`,
kalibriert auf CPT **3.5**`est ≈ echte prompt_tokens` auf ~13 % bei echten Sessions):
- **est < Soft** → unverändert durchreichen.
- **est ≥ Soft (`GOV_THRESHOLD`, Default 25000)** → hängt die SAVEPOINT-Stopp-Anweisung als
letzte User-Nachricht an, leitet weiter, loggt `FIRED`. Das Modell schreibt EINEN
ehrlichen Abschluss-Savepoint.
- **est ≥ Hart (`GOV_HARD_CEILING`, Default 0 = aus)** → der Governor antwortet SELBST mit
einer kurzen Stopp-Nachricht, **ohne** das Modell zu fragen; loggt `HARDSTOP`. Verhindert,
dass über die Grenze hinaus weitergearbeitet wird (siehe Befund unten).
Alle anderen Pfade (`/v1/models` etc.) werden roh durchgereicht. Streaming (SSE) wird
byteweise durchgereicht; die echten `prompt_tokens` aus der Antwort werden zur Kalibrierung
mitgeloggt.
### Sprach-Signal an Lucy (Phase 2)
Beim Feuern (soft ODER hart) POSTet der Governor — best-effort, gedrosselt (Default 300 s,
damit die Pro-Runde-Feuerung nicht spammt) — eine Meldung an Lucys vorhandene Announce-Pipeline
(`POST :9001/api/voice/announce`, `source:governor`, `priority:normal`). Lucy pollt diese Queue
ohnehin, dedupliziert per Cursor und spricht sie über ihr lokales TTS — gated durch ihren
„Box-Meldungen laut"-Schalter. **Kein Lucy-Code nötig.** Abschalten: `GOV_ANNOUNCE_URL=""`.
### Umgebungsvariablen
`GOV_PORT` (8100) · `GOV_HOST` (0.0.0.0) · `GOV_UPSTREAM` (http://127.0.0.1:8080) ·
`GOV_THRESHOLD` (25000) · `GOV_HARD_CEILING` (Default Soft+5000; 0=aus) · `GOV_CHARS_PER_TOKEN` (3.5) ·
`GOV_LOG` · `GOV_DIRECTIVE` · `GOV_HARDSTOP_MSG` · `GOV_ANNOUNCE_URL` (:9001/api/voice/announce; ""=aus) ·
`GOV_ANNOUNCE_THROTTLE` (300 s) · `GOV_ANNOUNCE_TEXT`.
## Auf der Box laufen lassen (wie in P0 aufgesetzt)
```bash
# Aider (einmalig, unter isoliertem Python 3.12 — System-Python 3.14 bricht Aiders Pins):
pipx install uv && uv tool install --python 3.12 aider-chat
# Dateien liegen in ~/governor-p0/. Governor starten (Hart-Deckel default AN = Soft+5000):
~/governor-p0/gov-ctl.sh start 25000 # Soft 25k, Hart 30k (auto)
# ~/governor-p0/gov-ctl.sh start 25000 0 # Hart AUS (nur weicher Schnitt)
# Aider-Sitzung durch den Governor:
~/governor-p0/run-driver.sh ~/governor-p0/msgs-todo.txt
```
Aider zeigt mit `OPENAI_API_BASE=http://127.0.0.1:8100/v1` und Modell
`openai/Qwen3-Coder-Next` auf den Governor.
## Wichtig: Aiders eigene Zusammenfassung MUSS aus
Der Treiber setzt `coder.summarizer.max_tokens` auf ~1e9. Sonst fasst Aider die Historie
selbst zusammen (Auto-Compaction) und die Anfrage wächst nie bis zur Schwelle — der
Governor wäre ausgehebelt, und man bekäme genau die über-komprimierte Halluzination, die
der Plan verwirft. Der Governor soll die **alleinige** Sitzungsgrenze sein.
## Ergebnisse & Befunde (24.07.2026)
### Akzeptanz — alle vier Kriterien bewiesen (Box, Qwen3-Coder-Next)
1. **Governor zählt + feuert an der Schwelle** — 12-Runden-Todo-Lauf (Soft 8000): Runden 1-6
`ok`, ab Runde 7 `FIRED` (est 8546 / echt 8652). Hart-Deckel: Anfrage mit est 11429 ≥ 10000
`HARDSTOP`, Governor antwortet selbst (kein Modell-Call). Alles im Log.
2. **Aider pflegt SAVEPOINT.md** — über den ganzen Aufbau hinweg strukturiert gehalten
(Ziel/Erledigt/Nächster Schritt/Stolpersteine/Dateien) gemäß `CONVENTIONS.md`.
3. **An der Grenze: ehrlicher Abschluss + Stopp** — beim ersten Feuern (Runde 7) schrieb das
Modell einen **ehrlichen** Savepoint (nur real Gebautes unter „Erledigt", Tests als nächster
Schritt) und verweigerte neuen Code.
4. **Frische Sitzung macht sauber weiter — keine Fassade** — neue Aider-Sitzung las den
Grenz-Savepoint, baute `test_todo.py` (der exakte nächste Schritt), und **alle 9 unittest-
Tests laufen grün** gegen die echte API. Kein Erfinden.
### Kalibrierung
`CHARS_PER_TOKEN = 3.5``est` traf die echten `prompt_tokens` bei realen Sessions auf ~1-3 %.
(Nur künstlicher, extrem repetitiver Fülltext bricht die Heuristik — irrelevant für echten Code.)
Der Coder läuft mit 128k Kontext (`-c 131072`), also keine Modell-Kappung bei 25k.
### Wichtigster Befund: Soft reicht nicht allein → Hart-Deckel nachgerüstet
Der **weiche** Schnitt erzeugt genau EINEN ehrlichen Grenz-Savepoint — solange die Grenze
respektiert wird. Schickt man aber über die Grenze hinaus weiter Aufträge (wie im Stresstest),
verweigert das Modell zwar den Code, schreibt die Absichten aber fortschreitend als „erledigt"
in SAVEPOINT — genährt von Aiders **irreführenden Commit-Nachrichten** (die aus dem SAVEPOINT-
Absichtstext geschöpft werden). Ergebnis: eine Fassade (behauptete test_todo.py/README, die es
nicht gab). Deshalb der optionale **Hart-Deckel** (`GOV_HARD_CEILING`): oberhalb davon antwortet
der Governor selbst, das Modell kann keine degradierenden Savepoints mehr schreiben. **Der Hart-
Deckel ist jetzt Default AN** (`GOV_HARD_CEILING` unset → Soft+5000; explizit `0` schaltet ihn
aus): ein Finalisier-Zug Luft, dann harter Riegel — das schliesst die Fassaden-Lücke.
### Weitere Befunde / Fallen
- **Aiders eigene Zusammenfassung MUSS aus** (`summarizer.max_tokens` hoch) — sonst compactet
Aider selbst und der Governor greift nie. Siehe oben.
- **Commit-Nachrichten überzeichnen** in der Abschluss-Phase (aus SAVEPOINT-Absicht). Der Code
ist die Wahrheit; git-Nachrichten sind es hier nicht. Der Hart-Deckel (jetzt Default) begrenzt
das auf ~1 Zug; wer es ganz sauber will, startet Aider mit `--no-auto-commits` (Commits von Hand).
- **Python 3.14 auf der Box bricht Aiders Pins** (numpy 1.24.3) → Aider via `uv` unter isoliertem
Python 3.12 installiert.
- **Aider-Scripting-API (`coder.run`) hängt** in einer Datei-Hinzufügen-Reflexion (Edits landeten
nicht). Für Einzel-Runden `aider --message` nutzen (sauberer, unterstützt). Der `driver.py`
taugt für Mehr-Runden-Akkumulation (Governor-Test), nicht als Produktions-Treiber.
### Bekannte Grenzen des Governors (aus adversarialer Review, für später)
- **Chunked Request-Bodies ohne `Content-Length`** werden verworfen (Aiders httpx sendet immer
`Content-Length` → schlummernd, aber ein Proxy-Hop mit Chunking bräche).
- **Tool-/Function-Calling**: der Soft-Einschub als letzte `user`-Nachricht kann die Nachrichten-
reihenfolge stören, wenn Aider ein Tool-Calling-Edit-Format nutzt (Aiders diff/whole sind reiner
Text → schlummernd). `estimate_tokens` zählt `tools`/`tool_calls` nicht mit.
- **`https://`-Upstream** wird nicht unterstützt (nur `http.client.HTTPConnection`). Für den
lokalen `:8080`-Endpoint irrelevant.
Behoben aus derselben Review: **inkrementelles Streaming** (`read1()` statt `read()` — vorher
puffernd), **Config-Crash** bei `{ }` in `GOV_DIRECTIVE` (sichere Substitution), **Query-String**
umging die Erkennung, **Socket-Leak** im Fehlerpfad, doppelte `Date`/`Server`-Header.
## Nächste Schritte (Phase 1+)
Terminal in Lucy einbetten (xterm.js + node-pty, MC2-Muster kopieren) → Voice-Hook auf das
Governor-Signal → Feinschliff + Aider/Pi-Finalentscheid. Governor evtl. später in mc2-gateway.
Kandidat für Phase 1-Härtung: Auto-Commit-Zügelung + Hart-Deckel als Default.
+23
View File
@@ -0,0 +1,23 @@
# SAVEPOINT
> Lebende Übergabe-Datei. Die aktuelle Sitzung hält sie fortlaufend aktuell; eine
> frische Sitzung liest sie ZUERST und macht allein daraus plus git weiter.
> (Anfangszustand — von der ersten Sitzung zu ersetzen.)
## Ziel
_(noch nichts — von der ersten Sitzung zu füllen)_
## Erledigt
- _(noch nichts)_
## Nächster Schritt
- Auftrag des Nutzers entgegennehmen und beginnen.
## Offene Fragen
- _(keine)_
## Stolpersteine
- _(keine bekannt)_
## Dateien
- `SAVEPOINT.md` — diese Übergabe-Datei.
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# announce-test.sh — loest ein Feuern aus und prueft, ob der Governor das Sprach-
# Signal an Lucys Announce-Queue (:9001) postet (Phase 2). Voraussetzung: Governor
# mit niedriger Schwelle gestartet, damit ein einzelner Request feuert.
set -u
GOV="http://127.0.0.1:8100/v1/chat/completions"
python3 - <<'PY' > /tmp/gov_fire.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"y"*35000}],"max_tokens":8,"stream":False}))
PY
echo "=== Feuern ausloesen (est ~10000 Tokens, ueber Hart-Deckel) ==="
curl -s -m 20 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_fire.json | jq '{id, finish: .choices[0].finish_reason}'
sleep 1.5 # Announce-Thread durchlassen
echo "=== Governor-Log (letzte 4 Zeilen) ==="
tail -4 ~/governor-p0/governor.log
echo "=== Announce-Queue: Governor-Eintraege ==="
curl -s -m 8 "http://127.0.0.1:9001/api/voice/announcements?after=0&limit=100" \
| jq '[.items[] | select(.source=="governor")] | (last // "KEINE governor-Meldung gefunden")'
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""driver.py — treibt EINE Aider-Sitzung ueber die Scripting-API durch den Governor.
Jede Zeile der Nachrichtendatei ist eine User-Runde. Weil derselbe Coder alle Runden
bedient, akkumuliert die Historie und die Anfrage waechst jede Runde — genau das, was
der Governor beobachtet. Aiders EIGENE History-Zusammenfassung wird abgeschaltet, damit
der Governor die alleinige Sitzungsgrenze ist (der Plan verwirft Auto-Compaction bewusst).
Aufruf (cwd muss das Test-Repo sein, mit venv-python):
.../aider-chat/bin/python driver.py <messages-file>
"""
import os
import sys
from aider.coders import Coder
from aider.io import InputOutput
from aider.models import Model
def main() -> int:
# Endpoint VOR den Aider-Aufrufen setzen (litellm liest es zur Laufzeit).
os.environ.setdefault("OPENAI_API_BASE", "http://127.0.0.1:8100/v1")
os.environ.setdefault("OPENAI_API_KEY", "dummy")
if len(sys.argv) < 2:
print("usage: driver.py <messages-file>", file=sys.stderr)
return 2
with open(sys.argv[1], encoding="utf-8") as fh:
messages = [ln.strip() for ln in fh if ln.strip() and not ln.lstrip().startswith("#")]
model = Model("openai/Qwen3-Coder-Next")
io = InputOutput(yes=True) # alle Rueckfragen automatisch bejahen
coder = Coder.create(
main_model=model,
io=io,
fnames=["SAVEPOINT.md"], # editierbar
read_only_fnames=["CONVENTIONS.md"], # nur-lesbar
auto_commits=True, # jede Etappe -> git-Commit
stream=False, # deterministische Logs fuer P0
map_tokens=512,
use_git=True,
)
# Aiders eigene Zusammenfassung ausschalten: too_big() wird nie wahr.
try:
coder.summarizer.max_tokens = 10 ** 9
print(f"[driver] summarizer.max_tokens -> {coder.summarizer.max_tokens}", flush=True)
except Exception as exc:
print(f"[driver] WARN konnte summarizer nicht abschalten: {exc}", flush=True)
for i, msg in enumerate(messages, 1):
print(f"\n===== TURN {i}/{len(messages)} =====", flush=True)
print(f">> {msg[:140]}", flush=True)
try:
coder.run(with_message=msg)
except Exception as exc:
print(f"[driver] TURN {i} Fehler: {exc!r}", flush=True)
break
sent = getattr(coder, "total_tokens_sent", "?")
recv = getattr(coder, "total_tokens_received", "?")
print(f"-- aider kum: gesendet={sent} empfangen={recv}", flush=True)
print("\n===== SESSION ENDE =====", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# gov-ctl.sh — Governor sauber starten/stoppen (Phase-0-Helfer).
# Nutzung:
# gov-ctl.sh start [SCHWELLE] # startet detached, Default-Schwelle 25000
# gov-ctl.sh stop
# gov-ctl.sh status
set -u
DIR="$HOME/governor-p0"
PIDF="$DIR/governor.pid"
LOG="$DIR/governor.log"
OUT="$DIR/governor.stdout"
start() {
stop
local thr="${1:-25000}"
cd "$DIR"
: > "$LOG"
# Voll detachen: eigene Session, alle FDs weg vom Aufrufer.
# Hart-Deckel nur setzen, wenn explizit uebergeben (Arg 2); sonst rechnet
# governor.py den Default (Soft + 5000). CPT-Default 3.5 (kalibriert 24.07.).
local hardenv=()
if [ -n "${2:-}" ]; then hardenv=(GOV_HARD_CEILING="$2"); fi
setsid env GOV_THRESHOLD="$thr" "${hardenv[@]}" \
GOV_CHARS_PER_TOKEN="${GOV_CHARS_PER_TOKEN:-3.5}" \
GOV_LOG="$LOG" GOV_PORT=8100 \
python3 "$DIR/governor.py" </dev/null >>"$OUT" 2>&1 &
echo $! > "$PIDF"
sleep 1.2
echo "started pid=$(cat "$PIDF") threshold=$thr hard=${2:-auto}"
ss -tlnp 2>/dev/null | grep ":8100" >/dev/null && echo "listening :8100 OK" || echo "WARN: not listening"
}
stop() {
pkill -f "$DIR/governor.py" 2>/dev/null || true
[ -f "$PIDF" ] && kill "$(cat "$PIDF")" 2>/dev/null || true
sleep 0.6
rm -f "$PIDF"
}
status() {
if pgrep -f "$DIR/governor.py" >/dev/null; then
echo "running pid=$(pgrep -f "$DIR/governor.py" | tr '\n' ' ')"
ss -tlnp 2>/dev/null | grep ":8100" || true
else
echo "not running"
fi
}
case "${1:-status}" in
start) shift; start "${1:-25000}" "${2:-}" ;;
stop) stop; echo stopped ;;
status) status ;;
*) echo "usage: gov-ctl.sh {start [soft] [hart]|stop|status}"; exit 2 ;;
esac
+568
View File
@@ -0,0 +1,568 @@
#!/usr/bin/env python3
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis —
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
einem einmal geratenen Wert festzuhaengen.
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
und Lucys `loop_status`.
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
Konfiguration per Umgebungsvariablen (alle optional):
GOV_PORT Listen-Port (Default 8100)
GOV_HOST Listen-Adresse (Default 0.0.0.0)
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
GOV_DIRECTIVE Text des Soft-Einschubs
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
reranker,vision,scout — Lucys Alltag wird nie unterbrochen)
"""
import http.client
import json
import os
import re
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
# ---- Konfiguration ---------------------------------------------------------
PORT = int(os.environ.get("GOV_PORT", "8100"))
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
_hard_env = os.environ.get("GOV_HARD_CEILING")
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
EXEMPT_MODELS = {m.strip().lower() for m in
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
DEFAULT_DIRECTIVE = (
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
)
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
DEFAULT_HARDSTOP = (
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
)
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
DEFAULT_ANNOUNCE = (
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
"Stand im Savepoint; am besten fangen wir gleich frisch an."
)
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
up = urlparse(UPSTREAM)
UP_HOST = up.hostname or "127.0.0.1"
UP_PORT = up.port or 80
HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
}
_log_lock = threading.Lock()
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
_announce_lock = threading.Lock()
_last_announce = 0.0
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
# ---- Zustand (klein, im Speicher) ------------------------------------------
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
_state_lock = threading.Lock()
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
_cpt_n: dict = {} # modell -> Anzahl Messungen
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
def log(line: str) -> None:
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
msg = f"{stamp} {line}"
with _log_lock:
print(msg, flush=True)
try:
with open(LOG_PATH, "a", encoding="utf-8") as fh:
fh.write(msg + "\n")
except OSError:
pass
def cpt_for(model: str) -> float:
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
with _state_lock:
return _cpt.get(model, CHARS_PER_TOKEN)
def calibrate(model: str, chars: int, exact: int) -> None:
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
return
ratio = chars / exact
if not (CPT_MIN <= ratio <= CPT_MAX):
return
with _state_lock:
n = _cpt_n.get(model, 0)
old = _cpt.get(model, CHARS_PER_TOKEN)
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
w = max(0.08, 1.0 / (n + 2))
_cpt[model] = old * (1 - w) + ratio * w
_cpt_n[model] = n + 1
def body_chars(data: dict) -> int:
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
Wir serialisieren einfach alles, was ans Modell geht.
"""
payload = {k: v for k, v in data.items()
if k in ("messages", "tools", "tool_choice", "system", "functions")}
try:
return len(json.dumps(payload, ensure_ascii=False))
except (TypeError, ValueError):
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
chars = 0
for m in data.get("messages") or []:
c = m.get("content") if isinstance(m, dict) else None
if isinstance(c, str):
chars += len(c) + 4
elif isinstance(c, list):
for p in c:
if isinstance(p, dict) and isinstance(p.get("text"), str):
chars += len(p["text"])
return chars
def safe_to_append(messages) -> bool:
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
"""
if not isinstance(messages, list) or not messages:
return False
last = messages[-1]
if not isinstance(last, dict):
return False
role = last.get("role")
if role == "tool":
return False
return not (role == "assistant" and last.get("tool_calls"))
def _post_announce(est) -> None:
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
try:
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
body = json.dumps({"text": text, "subject": "[Governor]",
"source": "governor", "priority": "normal"}).encode("utf-8")
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
_an.port or 80, timeout=4)
conn.request("POST", _an.path or "/api/voice/announce", body=body,
headers={"Content-Type": "application/json",
"Content-Length": str(len(body))})
resp = conn.getresponse()
resp.read()
conn.close()
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
except Exception as exc:
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
def maybe_announce(est) -> None:
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
if not _an:
return
global _last_announce
now = time.time()
with _announce_lock:
if now - _last_announce < ANNOUNCE_THROTTLE:
return
_last_announce = now
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
def status_payload() -> dict:
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
with _state_lock:
return {
"ok": True,
"upstream": UPSTREAM,
"soft": THRESHOLD,
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
"uptime_s": int(time.time() - _counters["started"]),
"counters": {k: v for k, v in _counters.items() if k != "started"},
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
for m, v in _cpt.items()},
"calibration_default": CHARS_PER_TOKEN,
"exempt_models": sorted(EXEMPT_MODELS),
"recent": list(_recent),
}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "Governor/2.0"
def log_message(self, *args):
pass
def do_GET(self):
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
self._send_json(200, status_payload())
return
self._proxy()
def do_POST(self):
self._proxy()
def do_PUT(self):
self._proxy()
def do_DELETE(self):
self._proxy()
def do_OPTIONS(self):
self._proxy()
# -- Kern ---------------------------------------------------------------
def _send_json(self, status: int, obj) -> None:
try:
data = json.dumps(obj).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
except OSError:
pass
def _read_body(self) -> bytes:
length = self.headers.get("Content-Length")
if length is None:
return b""
try:
return self.rfile.read(int(length))
except (ValueError, OSError):
return b""
def _proxy(self) -> None:
body = self._read_body()
path = self.path
clean_path = path.split("?", 1)[0]
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
action = "passthrough"
est = None
streaming = False
model = ""
chars = 0
if is_chat and body:
action, body, est, streaming, model, chars = self._decide(body)
if action in ("soft", "hard"):
maybe_announce(est)
if action == "hard":
self._send_canned_stop(model, streaming, est)
with _state_lock:
_counters["chat"] += 1
_counters["hard"] += 1
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
"exact": None, "action": "hard"})
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
f"HARDSTOP stream={streaming} status=200")
return
out_headers = {}
for k, v in self.headers.items():
kl = k.lower()
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
continue
out_headers[k] = v
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
out_headers["Accept-Encoding"] = "identity"
if body:
out_headers["Content-Length"] = str(len(body))
out_headers["Connection"] = "close"
conn = None
try:
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
conn.request(self.command, path, body=body or None, headers=out_headers)
resp = conn.getresponse()
except (OSError, http.client.HTTPException) as exc:
log(f"ERROR upstream {self.command} {path}: {exc!r}")
if conn is not None:
conn.close()
self._safe_error(502, f"governor upstream: {exc}")
return
self.send_response(resp.status)
for k, v in resp.getheaders():
kl = k.lower()
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
continue
self.send_header(k, v)
self.send_header("Connection", "close")
self.end_headers()
tail = bytearray()
try:
while True:
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
chunk = resp.read1(65536)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
tail.extend(chunk)
if len(tail) > 16384:
del tail[:-16384]
except OSError:
pass
finally:
conn.close()
exact = self._scan_prompt_tokens(tail)
if is_chat:
if exact:
calibrate(model, chars, exact)
with _state_lock:
_counters["chat"] += 1
_counters["soft" if action == "soft" else "passthrough"] += 1
if exact:
_counters["tokens_prompt"] += exact
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
"exact": exact, "action": action})
exact_s = str(exact) if exact is not None else "-"
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
def _decide(self, body: bytes):
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
try:
data = json.loads(body)
except (ValueError, UnicodeDecodeError):
return "passthrough", body, None, False, "", 0
if not isinstance(data, dict):
return "passthrough", body, None, False, "", 0
messages = data.get("messages")
streaming = bool(data.get("stream"))
model = (data.get("model") or "").strip()
chars = body_chars(data)
est = int(chars / max(cpt_for(model), 0.1))
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
base = model.split("/")[-1].lower()
if base in EXEMPT_MODELS:
return "passthrough", body, est, streaming, model, chars
if not isinstance(messages, list):
return "passthrough", body, est, streaming, model, chars
has_warned = False
marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]"
for m in reversed(messages):
if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]:
has_warned = True
break
is_safe = safe_to_append(messages)
# 1. Absolutes Not-Aus bei komplettem Amoklauf
if HARD_CEILING > 0 and est >= HARD_CEILING + 10000:
return "hard", body, est, streaming, model, chars
# 2. Sind wir am Limit?
if est >= THRESHOLD:
if not is_safe:
# Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen!
return "defer", body, est, streaming, model, chars
# Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input).
if HARD_CEILING > 0 and est >= HARD_CEILING:
if has_warned:
# Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt.
return "hard", body, est, streaming, model, chars
else:
# Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten.
# Gib ihm trotzdem noch den einen Finalisier-Zug (Soft).
pass
# Soft-Limit greift
if not has_warned:
directive = (DIRECTIVE.replace("{est}", str(est))
.replace("{threshold}", str(THRESHOLD)))
messages.append({"role": "user", "content": directive})
data["messages"] = messages
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
return "passthrough", body, est, streaming, model, chars
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
created = int(time.time())
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
"total_tokens": est or 0}
try:
if streaming:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
def sse(obj):
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
self.wfile.flush()
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
"created": created, "model": model}
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
"finish_reason": None}]})
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
"finish_reason": None}]})
sse({**base, "choices": [{"index": 0, "delta": {},
"finish_reason": "stop"}], "usage": usage})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
else:
payload = {
"id": "governor-hardstop", "object": "chat.completion",
"created": created, "model": model,
"choices": [{"index": 0, "finish_reason": "stop",
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
"usage": usage,
}
data = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
self.wfile.flush()
except OSError:
pass
def _safe_error(self, status: int, msg: str) -> None:
self._send_json(status, {"error": msg})
@staticmethod
def _scan_prompt_tokens(tail: bytearray):
if not tail:
return None
try:
text = tail.decode("utf-8", errors="ignore")
except Exception:
return None
matches = _PROMPT_TOKENS_RE.findall(text)
if not matches:
return None
try:
return int(matches[-1])
except ValueError:
return None
def main() -> int:
log_dir = os.path.dirname(LOG_PATH)
if log_dir and not os.path.isdir(log_dir):
try:
os.makedirs(log_dir, exist_ok=True)
except OSError:
pass
server = ThreadingHTTPServer((HOST, PORT), Handler)
server.daemon_threads = True
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
try:
server.serve_forever()
except KeyboardInterrupt:
log("Governor beendet (SIGINT).")
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+27
View File
@@ -0,0 +1,27 @@
[Unit]
# Governor v2 — Token-Waechter vor dem MC2-Gateway.
# Nutzer-Dienst (systemctl --user), weil er unter hitonabi laeuft und keine
# Root-Rechte braucht. Startet nach MC2, weil er dorthin weiterreicht.
Description=Governor v2 — Token-Waechter-Proxy (:8100 -> MC2 :9001)
After=network-online.target mission-control-2.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=%h/governor
ExecStart=/usr/bin/python3 %h/governor/governor.py
Restart=always
RestartSec=3
# --- Schwellen -------------------------------------------------------------
# Soft 45k: OpenCode startet mit ~10-15k allein fuer Systemprompt + Werkzeug-
# Schemata; 25k (der alte Aider-Wert) haette schon nach wenigen Zuegen gefeuert.
# Hart = Soft+5000 (ein Finalisier-Zug Luft), Default des Programms.
Environment=GOV_THRESHOLD=45000
Environment=GOV_UPSTREAM=http://127.0.0.1:9001
Environment=GOV_LOG=%h/governor/governor.log
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist kein Bau.
Environment=GOV_EXEMPT_MODELS=hermes,fast,embed,reranker,vision,scout
[Install]
WantedBy=default.target
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# hardstop-test.sh — prueft die drei Governor-Pfade mit direkten curls:
# klein -> passthrough (Modell antwortet normal)
# mittel -> soft-Einschub (Modell bekommt die Stopp-Anweisung, antwortet)
# gross -> HART-STOPP (Governor antwortet selbst, KEIN Modell-Call)
set -u
GOV="http://127.0.0.1:8100/v1/chat/completions"
python3 - <<'PY' > /tmp/gov_small.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"Reply with exactly: SMALL"}],"max_tokens":8,"stream":False}))
PY
python3 - <<'PY' > /tmp/gov_mid.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"BEGIN "+"lorem ipsum "*2500+" END"}],"max_tokens":40,"stream":False}))
PY
python3 - <<'PY' > /tmp/gov_big.json
import json
print(json.dumps({"model":"Qwen3-Coder-Next","messages":[{"role":"user","content":"x"*40000}],"max_tokens":16,"stream":False}))
PY
echo "=== TEST A: klein (passthrough) ==="
curl -s -m 60 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_small.json | jq -r '.choices[0].message.content'
echo "=== TEST B: mittel ~30k Zeichen (soft-Einschub, geht ans Modell) ==="
curl -s -m 120 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_mid.json | jq '{id, content: (.choices[0].message.content|.[0:80])}'
echo "=== TEST C: gross ~40k Zeichen (HART-STOPP, kein Modell-Call) ==="
curl -s -m 30 "$GOV" -H "Content-Type: application/json" --data @/tmp/gov_big.json | jq '{id, finish: .choices[0].finish_reason, content: (.choices[0].message.content|.[0:70]), usage}'
+13
View File
@@ -0,0 +1,13 @@
# Wegwerf-Aufgabe: kleine Todo-App, schrittweise. Jede Zeile = eine Runde.
Create todo.py with a TodoList class: add(text) appends a dict {"text": text, "done": False} to an internal list, and items() returns that list. Then update SAVEPOINT.md following our conventions.
Add complete(index) and remove(index) to TodoList, each with a bounds check that raises IndexError with a clear message when the index is out of range. Update SAVEPOINT.md.
Add save(path) and load(path) to TodoList that persist the items to and from a JSON file. Update SAVEPOINT.md.
Add pending_count() and completed_count() methods to TodoList. Update SAVEPOINT.md.
Create cli.py with an argparse command line interface exposing subcommands add, list, done, and rm that operate on a todos.json file via TodoList. Update SAVEPOINT.md.
Add a clear subcommand to cli.py that removes all completed items from todos.json. Update SAVEPOINT.md.
Create test_todo.py with unittest tests covering add, complete, remove, save, load, and the IndexError bounds checks. Update SAVEPOINT.md.
Add tests for pending_count and completed_count to test_todo.py. Update SAVEPOINT.md.
Create README.md documenting the CLI usage with a short example for each subcommand. Update SAVEPOINT.md.
Add type hints throughout todo.py and cli.py. Update SAVEPOINT.md.
Add an optional due date field (ISO date string) to each item and a due argument to TodoList.add and to the CLI add subcommand. Update SAVEPOINT.md.
Add an overdue subcommand to cli.py that lists items whose due date is before today. Update SAVEPOINT.md.
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# run-aider-msg.sh <repo> <message> — EINE Aider-Runde via --message (sauberer,
# unterstuetzter Einzel-Schuss ohne Scripting-Reflexions-Haenger), durch den Governor.
# todo.py/cli.py als Lesekontext, damit das Modell die echte API sieht (kein Erfinden)
# und keine "Datei hinzufuegen?"-Reflexion ausloest.
set -u
REPO="${1:?repo dir}"
MSG="${2:?message}"
export PATH="$HOME/.local/bin:$PATH"
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
export OPENAI_API_KEY="dummy"
cd "$REPO"
aider \
--model openai/Qwen3-Coder-Next \
--no-check-update --no-show-model-warnings --no-analytics --yes-always \
--map-tokens 512 \
--read CONVENTIONS.md --read todo.py --read cli.py \
SAVEPOINT.md \
--message "$MSG"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# run-aider.sh <messages-file> — treibt EINE Aider-Sitzung durch den Governor.
# Jede Zeile der Nachrichtendatei = eine User-Runde; die Historie akkumuliert,
# sodass die Anfrage jede Runde wächst (genau das prüft der Governor).
set -u
MSGS="${1:?Nachrichtendatei angeben}"
export PATH="$HOME/.local/bin:$PATH"
export OPENAI_API_BASE="http://127.0.0.1:8100/v1"
export OPENAI_API_KEY="dummy"
cd "$HOME/governor-p0/testrepo"
aider \
--model openai/Qwen3-Coder-Next \
--no-check-update \
--no-show-model-warnings \
--no-analytics \
--yes-always \
--map-tokens 512 \
--read CONVENTIONS.md \
SAVEPOINT.md \
< "$MSGS"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# run-driver.sh <messages-file> — Aider-Scripting-Sitzung durch den Governor.
set -u
MSGS="${1:?Nachrichtendatei angeben}"
REPO="${2:-$HOME/governor-p0/testrepo}"
VENV_PY="$HOME/.local/share/uv/tools/aider-chat/bin/python"
cd "$REPO"
exec "$VENV_PY" "$HOME/governor-p0/driver.py" "$MSGS"
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Hermes-Runtime-Patch-Traeger — haelt MC2-eigene Fixes im Hermes-Quellcode
(``~/.hermes/hermes-agent``), damit sie ein ``hermes update`` ueberleben.
Warum: Der Hermes-Agent wird aus git aktualisiert; ein Direkt-Edit der Laufzeit
wuerde beim naechsten Update still ueberschrieben. Dieses Skript wird von
``deploy/deploy.sh`` UND ``deploy/hermes-postcheck.sh`` aufgerufen und traegt die
Patches (re-)ein.
Mechanik: JEDER Patch ist eine idempotente String-Ersetzung (robust gegen
Zeilenverschiebungen durch Updates, anders als ``git apply``):
* ``marker`` schon im Quelltext -> Patch bereits drin, ueberspringen.
* ``old`` genau 1x vorhanden -> ersetzen, dann ``py_compile`` pruefen.
* ``old`` 0x oder >1x -> Kontext hat sich durch ein Update
geaendert -> LAUT scheitern (Exit 1), damit
der Postcheck rot wird und niemand denkt,
der Fix sei noch aktiv.
Bei Fehlschlag: Exit 1 (der Deploy/Postcheck behandelt das als Fehler).
"""
import os
import py_compile
import shutil
import sys
from pathlib import Path
HA = Path(os.environ.get("HERMES_AGENT_DIR", str(Path.home() / ".hermes" / "hermes-agent")))
HERE = Path(__file__).resolve().parent
# --- Zusatzmodule, die in die Hermes-Laufzeit kopiert werden (update-fest) ---
# (source relativ zu diesem Skript, dest relativ zum Hermes-Agent-Dir)
COPIES = [
("mc2_kanban_reaper.py", "hermes_cli/mc2_kanban_reaper.py"),
]
# --- Patch-Definitionen (chronologisch, Fan-out-/Muenz-Schleifen-Fixes 20.07.2026) ---
PATCHES = [
{
"name": "0001-needs-input-wait",
"file": "hermes_cli/kanban_db.py",
"marker": 'kind not in ("needs_input", "capability")',
"old": " if recurrences >= BLOCK_RECURRENCE_LIMIT:\n",
"new": (
" # MC2-Patch (20.07.2026): needs_input/capability sind genuin\n"
" # human-only — sie muessen in ``blocked`` auf den Commander warten\n"
" # (Antwort via Auftragsbuch), NICHT nach triage eskalieren. Sonst\n"
" # zerlegt der Auto-Decomposer sie neu = Duplikat-Karten-Schleife\n"
' # ("Muenz-Schleife", Fan-out-Vorfall 20.07.). Der Loop-Breaker bleibt\n'
" # fuer transient/untyped Blocks aktiv.\n"
' if recurrences >= BLOCK_RECURRENCE_LIMIT and kind not in ("needs_input", "capability"):\n'
),
},
{
"name": "0002-decompose-at-readiness",
"file": "hermes_cli/kanban_decompose.py",
"marker": "Startreife-Gate",
"old": (
" cfg = _load_config()\n"
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
),
"new": (
" # MC2-Patch (20.07.2026): Startreife-Gate. Eine triage-Karte mit noch\n"
" # OFFENEN Vorgaengern NICHT zerlegen — sonst raet der Decomposer die\n"
" # Schnittstellen der Vor-Etappe ins Blaue, bevor die gebaut hat\n"
" # (Fan-out-Vorfall 20.07.). Billiger Parent-Check VOR dem teuren\n"
" # LLM-Aufruf; erst zerlegen, wenn alle Eltern done/archived.\n"
" with kb.connect_closing() as _pconn:\n"
" _parents = [r[0] for r in _pconn.execute(\n"
' "SELECT parent_id FROM task_links WHERE child_id = ?", (task_id,))]\n'
" if _parents:\n"
' _ph = ",".join("?" * len(_parents))\n'
" _open = _pconn.execute(\n"
' "SELECT COUNT(*) FROM tasks WHERE id IN (%s) "\n'
" \"AND status NOT IN ('done', 'archived')\" % _ph, _parents\n"
" ).fetchone()[0]\n"
" if _open:\n"
" return DecomposeOutcome(\n"
" task_id, False,\n"
' f"defer: {_open} unfinished parent(s) — decompose when start-ready",\n'
" )\n"
" cfg = _load_config()\n"
" orchestrator = _resolve_orchestrator_profile(cfg)\n"
),
},
{
"name": "0003-orphan-guard",
"file": "gateway/kanban_watchers.py",
"marker": "mc2_kanban_reaper",
"old": (
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
" if pids:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
" len(pids),\n"
" pids,\n"
" )\n"
),
"new": (
" pids = await asyncio.to_thread(_kb.reap_worker_zombies)\n"
" if pids:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d zombie worker(s), pids=%s",\n'
" len(pids),\n"
" pids,\n"
" )\n"
" # MC2-Patch (20.07.2026): Orphan-Guard. Beende lebende\n"
" # Worker, deren Task nicht mehr 'running' ist (extern\n"
" # blockiert/reset) — sonst unterlaufen sie max_in_progress\n"
" # und verbrennen Rechenzeit.\n"
" try:\n"
" from hermes_cli import mc2_kanban_reaper as _mc2reaper\n"
" _orph = await asyncio.to_thread(\n"
" _mc2reaper.reap_orphaned_workers, _kb.connect_closing\n"
" )\n"
" if _orph:\n"
" logger.info(\n"
' "kanban dispatcher: reaped %d orphaned worker(s): %s",\n'
" len(_orph), _orph,\n"
" )\n"
" except Exception:\n"
' logger.exception("kanban dispatcher: orphan reaper failed")\n'
),
},
{
"name": "0004a-context-inherit-compute",
"file": "hermes_cli/kanban_decompose.py",
"marker": '_parent_ctx = ""',
"old": (
" children: list[dict] = []\n"
" for idx, entry in enumerate(raw_tasks):\n"
),
"new": (
" children: list[dict] = []\n"
" # MC2-Patch (20.07.2026): Projekt-/Repo-Kontext vererben. Der Parent-Body\n"
' # traegt oft harte Randbedingungen (Ziel-Repo, Tabus, "eigenes Projekt"),\n'
" # die bei der Zerlegung sonst verloren gehen (Worker klonte das falsche\n"
" # Repo, Fan-out-Vorfall 20.07.). Jeder Kind-Body bekommt den voran.\n"
' _parent_ctx = ""\n'
' if getattr(task, "body", None) and task.body.strip():\n'
" _parent_ctx = (\n"
' "## PROJEKT-KONTEXT (von der uebergeordneten Aufgabe — gilt fuer dich)\\n"\n'
" + task.body.strip()[:1500]\n"
' + "\\n\\n## DEIN TEILAUFTRAG\\n"\n'
" )\n"
" for idx, entry in enumerate(raw_tasks):\n"
),
},
{
"name": "0004b-context-inherit-apply",
"file": "hermes_cli/kanban_decompose.py",
"marker": "_parent_ctx + body",
"old": (
' body = entry.get("body")\n'
" if not isinstance(body, str):\n"
' body = ""\n'
),
"new": (
' body = entry.get("body")\n'
" if not isinstance(body, str):\n"
' body = ""\n'
" body = _parent_ctx + body # MC2 (20.07.): Projekt-Kontext vererben\n"
),
},
{
"name": "0005-create-chain-guard",
"file": "tools/kanban_tools.py",
"marker": "_MC2_RECENT_ROOTS",
"old": (
" new_task = kb.get_task(conn, new_tid)\n"
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
),
"new": (
" # MC2-Patch (20.07.2026): Etappen-Auto-Kette. Legt dieselbe\n"
" # Session mehrere ROOT-triage-Karten (ohne parents) im selben\n"
" # 120s-Fenster an, verkette sie — so greift das Startreife-Gate\n"
" # (Karte 2 wartet auf Karte 1) statt alle sofort zu zerlegen\n"
" # (Fan-out 20.07.). Unter max_in_progress:1 ist eine Fehl-\n"
" # Verkettung harmlos (laeuft eh seriell).\n"
" try:\n"
" if triage and not parents and session_id:\n"
" import time as _mc2t\n"
' _roots = globals().setdefault("_MC2_RECENT_ROOTS", {})\n'
" _prev = _roots.get(session_id)\n"
" _now = _mc2t.monotonic()\n"
" if _prev and (_now - _prev[1]) <= 120 and _prev[0] != new_tid:\n"
" try:\n"
" kb.link_tasks(conn, _prev[0], new_tid)\n"
" except Exception:\n"
" pass\n"
" _roots[session_id] = (new_tid, _now)\n"
" if len(_roots) > 64:\n"
" _roots.clear()\n"
" except Exception:\n"
" pass\n"
" new_task = kb.get_task(conn, new_tid)\n"
" subscribed = _maybe_auto_subscribe(conn, new_tid)\n"
),
},
]
def main() -> int:
if not HA.is_dir():
print(f"hermes-patches: Hermes-Agent-Dir fehlt ({HA}) — nichts zu tun.")
return 0
applied, skipped, failed = [], [], []
# --- Zusatzmodule kopieren (idempotent: nur wenn Inhalt abweicht) ---
for src_rel, dst_rel in COPIES:
src, dst = HERE / src_rel, HA / dst_rel
if not src.is_file():
failed.append((src_rel, f"Quelle fehlt: {src}"))
continue
try:
same = dst.is_file() and dst.read_bytes() == src.read_bytes()
if same:
skipped.append(f"copy:{dst_rel}")
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
if dst.suffix == ".py":
py_compile.compile(str(dst), doraise=True)
applied.append(f"copy:{dst_rel}")
except Exception as exc:
failed.append((src_rel, f"Kopie/Compile fehlgeschlagen: {exc}"))
for p in PATCHES:
f = HA / p["file"]
if not f.is_file():
failed.append((p["name"], f"Datei fehlt: {f}"))
continue
src = f.read_text(encoding="utf-8")
if p["marker"] in src:
skipped.append(p["name"])
continue
n = src.count(p["old"])
if n != 1:
failed.append((p["name"], (f"erwartete 1 Vorkommen von old, fand {n} "
"(Update hat den Kontext geaendert?)")))
continue
f.write_text(src.replace(p["old"], p["new"]), encoding="utf-8")
try:
py_compile.compile(str(f), doraise=True)
except Exception as exc:
failed.append((p["name"], f"py_compile fehlgeschlagen: {exc}"))
continue
applied.append(p["name"])
print(f"hermes-patches: applied={applied} skipped={skipped} failed={[n for n, _ in failed]}")
for name, why in failed:
print(f" FEHLER {name}: {why}", file=sys.stderr)
if failed:
return 1 # Fehler → Deploy/Postcheck rot
if applied:
return 10 # etwas geaendert → Aufrufer sollte hermes-gateway neu starten
return 0 # nichts zu tun (alles schon drin)
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,84 @@
"""MC2-Zusatzmodul (20.07.2026) — Orphan-Reaper für Kanban-Worker.
Wird vom Patch-Träger (``deploy/hermes-patches/apply.py``) nach
``~/.hermes/hermes-agent/hermes_cli/`` kopiert und vom Gateway-Dispatcher
(``gateway/kanban_watchers.py``, per Patch 0003) jeden Tick aufgerufen.
Problem: Wird eine ``running``-Karte von außen blockiert/zurückgesetzt (z. B.
über das Auftragsbuch oder die No-Progress-Bremse), setzt ``block_task``
``worker_pid = NULL`` — der Worker-PROZESS läuft aber weiter. Die DB zählt ihn
nicht mehr, also unterläuft der Orphan ``kanban.max_in_progress`` (Doppel-Worker
möglich) und verbrennt Rechenzeit. Dieser Reaper beendet lebende Worker, deren
Task nicht mehr ``running`` ist.
Linux-only (/proc-Scan). Best-effort: jeder Fehler → leere Liste, nie werfen.
"""
from __future__ import annotations
import os
import re
import signal
import sys
_TASK_RE = re.compile(rb"work kanban task (t_[0-9a-fA-F]+)")
def reap_orphaned_workers(connect_closing) -> list[tuple[int, str]]:
"""Beende lebende Kanban-Worker, deren Task nicht mehr ``running`` ist.
``connect_closing`` ist die gleichnamige Kontextmanager-Factory aus
``kanban_db`` (durchgereicht, um Import-Zyklen zu vermeiden). Gibt die Liste
der beendeten ``(pid, task_id)`` zurück.
"""
if sys.platform != "linux":
return []
candidates: dict[int, str] = {}
try:
entries = os.listdir("/proc")
except OSError:
return []
for entry in entries:
if not entry.isdigit():
continue
try:
with open(f"/proc/{entry}/cmdline", "rb") as fh:
cmd = fh.read().replace(b"\x00", b" ")
except OSError:
continue
m = _TASK_RE.search(cmd)
if m:
candidates[int(entry)] = m.group(1).decode()
if not candidates:
return []
task_ids = list(set(candidates.values()))
placeholders = ",".join("?" * len(task_ids))
running: set[str] = set()
try:
with connect_closing() as conn:
running = {
row[0]
for row in conn.execute(
f"SELECT id FROM tasks WHERE id IN ({placeholders}) "
"AND status = 'running'",
task_ids,
)
}
except Exception:
return []
killed: list[tuple[int, str]] = []
for pid, task_id in candidates.items():
if task_id in running:
continue
# Prozessgruppe zuerst (Worker starten start_new_session=True → eigene
# Gruppe; killpg räumt selbst gestartete Kinder wie uvicorn gleich mit).
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except OSError:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
continue
killed.append((pid, task_id))
return killed
+53 -5
View File
@@ -30,6 +30,25 @@ else
echo "FAIL · memory.provider nicht mehr gesetzt (Config vom Update überschrieben?)"; fail=1
fi
# --- Hermes-Runtime-Patch-Traeger (20.07.2026): ein Update setzt den Quellbaum ---
# --- zurueck → unsere Fixes (needs_input-wartet, Startreife-Gate, Orphan-Guard, ---
# --- Kontext-Vererbung, Etappen-Kette) hier RE-eintragen. Exit 10 = etwas neu ---
# --- angewandt → Gateway neu laden; Exit 1 = ein Patch passt nicht mehr → ROT. ---
PATCH_APPLY="${MC2_SRC:-$HOME/mission-control-v2}/deploy/hermes-patches/apply.py"
if [ -f "$PATCH_APPLY" ]; then
python3 "$PATCH_APPLY" >/dev/null 2>&1; _prc=$?
if [ "$_prc" -eq 10 ]; then
systemctl --user try-restart hermes-gateway >/dev/null 2>&1 || true
echo "PASS · Hermes-Runtime-Patches nach Update RE-angewandt (Gateway neu geladen)"
elif [ "$_prc" -eq 0 ]; then
echo "PASS · Hermes-Runtime-Patches unveraendert vorhanden"
else
echo "FAIL · Hermes-Runtime-Patches: ein Patch passt nicht mehr zum aktualisierten Quellcode — pruefen"; fail=1
fi
else
echo "WARN · hermes-patches/apply.py nicht gefunden ($PATCH_APPLY)"
fi
if ( cd "$HERMES/hermes-agent" && HERMES_HOME="$HERMES" ./venv/bin/python -c \
"import sys; sys.path.insert(0,'.'); from plugins.memory import load_memory_provider; p=load_memory_provider('mc2-memory'); assert p and p.name()=='mc2-memory'" \
>/dev/null 2>&1 ); then
@@ -53,12 +72,22 @@ fi
# --- Approval-/Tool-Ketten (Antwort muss kommen und darf nicht in pending_approval hängen). ---
API_KEY="$(grep -E '^API_SERVER_KEY=' "$HERMES/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | tr -d "'")"
if [ -n "$API_KEY" ]; then
TOOL_RESP=$(curl -sf -m 90 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
# Mit Retry — GENAU wie der Voice-Smoke darunter, und aus demselben Grund: direkt nach
# einem Update ist das Modell kalt. Ein Kaltstart kostet ~20-30 s Laden PLUS ~37 s
# Prefill für Hermes' ~70-KB-Systemprompt (33 Tool-Schemas) — zusammen mit zwei
# Inferenz-Runden sprengt das die 90 s des ersten Versuchs. Warm gemessen: 12 s.
# Ein Versuch allein war ein Fehlalarm-Generator (live erlebt 25.07., Job faf1137a).
TOOL_RESP=""
for try in 1 2 3; do
TOOL_RESP=$(curl -sf -m 120 -X POST "http://127.0.0.1:8642/v1/chat/completions" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-H "X-Hermes-Session-Id: postcheck-tool-smoke-$try" \
-d '{"model":"hermes","stream":false,"messages":[{"role":"user","content":"Führe im Terminal exakt den Befehl echo postcheck-ok aus und gib mir nur dessen Ausgabe zurück."}]}' 2>/dev/null)
echo "$TOOL_RESP" | grep -qi "postcheck-ok" && break
[ "$try" -lt 3 ] && sleep 10
done
if echo "$TOOL_RESP" | grep -qi "postcheck-ok"; then
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis"
echo "PASS · Tool-Smoke (terminal via Agent) liefert Ergebnis (Versuch $try)"
elif echo "$TOOL_RESP" | grep -qi "pending_approval"; then
echo "FAIL · Tool-Smoke hängt in pending_approval (approvals.mode prüfen!)"; fail=1
else
@@ -86,6 +115,25 @@ else
echo "FAIL · Voice-Smoke: /api/voice/chat streamt nicht (3 Versuche)"; fail=1
fi
# --- Browser-Tool: agent-browser überlebt das Update? (Vorfall 15./16.07.2026: das ---
# --- npm ci des Updates bog ~/.local/bin/agent-browser per postinstall auf ---
# --- node_modules/ um und löschte das Paket im selben Lauf → toter Link, jedes ---
# --- „Website öffnen" starb still als „Failed to open".) Heilung direkt hier, denn ---
# --- genau NACH einem Update entsteht der Bruch; try-restart räumt den ---
# --- „nicht installiert"-Cache, den browser_tool pro Prozess hält. ---
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
AB_LINK="$HOME/.local/bin/agent-browser"
AB_GOOD="$HOME/.local/lib/node_modules/agent-browser/bin/agent-browser-linux-x64"
if "$AB_LINK" --version >/dev/null 2>&1; then
echo "PASS · Browser-Tool (agent-browser erreichbar)"
elif [ -x "$AB_GOOD" ] && "$AB_GOOD" --version >/dev/null 2>&1 \
&& ln -sfn "$AB_GOOD" "$AB_LINK" && "$AB_LINK" --version >/dev/null 2>&1; then
systemctl --user try-restart hermes-gateway hermes-builtin-ui >/dev/null 2>&1 || true
echo "GEHEILT · Browser-Tool: toter agent-browser-Link nach Update neu gesetzt (Dienste neu gestartet)"
else
echo "FAIL · Browser-Tool: agent-browser nicht lauffähig (npm install -g agent-browser && agent-browser install --with-deps)"; fail=1
fi
if [ "$fail" -eq 0 ]; then
echo "=== GEHIRN OK ✓ ==="
else
+7
View File
@@ -0,0 +1,7 @@
[Unit]
Description=MC2 Morgen-Digest (Nächtliche Telegram-Zusammenfassung)
[Service]
Type=oneshot
ExecStart=/bin/bash %h/mission-control-v2/deploy/morgen-digest.sh
TimeoutStartSec=300
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Timer für MC2 Morgen-Digest (07:00 Uhr)
[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true
RandomizedDelaySec=5m
[Install]
WantedBy=timers.target
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""mem0-Konsolidierung (monatlich, 19.07.2026 — Nachtrag Phase 2 Drei-Welten-Plan).
mem0 OSS v2 ist ADD-only: Es legt Fakten an, loest aber Widersprueche nie auf und
legt Aehnliches nie zusammen — ohne Pflege waechst das Gedaechtnis unbegrenzt und
der Recall verrauscht. Der Steward-Dedupe faengt nur exakte Dubletten. Dieser Lauf
macht die fehlende Konsolidierung:
1. Cluster aehnlicher Fakten holen — ueber den bestehenden /graph-Endpunkt des
Sidecars (Kosinus der gespeicherten Embeddings, kein Re-Embedding, kein
Chroma-Zugriff noetig).
2. Je Cluster entscheidet das lokale Hirn (Alias `fast`, Thinking aus wie im
Sidecar): zusammenlegen / Widerspruch aufloesen (neuester Zeitstempel
gewinnt) / NICHTS tun.
3. Anwenden ueber die normalen Sidecar-Endpunkte (PUT/DELETE /memory/<id>).
Sicherheitsleitplanken (hart im Code, nicht im Prompt):
- Pro Cluster ueberlebt IMMER mindestens ein Fakt.
- Nur ids aus dem Cluster, max. 1 Update pro Cluster, Inhalt <= 600 Zeichen.
- LLM-Fehler / kaputtes JSON => Cluster bleibt unangetastet.
- Kappung pro Lauf (MAX_CLUSTERS), damit ein Lauf nie das halbe Gedaechtnis
umpfluegt — der naechste Monat macht weiter.
Aufruf: mem0-konsolidierung.py [--apply] (Default: Dry-Run, druckt nur Plan)
"""
import json
import sys
import time
import urllib.request
MEM0 = "http://127.0.0.1:8765"
LLM = "http://127.0.0.1:8080/v1/chat/completions"
LLM_MODEL = "fast"
MIN_SCORE = 0.82
MAX_CLUSTERS = 15
MAX_FACTS_PER_CLUSTER = 6
APPLY = "--apply" in sys.argv
def _http(method: str, url: str, body: dict | None = None, timeout: float = 120.0):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode() or "{}")
def clusters_from_graph() -> tuple[dict, list]:
g = _http("GET", f"{MEM0}/graph?min_score={MIN_SCORE}&top_k=5", timeout=180)
nodes = {n["id"]: n for n in g.get("nodes", [])}
parent: dict = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for e in g.get("edges", []):
a, b = find(e["source"]), find(e["target"])
if a != b:
parent[a] = b
groups: dict = {}
for nid in parent:
groups.setdefault(find(nid), []).append(nid)
clusters = [sorted(v) for v in groups.values() if len(v) >= 2]
clusters.sort(key=len, reverse=True)
return nodes, clusters[:MAX_CLUSTERS]
def judge(facts: list[dict]) -> list[dict]:
"""LLM entscheidet pro Cluster. Fehler => [] (nichts tun)."""
listing = "\n".join(
f"- id={f['id']} | kategorie={f.get('category')} | stand={f.get('updated_at', '')[:10]} | {f.get('content', '')}"
for f in facts)
system = (
"Du konsolidierst ein Langzeitgedaechtnis. Unten steht EINE Gruppe semantisch "
"aehnlicher Fakten. Entscheide konservativ: "
"(a) Sind es Facetten DESSELBEN Fakts: fasse sie in EINEM praezisen deutschen Satz "
"zusammen -> genau ein update auf die beste id, die anderen delete. "
"(b) Widersprechen sie sich: der Fakt mit dem NEUESTEN Stand gewinnt -> veraltete delete. "
"(c) Sind es VERSCHIEDENE Fakten (nur thematisch verwandt): TUE NICHTS. "
"Im Zweifel IMMER (c). Antworte NUR als JSON: "
'{"actions":[{"op":"update","id":"...","content":"..."},{"op":"delete","id":"..."}]} '
'oder {"actions":[]}.'
)
try:
resp = _http("POST", LLM, {
"model": LLM_MODEL,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": "Fakten-Gruppe:\n" + listing}],
"temperature": 0.1, "max_tokens": 800,
"response_format": {"type": "json_object"},
"chat_template_kwargs": {"enable_thinking": False},
})
raw = resp["choices"][0]["message"]["content"] or "{}"
actions = json.loads(raw).get("actions", [])
except Exception as exc:
print(f" (LLM-Fehler, Gruppe unangetastet: {exc})")
return []
# Harte Validierung — Leitplanken siehe Docstring.
ids = {f["id"] for f in facts}
updates = [a for a in actions if a.get("op") == "update" and a.get("id") in ids
and isinstance(a.get("content"), str) and 0 < len(a["content"]) <= 600]
deletes = [a for a in actions if a.get("op") == "delete" and a.get("id") in ids]
if len(updates) > 1:
return []
del_ids = {a["id"] for a in deletes} - {a["id"] for a in updates}
if len(del_ids) >= len(ids):
return []
return updates + [{"op": "delete", "id": i} for i in sorted(del_ids)]
def main() -> None:
t0 = time.time()
nodes, clusters = clusters_from_graph()
total = len(nodes)
print(f"mem0-Konsolidierung {'(APPLY)' if APPLY else '(DRY-RUN)'}"
f"{total} Fakten, {len(clusters)} Gruppen >= {MIN_SCORE}")
n_upd = n_del = 0
for ci, cl in enumerate(clusters, 1):
facts = [nodes[i] for i in cl][:MAX_FACTS_PER_CLUSTER]
actions = judge(facts)
if not actions:
continue
print(f"Gruppe {ci} ({len(facts)} Fakten):")
for f in facts:
print(f" [{f['id'][:8]}] {f.get('content', '')[:90]}")
for a in actions:
if a["op"] == "update":
print(f" -> UPDATE {a['id'][:8]}: {a['content'][:90]}")
n_upd += 1
if APPLY:
_http("PUT", f"{MEM0}/memory/{a['id']}", {"content": a["content"]})
else:
print(f" -> DELETE {a['id'][:8]}")
n_del += 1
if APPLY:
_http("DELETE", f"{MEM0}/memory/{a['id']}")
print(f"Ergebnis: {n_upd} zusammengefasst, {n_del} geloescht, "
f"{total - (n_del if APPLY else 0)} Fakten verbleiben. "
f"({int(time.time() - t0)} s)")
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Monatlicher mem0-Konsolidierungslauf (Cron-Wrapper, 3. des Monats 06:40 —
# nach Restore-Probe am 1. und Venv-Audit am 2.). no_agent-Job: stdout geht
# als Bericht direkt an Telegram. Logik: deploy/mem0-konsolidierung.py (--apply).
set -u
exec python3 "$HOME/.hermes/scripts/mem0-konsolidierung.py" --apply 2>&1
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Morgen-Digest: Sammelt die zurückgehaltenen nächtlichen Meldungen (00:00 - 06:59)
# und lässt Lucy (LLM) eine Telegram-Zusammenfassung daraus bauen.
set -uo pipefail
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
if [ ! -f "$NIGHT_QUEUE" ]; then
# Keine nächtlichen Meldungen
exit 0
fi
# Wenn die Datei existiert, aber leer ist, löschen und beenden.
if [ ! -s "$NIGHT_QUEUE" ]; then
rm -f "$NIGHT_QUEUE"
exit 0
fi
EVIDENCE="$(cat "$NIGHT_QUEUE")"
PROMPT="Du bist Lucy, die KI-Assistenz der Mission-Control-Box.
Hier sind die Systemmeldungen der vergangenen Nacht.
Der Commander möchte ordentlich Futter und Details zum Frühstück! Fasse die Meldungen zu einem gehaltvollen Morgen-Digest zusammen.
Beachte dabei:
- Nenne konkrete Fakten, Namen und Versionen.
- Welche Modelle wurden z.B. vom Radar entdeckt oder evaluiert? Was waren die Ergebnisse?
- Welche Updates liefen genau und was haben sie Neues gebracht? Was muss sich der Commander noch anschauen?
- Strukturiere die Nachricht übersichtlich (z.B. mit kleinen Emojis oder Bulletpoints), aber bleibe in deiner Rolle als Lucy.
Nächtliche Meldungen:
$EVIDENCE"
# LLM aufrufen (llama-swap / gpt-oss-120b)
ENDPOINT="${CHEF_ENDPOINT:-http://127.0.0.1:8080/v1/chat/completions}"
MODEL="${CHEF_MODEL:-gpt-oss-120b}"
REQ="$(jq -n --arg m "$MODEL" --arg sys "Du bist Lucy." --arg usr "$PROMPT" --argjson mt 1200 \
'{model:$m, messages:[{role:"system",content:$sys},{role:"user",content:$usr}],
max_tokens:$mt, temperature:0.4}')"
DIGEST="$(curl -s -m 180 "$ENDPOINT" -H 'Content-Type: application/json' --data-binary "$REQ" \
| python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
msg = d["choices"][0]["message"]
print((msg.get("content") or msg.get("reasoning_content") or "").strip())
except Exception:
pass'
)"
if [ -n "$DIGEST" ]; then
# Sende den Digest via hermes send (Telegram)
bash -lc 'hermes send --to telegram --subject "[🌅 Morgen-Digest]" -- "$1"' _ "$DIGEST"
if [ $? -eq 0 ]; then
# Erfolgreich gesendet, Queue leeren
rm -f "$NIGHT_QUEUE"
else
echo "Fehler beim Senden des Morgen-Digests an Telegram. Queue bleibt erhalten." >&2
fi
else
echo "Leerer Digest vom LLM. Queue bleibt erhalten." >&2
fi
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Erwartet den Job-Namen und das Skript als ein String oder zwei Argumente
if [ $# -eq 1 ]; then
read -r JOB_NAME SCRIPT_NAME <<< "$1"
else
JOB_NAME="$1"
SCRIPT_NAME="$2"
fi
if [ -z "$SCRIPT_NAME" ]; then
SCRIPT_NAME="$JOB_NAME"
fi
# Pfad zusammenbauen
SCRIPT_PATH="$HOME/mission-control-v2/deploy/$SCRIPT_NAME"
if [ ! -f "$SCRIPT_PATH" ]; then
# Versuche pruefstand/
SCRIPT_PATH="$HOME/mission-control-v2/deploy/pruefstand/$SCRIPT_NAME"
fi
bash "$SCRIPT_PATH" | bash "$HOME/mission-control-v2/deploy/notify.sh" -s "$JOB_NAME"
+19 -2
View File
@@ -22,8 +22,8 @@ shift $((OPTIND - 1))
MSG="${1:-}"
[ -z "$MSG" ] && [ ! -t 0 ] && MSG="$(cat)"
if [ -z "$MSG" ]; then
echo "usage: notify.sh [-s subject] <nachricht>" >&2
exit 2
# Bei leerer Pipe-Eingabe lautlos beenden (wichtig für Cron-Skripte ohne Ausgabe)
exit 0
fi
TS="$(date '+%Y-%m-%d %H:%M:%S')"
@@ -41,6 +41,23 @@ PY
)" >/dev/null 2>&1 || true
fi
# Nachtweiche: Zwischen 00:00 und 06:59 Uhr (inklusive) fangen wir Telegram-Nachrichten ab
# und sammeln sie für den Morgen-Digest, ES SEI DENN es ist ein Alarm.
HOUR="$(date +%H)"
if [ "$HOUR" -ge 0 ] && [ "$HOUR" -lt 7 ]; then
case "${SUBJECT,,}" in
*alarm*|*notfall*)
;; # Alarme gehen immer sofort durch
*)
# Sammeln für Morgen-Digest
NIGHT_QUEUE="$HOME/.hermes/night-queue.txt"
echo "- ${SUBJECT:+$SUBJECT: }$MSG" >> "$NIGHT_QUEUE"
echo "$TS QUEUED für Morgen-Digest: $MSG" >> "$LOG"
exit 0
;;
esac
fi
# Primärweg: Telegram via hermes send (Login-Shell-PATH, falls aus Timer/cron aufgerufen).
if [ -n "$SUBJECT" ]; then
SEND_OUT="$(bash -lc 'hermes send --to telegram --subject "$1" -- "$2"' _ "$SUBJECT" "$MSG" 2>&1)"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# opencode-lauf.sh — EIN begrenzter Agentenlauf auf der Box, mit denselben Regeln wie in Zed.
#
# Das ist die Nacht-Seite von „ein Regelwerk, zwei Ausloeser": tagsueber tippst du in Zed,
# nachts ruft ein Hermes-Cron dieses Skript. Beide Wege benutzen
# * dieselbe OpenCode-Version,
# * dieselbe Mannschaft (~/.config/opencode/opencode.json: coder/hermes/kritiker),
# * dasselbe Plugin (~/.config/opencode/plugin/mc2-governor.ts: Zaun + Pruef-Tor),
# * denselben Token-Waechter (:8100).
#
# Unterschied zu deploy/worker.sh: worker.sh ist EIN zustandsloser Completion-Aufruf
# (Hermes schreibt die Dateien selbst). Hier laeuft ein ECHTER Agent mit Datei-Haenden,
# Subagenten und Pruef-Tor — fuer ganze Karten statt fuer Schnipsel.
#
# Nutzung:
# opencode-lauf.sh <repo-pfad> "<auftrag>"
# opencode-lauf.sh ~/projekte/foo "Baue X. Halte dich an AGENTS.md."
#
# Env:
# LAUF_TIMEOUT Sekunden Hoechstdauer (Default 3600)
# LAUF_LAUT 1 = Lucy spricht mit (Default 0 = still, Nachtbetrieb)
# LAUF_AGENT OpenCode-Agent (Default build)
set -uo pipefail
REPO="${1:-}"
AUFTRAG="${2:-}"
TIMEOUT="${LAUF_TIMEOUT:-3600}"
AGENT="${LAUF_AGENT:-build}"
OC="$HOME/.opencode/bin/opencode"
ANNOUNCE="${MC_ANNOUNCE_URL:-http://127.0.0.1:9001/api/voice/announce}"
melde () { # melde <betreff> <text> [prioritaet]
curl -sf -m 5 -X POST "$ANNOUNCE" -H 'Content-Type: application/json' \
--data "$(python3 -c 'import json,sys; print(json.dumps({"subject":sys.argv[1],"text":sys.argv[2],"source":"loop","priority":sys.argv[3]}))' \
"$1" "$2" "${3:-silent}")" >/dev/null 2>&1 || true
}
if [ -z "$REPO" ] || [ -z "$AUFTRAG" ]; then
echo "Nutzung: $0 <repo-pfad> \"<auftrag>\"" >&2
exit 2
fi
if [ ! -d "$REPO" ]; then
echo "FEHLER: '$REPO' ist kein Verzeichnis." >&2
exit 2
fi
if [ ! -x "$OC" ]; then
echo "FEHLER: OpenCode nicht gefunden ($OC)." >&2
exit 2
fi
# Der Governor MUSS stehen — ohne ihn liefe der Lauf ohne Sitzungs-Bremse.
if ! curl -sf -m 5 -o /dev/null "http://127.0.0.1:8100/governor/status"; then
echo "FEHLER: Governor (:8100) antwortet nicht — Lauf abgebrochen (keine Sitzungs-Bremse)." >&2
melde "[Lauf]" "Ich habe einen Nachtlauf abgebrochen: der Token-Waechter antwortet nicht." "normal"
exit 3
fi
# Nachts still: Meldungen landen im Briefkasten, Lucy spricht sie aber nicht aus.
# Das Plugin liest diese Variable; die Morgen-Zusammenfassung kommt vom Daily-Briefing.
if [ "${LAUF_LAUT:-0}" = "1" ]; then export MC2_LOOP_SILENT=0; else export MC2_LOOP_SILENT=1; fi
LOGDIR="$HOME/.hermes/logs"; mkdir -p "$LOGDIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
LOG="$LOGDIR/opencode-lauf-$STAMP.log"
MAXRUNDEN="${LAUF_MAX_RUNDEN:-3}"
cd "$REPO" || exit 2
NAME="$(basename "$REPO")"
melde "[Lauf]" "Ich fange an zu bauen: $NAME." "silent"
echo "=== Lauf $STAMP · Repo $REPO · Agent $AGENT · Timeout ${TIMEOUT}s ===" | tee "$LOG"
# Verify-Befehl des Projekts lesen (gleiche Datei und gleiche Regeln wie im Plugin).
verify_cmd () {
[ -r "$REPO/VERIFY" ] || return 1
grep -vE '^\s*(#|$)' "$REPO/VERIFY" | paste -sd' && ' -
}
START=$(date +%s)
CODE=0
RUNDE=0
AUFGABE="$AUFTRAG"
# ── Bau-Schleife ────────────────────────────────────────────────────────────
# Warum hier UND im Plugin? Das Plugin haengt am Ereignis `session.idle` — in Zed
# laeuft der Prozess weiter und alles ist gut. Bei `opencode run` beendet sich der
# Prozess aber, bevor das Pruef-Tor fertig ist (gemessen 25.07.). Fuer unbeaufsichtigte
# Laeufe muss die Schleife deshalb HIER liegen, wo sie den Prozess ueberlebt.
while :; do
RUNDE=$((RUNDE + 1))
echo "--- Runde $RUNDE/$MAXRUNDEN ---" | tee -a "$LOG"
timeout "$TIMEOUT" "$OC" run --agent "$AGENT" "$AUFGABE" >>"$LOG" 2>&1
CODE=$?
[ "$CODE" -eq 124 ] && { echo "ZEITUEBERSCHREITUNG" | tee -a "$LOG"; break; }
VCMD="$(verify_cmd)" || { echo "Kein VERIFY — Pruef-Tor aus, Lauf endet." | tee -a "$LOG"; break; }
echo "--- Pruef-Tor: $VCMD ---" | tee -a "$LOG"
VOUT="$(cd "$REPO" && eval "$VCMD" 2>&1)"; VCODE=$?
printf '%s\n' "$VOUT" | tail -20 >> "$LOG"
if [ "$VCODE" -eq 0 ]; then
echo "PRUEF-TOR GRUEN" | tee -a "$LOG"
melde "[Pruefung]" "$NAME: Tests gruen nach $RUNDE Runde(n)." "normal"
CODE=0
break
fi
if [ "$RUNDE" -ge "$MAXRUNDEN" ]; then
echo "PRUEF-TOR ROT — Reparaturrunden aufgebraucht." | tee -a "$LOG"
melde "[Pruefung]" "$NAME: Tests bleiben rot nach $RUNDE Runden. Hier komme ich allein nicht weiter." "normal"
CODE=1
break
fi
echo "PRUEF-TOR ROT — Runde $((RUNDE + 1)) folgt." | tee -a "$LOG"
melde "[Pruefung]" "$NAME: Tests rot, ich repariere selbst weiter (Runde $((RUNDE + 1))/$MAXRUNDEN)." "silent"
AUFGABE="[MC2-PRUEFTOR] Der Verify-Befehl des Projekts ist fehlgeschlagen.
Befehl: $VCMD
Ausgabe (Ende):
$(printf '%s' "$VOUT" | tail -c 3000)
Behebe die URSACHE — nicht das Symptom. Schalte keinen Test ab und aendere keine Tests.
Urspruenglicher Auftrag war: $AUFTRAG"
done
DAUER=$(( $(date +%s) - START ))
# Nachweis statt Behauptung: was hat der Lauf im Arbeitsbaum tatsaechlich veraendert?
GEAENDERT="$(git -C "$REPO" status --porcelain 2>/dev/null | wc -l | tr -d ' ')"
case "$CODE" in
0) ERG="fertig, Pruefung bestanden" ;;
124) ERG="ZEITUEBERSCHREITUNG nach ${TIMEOUT}s" ;;
*) ERG="Pruefung NICHT bestanden (Exitcode $CODE)" ;;
esac
echo "=== Ergebnis: $ERG · ${DAUER}s · $RUNDE Runde(n) · $GEAENDERT geaenderte Dateien · Log $LOG ===" | tee -a "$LOG"
melde "[Lauf]" "$NAME: $ERG nach $((DAUER/60)) Minuten, $RUNDE Runde(n), $GEAENDERT Dateien angefasst." "silent"
exit "$CODE"
+82
View File
@@ -0,0 +1,82 @@
# Persönliche Regeln (gelten für ALLE Projekte)
## So arbeitest du mit mir
- Antworte auf **Deutsch**, Fazit zuerst, Technik in Alltagssprache. Ich bin **kein Entwickler**.
- **Plan vor Code** bei allem Größeren: erst Vorgehen zeigen, auf mein **„los"** warten.
- **Kleine Schritte**, eine Sache zur Zeit. Nichts Ungefragtes ausführen; **frag**, bevor du löschst
oder überschreibst.
- **Beweisen statt behaupten** — am Ende live testen, nicht „sollte gehen".
- Nimm **Standard-Wege**, keine exotische Technik, außer ich will es ausdrücklich anders.
- **Geheimnisse** (Schlüssel/Passwörter/Tokens) nie in den Code — in `.env` (+ `.gitignore`); warnen,
bevor so etwas in einen Commit wandert.
- **Nicht festfahren:** klemmt dieselbe Sache nach 2 Versuchen, anhalten, sichern, erklären und fragen.
- **Bau-Prinzipien sind DEIN Job, nicht meine Frage:** KISS, YAGNI, Zuständigkeiten trennen, DRY,
sprechende Namen, schlank starten. Frag mich nie danach — frag mich nur nach Zielen in Alltagssprache.
- **Faulheits-Leiter vor jeder neuen Zeile/Abhängigkeit:** (1) muss das überhaupt sein? (2) gibt's das
schon (wiederverwenden)? (3) reicht Bordmittel/Standard-Bibliothek? (4) erst dann minimal selbst bauen.
Faul bei Lösungen, nie beim Verstehen; Sicherheitsnetze (Prüfung/Fehlerbehandlung/keine Geheimnisse)
bleiben. „Prüf auf Über-Engineering" = Diff auf unnötige Abhängigkeiten/Abstraktion/Verdachts-Features durchsehen.
## Deine Mannschaft — delegiere, statt alles selbst zu tun
- **`explore`** (Subagent): Code durchsuchen, Dateien finden, Fragen zum Bestand beantworten. Läuft auf
dem ohnehin warmen Hirn und kostet praktisch nichts. **Nutze ihn**, statt dich selbst durch den Baum
zu wühlen — dein eigener Kontext bleibt dadurch sauber und du hältst länger durch.
- **`review`** (Subagent): kritisches Gegenlesen nach jeder Etappe. Läuft bewusst auf einer **fremden
Modellfamilie**, damit er andere blinde Flecken hat als du. Nimm seine Befunde ernst.
- **`plan`**: nur lesen, keine Änderungen.
- Es gibt **keinen Modellwechsel mehr**. Früher galt „zum Bauen oben auf Coder umschalten" — Planen und
Bauen laufen inzwischen auf demselben Modell. Bitte mich nie, etwas umzustellen.
## Das Prüf-Tor (`VERIFY`)
- Im Projekt liegt eine Datei `VERIFY` mit **einer Zeile: wie man dieses Projekt testet**. Sobald du
„fertig" meldest, wird sie automatisch ausgeführt.
- **Rot heißt nicht fertig.** Der Fehler kommt zu dir zurück, du behebst die **Ursache** — nicht das
Symptom. Einen Test abschwächen, überspringen oder umschreiben, damit er grün wird, gilt als Betrug.
- Fehlt die Datei, ist das Prüf-Tor aus. Dann **frag mich**, was der richtige Testbefehl wäre, statt
ungeprüft weiterzubauen.
## Kontext-Wächter
- Ein Token-Wächter zählt jede Sitzung mit und schneidet sie bei ~45.000 Tokens. Du musst nichts messen
— die Ansage kommt von allein.
- Kommt sie: **`SAVEPOINT.md` ehrlich fertigschreiben und anhalten.** Ehrlich heißt: nur was **wirklich
im Code steht**. Nichts aus Absicht, Plan oder git-Nachrichten ableiten. Dazu der genaue nächste
Schritt, offene Fragen und Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung
allein damit weitermachen kann.
- Merkst du vorher schon, dass du dich wiederholst oder abdriftest: sag es proaktiv und schlag einen
frischen Chat vor.
## Wenn niemand da ist (unbeaufsichtigte Läufe)
Nachts startet ein Cron dieselbe Umgebung ohne Menschen davor. Erkennst du das — der Auftrag kam als
ein Block, auf Rückfragen antwortet niemand: **warte nicht auf ein „los"**. Halte dich an `KONZEPT`/
`SAVEPOINT.md`, bau in kleinen Etappen und lass das Prüf-Tor entscheiden. Was du ohne Antwort nicht
sauber entscheiden kannst, schreibst du als offene Frage in `SAVEPOINT.md`, statt zu raten.
## Was du nie tust
`git push`, Historie umschreiben, rekursiv löschen, `sudo`, Pakete veröffentlichen. Das ist gezäunt und
wird geblockt — nicht weil du es falsch machst, sondern weil Veröffentlichen meine Entscheidung ist.
Brauchst du so etwas wirklich: sag es mir, ich mache es selbst.
## Bestehendes Projekt fortsetzen?
Ordner nicht leer / neue Sitzung: zuerst **`SAVEPOINT.md` + `AGENTS.md` lesen**, mir in 2 Sätzen sagen
„hier stehen wir / nächster Schritt", dann weitermachen — nicht neu planen.
## Neues Projekt? → Projekt-Start-Ablauf
Wenn ein **neues** Projekt beginnt (leerer/neuer Ordner, „bau mir…", „neues Projekt…", „ich hätte
gern…"): folge diesem Ablauf. **Keine Datei, kein Code vor meiner Freigabe.**
1. **Verstehen:** erst herausfinden, *was für ein* Projekt (Web-App / kleines Skript /
Box-Lucy-Erweiterung / Experiment / Datensache), dann **36 typ-passende Fragen** + „woran ist
es fertig?".
2. **Plan vorlegen** (Ziel · Vorgehen · Technik + warum · Fertig-Kriterien · Risiken) → **STOPP**,
auf mein „los" warten.
3. **Erst dann Gerüst:** `git init` + die Projektdateien (`AGENTS.md`, `SAVEPOINT.md`, `.aiexclude`,
`VERIFY`) + `README`, dann **Sicherungspunkt „start"** (erster Commit).
4. In **kleinen Schritten** bauen, an jedem Meilenstein + vor riskanten Änderungen einen
**Sicherungspunkt** (Commit mit klarem Namen) setzen, am Ende **live testen**.
## Bei großem Meilenstein UND Session-Ende („Feierabend"/„sichern")
Immer fortschreiben: **`SAVEPOINT.md`** (Stand · Fertiges · nächste Schritte · offene Fragen — die
Übergabe an die nächste Sitzung), **`AGENTS.md`** (falls Konventionen sich änderten), **`.aiexclude`**
(neue Geheimnisse/große Dateien), **`VERIFY`** (falls sich der Testbefehl geändert hat), dann
Sicherungspunkt setzen.
Volle Fassung des Projekt-Start-Ablaufs: `F:\Coding Stuff\projekt-start\SKILL.md`
+75
View File
@@ -0,0 +1,75 @@
# OpenCode-Seite: ein Regelwerk, zwei Auslöser
Tagsüber tippst du in **Zed** (PC), nachts ruft ein **Hermes-Cron** dasselbe (Box).
Beide Wege benutzen dieselbe OpenCode-Version, dieselbe Mannschaft, dasselbe Plugin
und denselben Token-Wächter. Der einzige Unterschied ist die Adresse des Governors.
## Was wohin gehört
| Datei hier | Ziel auf dem PC | Ziel auf der Box |
|---|---|---|
| `opencode.pc.json` | `~/.config/opencode/opencode.json` | — |
| `opencode.box.json` | — | `~/.config/opencode/opencode.json` |
| `AGENTS.global.md` | `~/.config/opencode/AGENTS.md` | `~/.config/opencode/AGENTS.md` |
| `plugin/mc2-governor.ts` | `~/.config/opencode/plugin/` | `~/.config/opencode/plugin/` |
| `VERIFY.template` | — | wird von `gitea-repo-create.sh` in **jedes neue Repo** als `VERIFY` gesät |
### Warum die Regeln hier liegen und nicht in Zed
Die persönlichen Arbeitsregeln standen bis 25.07.2026 in `AppData\Roaming\Zed\AGENTS.md` — und
haben dort **nie den Coding-Agenten erreicht**. Zeds eigene Doku ist eindeutig: `AGENTS.md` und
Skills gelten **nur für Zeds nativen Agenten**, nicht für ACP-Agenten wie OpenCode. Da hier über
ACP gearbeitet wird, liefen die Regeln jahrelang ins Leere.
OpenCode liest stattdessen (in dieser Reihenfolge): `AGENTS.md` im Projekt (aufwärts gesucht) und
`~/.config/opencode/AGENTS.md` global. Beide gelten zusammen — Projektregeln ergänzen die globalen.
Beim Umzug bewusst geändert:
- **Zeds Commit-Vorlage entfernt.** Sie war in dieselbe Datei gerutscht und enthielt „Only return
the commit message in your response" — in einer dauerhaft aktiven Regeldatei ein Fehlerherd.
- **Modellwähler-Absatz gestrichen.** „Zum Bauen oben auf Coder umschalten" stimmt seit der
Mannschafts-Umstellung nicht mehr: `plan` und `build` laufen auf demselben Modell.
- **Kontext-Wächter umgeschrieben** auf den Governor, der den Füllstand wirklich kennt.
- **Neu:** Prüf-Tor (`VERIFY`), die Subagenten-Mannschaft und ein Abschnitt für **unbeaufsichtigte
Läufe** — nachts sagt niemand „los", also darf der Agent dort nicht auf Freigabe warten.
Der Governor selbst liegt in `../governor/` (Proxy + systemd-Unit), der unbeaufsichtigte
Läufer in `../opencode-lauf.sh`.
## Die Mannschaft
| Rolle | Modell | Gemessen (25.07.2026) | Warum |
|---|---|---|---|
| `plan` + `build` | `coder` — Qwen3-Coder-Next | **51,5 t/s** | Hält den Faden, verteilt Zuarbeit. MoE mit 3B aktiv → schnell auf Strix Halo. |
| `explore` | `hermes` — Qwen3.6-35B | **69,6 t/s** | Ohnehin dauerwarm → kostet **null** zusätzlichen Speicher. Sucht, liest, meldet kurz zurück. |
| `review` | `kritiker` — Devstral-Small-2 | **15,0 t/s** | Bewusst eine **fremde Modellfamilie** (Mistral statt Qwen) → andere blinde Flecken. Dicht = langsam beim Schreiben, aber ein Kritiker liest viel und schreibt wenig. |
`heavy` (gpt-oss-120b, 63 GB) ist **nicht** mehr in der Tagesrolle: es würde beim Laden
das ganze warme Set verdrängen. Es bleibt der Nacht-Gutachter (4:30-Cron).
## Nach einer Änderung
Die Dateien hier sind **Vorlagen**, keine Live-Konfiguration. Nach einer Änderung
verteilen:
```bash
# Box
scp deploy/opencode/opencode.box.json hitonabi@192.168.178.151:~/.config/opencode/opencode.json
scp deploy/opencode/plugin/*.ts hitonabi@192.168.178.151:~/.config/opencode/plugin/
# PC (aus dem Repo heraus)
cp deploy/opencode/opencode.pc.json ~/.config/opencode/opencode.json
cp deploy/opencode/plugin/*.ts ~/.config/opencode/plugin/
```
**Zed muss danach neu gestartet werden** — OpenCode liest seine Konfiguration nur beim Start.
## Fallen, die Zeit gekostet haben
- **Kein `_comment`-Schlüssel in `opencode.json`.** OpenCode validiert streng und
verweigert den Start mit „Unrecognized key". Kommentare gehören in dieses README.
- **Das Plugin läuft auf Windows UND Linux.** Deshalb `node:fs` statt `cat` und Buns
`${{ raw: cmd }}` statt `bash -lc` — beides fehlt auf Windows bzw. verschluckt den Befehl.
- **Bei `opencode run` beendet sich der Prozess, bevor `session.idle` fertig ist**
(gemessen 25.07.). Für unbeaufsichtigte Läufe liegt die Prüf-Schleife deshalb
zusätzlich in `opencode-lauf.sh`, wo sie den Prozess überlebt. In Zed greift das Plugin.
- **Plugin-Verzeichnis:** `plugin/` und `plugins/` werden beide erkannt; wir nutzen `plugin/`.
+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
+88
View File
@@ -0,0 +1,88 @@
{
"$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, 16k gedeckelt)",
"limit": {
"context": 16384,
"output": 4096
}
},
"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"
}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"aibox": {
"npm": "@ai-sdk/openai-compatible",
"name": "AI-Box ueber Governor (192.168.178.151:8100)",
"options": {
"baseURL": "http://192.168.178.151:8100/v1",
"apiKey": "local"
},
"models": {
"heavy": {
"name": "heavy — Planer (gpt-oss-120b, 32k)",
"limit": {
"context": 32768,
"output": 8192
}
},
"coder": {
"name": "coder — Bauen (Qwen3-Coder-Next, 131k)",
"limit": {
"context": 131072,
"output": 16384
}
},
"hermes": {
"name": "hermes — Erkunden (Qwen3.6, immer warm, 69,6 t/s)",
"limit": {
"context": 65536,
"output": 8192
}
},
"kritiker": {
"name": "kritiker — Gegenlesen (Devstral-2, Mistral, 16k gedeckelt)",
"limit": {
"context": 16384,
"output": 4096
}
}
}
}
},
"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, läuft 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, ändert nichts. Läuft 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"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More