Auftragsbuch: Konzepte sichtbar, Modi unterscheidbar, Ideen weiterfuehrbar

Drei Luecken, die die neue Idee-Bahn unbrauchbar machten (User-Fund 21.07.):

1. Konzept unsichtbar. Der projektstart-Worker folgte dem Ende des
   konzept-fliessband-Skills (Kopie nach ~/konzepte + Telegram) statt seiner
   SOUL (Repo + KONZEPT.md) und schloss die Karte ab. Die Konzept-Ansicht sucht
   nur in Gitea -> "noch kein Repo hinterlegt", obwohl das Konzept fertig war
   (zweimal passiert: arm-ui, pomodoro-timer). konzept_of() faellt jetzt auf
   ~/konzepte/*.md zurueck (Pfad aus der Abschlussmeldung, sonst ueber den Titel
   geraten; streng auf dieses Verzeichnis begrenzt) und nennt die Quelle.
   Zusaetzlich sagen SOUL und Skill jetzt ausdruecklich, dass die Skill-Ablage
   fuer Kanban-Worker NICHT das Ende ist.

2. Modus nicht erkennbar. Idee und IDE-Projekt trugen dasselbe "IDE"-Abzeichen,
   Box-Karten gar keins. list_queue() liefert jetzt `art` (aus dem Body-Marker
   AUFTRAGS-ART: IDEE PRUEFEN), die UI zeigt BOX / IDEE / IDE mit Erklaerung —
   plus eine immer sichtbare Zeile, wann man welchen Modus nimmt.

3. Idee war eine Sackgasse. Neu: POST /api/ideen/weiterfuehren legt aus einer
   fertig durchdachten Idee eine IDE-Projekt-Karte an — verkettet an die Idee,
   mit dem fertigen Konzept als Vorlage ("denk es NICHT neu"), Titel aus der
   Konzept-Ueberschrift statt aus dem Rohtext. Liegt das Konzept schon in einem
   Repo, wird genau dieses weitergenutzt statt ein zweites anzulegen.
   In der UI sitzt der Knopf unter dem gelesenen Konzept (Ziel + Aufwand).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-21 08:39:54 +02:00
parent 9307c8b752
commit 3cdd5cdd10
32 changed files with 420 additions and 98 deletions
+169 -9
View File
@@ -243,14 +243,20 @@ def list_queue() -> dict:
items = []
for t in raw[:60]:
try:
body = t.get("body") or ""
ide = t.get("assignee") == "projektstart"
items.append({
"id": t.get("id", ""),
"titel": (t.get("title") or "(ohne Titel)")[:200],
"body": (t.get("body") or "")[:600],
"body": body[:600],
"status": t.get("status", "?"),
"assignee": t.get("assignee"),
# IDE-Welt = das Vorbereiter-Profil (Konzept + Repo + Doku, kein Bau).
"welt": "ide" if t.get("assignee") == "projektstart" else "box",
"welt": "ide" if ide else "box",
# Auftrags-Art innerhalb der IDE-Welt: nur durchdenken oder Projekt
# vorbereiten? Steht nirgends als Feld — der Marker im Body ist die
# Wahrheit (schreibt add_idea, siehe _ART_IDEE).
"art": ("idee" if _ART_MARKER in body else "projekt") if ide else "box",
"erstellt": t.get("created_at"),
"fertig": t.get("completed_at"),
"von": t.get("created_by"),
@@ -313,6 +319,9 @@ _ZIEL_HINWEIS = ("Richte den Tech-Vorschlag an der gewaehlten ZIEL-PLATTFORM aus
"nicht am Industrie-Default.")
# Auftrags-Art „Idee pruefen": Produkt-Blick statt Umsetzungs-Blick, KEIN Projektstart.
# Die erste Zeile ist zugleich der MARKER, an dem die Queue-Sicht die Art wiedererkennt
# (das Kanban kennt kein eigenes Feld dafuer) — beim Aendern _ART_MARKER mitziehen.
_ART_MARKER = "AUFTRAGS-ART: IDEE PRUEFEN"
_ART_IDEE = (
"AUFTRAGS-ART: IDEE PRUEFEN (KEIN Projektstart!). Der Commander will diese Produkt-Idee erst "
"einmal DURCHDACHT haben — er hat sich NICHT entschieden, sie zu bauen.\n"
@@ -678,6 +687,39 @@ def _clean_repo(raw: str) -> str | None:
_KONZEPT_NAMES = ("KONZEPT.md", "Konzept.md", "konzept.md", "CONCEPT.md")
_konzept_cache: dict = {}
# Zweiter Ablage-Ort (Bug-Fund 21.07.): der `konzept-fliessband`-Skill endet mit
# „cp … ~/konzepte/<slug>-konzept.md" — Worker folgen dieser Skill-Anweisung auch dann,
# wenn ihre Profil-SOUL eigentlich ein Repo verlangt. Das Konzept war dann fertig, aber
# im Auftragsbuch unsichtbar („noch kein Repo hinterlegt"). Also: liegt kein Repo vor,
# hier nachsehen. Bewusst eng — nur ~/konzepte, nur .md.
_KONZEPT_DIR = Path("~/konzepte").expanduser()
_KONZEPT_PFAD_RX = re.compile(r"(?:~|/home/[A-Za-z0-9_.-]+)?/?konzepte/([A-Za-z0-9_.-]+\.md)")
def _lokales_konzept(text: str, titel: str = "") -> tuple | None:
"""(pfad, inhalt) einer Konzept-Datei aus ~/konzepte — aus dem Text oder per Titel geraten."""
kandidaten: list[str] = []
m = _KONZEPT_PFAD_RX.search(text or "")
if m:
kandidaten.append(m.group(1))
# Kein Pfad genannt (aeltere Karten): ueber den Titel raten — grobe Wortschnittmenge
# zwischen Dateiname und Kartentitel, nur bei einem eindeutigen Treffer.
if not kandidaten and titel and _KONZEPT_DIR.is_dir():
worte = {w for w in re.findall(r"[a-z0-9]{3,}", titel.lower())}
treffer = [p.name for p in _KONZEPT_DIR.glob("*.md")
if worte & set(re.findall(r"[a-z0-9]{3,}", p.stem.lower()))]
if len(treffer) == 1:
kandidaten.append(treffer[0])
for name in kandidaten:
pfad = (_KONZEPT_DIR / name).resolve()
if pfad.parent != _KONZEPT_DIR.resolve() or not pfad.is_file():
continue
try:
return str(pfad), pfad.read_text(encoding="utf-8", errors="replace")
except Exception:
log.warning("ideen: Konzept-Datei %s nicht lesbar", pfad, exc_info=True)
return None
def _gitea_creds() -> tuple | None:
"""(user, token) fuer den oeffentlichen Gitea-Host aus ~/.git-credentials."""
@@ -704,7 +746,8 @@ def _extract_repo(text: str) -> str | None:
def konzept_of(task_id: str) -> dict:
"""Das KONZEPT.md des vorbereiteten IDE-Projekts holen (aus dem Gitea-Repo)."""
"""Das ausgearbeitete Konzept einer IDE-Karte holen aus dem Gitea-Repo ODER,
falls der Worker keins angelegt hat, aus seiner Datei in ~/konzepte."""
if not _available():
return {"available": False}
if not _TASK_ID_RX.match(task_id or ""):
@@ -720,19 +763,37 @@ def konzept_of(task_id: str) -> dict:
return {"available": True, "error": "Karte nicht lesbar."}
# `kanban show --json` legt die Task-Felder verschachtelt unter "task" ab und
# liefert die jüngste Abschluss-Zusammenfassung als "latest_summary".
haystack, task_status = "", None
haystack, task_status, titel = "", None, ""
if isinstance(d, dict):
t = d.get("task") if isinstance(d.get("task"), dict) else d
task_status = t.get("status")
titel = str(t.get("title") or "")
haystack = "\n".join(str(x or "") for x in (
t.get("result"), t.get("body"), d.get("latest_summary")))
for ev in d.get("events") or []:
if isinstance(ev, dict) and ev.get("kind") == "completed":
haystack += "\n" + str((ev.get("payload") or {}).get("summary") or "")
def _datei_antwort(repo: str | None = None) -> dict | None:
"""Konzept aus ~/konzepte als vollwertige Antwort — der zweite Ablage-Ort."""
lokal = _lokales_konzept(haystack, titel)
if not lokal:
return None
pfad, inhalt = lokal
return {"available": True, "repo": repo, "quelle": "datei", "pfad": pfad,
"datei": Path(pfad).name, "konzept": inhalt[:60000]}
repo = _extract_repo(haystack)
if not repo:
data = _datei_antwort()
if data:
if task_status == "done":
if len(_konzept_cache) >= 20:
_konzept_cache.pop(next(iter(_konzept_cache)))
_konzept_cache[task_id] = data
return data
return {"available": True, "repo": None,
"error": "Noch kein Repo hinterlegt — das Projekt wird gerade vorbereitet."}
"error": "Noch kein Konzept hinterlegt — die Box arbeitet noch daran."}
creds = _gitea_creds()
auth = creds if creds else None
@@ -748,13 +809,16 @@ def konzept_of(task_id: str) -> dict:
break
except Exception:
log.warning("ideen: KONZEPT-Abruf für %s (%s) fehlgeschlagen", task_id, repo, exc_info=True)
return {"available": True, "repo": repo, "error": "Konzept konnte nicht geladen werden."}
return (_datei_antwort(repo)
or {"available": True, "repo": repo, "error": "Konzept konnte nicht geladen werden."})
if not konzept:
return {"available": True, "repo": repo,
"error": "KONZEPT.md im Repo (noch) nicht gefunden."}
# Repo genannt, aber (noch) ohne KONZEPT.md → die lokale Ablage kann trotzdem stehen.
return (_datei_antwort(repo)
or {"available": True, "repo": repo,
"error": "KONZEPT.md im Repo (noch) nicht gefunden."})
clone_url = f"https://{_GITEA_HOST}/{repo}.git"
data = {"available": True, "repo": repo, "clone_url": clone_url,
data = {"available": True, "repo": repo, "clone_url": clone_url, "quelle": "repo",
"datei": used_name, "konzept": konzept[:60000]}
# Fertige Konzepte ändern sich selten → cachen (aber nur, wenn die Karte done ist).
if task_status == "done":
@@ -764,6 +828,102 @@ def konzept_of(task_id: str) -> dict:
return data
# ── „Gefaellt mir" — aus der geprueften Idee ein IDE-Projekt machen (21.07.) ──
# Ohne das war die Idee eine Sackgasse: der Commander haette den Titel neu eintippen
# muessen und die Box haette das Konzept von vorn gedacht. Hier bekommt die neue Karte
# das FERTIGE Konzept als Vorlage mit — plus die Kette zur Idee-Karte.
_AUS_IDEE_KOPF = (
"AUFTRAGS-ART: PROJEKT AUS GEPRUEFTER IDEE. Der Commander hat diese Idee erst durchdenken "
"lassen — das Konzept gefaellt ihm, jetzt soll daraus ein startklares Repo werden, das er "
"SELBST in Zed baut.")
_AUS_IDEE_FUSS = (
"· Denk das Konzept NICHT neu. Es ist bereits mit voller Kaskade + Advocatus Diaboli "
"gehaertet. Lies es vollstaendig und uebernimm es; eine kurze Nachschaerf-Runde fuer die "
"Bau-Sicht (Etappen, Ziel-Plattform) ist erlaubt, eine komplette neue Denk-Kaskade ist "
"Verschwendung.\n"
"· Der Rest ist deine normale Projektstart-Rolle: das Repo enthaelt am Ende NUR Doku "
"(KONZEPT.md, ROADMAP.md, AGENTS.md, README.md, SAVEPOINT.md, .aiexclude) — KEIN Code.\n"
"· Uebergabe wie ueblich: Repo-URL nennen, damit der Commander in Zed loslegen kann.")
def projekt_aus_idee(task_id: str, ziel: str = "", tiefe: str = "") -> dict:
"""Aus einer fertig durchdachten Idee eine IDE-Projekt-Karte machen.
Das Konzept der Quell-Karte (Repo oder ~/konzepte-Datei) wird als Vorlage in den
Auftrag geschrieben; die neue Karte haengt als Kind an der Idee (task_links), damit
das Auftragsbuch beide als eine Kette zeigt.
"""
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}"}
konz = konzept_of(task_id)
if not konz.get("konzept"):
return {"ok": False, "error": konz.get("error")
or "Zu dieser Idee gibt es noch kein Konzept — erst durchdenken lassen."}
try:
r = _hermes(["show", task_id, "--json"], timeout=30)
d = _parse_json(r.stdout)
except Exception as exc:
return {"ok": False, "error": f"Karte nicht lesbar: {exc}"}
t = (d.get("task") if isinstance(d, dict) and isinstance(d.get("task"), dict)
else d if isinstance(d, dict) else {})
quell_titel = re.sub(r"^\s*Idee\s*[:\-]\s*", "", str(t.get("title") or "").strip())
if not quell_titel:
return {"ok": False, "error": "Quell-Karte hat keinen Titel."}
# Der Kartentitel ist der ROHE Zuruf des Commanders („Recherchiere zu … Lass ma…") —
# als Projektname unbrauchbar. Die Ueberschrift des Konzepts ist die aufgeraeumte
# Fassung derselben Sache; nur wenn es keine gibt, den Zuruf kappen.
h1 = re.search(r"^#\s+(.+)$", konz["konzept"], re.M)
kurz = h1.group(1).strip() if h1 else re.split(r"(?<=[.!?])\s", quell_titel)[0]
kurz = (kurz[:85].rstrip() + "") if len(kurz) > 86 else kurz
titel = f"Projekt: {kurz}"[:200]
if konz.get("repo"):
# Liegt das Konzept schon in einem Gitea-Repo, ist DAS das Projekt-Repo —
# ein zweites anzulegen wuerde den Commander nur mit Dubletten beschenken.
quelle = (f"· Das gepruefte Konzept liegt bereits im Gitea-Repo `{konz['repo']}` "
f"({konz.get('clone_url') or ''}) als `{konz.get('datei') or 'KONZEPT.md'}`. "
"NUTZE GENAU DIESES REPO als Projekt-Repo — lege KEIN neues an (Schritt 2 "
"entfaellt). Klone es voll, ergaenze die fehlenden Doku-Dateien und pushe auf main.")
else:
quelle = (f"· Das gepruefte Konzept liegt als Datei `{konz.get('pfad')}` auf dieser Box. "
"Lies sie vollstaendig, lege dann wie in Schritt 2 ein frisches Repo an und "
"uebernimm den Inhalt als `KONZEPT.md`.")
zielwahl = _ZIEL.get(str(ziel).lower())
infra = _INFRA_DOCKER if str(ziel).lower() == "docker" else _INFRA_LXC
teile = [f"{_AUS_IDEE_KOPF}\n{quelle}\n{_AUS_IDEE_FUSS}"]
if _AUFWAND.get(str(tiefe).lower()):
teile.append(_AUFWAND[str(tiefe).lower()])
if zielwahl:
teile.append(zielwahl)
teile.append(infra + " " + _ZIEL_HINWEIS + (" " + _ZIEL_EHRLICH if zielwahl else ""))
notiz = "\n\n".join(teile)
args = ["create", titel, "--body", notiz[:4000], "--assignee", "projektstart",
"--parent", task_id, "--created-by", "mc2-ui", "--json"]
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
try:
from services import announce
announce.add(f"Aus der Idee „{quell_titel}“ wird ein Projekt: {task['id']} — die Box "
"bereitet das Repo vor, gebaut wird in Zed.",
"[Ideen-Queue]", "ideen-queue", "silent")
except Exception:
pass
return {"ok": True, "id": task["id"], "titel": titel}
def log_of(task_id: str) -> dict:
"""Worker-Log einer Idee (hermes kanban log <id>), ANSI entfernt, letzte ~120 Zeilen."""
if not _available():