Files
ClaudeDo/docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md

16 KiB

Unit-Branch + Draft-PR — Design

Datum: 2026-10-01 · Status: abgenommen (mit Basis-Branch pro Liste + Fetch) Diagramme: Obsidian 07 - Plattform & Technik/ClaudeDo/ClaudeDo - Branch und PR Ablauf.md Ersetzt: die offene Entscheidung aus 2026-08-06-planning-chain-fork-base-design.md

Ziel

Aufgaben starten und später zu fertigen PRs zurückkommen. In Firmen-Repos (Azure DevOps Server) heißen Branches nach dem work-conventions-Schema, und das Review endet in einem Draft-PR statt in einem lokalen Merge auf main. Nebenbei sehen Planning-Children endlich die Arbeit ihres Vorgängers.

Begriffe

  • Unit: ein einzelner Task, oder ein Parent (Planning oder Improvement) samt Children.
  • Unit-Branch: der eine Branch, auf dem die ganze Arbeit einer Unit landet. Er gehört dem Worktree des Unit-Roots (Einzeltask bzw. Parent).
  • Firmen-Repo: Die origin-URL enthält company_remote_pattern (worker.config.json, Default /_git/, nur per Hand editierbar). Geprüft wird live über git remote get-url origin und ohne Cache.

Entscheidungen

Frage Entscheidung
Branchname Einheitlich <typ>/<nummer>-<slug>. Die Nummer ist die Ticket-Id aus TicketRef (beim Child die des Unit-Roots), sonst privat die Task-Nummer. Im Firmen-Repo ohne Ticket entfällt sie (<typ>/<slug>). claudedo/{nr}-… fällt weg. Planning-Session-Worktrees bleiben claudedo/planning/{guid}.
Typ feat→feature. fix, refactor, docs, test, chore bleiben, wie sie sind. Alles andere (ci, perf, style, build) wird chore.
Slug CommitMessageBuilder.ToSlug plus Umlaut-Auflösung (ä→ae, ö→oe, ü→ue, ß→ss), ≤40 Zeichen wie heute.
Children Starten vom aktuellen Head des Unit-Branches. Ist ein Child fertig, wird der Unit-Branch sofort per fast-forward auf den Head des Childs gezogen. Das gilt privat und in Firmen-Repos.
Review-Ende privat Unverändert: Approve merged den Unit-Branch auf main, dann läuft Verify auf main.
Review-Ende Firma Approve führt Verify im Worktree des Unit-Roots aus, dann Push und Draft-PR. Der Task bleibt WaitingForReview mit PR-Link. Done setzt Mika per Toggle nach dem ADO-Merge.
PR-Automatik Kein Auto-PR bei einem Statuswechsel. Automatisiert wird über den Merge-Helper, dessen approve dann den PR erzeugt.
ADO-Zugang Eigener PAT (Scope Code Read & Write). Er wird per DpapiTokenStore in ~/.claudeDo/azdo.pat gespeichert, ohne Abhängigkeit auf das work-conventions-Plugin.
dependsOn über Units Wartet bewusst auf den echten Merge. Abhängige Arbeit gehört in eine Unit mit einem PR.
Basis-Branch Pro Liste einstellbar (list_config.base_branch, z.B. dev für die LagerApp). Leer heißt: privat der gerade ausgecheckte Branch des Main-Checkouts (wie heute), Firma der Default-Branch des ADO-Repos. Gilt als Basis des Unit-Branches, als PR-Ziel und als Vorauswahl des privaten Merge-Ziels.
Basis aktualisieren Firmen-Repo: git fetch origin <basis>, bevor der Unit-Branch angelegt wird. Der Unit-Branch startet von origin/<basis>, der lokale Basis-Branch wird best-effort per fast-forward nachgezogen.

1. Branch-Naming (WorktreeManager)

  • NameFor(task, ticketId, isCompany) liefert den Leaf <nummer>-<slug> bzw. <slug>, und BranchFor liefert <typ>/<leaf>. Der Ordnername des Worktrees bleibt der Leaf.
  • Kollisionsprüfung (heute Contains($"claudedo/{name}"), Zeile 83) prüft den neuen vollen Branchnamen. Bei einem Treffer kommt das Id-Suffix dran, wie heute.
  • Self-Heal (git branch -D nach „already exists“) läuft nur noch, wenn der Branch in einer worktrees-Zeile steht, deren Task der aktuelle Task ist. Sonst gibt es keinen Delete, sondern das Id-Suffix, und der Branch des Nutzers bleibt unangetastet. Ohne das Präfix claudedo/ ist das Pflicht, sonst löscht ClaudeDo einen fremden feature/…-Branch.
  • UntrackedWorktreeRecovery filtert statt nach dem Präfix nach „Worktree-Pfad liegt unter einer ClaudeDo-Worktree-Root (.claudedo-worktrees bzw. Central-Root), aber nicht unter planning/“.
  • PlanningAggregator.CleanupIntegrationBranchAsync:137 (Präfix-Check beim Checkout-Fallback) wird mit Abschnitt 3 ohnehin hinfällig.
  • Bestehende Worktrees behalten ihren Namen. Der Branchname steht in der DB und wird nie neu abgeleitet, eine Migration ist also nicht nötig.
  • Bugfix nebenbei: DetailsIslandViewModel.cs:1223/1385 baut für Dialogtexte claudedo/{Task.Id}, das ist schon heute falsch. Künftig steht dort worktree.BranchName. Das literale "← main" (:670/:933) zeigt das echte Ziel an.

