"""Die Planung an einen Agenten übergeben. Claude implementiert die geplanten Änderungen im Arbeitsverzeichnis — lokal über die Claude-CLI mit dem angemeldeten Konto. Solange er arbeitet, bleibt die Planung stehen: die Übersicht sagt, was das Projekt kann, und das stimmt erst, wenn der Code da ist. Erst nach Erfolg wird die Planung übernommen. Ein Fehlschlag übernimmt nichts; die Planung steht noch und kann erneut los. Der Agent meldet jeden erledigten Schritt als eigene Zeile; der Server liest den Stream mit und hakt die Schritte ab. Zustand nur im Speicher — er überlebt keinen Serverneustart, der laufende Prozess arbeitet trotzdem zu Ende, übernimmt dann aber nichts mehr sichtbar. """ import asyncio import difflib import json import re import parser import planung import projekte import verzeichnis # stream-json liefert je Zeile ein Ereignis: sichtbar wird, woran der Agent # gerade arbeitet, und die ERLEDIGT-Zeilen kommen an, sobald sie fallen. KOMMANDO = ("claude", "-p", "--model", "opus", "--permission-mode", "acceptEdits", "--output-format", "stream-json", "--verbose") MARKE = "ERLEDIGT:" # Einzelne Stream-Zeilen tragen ganze Dateien — die Standardgrenze ist zu klein PUFFER = 10 * 1024 * 1024 # projektname -> {"laeuft", "fehler", "text", "schritte", "aktivitaet", "uebernommen"} _laeufe = {} _aufgaben = set() PRUEFEN = "PRÜFEN" # Eine neue Funktion ist immer unbewiesen — es gibt noch keinen Code, der sie leistet. ART = {"weg": "GESTRICHEN", "geaendert": "GEÄNDERT", "neu": PRUEFEN} AUFTRAG = """Du bekommst die Feature-Karten eines Projekts und die geplanten Änderungen. Eine Karte ist ein Auftrag: Regeln sind Zusicherungen, eine Funktion ist etwas, das man für sich testen kann. Setze den Code in diesem Verzeichnis so um, dass er den geänderten Karten entspricht. - Lies zuerst den bestehenden Code und folge seinen Konventionen. - Ändere nur, was die Arbeitsschritte verlangen. - {pruefen} heißt: die Funktion gilt als unbewiesen. Prüfe, ob der Code genau das leistet, was ihre Testschritte beschreiben. Fehlt sie, baue sie. Weicht sie ab, bring sie in Ordnung. Sie kann schon einmal gebaut worden sein und trotzdem falsch arbeiten — glaube dem Code nicht, weil er existiert. - GEÄNDERT heißt: gebaut, aber die Karte sagt jetzt etwas anderes. - GESTRICHEN heißt: die Fähigkeit wird entfernt. - Ein Kommando in Anführungszeichen muss danach genau so aufrufbar sein. - Führe vorhandene Tests aus, wenn es welche gibt. Die Arbeitsschritte: {schritte} Gib nach jedem abgeschlossenen Schritt sofort eine eigene Textzeile aus, mit dem Schritt wörtlich wie oben: {marke} Hake nur ab, was du geprüft hast. Konntest du einen Ablauf nicht ausführen — weil kein Fenster, kein Gerät oder kein Dienst da ist — dann melde ihn NICHT als erledigt. Schreib am Ende in einer eigenen Zeile, was du nicht prüfen konntest und warum. Die betroffenen Karten, wie sie danach gelten: {karten}""" def _kartentexte(projekt: str, titel: set) -> str: """Der volle Text der geplanten Karten — der Sollzustand, nicht der Diff.""" texte = [] for datei in sorted(projekte.wurzel(projekt).glob("*.md")): roh = datei.read_text() if parser.zerlege(roh)["titel"] in titel: texte.append(roh.strip()) return "\n\n=====\n\n".join(texte) or "(keine — nur Streichungen)" def _offene(projekt: str) -> set: """(Karte, Funktion) für alles, was noch nicht bewiesen ist. Auch in Karten, die sich gegenüber dem Stand nicht geändert haben: eine Funktion zurück auf „offen“ zu setzen ist die Art, eine kaputte Umsetzung zu melden. Sie muss dann in die Arbeitsliste, sonst hätte der Klick keine Folge. """ raus = set() for datei in projekte.wurzel(projekt).glob("*.md"): s = parser.zerlege(datei.read_text()) if s["art"] == parser.VISION: continue for f in s["funktionen"]: if f["stand"] == parser.OFFEN: raus.add((s["titel"], f["text"])) return raus def _schritte(projekt: str, aenderungen: dict) -> list[dict]: """Die Arbeitsliste: ein Eintrag je Streichung, Kopfänderung und Funktion. Karte und Funktion stehen daneben, nicht nur im Text. Beim Abhaken wird die Funktion auf „gebaut“ gesetzt — den Namen aus dem Satz zurückzuparsen wäre eine zweite Wahrheit über dieselbe Sache. """ offen = _offene(projekt) raus = [] erledigt = set() def funktionsschritt(titel: str, name: str, art: str): erledigt.add((titel, name)) # Offen schlägt geändert: „prüfen und herstellen“ deckt beides ab, und der # Agent soll dem vorhandenen Code nicht glauben. marke = PRUEFEN if (titel, name) in offen else ART[art] raus.append({"text": f"{titel} / {name}: {marke}", "karte": titel, # Eine gestrichene Funktion gibt es hinterher nicht mehr "funktion": "" if art == "weg" else name}) for titel in sorted(aenderungen): e = aenderungen[titel] if e["art"] == "weg": raus.append({"text": f"Karte „{titel}“ streichen", "karte": titel, "funktion": ""}) erledigt |= {(titel, n) for n in e.get("funktionen", {})} continue if e.get("kopf"): raus.append({"text": f"Karte „{titel}“: Kartenkopf anpassen", "karte": titel, "funktion": ""}) for name, art in sorted(e.get("funktionen", {}).items()): funktionsschritt(titel, name, art) for titel, name in sorted(offen - erledigt): funktionsschritt(titel, name, "geaendert") return raus def _visionen(projekt: str) -> set: """Titel der Vision-Karten, in Stand und Arbeitskopie.""" titel = set() for ordner in (projekte.stand(projekt), projekte.wurzel(projekt)): for datei in ordner.glob("*.md"): s = parser.zerlege(datei.read_text()) if s["art"] == parser.VISION: titel.add(s["titel"]) return titel def bau_auftrag(projekt: str) -> tuple[str, list[dict]]: """Prompt und Arbeitsliste, aus dem Stand der laufenden Planung. Die Vision bleibt außen vor: sie beschreibt Absicht, keinen Auftrag — eine geänderte Vision ist nichts, was ein Agent bauen soll. """ if not planung.laeuft(projekt): return "", [] visionen = _visionen(projekt) aenderungen = {t: e for t, e in planung.aenderungen(projekt).items() if t not in visionen} schritte = _schritte(projekt, aenderungen) if not schritte: return "", [] # Der Sollzustand jeder Karte, die Arbeit trägt — auch der unveränderten mit # einer offenen Funktion. Ohne ihren Text wüsste der Agent nicht, wogegen er prüft. bleibend = {s["karte"] for s in schritte if s["karte"] not in { t for t, e in aenderungen.items() if e["art"] == "weg"}} text = AUFTRAG.format( schritte="\n".join(f'- {s["text"]}' for s in schritte), marke=MARKE, pruefen=PRUEFEN, karten=_kartentexte(projekt, bleibend)) return text, schritte def status(projekt: str) -> dict: return _laeufe.get(projekt) or { "laeuft": False, "fehler": "", "text": "", "schritte": [], "aktivitaet": "", "uebernommen": False, } # „offen“ steht als eigener Block in der Datei; fehlt er, gilt die Funktion als # gebaut. Umstellen heißt darum: den Block streichen. Ein Umbau über den Parser # würde die Datei neu schreiben und dabei unbekannte Blöcke verlieren. _FUNKTIONSMARKE = re.compile(r"^[ \t]*$", re.M | re.I) _OFFENBLOCK = re.compile( r"^[ \t]*\n[ \t]*" + parser.OFFEN + r"[ \t]*\n", re.M | re.I) def _als_gebaut(roh: str, funktion: str) -> str: """Streicht den offen-Vermerk genau dieser Funktion.""" marken = [m.start() for m in _FUNKTIONSMARKE.finditer(roh)] for i, start in enumerate(marken): ende = marken[i + 1] if i + 1 < len(marken) else len(roh) block = roh[start:ende] zeilen = [z.strip() for z in block.splitlines()[1:] if z.strip()] titel = next((z for z in zeilen if not z.startswith("