fdabe1768e
User-Feedback 15.07.:
1) 'Von allein' heisst jetzt 'Skills' (id #eigenleben bleibt, Links halten).
2) Jeder Skill traegt aktiv/inaktiv - Quelle ist Hermes' EIGENE Wahrheit
(hermes_cli.banner.get_available_skills via Hermes-venv, dieselbe Logik
wie das eingebaute WebUI: Plattform/Voraussetzungen/Config). Bewusst NUR
die Datenquelle wiederverwendet, nicht deren UI - Skill-VERWALTUNG bleibt
im Hermes-GUI-Tab, alles andere waere Bloat/Doppelbau.
3) Auftragsbuch: fertige Queue-Karten sind aufklappbar und zeigen das
ERGEBNIS des Workers (kanban_complete-Summary, lazy via
GET /api/ideen/{id}/ergebnis) - 'Dinge werden getestet, ich sehe aber
das Ergebnis nicht' ist damit zu.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""
|
|
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
|
|
|
|
# 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 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 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
|