2. Unit-Branch und Children

Wer besitzt den Unit-Branch?

  • Einzeltask: sein eigener Worktree. Am Ablauf ändert sich nichts.
  • Improvement-Parent: sein eigener Worktree, den er schon heute hat. Seine Children starten schon heute von parent.HeadCommit.
  • Planning-Parent: hat heute keinen Worktree. Neu: QueuePlanAsync legt per WorktreeManager.CreateAsync einen Worktree für den Parent an, auf dem Unit-Branch und mit main HEAD als Basis. Damit hat jeder Parent eine worktrees-Zeile, die den Unit-Branch und später die PR-URL trägt.

Basis eines Childs (ResolveBaseCommitAsync): Das ist immer der aktuelle Head des Unit-Branches (rev-parse <unitBranch>). Der Sonderfall „nur bei PlanningPhase.None“ fällt weg.

Basis eines Unit-Roots (Einzeltask, Parent):

  • Privat: list_config.base_branch, sonst der ausgecheckte Branch des Main-Checkouts. Heute ist es RevParseHeadAsync. Ist base_branch gesetzt, wird es rev-parse <base_branch>, auch wenn gerade ein anderer Branch ausgecheckt ist.
  • Firma:
    • Zuerst git fetch origin <basis>. <basis> ist base_branch, sonst der Default-Branch des Repos per ADO-API, ersatzweise git symbolic-ref refs/remotes/origin/HEAD.
    • Der Unit-Branch startet von origin/<basis>.
    • Dann wird der lokale <basis> best-effort nachgezogen:
      • nicht ausgecheckt: git fetch origin <basis>:<basis> (lehnt einen nicht-fast-forward selbst ab)
      • ausgecheckt und sauber: git -C <workingDir> merge --ff-only origin/<basis>
      • schmutzig oder divergiert: Warnung ins Log, kein Eingriff in den Checkout
    • Schlägt der Fetch fehl (offline, keine Credentials), wird gewarnt und es geht mit dem lokalen <basis> weiter. Der Task scheitert daran nicht.
    • Der Fetch nutzt dieselbe PAT-Übergabe wie der Push (Abschnitt 3).
  • Der Fetch läuft einmal pro Unit-Root, nicht pro Child. Children starten vom Unit-Branch.

Spalte list_config.base_branch (nullable string, Migration):

  • Sie wird im Listeneinstellungs-Modal gepflegt, als Freitext mit Vorschlägen aus ListLocalBranchesAsync, und in get_list_config mitgeliefert.
  • ⚠️ ListRepository.SetConfigAsync kopiert verbatim (siehe TicketProjectId). Jeder Schreiber muss den Wert durchreichen, sonst setzt ihn z.B. set_list_config still zurück: WorkerHub.UpdateListConfig, ConfigMcpTools, alle Stellen, die eine frische ListConfigEntity bauen.
  • UpdateListConfigDto braucht ihn tri-state wie TicketProjectId, damit ein Aufrufer, der das Feld nicht kennt, es nicht löscht.
  • Merge-Ziel privat: TaskMergeService.GetTargetsAsync setzt DefaultBranch auf base_branch, falls gesetzt. Das leere Ziel (ApproveAndMergeAsync:1077, PreviewAsync:910) löst ebenfalls auf base_branch auf, sonst wie heute.

