Files
mission-control-v2/backend/services/ideen.py
T
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

406 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 sqlite3
import subprocess
import time
from pathlib import Path
log = logging.getLogger(__name__)
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
# Ketten-Sicht (20.07.): Eltern-Links + Heartbeat-Notizen stehen NICHT im `list --json`
# der CLI — dafür (und NUR dafür) ein read-only-Blick in die kanban.db. Das bricht die
# „alles über die CLI"-Regel bewusst minimal: zwei stabile Tabellen (task_links 2 Spalten,
# task_events), Verbindung strikt mode=ro, jeder Fehler fällt lautlos auf die flache
# Sicht zurück. Schreiben läuft weiterhin ausschließlich über die CLI.
KANBAN_DB = Path(os.environ.get("MC_KANBAN_DB", "~/.hermes/kanban.db")).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
# Cache für Ergebnis (Abschluss-Zusammenfassung) — ändert sich nach done nie mehr
_erg_cache: dict = {}
_ERG_MAX = 40 # simple Deckelung gegen unbegrenztes Wachsen
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 _projekt_titel(titles: list[str]) -> str:
"""Anzeigename einer Karten-Familie: gemeinsamer Titel-Präfix (z. B. „Homelab-Dashboard 2.0"),
sonst der Titel der ältesten Karte. Reine Anzeige-Heuristik, nichts hängt davon ab."""
if not titles:
return "Projekt"
prefix = titles[0]
for t in titles[1:]:
while prefix and not t.startswith(prefix):
prefix = prefix[:-1]
prefix = prefix.strip(" -:·")
return prefix if len(prefix) >= 10 else titles[0][:80]
def _ketten_anreichern(items: list[dict]) -> list[dict]:
"""Ketten-Sicht über die Queue legen (read-only, best-effort).
Ergänzt pro Karte: `eltern` (offene Vorgänger-IDs), `wartet_auf` (deren Titel),
`notiz` (jüngste Heartbeat-Notiz des Workers bei running) und `projekt`
(Familien-Schlüssel). Liefert die Projekt-Gruppen für die UI: zusammenhängende
Karten (task_links) = ein Projekt; Einzelkarten bleiben ohne `projekt`.
Jeder Fehler (Windows-Dev, Schema-Drift, Lock) → unveränderte flache Sicht.
"""
for it in items:
it.setdefault("eltern", [])
it.setdefault("wartet_auf", [])
it.setdefault("notiz", None)
it.setdefault("projekt", None)
if not items or not KANBAN_DB.is_file():
return []
by_id = {it["id"]: it for it in items if it.get("id")}
try:
con = sqlite3.connect(f"file:{KANBAN_DB}?mode=ro", uri=True, timeout=3)
try:
con.execute("PRAGMA busy_timeout=2000")
links = [(p, c) for p, c in con.execute(
"SELECT parent_id, child_id FROM task_links")
if p in by_id or c in by_id]
for it in items:
if it.get("status") != "running":
continue
for (payload,) in con.execute(
"SELECT payload FROM task_events WHERE task_id=? AND kind='heartbeat' "
"AND payload IS NOT NULL ORDER BY created_at DESC LIMIT 5", (it["id"],)):
try:
note = (json.loads(payload) or {}).get("note")
except Exception:
note = None
if note:
it["notiz"] = str(note)[:300]
break
finally:
con.close()
except Exception:
log.debug("ideen: Ketten-Anreicherung fehlgeschlagen", exc_info=True)
return []
# Eltern/wartet_auf: nur Vorgänger zählen, die selbst noch offen in der Liste stehen —
# archivierte/fremde Parents gelten als erledigt.
ERLEDIGT = ("done",)
for parent, child in links:
it = by_id.get(child)
p = by_id.get(parent)
if it and p and p.get("status") not in ERLEDIGT:
it["eltern"].append(parent)
if len(it["wartet_auf"]) < 3:
it["wartet_auf"].append((p.get("titel") or parent)[:90])
# Familien = zusammenhängende Komponenten über die Links (Union-Find, klein genug).
chef: dict[str, str] = {}
def boss(x: str) -> str:
while chef.get(x, x) != x:
chef[x] = chef.get(chef[x], chef[x])
x = chef[x]
return x
for parent, child in links:
if parent in by_id and child in by_id:
a, b = boss(parent), boss(child)
if a != b:
chef[a] = b
familien: dict[str, list[dict]] = {}
for it in items:
familien.setdefault(boss(it["id"]), []).append(it)
projekte = []
for wurzel, mitglieder in familien.items():
if len(mitglieder) < 2:
continue
mitglieder.sort(key=lambda i: i.get("erstellt") or 0)
key = mitglieder[0]["id"]
for it in mitglieder:
it["projekt"] = key
fertig = sum(1 for i in mitglieder if i["status"] in ERLEDIGT)
projekte.append({
"key": key,
"titel": _projekt_titel([i["titel"] for i in mitglieder]),
"gesamt": len(mitglieder),
"fertig": fertig,
"laeuft": sum(1 for i in mitglieder if i["status"] == "running"),
"haengt": sum(1 for i in mitglieder if i["status"] == "blocked"),
})
# Aktive Projekte zuerst (hängend > laufend > Rest), dann nach Größe.
projekte.sort(key=lambda p: (-p["haengt"], -p["laeuft"], -p["gesamt"]))
return projekte
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"]
projekte = _ketten_anreichern(items)
offen = sum(1 for i in items if i["status"] not in ("done",))
data = {"available": True, "items": items, "offen": offen, "projekte": projekte}
_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 ergebnis_of(task_id: str) -> dict:
"""Das ERGEBNIS einer fertigen Aufgabe — die Abschluss-Zusammenfassung des Workers.
User-Wunsch 15.07.: „im Auftragsbuch werden Dinge getestet, ich sehe aber das
Ergebnis nicht." Die Wahrheit liegt beim kanban_complete-Aufruf (summary/result)
im jüngsten `completed`-Event; `list --json` trägt sie nicht → lazy per `show`,
nur wenn der User die Karte aufklappt. Fertige Ergebnisse ändern sich nie → Cache.
"""
if not _available():
return {"available": False, "summary": "", "result": ""}
if not _TASK_ID_RX.match(task_id or ""):
return {"available": True, "summary": "", "result": ""}
if task_id in _erg_cache:
return _erg_cache[task_id]
try:
r = _hermes(["show", task_id, "--json"], timeout=30)
d = _parse_json(r.stdout)
except Exception:
log.warning("ideen: kanban show %s (ergebnis) fehlgeschlagen", task_id, exc_info=True)
return {"available": True, "summary": "", "result": ""}
summary, result = "", ""
if isinstance(d, dict):
if isinstance(d.get("result"), str):
result = d["result"].strip()
for ev in d.get("events") or []: # chronologisch → letztes completed gewinnt
if isinstance(ev, dict) and ev.get("kind") == "completed":
s = ((ev.get("payload") or {}).get("summary") or "").strip()
if s:
summary = s
data = {"available": True, "summary": summary[:4000], "result": result[:4000]}
if len(_erg_cache) >= _ERG_MAX:
_erg_cache.pop(next(iter(_erg_cache)))
_erg_cache[task_id] = data
return data
def log_of(task_id: str) -> dict:
"""Worker-Log einer Idee (hermes kanban log <id>), 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