""" 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 _blocker_frage(task_id: str) -> dict | None: """Für eine blockierte Aufgabe: die Frage/den Grund des Workers holen (needs_input etc.). Die Wahrheit steckt im jüngsten `blocked`-Event (payload.reason/kind); fällt das leer aus, greifen wir auf den letzten „BLOCKED:"-Kommentar zurück. Ein Extra-`show`-Aufruf pro hängender Karte ist billig — die gibt es fast nie und wenn, dann nur eine Handvoll. """ try: r = _hermes(["show", task_id, "--json"], timeout=30) d = _parse_json(r.stdout) except Exception: log.warning("ideen: kanban show %s fehlgeschlagen", task_id, exc_info=True) return None if not isinstance(d, dict): return None frage, kind = "", "" for ev in d.get("events") or []: # chronologisch → letztes blocked = aktive Frage if isinstance(ev, dict) and ev.get("kind") == "blocked": p = ev.get("payload") or {} frage = (p.get("reason") or "").strip() kind = (p.get("kind") or "").strip() if not frage: # Fallback: letzter „BLOCKED:"-Kommentar for c in d.get("comments") or []: body = (c.get("body") or "").strip() if body.startswith("BLOCKED:"): frage = body[len("BLOCKED:"):].strip() return {"frage": frage[:800], "kind": kind} 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"), "frage": None, # bei blocked die Worker-Frage (unten nachgeladen) "frage_kind": None, }) except Exception: continue # Hängende Karten anreichern: die Frage des Workers holen, damit die Zentrale sie # beantworten kann (Sackgasse-Fix). Deckel gegen Ausreißer, damit ein Poll nie hängt. for it in [i for i in items if i["status"] == "blocked"][:6]: info = _blocker_frage(it["id"]) if info: it["frage"] = info["frage"] it["frage_kind"] = info["kind"] 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 answer_task(task_id: str, antwort: str) -> dict: """Eine hängende Aufgabe beantworten: Antwort als Kommentar protokollieren + entsperren. `unblock --reason` schreibt die Antwort als „UNBLOCK:"-Kommentar und stellt die Karte auf ready — der Dispatcher spawnt den Worker neu, der die Antwort im Task-Kontext vorfindet und weitermacht. Genau der Ausweg aus der Sackgasse „Aufgabe fragt, aber niemand kann antworten". """ 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}"} antwort = (antwort or "").strip() if not (1 <= len(antwort) <= 2000): return {"ok": False, "error": "Bitte eine Antwort zwischen 1 und 2000 Zeichen."} try: r = _hermes(["unblock", task_id, "--reason", antwort]) 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 "Entsperren fehlgeschlagen").strip()[:300]} _list_cache["ts"] = 0.0 # nächster Poll zeigt den neuen Status (ready/running) sofort try: from services import announce announce.add(f"Deine Antwort ging an die hängende Aufgabe {task_id} — die Box macht weiter.", "[Ideen-Queue]", "ideen-queue", "silent") except Exception: pass return {"ok": True} 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