Absorbieren eines Childs: Neu ist UnitBranchService.AbsorbChildAsync(childId), das in git -C <parentWorktree> merge --ff-only <childBranch> mündet.

  • Die Kette ist seriell (BlockedByTaskId), und das Child startet vom Head des Unit-Branches. Deshalb ist es immer ein fast-forward, Konflikte sind ausgeschlossen. Lässt es sich nicht fast-forwarden, z.B. weil jemand ein Child manuell parallel per „Run now“ gestartet hat, wird das Child Failed mit einer klaren Meldung, ohne Merge-Versuch. ponytail: kein Merge-Fallback; einbauen, falls parallele Children ein echter Fall werden.
  • Danach wird der Worktree des Childs entfernt, der Branch gelöscht und die Zeile auf Merged gesetzt (MergeCommit = null, denn es gibt keinen Merge-Commit). revert_merge greift auf Child-Ebene deshalb nicht, revertiert wird die ganze Unit.
  • Aufgerufen wird es aus TaskRunner.HandleSuccess direkt vor _state.CompleteAsync. Ein ff-Fehler wird so zu FailAsync statt zu einem Unblock des Nachfolgers.
  • Interaktive Children landen in WaitingForReview, und ihr Approve geht durch TaskMergeService.ApproveAndMergeAsync. Dort gilt neu: Hat der Task einen Parent mit eigenem Worktree, wird AbsorbChildAsync aufgerufen statt auf das Ziel gemergt.
  • Kein Git-Code in TaskStateService oder PlanningChainCoordinator, die bleiben git-frei.

Was dabei wegfällt: Den Unit-Merge Child für Child am Ende gibt es für neue Units nicht mehr. Ein Parent wird beim Approve wie ein Einzeltask behandelt, über seinen eigenen Worktree in TaskMergeService.ApproveAndMergeAsync. Auch der Integration-Branch (PlanningAggregator, planning/{slug}-integration, dafür checkout -b im Main-Checkout) ist für neue Units unnötig, denn der kombinierte Diff ist unitBase..unitHead.

Routing beim Approve (WorkerHub.ApproveReview, ExternalMcpService.ReviewTask): Heute geht jeder Task mit Children an den Orchestrator. Neu gilt: Gibt es ein Done-Child mit einem Active-Worktree, also eins, das nicht absorbiert wurde, läuft der Legacy-Orchestrator. Sonst geht es über ApproveAndMergeAsync auf den Worktree des Parents.

Altbestand: Parents ohne eigenen Worktree (vor dem Rollout geplant) laufen weiter über PlanningMergeOrchestrator und den bestehenden Aggregator. Diesen Legacy-Pfad entfernen wir in einem Folge-Task, sobald keine solchen Units mehr offen sind.

3. Approve im Firmen-Repo

TaskMergeService.ApproveAndMergeAsync verzweigt nach IsCompanyRepo(list):

  1. Verify im Worktree des Unit-Roots (IVerifyCommandRunner.RunAsync(worktreePath, …)), nicht im Main-Checkout. Ohne Verify-Command entfällt der Schritt. Rot heißt: Ergebnis verify_failed, der Task bleibt WaitingForReview, kein Push. skipVerify wird im Firmen-Modus ignoriert, weil kein Merge landet, auf den man das Verify verschieben könnte.
  2. Push mit git -C <worktree> push -u origin <unitBranch>. Der PAT geht als http.extraHeader über GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0 in der Prozess-Umgebung mit. Er steht also nicht in der Kommandozeile, und es gibt keinen Credential-Manager-Prompt im Hintergrundprozess.
  3. PR, neuer AzureDevOpsClient, portiert aus pr-create.ps1:
    • Der Remote wird an /_git/ zerlegt: API-Basis = alles davor, Repo = Segment danach. Eingebettete Zugangsdaten fliegen raus.
    • GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/<branch>&searchCriteria.status=active. Gibt es schon einen PR, wird er wiederverwendet (idempotent, auch wenn der Skill in der Session schon einen angelegt hat).
    • Sonst POST …/pullrequests mit isDraft: true und targetRefName = <basis>. Das ist list_config.base_branch, sonst defaultBranch aus GET …/repositories/<repo>.
    • Die API-Version fällt auf die zurück, die der Server in seiner Fehlermeldung nennt, wie im Skript.
  4. Die PR-URL wird gespeichert, in der neuen nullable Spalte worktrees.pull_request_url (Migration). Danach folgen WorktreeUpdated und der Rückgabestatus pr_created bzw. pr_updated.
  5. Der Task bleibt WaitingForReview, der Worktree bleibt Active, und das Ticket bleibt auf InBearbeitung.

