Auftragsbuch ausgebaut: Karten-Annahme, Bagatell-Annahme, Lucy-Annahme raus — Gate ist Ampel + User-Merge
Ampel / ampel (push) Failing after 24s

Die Vorschlags-Inbox (routers/services auftragsbuch, AuftragsbuchView, /auftraege, Cockpit-
Kachel + Handlungsbedarf-Eintrag, deploy/auftrag-annehmen.sh, lucy-annahme.sh, bagatell-
annahme.sh, docs/AUFTRAGSBUCH.md) war seit 02.08.2026 ungenutzt: Kanban-Tools seit 21.08. aus,
v3-Umbau lief ueber Branch -> Ampel -> Merge -> deploy.sh, PC-Executor-IP in Box und Config
veraltet. Doku behauptete den Karten-Weg trotzdem als Standard.

Jetzt:
- Cockpit: Kachel "Ideen" (offen/haengend) statt "Auftragsbuch"; Handlungsbedarf zeigt haengende
  Ideen und springt in die Ideen-Ansicht.
- Guide/Schaubild: Kreislauf Idee -> Ideen-Queue -> IDE am PC -> Ampel -> DEIN Merge -> Live.
- events.py: kein Auftragsbuch-Fingerprint mehr; config.py: PC_EXECUTOR_URL-Default auf .22.
- Skills/Skripte: selbst-inventur ohne Karten-Abschnitt, morning-report nennt offene Branches,
  konzept-fliessband verweist auf die Ideen-Ansicht.
- Doku: STACK "Deploy & Pipeline" = Branch -> Ampel -> User-Merge -> deploy.sh (einziger Weg),
  GRENZEN/ARBEITSWEISE/FALLEN/README/BEDIENUNG/RUNBOOK/GEMINI_BRIEFING/gitea-workflow angepasst,
  ZIELBILD Punkt 9, OFFENE-FAEDEN + RAPHAEL.md: erledigt.
- Bewusst geblieben: werkstatt-SOUL/projektstart-SOUL (Werkstatt-Persona, eigener Faden),
  Chronik-Kategorie "auftragsbuch" fuer historische Eintraege.
Auf der Box bereits erledigt (Hand): mc2-bagatell.timer deaktiviert + Units archiviert,
PC-Executor-URL in ~/.hermes/config.yaml auf .22.

