""" Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit). Quellen der Karten: • Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen. Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App). • Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach". Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached): • mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot. • lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor) und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte). Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin. Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur. Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:" (kollisionsfrei). Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen. """ import json import logging import os import re import shutil import subprocess import time from pathlib import Path from config import MODELS_DIR log = logging.getLogger(__name__) REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser() LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser() VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser() STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json"))) # Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt, # die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es). GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json"))) ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl"))) # Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der # Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC. REPOS: dict[str, dict] = { "mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""}, "lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"}, } # Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen). PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/") # Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion). _BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$") _KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$") _fetch_cache: dict = {} _FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen def _available() -> bool: return os.name == "posix" and (REPO / ".git").exists() def _repo_ok(repo: str) -> bool: r = REPOS.get(repo) return bool(r) and os.name == "posix" and (r["path"] / ".git").exists() def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess: return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args], capture_output=True, text=True, timeout=timeout) def _status_key(repo: str, branch: str) -> str: return f"{REPOS[repo]['key']}{branch}" def _fetch_throttled(repo: str) -> None: now = time.time() if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY: return _fetch_cache[repo] = now try: _git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30) except Exception: log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True) def _statuses() -> dict: try: return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {}) except Exception: return {} def _set_status(key: str, state: str, detail: str) -> None: try: try: data = json.loads(STATUS_PATH.read_text(encoding="utf-8")) except Exception: data = {} data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()} tmp = STATUS_PATH.with_suffix(".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") tmp.replace(STATUS_PATH) except OSError: log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True) def _gutachten() -> dict: """Alle Karten-Stempel (":" → {empfehlung, satz, richter, commit_ts, ts}).""" try: data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except Exception: return {} def _remote_branches(repo: str) -> list[str]: r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"]) out = [] for line in (r.stdout or "").splitlines(): name = line.strip().removeprefix("origin/") if name and name != "HEAD" and name.startswith(PREFIXES): out.append(name) return out def _valid_branch(branch: str) -> bool: return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES) def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]: items = [] for branch in _remote_branches(repo): ref = f"origin/{branch}" status = statuses.get(_status_key(repo, branch)) try: ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0) if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")): continue # bereits in main enthalten → keine offene Entscheidung mehr behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0) show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref]) subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4] ts_val = int(cts) if cts.strip().isdigit() else None # Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener # Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu). stempel = gutachten.get(f"{repo}:{branch}") if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val): stempel = None stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip() files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines() files = [] for line in files_raw[:60]: parts = line.split("\t") if len(parts) >= 2: files.append({"status": parts[0][:2], "path": parts[-1]}) paths = [f["path"] for f in files] frontend_src = any(p.startswith("frontend/src") for p in paths) frontend_dist = any(p.startswith("frontend/dist") for p in paths) items.append({ "repo": repo, "branch": branch, "kind": branch.split("/", 1)[0], "subject": subject.strip(), "body": body.strip()[:2000], "author": author.strip(), "ts": ts_val, "empfehlung": stempel, "ahead": ahead, "behind": behind, "shortstat": stat, "files": files, "files_truncated": len(files_raw) > 60, # Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie # es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo). "frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist, "status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status, }) except Exception: log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True) return items def list_proposals() -> dict: """Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung + Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.""" if not _available(): return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0} statuses = _statuses() gutachten = _gutachten() items = [] for repo in REPOS: if not _repo_ok(repo): continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen _fetch_throttled(repo) items.extend(_repo_items(repo, statuses, gutachten)) items.sort(key=lambda i: i.get("ts") or 0, reverse=True) kandidaten = list_skill_kandidaten() open_count = sum(1 for i in items if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten) return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count} def diff_of(branch: str, repo: str = "mc2") -> dict: if not _repo_ok(repo): return {"ok": False, "error": "Nur auf der Box verfügbar."} if not _valid_branch(branch) or branch not in _remote_branches(repo): return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"} r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30) text = r.stdout or "" truncated = len(text) > 200_000 return {"ok": True, "diff": text[:200_000], "truncated": truncated} def accept(branch: str, repo: str = "mc2") -> dict: """Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback. lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an).""" if not _repo_ok(repo): return {"ok": False, "error": "Annehmen geht nur auf der Box."} if not _valid_branch(branch) or branch not in _remote_branches(repo): return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"} key = _status_key(repo, branch) state = (_statuses().get(key) or {}).get("state") if state in ("laeuft", "rollback"): return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."} # Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript # würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle). src = REPO / "deploy" / REPOS[repo]["runner"] if not src.is_file(): return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"} runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh") try: shutil.copyfile(src, runner) except OSError as exc: return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"} slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-") unit = f"mc2-auftrag-{slug}-{int(time.time())}" r = subprocess.run( ["systemd-run", "--user", "--collect", f"--unit={unit}", "/bin/bash", str(runner), branch], capture_output=True, text=True, timeout=20) if r.returncode != 0: return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"} _set_status(key, "laeuft", "Annahme-Lauf gestartet") return {"ok": True, "unit": unit} def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict: """Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar, aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten.""" if not _repo_ok(repo): return {"ok": False, "error": "Ablehnen geht nur auf der Box."} if not _valid_branch(branch) or branch not in _remote_branches(repo): return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"} grund = (grund or "").strip()[:400] # Betreff VOR dem Löschen sichern — danach ist der Ref weg. subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200] r = _git(repo, ["push", "origin", "--delete", branch], timeout=30) if r.returncode != 0: return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"} _git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30) detail = "Vom Commander abgelehnt — Branch gelöscht" if grund: detail += f" (Grund: {grund})" _set_status(_status_key(repo, branch), "abgelehnt", detail) try: with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch, "subject": subject, "grund": grund}, ensure_ascii=False) + "\n") except OSError: log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True) try: from services import announce wo = "im Lucy-Repo " if repo == "lucy" else "" warum = f" Grund: {grund}" if grund else "" announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}", "[Auftragsbuch]", "auftragsbuch", "silent") except Exception: pass return {"ok": True} # ── Skill-Kandidaten (Wissens-Vault) ───────────────────────────────────────── def _kandidaten_dir() -> Path: return VAULT / "skill-kandidaten" def list_skill_kandidaten() -> list[dict]: d = _kandidaten_dir() if os.name != "posix" or not d.is_dir(): return [] out = [] for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True): try: text = p.read_text(encoding="utf-8", errors="replace") first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem) out.append({"file": p.name, "title": first[:160], "preview": text[:3000], "mtime": p.stat().st_mtime}) except OSError: continue return out def _vault_git(args: list[str]) -> None: try: subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15) except Exception: pass def _kandidat_path(fname: str) -> Path | None: if not _KANDIDAT_RX.match(fname): return None p = (_kandidaten_dir() / fname).resolve() if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file(): return None return p def skill_accept(fname: str) -> dict: """Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte `hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht). Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht.""" if not _available(): return {"ok": False, "error": "Beauftragen geht nur auf der Box."} p = _kandidat_path(fname) if not p: return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"} beauftragt = _kandidaten_dir() / "beauftragt" beauftragt.mkdir(parents=True, exist_ok=True) target = beauftragt / p.name try: content = p.read_text(encoding="utf-8", errors="replace") p.rename(target) except OSError as exc: return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"} _vault_git(["add", "-A"]) _vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"]) order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). " "Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n" + content) order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt") try: order_file.write_text(order, encoding="utf-8") except OSError as exc: return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"} unit = f"mc2-skill-auftrag-{int(time.time())}" r = subprocess.run( ["systemd-run", "--user", "--collect", f"--unit={unit}", "/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'], capture_output=True, text=True, timeout=20) if r.returncode != 0: return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"} try: from services import announce announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, " "das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.", "[Auftragsbuch]", "auftragsbuch", "silent") except Exception: pass return {"ok": True, "unit": unit} def skill_reject(fname: str) -> dict: if not _available(): return {"ok": False, "error": "Verwerfen geht nur auf der Box."} p = _kandidat_path(fname) if not p: return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"} verworfen = _kandidaten_dir() / "verworfen" verworfen.mkdir(parents=True, exist_ok=True) try: p.rename(verworfen / p.name) except OSError as exc: return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"} _vault_git(["add", "-A"]) _vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"]) return {"ok": True}