""" Ideen-Queue — DIE eine Sammelstelle für Ideen des Commanders (Entscheid 10.07.2026). Die Queue selbst ist das NATIVE Hermes-Kanban (SQLite-Board, Dispatcher im Gateway): Idee rein (triage) → der Specifier arbeitet sie aus → ein Worker-Profil setzt sie um → Code-Ergebnisse kommen als Vorschlags-Branch zurück und erscheinen als Karte im Auftragsbuch. Dieser Service ist nur die MC2-Tür dazu: anlegen + anzeigen + aufräumen, alles über die Hermes-CLI (stabile --json-Schnittstelle, kein Griff in die kanban.db). Türen insgesamt: Telegram/Desktop (natives kanban_create-Tool), Lucy-Voice (mcp_voice.idee_notieren → POST /api/ideen) und die Zentrale (Auftragsbuch-Tab → hier). Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen. """ import json import logging import os import re import subprocess import time from pathlib import Path log = logging.getLogger(__name__) HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser() _TASK_ID_RX = re.compile(r"^t_[0-9a-f]{4,16}$") # Die CLI kostet pro Aufruf ein paar Sekunden Python-Start — die UI pollt aber. _list_cache: dict = {"ts": 0.0, "data": None} _LIST_EVERY = 15.0 # s # Cache für worker log _log_cache: dict = {"ts": 0.0, "task_id": None, "data": None} _LOG_EVERY = 4.0 # s def _available() -> bool: return os.name == "posix" and HERMES.is_file() def _hermes(args: list[str], timeout: int = 60) -> subprocess.CompletedProcess: return subprocess.run([str(HERMES), "kanban", *args], capture_output=True, text=True, timeout=timeout) def _parse_json(stdout: str): """CLI-Ausgabe kann Log-Zeilen vor dem JSON enthalten — ab der ersten Klammer parsen.""" text = stdout or "" for opener in ("[", "{"): idx = text.find(opener) if idx >= 0: try: return json.loads(text[idx:]) except json.JSONDecodeError: continue return None def list_queue() -> dict: """Alle nicht archivierten Aufgaben des Boards, jüngste zuerst — die Queue-Sicht der UI.""" if not _available(): return {"available": False, "items": [], "offen": 0} now = time.time() if _list_cache["data"] is not None and now - _list_cache["ts"] < _LIST_EVERY: return _list_cache["data"] try: r = _hermes(["list", "--json", "--sort", "created-desc"]) raw = _parse_json(r.stdout) except Exception: log.warning("ideen: kanban list fehlgeschlagen", exc_info=True) raw = None if not isinstance(raw, list): # CLI kaputt/Timeout → ehrlich leer melden, aber nicht cachen (nächster Poll versucht's neu) return {"available": True, "items": [], "offen": 0, "fehler": "Queue nicht lesbar"} items = [] for t in raw[:60]: try: items.append({ "id": t.get("id", ""), "titel": (t.get("title") or "(ohne Titel)")[:200], "body": (t.get("body") or "")[:600], "status": t.get("status", "?"), "assignee": t.get("assignee"), "erstellt": t.get("created_at"), "fertig": t.get("completed_at"), "von": t.get("created_by"), }) except Exception: continue offen = sum(1 for i in items if i["status"] not in ("done",)) data = {"available": True, "items": items, "offen": offen} _list_cache.update(ts=now, data=data) return data def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui") -> dict: """Idee in die Queue legen: triage — der Specifier der Box arbeitet sie selbst aus.""" if not _available(): return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} titel = (titel or "").strip() if not (3 <= len(titel) <= 200): return {"ok": False, "error": "Titel bitte zwischen 3 und 200 Zeichen."} args = ["create", titel, "--triage", "--created-by", created_by, "--json"] notiz = (notiz or "").strip() if notiz: args[2:2] = ["--body", notiz[:4000]] try: r = _hermes(args) except Exception as exc: return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} task = _parse_json(r.stdout) if r.returncode != 0 or not isinstance(task, dict) or not task.get("id"): return {"ok": False, "error": (r.stderr or r.stdout or "kanban create fehlgeschlagen").strip()[:300]} _list_cache["ts"] = 0.0 # nächster Poll zeigt die neue Idee sofort try: from services import announce announce.add(f"Neue Idee in der Queue: „{titel}“ ({task['id']}) — die Box arbeitet sie aus.", "[Ideen-Queue]", "ideen-queue", "silent") except Exception: pass return {"ok": True, "id": task["id"], "status": task.get("status")} def archive_task(task_id: str) -> dict: """Erledigtes/Verworfenes aus der Sicht räumen (Kanban-Archiv, nichts wird gelöscht).""" if not _available(): return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."} if not _TASK_ID_RX.match(task_id or ""): return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"} try: r = _hermes(["archive", task_id]) except Exception as exc: return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"} if r.returncode != 0: return {"ok": False, "error": (r.stderr or r.stdout or "Archivieren fehlgeschlagen").strip()[:300]} _list_cache["ts"] = 0.0 return {"ok": True} def log_of(task_id: str) -> dict: """Worker-Log einer Idee (hermes kanban log ), ANSI entfernt, letzte ~120 Zeilen.""" if not _available(): return {"available": False, "lines": []} if not _TASK_ID_RX.match(task_id or ""): return {"available": True, "lines": []} now = time.time() if ( _log_cache["data"] is not None and _log_cache["task_id"] == task_id and now - _log_cache["ts"] < _LOG_EVERY ): return _log_cache["data"] try: r = _hermes(["log", task_id]) except Exception as exc: log.warning("ideen: kanban log fehlgeschlagen", exc_info=True) return {"available": True, "lines": []} if r.returncode != 0: log.warning("ideen: kanban log exit=%s stderr=%s", r.returncode, r.stderr) return {"available": True, "lines": []} raw = r.stdout or "" # ANSI-Steuerzeichen entfernen ansi = re.compile(r"\x1b\[[0-9;]*m") cleaned = ansi.sub("", raw) # Letzte ~120 Zeilen lines = cleaned.strip().splitlines()[-120:] data = {"available": True, "lines": lines} _log_cache.update(ts=now, task_id=task_id, data=data) return data