Baut auf wartung/lucy-stimme-proxy-raphael auf (Proxy /api/lucy/stimme/*).
Gates: eslint 0 Fehler, vitest 59/59, tsc + vite build gruen, frontend/dist committet,
py_compile gruen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-04 16:25:05 +02:00
co-authored by Claude Fable 5.1
parent df8d088fac
commit 479fecd2c6
72 changed files with 569 additions and 2323 deletions
-2
View File
@@ -21,7 +21,6 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from routers import (
agent,
auftragsbuch,
chronik,
connect,
console,
@@ -126,7 +125,6 @@ if V1_UPSTREAM:
else:
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
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(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
+142 -142
View File
@@ -1,142 +1,142 @@
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
"""
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
)
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
# passen. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall
# anfasst, öffnet damit auch diese Oberfläche. MC2 erreicht sie über Loopback und reicht sie
# same-origin unter
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
"""
Zentrale Konfiguration für Mission Control 2.0.
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
"""
import os
from pathlib import Path
from ruamel.yaml import YAML
# --- Engine (llama-swap) -----------------------------------------------------
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa on --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
# zu --spec-draft-model, sonst ist Spec inaktiv).
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
# --spec-draft-model/draft-simple). 1,52× Durchsatz bei null Qualitätsverlust.
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
def _read_hermes_env(key: str) -> str:
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
try:
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(f"{key}="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
HERMES_API_KEY = (
os.environ.get("HERMES_API_KEY")
or os.environ.get("API_SERVER_KEY")
or _read_hermes_env("API_SERVER_KEY")
)
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
# passen. MC2 proxyt nach außen.
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
BOX_CONSOLE_PATH = "/console/"
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0` und setzt dafür ein
# Session-Token. Dass sie trotzdem nicht im LAN hängt, liegt allein an ufw (9119 ist nicht
# freigegeben — am 27.08.2026 von einem zweiten Rechner aus gegengeprüft). Wer die Firewall
# anfasst, öffnet damit auch diese Oberfläche. MC2 erreicht sie über Loopback und reicht sie
# same-origin unter
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
# GitHub-Repo für Update-Checks.
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.22:7777").rstrip("/") # PC-IP seit 09/2026 .22 (vorher .98)
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
PORT = int(os.environ.get("MC_PORT", "9000"))
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-w8"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
yaml.preserve_quotes = True
-63
View File
@@ -1,63 +0,0 @@
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
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")
class BranchIn(BaseModel):
branch: str
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
class KandidatIn(BaseModel):
file: str
@router.get("/auftragsbuch")
def list_proposals() -> dict:
return auftragsbuch.list_proposals()
@router.get("/auftragsbuch/diff")
def diff(branch: str, repo: str = "mc2") -> dict:
res = auftragsbuch.diff_of(branch, repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
return res
@router.post("/auftragsbuch/annehmen")
def accept(body: BranchIn) -> dict:
res = auftragsbuch.accept(body.branch, body.repo)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/ablehnen")
def reject(body: BranchIn) -> dict:
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/annehmen")
def skill_accept(body: KandidatIn) -> dict:
res = auftragsbuch.skill_accept(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
return res
@router.post("/auftragsbuch/skill/ablehnen")
def skill_reject(body: KandidatIn) -> dict:
res = auftragsbuch.skill_reject(body.file)
if not res.get("ok"):
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
return res
+1 -1
View File
@@ -1,6 +1,6 @@
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons — alle
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
from fastapi import APIRouter
+1 -3
View File
@@ -79,9 +79,7 @@ def _fingerprints() -> dict[str, object]:
)
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"))
# Erinnerungen: Datei-mtime (das Auftragsbuch ist seit 04.09.2026 ausgebaut)
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
return fp
-525
View File
@@ -1,525 +0,0 @@
"""
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
Quellen der Karten:
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
"""
import json
import logging
import os
import re
import shutil
import subprocess
import time
import urllib.request
from pathlib import Path
from config import MODELS_DIR
log = logging.getLogger(__name__)
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
REPOS: dict[str, dict] = {
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
}
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
# Muster wie mc2-announce.json unter MODELS_DIR.
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
_fetch_cache: dict = {}
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
def _gitea_creds() -> tuple | None:
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
try:
import subprocess
p = subprocess.run(
["git", "credential", "fill"],
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
capture_output=True,
check=True
)
out = p.stdout.decode(errors="replace")
u_match = re.search(r"username=(.+)", out)
p_match = re.search(r"password=(.+)", out)
if u_match and p_match:
return u_match.group(1).strip(), p_match.group(1).strip()
except Exception:
pass
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
try:
cred = Path("~/.git-credentials").expanduser()
for line in cred.read_text(encoding="utf-8").splitlines():
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
if m: return m.group(1), m.group(2)
except Exception:
pass
return None
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
# Nur auf der Box (wo creds liegen) sinnvoll
creds = _gitea_creds()
if not creds: return None
_user, token = creds
cache_key = (repo, branch, head_sha)
cached = _CI_CACHE.get(cache_key)
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
return cached["status"]
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
try:
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
with urllib.request.urlopen(req, timeout=5) as r:
data = json.load(r)
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
if runs:
run = runs[0]
if run.get("head_sha", "").startswith(head_sha[:7]):
st = run.get("status")
if st:
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
return st
except Exception as e:
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
return None
def _available() -> bool:
return os.name == "posix" and (REPO / ".git").exists()
def _repo_ok(repo: str) -> bool:
r = REPOS.get(repo)
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
capture_output=True, text=True, timeout=timeout)
def _status_key(repo: str, branch: str) -> str:
return f"{REPOS[repo]['key']}{branch}"
def _fetch_throttled(repo: str) -> None:
now = time.time()
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
return
_fetch_cache[repo] = now
try:
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
except Exception:
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
def _load_announce_branches() -> set:
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
try:
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
return set(data) if isinstance(data, list) else set()
except Exception:
return set()
def _save_announce_branches(branches: set) -> None:
"""Speichert die Menge gemelder Branch-Namen."""
try:
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
tmp.replace(ANNOUNCE_BRANCHES_PATH)
except OSError:
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)
def _statuses() -> dict:
try:
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
except Exception:
return {}
def _set_status(key: str, state: str, detail: str) -> None:
try:
try:
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
except Exception:
data = {}
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
tmp = STATUS_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
tmp.replace(STATUS_PATH)
except OSError:
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
def _gutachten() -> dict:
"""Alle Karten-Stempel ("<repo>:<branch>"{empfehlung, satz, richter, commit_ts, ts})."""
try:
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _remote_branches(repo: str) -> list[str]:
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
out = []
for line in (r.stdout or "").splitlines():
name = line.strip().removeprefix("origin/")
if name and name != "HEAD" and name.startswith(PREFIXES):
out.append(name)
return out
def _valid_branch(branch: str) -> bool:
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
items = []
for branch in _remote_branches(repo):
ref = f"origin/{branch}"
status = statuses.get(_status_key(repo, branch))
try:
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
continue # bereits in main enthalten → keine offene Entscheidung mehr
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
# garantierter Fehllauf („refusing to merge unrelated histories").
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
ts_val = int(cts) if cts.strip().isdigit() else None
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
stempel = gutachten.get(f"{repo}:{branch}")
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
stempel = None
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else None
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
files = []
for line in files_raw[:60]:
parts = line.split("\t")
if len(parts) >= 2:
files.append({"status": parts[0][:2], "path": parts[-1]})
paths = [f["path"] for f in files]
frontend_src = any(p.startswith("frontend/src") for p in paths)
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
items.append({
"repo": repo,
"branch": branch,
"kind": branch.split("/", 1)[0],
"subject": subject.strip(),
"body": body.strip()[:2000],
"author": author.strip(),
"ts": ts_val,
"empfehlung": stempel,
"ahead": ahead,
"behind": behind,
"verwaist": verwaist,
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
"leer": not verwaist and ahead > 0 and not files_raw,
"shortstat": stat,
"files": files,
"files_truncated": len(files_raw) > 60,
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
"ci_status": ci_status,
})
except Exception:
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
return items
def list_proposals() -> dict:
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
if not _available():
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
statuses = _statuses()
gutachten = _gutachten()
items = []
for repo in REPOS:
if not _repo_ok(repo):
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
_fetch_throttled(repo)
items.extend(_repo_items(repo, statuses, gutachten))
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
aktuelle_branches = set()
for repo in REPOS:
if _repo_ok(repo):
aktuelle_branches.update(_remote_branches(repo))
# Bereits gemeldete Branches laden
gemeldet = _load_announce_branches()
# Nur wirklich NEUE Branches pingen
neue = aktuelle_branches - gemeldet
for branch in neue:
# NotifyTelegram best-effort, niemals crashen
try:
from services import announce
subject = "[Auftragsbuch]"
text = f"Neue Karte wartet auf deinen Klick: {branch}"
announce.notify_telegram(subject, text)
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
announce.add(text, subject, "auftragsbuch", "normal")
except Exception:
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
# 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
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
def diff_of(branch: str, repo: str = "mc2") -> dict:
if not _repo_ok(repo):
return {"ok": False, "error": "Nur auf der Box verfügbar."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
text = r.stdout or ""
truncated = len(text) > 200_000
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
def accept(branch: str, repo: str = "mc2") -> dict:
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
if not _repo_ok(repo):
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
key = _status_key(repo, branch)
state = (_statuses().get(key) or {}).get("state")
if state in ("laeuft", "rollback"):
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
src = REPO / "deploy" / REPOS[repo]["runner"]
if not src.is_file():
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
try:
shutil.copyfile(src, runner)
except OSError as exc:
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", str(runner), branch],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
return {"ok": True, "unit": unit}
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
if not _repo_ok(repo):
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
if not _valid_branch(branch) or branch not in _remote_branches(repo):
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
grund = (grund or "").strip()[:400]
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
if r.returncode != 0:
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
detail = "Vom Commander abgelehnt — Branch gelöscht"
if grund:
detail += f" (Grund: {grund})"
_set_status(_status_key(repo, branch), "abgelehnt", detail)
try:
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
except OSError:
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
try:
from services import announce
wo = "im Lucy-Repo " if repo == "lucy" else ""
warum = f" Grund: {grund}" if grund else ""
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True}
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
def _kandidaten_dir() -> Path:
return VAULT / "skill-kandidaten"
def list_skill_kandidaten() -> list[dict]:
d = _kandidaten_dir()
if os.name != "posix" or not d.is_dir():
return []
out = []
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
try:
text = p.read_text(encoding="utf-8", errors="replace")
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
out.append({"file": p.name, "title": first[:160],
"preview": text[:3000], "mtime": p.stat().st_mtime})
except OSError:
continue
return out
def _vault_git(args: list[str]) -> None:
try:
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
except Exception:
pass
def _kandidat_path(fname: str) -> Path | None:
if not _KANDIDAT_RX.match(fname):
return None
p = (_kandidaten_dir() / fname).resolve()
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
return None
return p
def skill_accept(fname: str) -> dict:
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
if not _available():
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
beauftragt = _kandidaten_dir() / "beauftragt"
beauftragt.mkdir(parents=True, exist_ok=True)
target = beauftragt / p.name
try:
content = p.read_text(encoding="utf-8", errors="replace")
p.rename(target)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
+ content)
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
try:
order_file.write_text(order, encoding="utf-8")
except OSError as exc:
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
unit = f"mc2-skill-auftrag-{int(time.time())}"
r = subprocess.run(
["systemd-run", "--user", "--collect", f"--unit={unit}",
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
capture_output=True, text=True, timeout=20)
if r.returncode != 0:
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
try:
from services import announce
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
"[Auftragsbuch]", "auftragsbuch", "silent")
except Exception:
pass
return {"ok": True, "unit": unit}
def skill_reject(fname: str) -> dict:
if not _available():
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
p = _kandidat_path(fname)
if not p:
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
verworfen = _kandidaten_dir() / "verworfen"
verworfen.mkdir(parents=True, exist_ok=True)
try:
p.rename(verworfen / p.name)
except OSError as exc:
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
_vault_git(["add", "-A"])
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
return {"ok": True}
+1 -1
View File
@@ -101,7 +101,7 @@ if os.environ.get("MC_SENTRY_WATCH_MC2", "") == "1":
_MC2_URL = os.environ.get("MC_SENTRY_MC2_URL", "http://127.0.0.1:9001")
_GW_URL = os.environ.get("MC_SENTRY_GATEWAY_URL", "http://127.0.0.1:9010")
CHECKS["mc2"] = (lambda: _reach(_MC2_URL, "/api/health"),
"Das Steuerpult ist ausgefallen — Dashboard, Briefkasten und Auftragsbuch gehen gerade nicht.",
"Das Steuerpult ist ausgefallen — Dashboard und Briefkasten gehen gerade nicht.",
"Das Steuerpult ist wieder da.")
CHECKS["gateway"] = (lambda: _reach(_GW_URL, "/gw/health"),
"Der Modell-Gateway ist ausgefallen — meine Denk-Anfragen und die der Worker hängen gerade.",