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>
This commit is contained in:
Hitonabi
2026-07-20 13:06:46 +02:00
parent eb1f333bba
commit a91d70d9b7
30 changed files with 519 additions and 158 deletions
+116 -1
View File
@@ -17,6 +17,7 @@ import json
import logging
import os
import re
import sqlite3
import subprocess
import time
from pathlib import Path
@@ -24,6 +25,12 @@ 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}$")
@@ -92,6 +99,113 @@ def _blocker_frage(task_id: str) -> dict | None:
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():
@@ -137,8 +251,9 @@ def list_queue() -> dict:
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}
data = {"available": True, "items": items, "offen": offen, "projekte": projekte}
_list_cache.update(ts=now, data=data)
return data