PR-Inhalt:

  • Titel: <commitType>(<list-slug>): <titel>, Resolves #<ticket>. Ohne Ticket entfällt Resolves.
  • Beschreibung:
    • Resolves [#412](<ticket-url>). Die Projekt-Id kommt über BandelTicketClient.GetTicketAsync → ParentTicketID (bei Tasks eine Ebene höher). Ohne Id gibt es keinen Link.
    • Leerzeile, dann die Task-Beschreibung.
    • Leerzeile, dann Geprüft: <verify command> grün bzw. Geprüft: nichts automatisch.

Commits im Firmen-Repo: CommitMessageBuilder lässt den Trailer ClaudeDo-Task: weg. Er wird nirgends gelesen, und die Konvention verbietet Signatur-Trailer.

Rerun nach Änderungswunsch: Ein normaler Reject mit Feedback, der auf demselben Worktree weiterläuft. Der nächste Approve pusht nach, und der vorhandene PR aktualisiert sich.

Fehler: Fehlt der PAT, wird der Push abgelehnt oder scheitert ein REST-Aufruf, dann landen Statusmeldung und Footer-Strip (FlashFooterError) beim Nutzer, und der Task bleibt WaitingForReview. Scheitert der PR nach einem erfolgreichen Push, sagt die Meldung das genau so: „gepusht, PR fehlgeschlagen: …“.

Laufzeit: Verify plus Push können länger als 5 s dauern. Der MCP-Pfad meldet deshalb Progress über ProgressReporter, sonst bricht der MCP-Client nach 300 s Leerlauf ab.

4. Merge-Helper

Die Prompts in PromptFiles (merge und merge_final) bekommen einen Firmen-Repo-Absatz:

  • review_task approve liefert pr_created/pr_updated mit URL.
  • verify_merges entfällt, weil das Verify bereits im Approve läuft.
  • Die Abschluss-Zusammenfassung listet die PR-Links auf.
  • Die Regel „erst mergen, dann neu starten“ bleibt harmlos, da main sich nicht bewegt.

5. UI

  • Der Settings-Reiter „Ticketsystem“ heißt künftig „Externe Dienste“. Er bekommt einen zweiten Abschnitt Azure DevOps mit PAT-Feld, Test-Button und Löschen-Button, gespiegelt vom Ticket-PAT.
  • Neue Hub-Methoden: GetAzureDevOpsSettings, SetAzureDevOpsToken, ClearAzureDevOpsToken, TestAzureDevOpsConnection. Neuer DI-Marker AzureDevOpsPatStore.
  • Der Test braucht ein Firmen-Repo als Kontext: Er nimmt die erste Liste, deren origin passt, und ruft GET …/repositories/<repo> auf.
  • Im Detail-Pane heißt der Approve-Button im Firmen-Repo „Draft-PR erstellen“. Er ist weiter hinter „Diff öffnen“ gegated, und das Ziel-Dropdown ist ausgeblendet. Ist eine PR-URL gesetzt, erscheint ein Link und der Button heißt „PR aktualisieren“.
  • Locale-Keys kommen in en.json und de.json gleichzeitig hinzu.

6. Bewusst draußen

  • PR-Status-Polling bzw. automatisches Done nach dem ADO-Merge.
  • Andere PR-Provider als Azure DevOps. company_remote_pattern schaltet nur das Schema um, die PR-API ist ADO-spezifisch.
  • Aufräumen von Worktree und lokalem Branch, wenn ein Firmen-Task auf Done gesetzt wird. Das geht über die bestehende Worktree-Übersicht.
  • git fetch in privaten Repos. Die Basis bleibt dort lokal.
  • Eine UI für company_remote_pattern.

7. Tests

Keine Tests mit der echten Claude-CLI und keine gegen das echte ADO.

  • Naming: Typ-Mapping, Nummernregel (Ticket, privat, Firma ohne Ticket), Umlaute, Kollision mit einem fremden Branch (der Branch überlebt, das Suffix greift), Self-Heal löscht nur DB-eigene Branches. Die betroffenen Asserts in WorktreeManagerTests (:69, :131/138, :160, :314) passe ich an.
  • Unit-Branch (echtes git, Worker.Tests):
    • Ein Child startet vom Head des Unit-Branches.
    • Zwei serielle Children werden per fast-forward absorbiert, und Child 2 sieht die Dateien von Child 1.
    • Ein nicht fast-forwardbares Child wird Failed.
    • Ein Planning-Parent bekommt seinen Worktree bei QueuePlanAsync.
  • Approve Firma:
    • Ein lokales Bare-Repo dient als origin, und das Muster wird auf dessen Pfad gesetzt.
    • AzureDevOpsClient wird gefakt: PR anlegen, vorhandenen PR wiederverwenden, Verify rot heißt kein Push, PR-Fehler nach dem Push.
  • Remote-Parsing: die Fälle aus dem -SelfTest des Skripts.
  • Basis:
    • base_branch gesetzt: Der Unit-Root startet davon, auch wenn ein anderer Branch ausgecheckt ist.
    • Firma: Nach dem Fetch startet der Unit-Root von origin/<basis>. Das testet ein Bare-Repo, das einen Commit voraus ist.
    • Der lokale Basis-Branch wird nachgezogen, ein schmutziger Checkout bleibt unangetastet.
    • Ein Fetch-Fehler bricht den Task nicht.
    • SetConfigAsync, set_list_config und UpdateListConfig erhalten base_branch.
  • Manueller Smoke-Test (Mika): PAT eintragen, Test-Button. Ein Ticket-Task in einem echten Firmen-Repo bis zum Draft-PR durchspielen, danach eine Planning-Unit mit 2 Children.