diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3aee565..1e92ad5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "mcp__plugin_context-mode_context-mode__batch_execute", "mcp__plugin_context-mode_context-mode__execute", "mcp__plugin_context7_context7__query-docs", - "mcp__plugin_context-mode_context-mode__search" + "mcp__plugin_context-mode_context-mode__search", + "Bash(git fetch *)", + "PowerShell(cmdkey *)", + "mcp__plugin_context7_context7__resolve-library-id" ] } } diff --git a/docs/superpowers/plans/2026-04-24-planning-merge-all.md b/docs/superpowers/plans/2026-04-24-planning-merge-all.md new file mode 100644 index 0000000..02bb752 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-planning-merge-all.md @@ -0,0 +1,1918 @@ +# Planning Merge-All & Subtask Visibility — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix three Planning-feature problems: keep subtasks visible under their Planning parent until it is Done, roll up children's status onto the Planning parent in the Queue List, and provide an aggregated diff viewer plus a single "Merge all subtasks" action with VS-Code-assisted conflict resolution. + +**Architecture:** Add two Worker services (`PlanningAggregator`, `PlanningMergeOrchestrator`) that compose the existing `TaskMergeService` (which gains conflict-resume support). Expose via `WorkerHub`. On the UI side, change `TasksIslandViewModel.Regroup` to treat Planning parents as roll-ups, and add two new Avalonia views (aggregated diff viewer + conflict resolution dialog). + +**Tech Stack:** .NET 8, ASP.NET Core, SignalR, Avalonia 12, EF Core Sqlite, CommunityToolkit.Mvvm, xUnit with real git + SQLite fixtures. + +**Spec:** `docs/superpowers/specs/2026-04-24-planning-merge-all-design.md` + +--- + +## File Structure + +**New files:** +- `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` — integration branch build + per-subtask diff aggregation. +- `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` — singleton, owns in-memory Merge-all state, coordinates sequential merges. +- `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs` — record types for SignalR payloads. +- `src/ClaudeDo.Ui/ViewModels/Planning/PlanningDiffViewModel.cs` +- `src/ClaudeDo.Ui/ViewModels/Planning/ConflictResolutionViewModel.cs` +- `src/ClaudeDo.Ui/Views/Planning/PlanningDiffView.axaml` + `.axaml.cs` +- `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` + `.axaml.cs` +- `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` +- `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` + +**Modified files:** +- `src/ClaudeDo.Worker/Services/TaskMergeService.cs` — new `leaveConflictsInTree` param, `ContinueMergeAsync`, `AbortMergeAsync`. +- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — new hub methods. +- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — new broadcast events for the orchestrator. +- `src/ClaudeDo.Worker/Program.cs` — DI registration. +- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `Regroup()` changes + virtual-list filter change. +- `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — extend with conflict-leave tests. + +**Sequence:** backend first (Tasks 1–11), then UI visibility fix (Task 12), then UI views (Tasks 13–15). Each task is independently committable and testable. + +--- + +## Phase 1 — `TaskMergeService` extensions + +### Task 1: Add `leaveConflictsInTree` parameter to `MergeAsync` + +**Goal:** When the caller sets `leaveConflictsInTree: true`, a conflict leaves the repo mid-merge (no `git merge --abort`) and the conflicted files are returned on the result. Default remains `false` so all existing callers are unchanged. + +**Files:** +- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` + +- [ ] **Step 1: Write failing test for `leaveConflictsInTree: true`** + +Add to `TaskMergeServiceTests.cs` (follow existing patterns in the file; use `NewRepo()`, `NewDb()`, `SeedListAndTask`, `BuildService` helpers already present). Create a real conflict by adding a worktree branch and target branch that both modify the same file. + +```csharp +[Fact] +public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles() +{ + var db = NewDb(); + var repo = NewRepo(); + + // On main: modify README + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); + + // Worktree branch: conflicting change to README + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, wtPath)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t1", wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); + GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); + + var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + await SeedWorktree(db, task.Id, wtPath, "claudedo/t1", repo.BaseCommit); + + var (svc, _) = BuildService(db); + + var result = await svc.MergeAsync( + task.Id, "main", removeWorktree: false, "msg", + leaveConflictsInTree: true, + CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusConflict, result.Status); + Assert.Contains("README.md", result.ConflictFiles); + + // Repo should STILL be mid-merge (we asked it not to abort). + var midMerge = File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD")); + Assert.True(midMerge, "repo should be left in mid-merge state"); + + // Cleanup: abort so subsequent tests can run. + GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort"); +} +``` + +(If `SeedWorktree` helper does not yet exist, add it — look at how existing tests seed worktrees. The pattern follows `SeedListAndTask`.) + +- [ ] **Step 2: Run test — expect fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~MergeAsync_LeaveConflicts" -v minimal` +Expected: FAIL — compile error: "MergeAsync does not take 6 arguments". + +- [ ] **Step 3: Add parameter and skip the abort when requested** + +Modify `src/ClaudeDo.Worker/Services/TaskMergeService.cs`: + +Change the `MergeAsync` signature to add `leaveConflictsInTree`: + +```csharp +public async Task MergeAsync( + string taskId, + string targetBranch, + bool removeWorktree, + string commitMessage, + bool leaveConflictsInTree, + CancellationToken ct) +``` + +Inside the conflict branch (currently lines 86–108), change the abort logic: + +```csharp +var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, commitMessage, ct); +if (exitCode != 0) +{ + List files; + try { files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); } + catch { files = new(); } + + if (leaveConflictsInTree && files.Count > 0) + { + // Caller (Merge-all flow) will drive ContinueMergeAsync or AbortMergeAsync. + return new MergeResult(StatusConflict, files, null); + } + + try { await _git.MergeAbortAsync(list.WorkingDir, ct); } + catch (Exception ex) + { + _logger.LogError(ex, "git merge --abort failed after conflict — repo is mid-merge"); + return Blocked($"merge conflict and abort failed: {ex.Message} — repo is mid-merge, resolve manually"); + } + + if (files.Count == 0) + { + return new MergeResult(StatusBlocked, Array.Empty(), $"merge failed: {stderr}"); + } + + return new MergeResult(StatusConflict, files, null); +} +``` + +Also add a backward-compat overload so existing callers compile without change: + +```csharp +public Task MergeAsync( + string taskId, + string targetBranch, + bool removeWorktree, + string commitMessage, + CancellationToken ct) + => MergeAsync(taskId, targetBranch, removeWorktree, commitMessage, leaveConflictsInTree: false, ct); +``` + +- [ ] **Step 4: Run test — expect pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~MergeAsync_LeaveConflicts" -v minimal` +Expected: PASS. + +Also run the full merge-service test class to ensure no regression: +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~TaskMergeServiceTests" -v minimal` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +git commit -m "feat(worker): add leaveConflictsInTree option to TaskMergeService.MergeAsync" +``` + +--- + +### Task 2: Add `ContinueMergeAsync` + +**Goal:** When the repo is mid-merge (after a conflicted `MergeAsync` with `leaveConflictsInTree: true`), this method stages all conflicted files and commits the merge, then flips the worktree to `Merged`. + +**Files:** +- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task ContinueMergeAsync_AfterUserResolves_CompletesMergeAndSetsWorktreeMerged() +{ + var db = NewDb(); + var repo = NewRepo(); + + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, wtPath)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t2", wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); + GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); + + var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + await SeedWorktree(db, task.Id, wtPath, "claudedo/t2", repo.BaseCommit); + + var (svc, _) = BuildService(db); + + var first = await svc.MergeAsync(task.Id, "main", false, "msg", + leaveConflictsInTree: true, CancellationToken.None); + Assert.Equal(TaskMergeService.StatusConflict, first.Status); + + // Simulate the user resolving the conflict in VS Code. + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n"); + + var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusMerged, result.Status); + Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"))); + + using var ctx = db.CreateContext(); + var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id); + Assert.Equal(WorktreeState.Merged, wt.State); +} +``` + +- [ ] **Step 2: Run test — expect fail** (ContinueMergeAsync does not exist). + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ContinueMergeAsync_After" -v minimal` +Expected: FAIL (compile error). + +- [ ] **Step 3: Implement `ContinueMergeAsync`** + +Add to `TaskMergeService.cs`: + +```csharp +public async Task ContinueMergeAsync(string taskId, CancellationToken ct) +{ + TaskEntity task; + ListEntity list; + WorktreeEntity? wt; + + using (var ctx = _dbFactory.CreateDbContext()) + { + task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); + list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); + } + + if (wt is null) return Blocked("task has no worktree"); + if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); + if (!await _git.IsMidMergeAsync(list.WorkingDir, ct)) + return Blocked("repo is not mid-merge"); + + var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct); + if (remaining.Count > 0) + return new MergeResult(StatusConflict, remaining, "conflicts not fully resolved"); + + await _git.AddAllAsync(list.WorkingDir, ct); + await _git.CommitAsync(list.WorkingDir, $"Merge branch '{wt.BranchName}'", ct); + + using (var ctx = _dbFactory.CreateDbContext()) + { + await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Merged, ct); + } + await _broadcaster.WorktreeUpdated(taskId); + _logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName); + + return new MergeResult(StatusMerged, Array.Empty(), null); +} +``` + +(`AddAllAsync` and `CommitAsync` already exist on `GitService` per its public API — see spec §7.) + +- [ ] **Step 4: Run test — expect pass.** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ContinueMergeAsync" -v minimal` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +git commit -m "feat(worker): add ContinueMergeAsync to resume a conflicted merge" +``` + +--- + +### Task 3: Add `AbortMergeAsync` + +**Goal:** Cancel an in-progress conflicted merge (`git merge --abort`), restoring the repo to its pre-merge state. Worktree state is unchanged (still `Active`). + +**Files:** +- Modify: `src/ClaudeDo.Worker/Services/TaskMergeService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task AbortMergeAsync_AfterConflict_RestoresCleanStateAndLeavesWorktreeActive() +{ + var db = NewDb(); + var repo = NewRepo(); + + File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change"); + + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _wtCleanups.Add((repo.RepoDir, wtPath)); + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/t3", wtPath, repo.BaseCommit); + File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n"); + GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change"); + + var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done); + await SeedWorktree(db, task.Id, wtPath, "claudedo/t3", repo.BaseCommit); + + var (svc, _) = BuildService(db); + await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None); + + var result = await svc.AbortMergeAsync(task.Id, CancellationToken.None); + + Assert.Equal("aborted", result.Status); + Assert.False(File.Exists(Path.Combine(repo.RepoDir, ".git", "MERGE_HEAD"))); + + using var ctx = db.CreateContext(); + var wt = ctx.Worktrees.Single(w => w.TaskId == task.Id); + Assert.Equal(WorktreeState.Active, wt.State); +} +``` + +- [ ] **Step 2: Run test — expect fail.** + +- [ ] **Step 3: Implement `AbortMergeAsync` + add `StatusAborted` constant** + +Add to `TaskMergeService.cs`: + +```csharp +public const string StatusAborted = "aborted"; + +public async Task AbortMergeAsync(string taskId, CancellationToken ct) +{ + ListEntity list; + WorktreeEntity? wt; + + using (var ctx = _dbFactory.CreateDbContext()) + { + var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); + list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); + } + + if (string.IsNullOrWhiteSpace(list.WorkingDir)) return Blocked("list has no working directory"); + if (!await _git.IsMidMergeAsync(list.WorkingDir, ct)) + return Blocked("repo is not mid-merge"); + + await _git.MergeAbortAsync(list.WorkingDir, ct); + _logger.LogInformation("Aborted merge of task {TaskId}", taskId); + + return new MergeResult(StatusAborted, Array.Empty(), null); +} +``` + +- [ ] **Step 4: Run test — expect pass.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Services/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +git commit -m "feat(worker): add AbortMergeAsync to cancel a conflicted merge" +``` + +--- + +## Phase 2 — `PlanningAggregator` + +### Task 4: `PlanningAggregator.GetAggregatedDiffAsync` + +**Goal:** Return per-subtask diff entries (title, branch, base commit, head commit, file stats, raw unified diff) for all children of a Planning task. + +**Files:** +- Create: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` + +- [ ] **Step 1: Define the result records** + +Create `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` with: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Planning; + +public sealed record SubtaskDiff( + string SubtaskId, + string Title, + string BranchName, + string BaseCommit, + string HeadCommit, + string? DiffStat, + string UnifiedDiff); + +public sealed record CombinedDiffSuccess(string IntegrationBranch, string UnifiedDiff); +public sealed record CombinedDiffFailure(string FirstConflictSubtaskId, IReadOnlyList ConflictedFiles); + +public abstract record CombinedDiffResult +{ + public sealed record Ok(CombinedDiffSuccess Value) : CombinedDiffResult; + public sealed record Failed(CombinedDiffFailure Value) : CombinedDiffResult; +} + +public sealed class PlanningAggregator +{ + private readonly IDbContextFactory _dbFactory; + private readonly GitService _git; + private readonly ILogger _logger; + + public PlanningAggregator( + IDbContextFactory dbFactory, + GitService git, + ILogger logger) + { + _dbFactory = dbFactory; + _git = git; + _logger = logger; + } + + public async Task> GetAggregatedDiffAsync( + string planningTaskId, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var children = await ctx.Tasks + .Include(t => t.Worktree) + .Where(t => t.ParentTaskId == planningTaskId) + .OrderBy(t => t.SortOrder) + .ToListAsync(ct); + + var result = new List(); + foreach (var child in children) + { + if (child.Worktree is null) continue; + var wt = child.Worktree; + var head = wt.HeadCommit ?? await _git.RevParseHeadAsync(wt.Path, ct); + string unified; + try + { + unified = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "diff failed for subtask {Id}", child.Id); + unified = ""; + } + result.Add(new SubtaskDiff( + child.Id, child.Title, wt.BranchName, wt.BaseCommit, head, wt.DiffStat, unified)); + } + return result; + } + + // BuildIntegrationBranchAsync & CleanupIntegrationBranchAsync added in Tasks 5 & 6. +} +``` + +- [ ] **Step 2: Write failing test** + +Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs`: + +```csharp +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public class PlanningAggregatorTests : IDisposable +{ + private readonly List _dbs = new(); + private readonly List _repos = new(); + private readonly List<(string repoDir, string wtPath)> _wtCleanups = new(); + + private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; } + private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; } + + public void Dispose() + { + foreach (var (repo, wt) in _wtCleanups) + try { GitRepoFixture.RunGit(repo, "worktree", "remove", "--force", wt); } catch { } + foreach (var d in _dbs) try { d.Dispose(); } catch { } + foreach (var r in _repos) try { r.Dispose(); } catch { } + } + + [Fact] + public async Task GetAggregatedDiffAsync_ReturnsOneEntryPerSubtaskInSortOrder() + { + var db = NewDb(); + var repo = NewRepo(); + + // Seed a planning parent and two child subtasks, each with a worktree + branch. + var (parentId, subA, subB) = await SeedPlanningWithChildren(db, repo); + + var svc = new PlanningAggregator(db.CreateFactory(), + new GitService(NullLogger.Instance), + NullLogger.Instance); + + var result = await svc.GetAggregatedDiffAsync(parentId, CancellationToken.None); + + Assert.Equal(2, result.Count); + Assert.Equal(subA, result[0].SubtaskId); + Assert.Equal(subB, result[1].SubtaskId); + Assert.Contains("diff --git", result[0].UnifiedDiff); + } + + // Helper: creates a planning parent + 2 children with real worktrees. + private async Task<(string parent, string childA, string childB)> SeedPlanningWithChildren( + DbFixture db, GitRepoFixture repo) + { + // ... (follow SeedListAndTask pattern from TaskMergeServiceTests; + // for each child: git worktree add -b claudedo/ ; + // write a file in the worktree; commit; record HeadCommit on WorktreeEntity) + throw new NotImplementedException("implement during task"); + } +} +``` + +(Fill in `SeedPlanningWithChildren` with actual EF Core inserts + `git worktree add` calls using `GitRepoFixture.RunGit` and `File.WriteAllText`. The two children must each have at least one commit on their branch so the diff has content.) + +- [ ] **Step 3: Run test — expect fail** (helper throws). + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~PlanningAggregatorTests" -v minimal` +Expected: FAIL. + +- [ ] **Step 4: Implement the seeder helper in the test (not the service — the service is already written in step 1). Re-run test — expect PASS.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs +git commit -m "feat(worker): add PlanningAggregator.GetAggregatedDiffAsync" +``` + +--- + +### Task 5: `PlanningAggregator.BuildIntegrationBranchAsync` + +**Goal:** Create/reset `planning/-integration` off the target branch, `git merge --no-ff` each child's branch sequentially. On conflict: abort, reset the integration branch, return `Failed`. On success: return the combined diff. + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` + +- [ ] **Step 1: Write failing happy-path test** + +Add to `PlanningAggregatorTests.cs`: + +```csharp +[Fact] +public async Task BuildIntegrationBranchAsync_NonConflictingChildren_ReturnsOkWithCombinedDiff() +{ + var db = NewDb(); + var repo = NewRepo(); + + // Two children that edit DIFFERENT files, so no conflict. + var (parentId, _, _) = await SeedPlanningWithChildrenTouchingDifferentFiles(db, repo); + + var git = new GitService(NullLogger.Instance); + var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.BuildIntegrationBranchAsync( + parentId, targetBranch: "main", CancellationToken.None); + + var ok = Assert.IsType(result); + Assert.EndsWith("-integration", ok.Value.IntegrationBranch); + Assert.Contains("diff --git", ok.Value.UnifiedDiff); + + // Branch exists and contains both children's commits. + var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None); + Assert.Contains(branches, b => b == ok.Value.IntegrationBranch); +} + +[Fact] +public async Task BuildIntegrationBranchAsync_ConflictingChildren_ReturnsFailedAndResetsBranch() +{ + var db = NewDb(); + var repo = NewRepo(); + + // Two children that edit the SAME file → conflict on second merge. + var (parentId, subA, subB) = await SeedPlanningWithChildrenConflicting(db, repo); + + var git = new GitService(NullLogger.Instance); + var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); + + var result = await svc.BuildIntegrationBranchAsync( + parentId, targetBranch: "main", CancellationToken.None); + + var failed = Assert.IsType(result); + Assert.Equal(subB, failed.Value.FirstConflictSubtaskId); + Assert.NotEmpty(failed.Value.ConflictedFiles); + + // Repo must not be left mid-merge. + Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None)); +} +``` + +- [ ] **Step 2: Run — expect fail.** + +- [ ] **Step 3: Implement** + +Add to `PlanningAggregator.cs`: + +```csharp +public async Task BuildIntegrationBranchAsync( + string planningTaskId, string targetBranch, CancellationToken ct) +{ + var (planning, repoDir, childSubtasks) = await LoadPlanningContextAsync(planningTaskId, ct); + + var integrationBranch = BuildIntegrationBranchName(planning); + + // Reset: delete if exists, then recreate off target. + try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } + catch { /* didn't exist — ignore */ } + + await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); + // Create new branch pointing at target's HEAD. + GitRaw(repoDir, "checkout", "-b", integrationBranch); + + foreach (var child in childSubtasks) + { + if (child.Worktree is null) continue; + var (code, stderr) = await _git.MergeNoFfAsync( + repoDir, child.Worktree.BranchName, + $"Integrate subtask: {child.Title}", ct); + if (code != 0) + { + List files; + try { files = await _git.ListConflictedFilesAsync(repoDir, ct); } + catch { files = new(); } + + try { await _git.MergeAbortAsync(repoDir, ct); } catch { } + try { await _git.CheckoutBranchAsync(repoDir, targetBranch, ct); } catch { } + try { await _git.BranchDeleteAsync(repoDir, integrationBranch, force: true, ct); } catch { } + + return new CombinedDiffResult.Failed( + new CombinedDiffFailure(child.Id, files)); + } + } + + var unifiedDiff = GitRaw(repoDir, "diff", $"{targetBranch}..{integrationBranch}"); + return new CombinedDiffResult.Ok(new CombinedDiffSuccess(integrationBranch, unifiedDiff)); +} + +private async Task<(TaskEntity planning, string repoDir, IReadOnlyList children)> + LoadPlanningContextAsync(string planningTaskId, CancellationToken ct) +{ + using var ctx = _dbFactory.CreateDbContext(); + var planning = await ctx.Tasks + .Include(t => t.List) + .Include(t => t.Children).ThenInclude(c => c.Worktree) + .SingleOrDefaultAsync(t => t.Id == planningTaskId, ct) + ?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found."); + var repoDir = planning.List.WorkingDir + ?? throw new InvalidOperationException("List has no working directory."); + var children = planning.Children.OrderBy(c => c.SortOrder).ToList(); + return (planning, repoDir, children); +} + +private static string BuildIntegrationBranchName(TaskEntity planning) +{ + var slug = new string(planning.Title + .ToLowerInvariant() + .Select(c => char.IsLetterOrDigit(c) ? c : '-') + .ToArray()) + .Trim('-'); + if (string.IsNullOrEmpty(slug)) slug = planning.Id[..8]; + if (slug.Length > 40) slug = slug[..40].TrimEnd('-'); + return $"planning/{slug}-integration"; +} + +private static string GitRaw(string cwd, params string[] args) +{ + var psi = new System.Diagnostics.ProcessStartInfo("git") + { + WorkingDirectory = cwd, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = System.Diagnostics.Process.Start(psi)!; + var stdout = p.StandardOutput.ReadToEnd(); + var stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(); + if (p.ExitCode != 0) throw new InvalidOperationException($"git {string.Join(' ', args)} failed: {stderr}"); + return stdout; +} +``` + +(Note: `GitRaw` is a private fallback for commands `GitService` doesn't currently expose. If you prefer, add `CheckoutNewBranchAsync` and `DiffRangeAsync` to `GitService` instead and replace the calls. Either way is acceptable — keep this scoped to this class if you want the commit minimal.) + +- [ ] **Step 4: Run — expect both tests pass.** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~BuildIntegrationBranch" -v minimal` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs +git commit -m "feat(worker): add PlanningAggregator.BuildIntegrationBranchAsync" +``` + +--- + +### Task 6: `PlanningAggregator.CleanupIntegrationBranchAsync` + +**Goal:** Delete the integration branch if it exists. + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task CleanupIntegrationBranchAsync_RemovesBranchIfPresent() +{ + var db = NewDb(); + var repo = NewRepo(); + var (parentId, _, _) = await SeedPlanningWithChildrenTouchingDifferentFiles(db, repo); + + var git = new GitService(NullLogger.Instance); + var svc = new PlanningAggregator(db.CreateFactory(), git, NullLogger.Instance); + + var built = await svc.BuildIntegrationBranchAsync(parentId, "main", CancellationToken.None); + var ok = Assert.IsType(built); + + await svc.CleanupIntegrationBranchAsync(parentId, CancellationToken.None); + + var branches = await git.ListLocalBranchesAsync(repo.RepoDir, CancellationToken.None); + Assert.DoesNotContain(branches, b => b == ok.Value.IntegrationBranch); +} +``` + +- [ ] **Step 2: Run — expect fail.** + +- [ ] **Step 3: Implement** + +```csharp +public async Task CleanupIntegrationBranchAsync(string planningTaskId, CancellationToken ct) +{ + var (planning, repoDir, _) = await LoadPlanningContextAsync(planningTaskId, ct); + var branch = BuildIntegrationBranchName(planning); + + // Ensure we're not on the integration branch when deleting it. + var current = await _git.GetCurrentBranchAsync(repoDir, ct); + if (string.Equals(current, branch, StringComparison.Ordinal)) + { + // Checkout any other branch before deleting current. + var branches = await _git.ListLocalBranchesAsync(repoDir, ct); + var target = branches.FirstOrDefault(b => b != branch) ?? "main"; + await _git.CheckoutBranchAsync(repoDir, target, ct); + } + + try { await _git.BranchDeleteAsync(repoDir, branch, force: true, ct); } catch { } +} +``` + +- [ ] **Step 4: Run — expect pass.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningAggregator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningAggregatorTests.cs +git commit -m "feat(worker): add PlanningAggregator.CleanupIntegrationBranchAsync" +``` + +--- + +## Phase 3 — `PlanningMergeOrchestrator` + +### Task 7: Orchestrator happy path (`StartAsync` with no conflicts) + +**Goal:** Sequentially merge all child subtasks via `TaskMergeService.MergeAsync` with `leaveConflictsInTree: true`. On all-success, set Planning to `Done`, cleanup integration branch, emit `PlanningCompleted`. + +**Files:** +- Create: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` +- Create: `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs` +- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` + +- [ ] **Step 1: Define event records** + +Create `src/ClaudeDo.Worker/Planning/PlanningMergeEvents.cs`: + +```csharp +namespace ClaudeDo.Worker.Planning; + +public sealed record PlanningMergeStarted(string PlanningTaskId, string TargetBranch); +public sealed record PlanningSubtaskMerged(string PlanningTaskId, string SubtaskId); +public sealed record PlanningMergeConflict( + string PlanningTaskId, string SubtaskId, IReadOnlyList ConflictedFiles); +public sealed record PlanningMergeAborted(string PlanningTaskId); +public sealed record PlanningCompleted(string PlanningTaskId); +``` + +- [ ] **Step 2: Add broadcaster methods** + +Modify `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — add one method per event, following the pattern of existing methods (e.g., `WorktreeUpdated`). Example: + +```csharp +public Task PlanningMergeStarted(string planningTaskId, string targetBranch) => + _hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch); + +public Task PlanningSubtaskMerged(string planningTaskId, string subtaskId) => + _hub.Clients.All.SendAsync("PlanningSubtaskMerged", planningTaskId, subtaskId); + +public Task PlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList files) => + _hub.Clients.All.SendAsync("PlanningMergeConflict", planningTaskId, subtaskId, files); + +public Task PlanningMergeAborted(string planningTaskId) => + _hub.Clients.All.SendAsync("PlanningMergeAborted", planningTaskId); + +public Task PlanningCompleted(string planningTaskId) => + _hub.Clients.All.SendAsync("PlanningCompleted", planningTaskId); +``` + +- [ ] **Step 3: Write failing happy-path test** + +Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs`: + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Services; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +// ... usings for fixtures and NullLogger + +public class PlanningMergeOrchestratorTests : IDisposable +{ + // ... same fixture management as PlanningAggregatorTests ... + + [Fact] + public async Task StartAsync_AllChildrenMergeCleanly_MarksPlanningDoneAndEmitsCompleted() + { + var db = NewDb(); + var repo = NewRepo(); + var (parentId, subA, subB) = await SeedPlanningWithChildrenReadyToMerge(db, repo); + + var (orch, broadcasterSpy) = BuildOrchestrator(db, repo); + + await orch.StartAsync(parentId, targetBranch: "main", CancellationToken.None); + + using var ctx = db.CreateContext(); + var planning = ctx.Tasks.Single(t => t.Id == parentId); + Assert.Equal(TaskStatus.Done, planning.Status); + var wtA = ctx.Worktrees.Single(w => w.TaskId == subA); + var wtB = ctx.Worktrees.Single(w => w.TaskId == subB); + Assert.Equal(WorktreeState.Merged, wtA.State); + Assert.Equal(WorktreeState.Merged, wtB.State); + Assert.Contains(broadcasterSpy.Events, e => e is PlanningCompleted pc && pc.PlanningTaskId == parentId); + } + + // BuildOrchestrator: wires real TaskMergeService + PlanningAggregator + a + // fake broadcaster that records events into a list. +} +``` + +(Implement `BuildOrchestrator` and `SeedPlanningWithChildrenReadyToMerge` — the children must have non-conflicting commits on their branches and their `TaskEntity.Status = Done`, worktrees in `Active` state.) + +- [ ] **Step 4: Run — expect fail.** + +- [ ] **Step 5: Implement orchestrator happy path** + +Create `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`: + +```csharp +using System.Collections.Concurrent; +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Services; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningMergeOrchestrator +{ + private readonly IDbContextFactory _dbFactory; + private readonly TaskMergeService _merge; + private readonly PlanningAggregator _aggregator; + private readonly HubBroadcaster _broadcaster; + private readonly ILogger _logger; + + private sealed class State + { + public required string TargetBranch { get; init; } + public required Queue RemainingSubtaskIds { get; init; } + public string? CurrentSubtaskId { get; set; } + } + + private readonly ConcurrentDictionary _states = new(); + + public PlanningMergeOrchestrator( + IDbContextFactory dbFactory, + TaskMergeService merge, + PlanningAggregator aggregator, + HubBroadcaster broadcaster, + ILogger logger) + { + _dbFactory = dbFactory; + _merge = merge; + _aggregator = aggregator; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task StartAsync(string planningTaskId, string targetBranch, CancellationToken ct) + { + // Pre-flight (Task 10 adds the full checks — leave minimal here for now): + using (var ctx = _dbFactory.CreateDbContext()) + { + var children = await ctx.Tasks + .Include(t => t.Worktree) + .Where(t => t.ParentTaskId == planningTaskId) + .OrderBy(t => t.SortOrder) + .ToListAsync(ct); + + var queue = new Queue( + children + .Where(c => c.Worktree is not null && c.Worktree.State != WorktreeState.Merged) + .Select(c => c.Id)); + + _states[planningTaskId] = new State + { + TargetBranch = targetBranch, + RemainingSubtaskIds = queue, + }; + } + + await _broadcaster.PlanningMergeStarted(planningTaskId, targetBranch); + await DrainAsync(planningTaskId, ct); + } + + private async Task DrainAsync(string planningTaskId, CancellationToken ct) + { + if (!_states.TryGetValue(planningTaskId, out var state)) return; + + while (state.RemainingSubtaskIds.TryDequeue(out var subtaskId)) + { + state.CurrentSubtaskId = subtaskId; + var result = await _merge.MergeAsync( + subtaskId, + state.TargetBranch, + removeWorktree: true, + commitMessage: "Merge subtask", + leaveConflictsInTree: true, + ct); + + if (result.Status == TaskMergeService.StatusConflict) + { + await _broadcaster.PlanningMergeConflict(planningTaskId, subtaskId, result.ConflictFiles); + return; // Halt — user calls ContinueAsync / AbortAsync. + } + + if (result.Status != TaskMergeService.StatusMerged) + { + // Non-conflict failure (blocked etc.). Halt and surface. + await _broadcaster.PlanningMergeConflict( + planningTaskId, subtaskId, new[] { result.ErrorMessage ?? "merge blocked" }); + return; + } + + await _broadcaster.PlanningSubtaskMerged(planningTaskId, subtaskId); + } + + state.CurrentSubtaskId = null; + await FinalizePlanningDoneAsync(planningTaskId, ct); + _states.TryRemove(planningTaskId, out _); + await _broadcaster.PlanningCompleted(planningTaskId); + } + + private async Task FinalizePlanningDoneAsync(string planningTaskId, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var planning = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == planningTaskId, ct); + if (planning is null) return; + planning.Status = TaskStatus.Done; + planning.FinishedAt = DateTime.UtcNow; + await ctx.SaveChangesAsync(ct); + + try { await _aggregator.CleanupIntegrationBranchAsync(planningTaskId, ct); } + catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); } + } + + // ContinueAsync / AbortAsync added in Tasks 8 & 9. + // Pre-flight added in Task 10. +} +``` + +- [ ] **Step 6: Run test — expect pass.** + +- [ ] **Step 7: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/ src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +git commit -m "feat(worker): add PlanningMergeOrchestrator happy path" +``` + +--- + +### Task 8: Orchestrator `ContinueAsync` + +**Goal:** Resume a halted Merge-all after the user resolves conflicts. + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task ContinueAsync_AfterConflict_ResumesRemainingMergesAndCompletes() +{ + var db = NewDb(); + var repo = NewRepo(); + // Seed: subA non-conflicting, subB conflicting, subC non-conflicting + var (parentId, subA, subB, subC) = await SeedPlanningThreeChildrenMiddleConflicts(db, repo); + + var (orch, spy) = BuildOrchestrator(db, repo); + await orch.StartAsync(parentId, "main", CancellationToken.None); + + // subA merged, subB halted in conflict + Assert.Contains(spy.Events, e => e is PlanningSubtaskMerged m && m.SubtaskId == subA); + Assert.Contains(spy.Events, e => e is PlanningMergeConflict c && c.SubtaskId == subB); + + // Simulate user resolving in VS Code + var readme = Path.Combine(repo.RepoDir, "README.md"); + File.WriteAllText(readme, "resolved\n"); + + await orch.ContinueAsync(parentId, CancellationToken.None); + + using var ctx = db.CreateContext(); + Assert.Equal(TaskStatus.Done, ctx.Tasks.Single(t => t.Id == parentId).Status); + Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State); + Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subC).State); +} +``` + +- [ ] **Step 2: Run — expect fail.** + +- [ ] **Step 3: Implement `ContinueAsync`** + +```csharp +public async Task ContinueAsync(string planningTaskId, CancellationToken ct) +{ + if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null) + throw new InvalidOperationException("no in-progress merge to continue"); + + var current = state.CurrentSubtaskId; + var result = await _merge.ContinueMergeAsync(current, ct); + if (result.Status != TaskMergeService.StatusMerged) + { + await _broadcaster.PlanningMergeConflict(planningTaskId, current, result.ConflictFiles); + return; + } + await _broadcaster.PlanningSubtaskMerged(planningTaskId, current); + + state.CurrentSubtaskId = null; + await DrainAsync(planningTaskId, ct); +} +``` + +- [ ] **Step 4: Run — expect pass.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +git commit -m "feat(worker): add PlanningMergeOrchestrator.ContinueAsync" +``` + +--- + +### Task 9: Orchestrator `AbortAsync` + +**Goal:** Cancel an in-progress Merge-all after a conflict. Earlier merged subtasks stay merged. Planning stays `Planned`. + +- [ ] **Step 1: Write failing test** + +```csharp +[Fact] +public async Task AbortAsync_AfterConflict_RestoresCleanRepoAndClearsState() +{ + var db = NewDb(); + var repo = NewRepo(); + var (parentId, subA, subB, _) = await SeedPlanningThreeChildrenMiddleConflicts(db, repo); + + var (orch, spy) = BuildOrchestrator(db, repo); + await orch.StartAsync(parentId, "main", CancellationToken.None); + + await orch.AbortAsync(parentId, CancellationToken.None); + + using var ctx = db.CreateContext(); + Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status); + Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State); + Assert.Equal(WorktreeState.Active, ctx.Worktrees.Single(w => w.TaskId == subB).State); + Assert.Contains(spy.Events, e => e is PlanningMergeAborted pma && pma.PlanningTaskId == parentId); + + var git = new GitService(NullLogger.Instance); + Assert.False(await git.IsMidMergeAsync(repo.RepoDir, CancellationToken.None)); +} +``` + +- [ ] **Step 2: Run — expect fail.** + +- [ ] **Step 3: Implement `AbortAsync`** + +```csharp +public async Task AbortAsync(string planningTaskId, CancellationToken ct) +{ + if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null) + throw new InvalidOperationException("no in-progress merge to abort"); + + await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct); + _states.TryRemove(planningTaskId, out _); + await _broadcaster.PlanningMergeAborted(planningTaskId); +} +``` + +- [ ] **Step 4: Run — expect pass.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +git commit -m "feat(worker): add PlanningMergeOrchestrator.AbortAsync" +``` + +--- + +### Task 10: Pre-flight checks + idempotent restart + +**Goal:** Verify every subtask is `Done`, every worktree is `Active` or `Merged`, repo is clean, no mid-merge in progress. Already-`Merged` worktrees are skipped (idempotent). + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs` + +- [ ] **Step 1: Write failing tests for each pre-flight failure** + +```csharp +[Fact] +public async Task StartAsync_SubtaskStillRunning_ThrowsWithoutSideEffects() +{ + var db = NewDb(); + var repo = NewRepo(); + var (parentId, runningSub) = await SeedPlanningWithOneRunningChild(db, repo); + var (orch, _) = BuildOrchestrator(db, repo); + + var ex = await Assert.ThrowsAsync( + () => orch.StartAsync(parentId, "main", CancellationToken.None)); + Assert.Contains(runningSub, ex.Message); + + using var ctx = db.CreateContext(); + Assert.Equal(TaskStatus.Planned, ctx.Tasks.Single(t => t.Id == parentId).Status); +} + +[Fact] +public async Task StartAsync_DirtyRepo_ThrowsWithoutSideEffects() { /* ... */ } + +[Fact] +public async Task StartAsync_IdempotentRestart_SkipsAlreadyMergedWorktrees() +{ + var db = NewDb(); + var repo = NewRepo(); + var (parentId, alreadyMerged, stillToMerge) = + await SeedPlanningWithOneAlreadyMergedChild(db, repo); + + var (orch, spy) = BuildOrchestrator(db, repo); + await orch.StartAsync(parentId, "main", CancellationToken.None); + + // alreadyMerged should NOT have a PlanningSubtaskMerged event (it was skipped). + Assert.DoesNotContain(spy.Events, e => e is PlanningSubtaskMerged m && m.SubtaskId == alreadyMerged); + Assert.Contains(spy.Events, e => e is PlanningSubtaskMerged m && m.SubtaskId == stillToMerge); + Assert.Contains(spy.Events, e => e is PlanningCompleted); +} +``` + +- [ ] **Step 2: Run — expect fail.** + +- [ ] **Step 3: Add pre-flight to `StartAsync`** + +Replace the beginning of `StartAsync`: + +```csharp +public async Task StartAsync(string planningTaskId, string targetBranch, CancellationToken ct) +{ + string workingDir; + List children; + + using (var ctx = _dbFactory.CreateDbContext()) + { + var planning = await ctx.Tasks + .Include(t => t.List) + .Include(t => t.Children).ThenInclude(c => c.Worktree) + .SingleOrDefaultAsync(t => t.Id == planningTaskId, ct) + ?? throw new KeyNotFoundException($"Planning task '{planningTaskId}' not found."); + workingDir = planning.List.WorkingDir + ?? throw new InvalidOperationException("List has no working directory."); + children = planning.Children.OrderBy(c => c.SortOrder).ToList(); + } + + // Pre-flight + foreach (var c in children) + { + if (c.Status != TaskStatus.Done) + throw new InvalidOperationException($"subtask {c.Id} is not Done (status {c.Status})"); + if (c.Worktree is null) + throw new InvalidOperationException($"subtask {c.Id} has no worktree"); + if (c.Worktree.State != WorktreeState.Active && c.Worktree.State != WorktreeState.Merged) + throw new InvalidOperationException( + $"subtask {c.Id} worktree state is {c.Worktree.State}"); + } + + // Fail fast on mid-merge / dirty repo (MergeAsync also checks, but we want a clear + // pre-flight error instead of a per-subtask "blocked" result). + if (await _git.IsMidMergeAsync(workingDir, ct)) + throw new InvalidOperationException("repo is mid-merge"); + if (await _git.HasChangesAsync(workingDir, ct)) + throw new InvalidOperationException("working tree has uncommitted changes"); + + var queue = new Queue( + children + .Where(c => c.Worktree!.State == WorktreeState.Active) + .Select(c => c.Id)); + + _states[planningTaskId] = new State + { + TargetBranch = targetBranch, + RemainingSubtaskIds = queue, + }; + + await _broadcaster.PlanningMergeStarted(planningTaskId, targetBranch); + await DrainAsync(planningTaskId, ct); +} +``` + +Add `GitService` to the `PlanningMergeOrchestrator` constructor and store it as `_git`: + +```csharp +private readonly ClaudeDo.Data.Git.GitService _git; + +public PlanningMergeOrchestrator( + IDbContextFactory dbFactory, + TaskMergeService merge, + PlanningAggregator aggregator, + ClaudeDo.Data.Git.GitService git, + HubBroadcaster broadcaster, + ILogger logger) +{ + _dbFactory = dbFactory; + _merge = merge; + _aggregator = aggregator; + _git = git; + _broadcaster = broadcaster; + _logger = logger; +} +``` + +Update any orchestrator construction in tests (`BuildOrchestrator` helper) to pass a `GitService` instance. + +- [ ] **Step 4: Run — expect pass.** + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +git commit -m "feat(worker): add pre-flight checks and idempotent restart to PlanningMergeOrchestrator" +``` + +--- + +## Phase 4 — Hub wiring + DI + +### Task 11: Register services + add hub methods + +**Files:** +- Modify: `src/ClaudeDo.Worker/Program.cs` +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` + +- [ ] **Step 1: Register services in DI** + +Add to `src/ClaudeDo.Worker/Program.cs` (near the existing `AddSingleton` line): + +```csharp +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +- [ ] **Step 2: Add hub methods** + +Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs`: + +Add to constructor parameter list and store as fields: + +```csharp +PlanningAggregator planningAggregator, +PlanningMergeOrchestrator planningMergeOrchestrator, +``` + +Add these methods (follow the existing `MergeTask` pattern with `try/catch` for `KeyNotFoundException` and `InvalidOperationException`): + +```csharp +public async Task> GetPlanningAggregate(string planningTaskId) +{ + try { return await _planningAggregator.GetAggregatedDiffAsync(planningTaskId, CancellationToken.None); } + catch (KeyNotFoundException) { throw new HubException("planning task not found"); } +} + +public async Task BuildPlanningIntegrationBranch(string planningTaskId, string targetBranch) +{ + try { return await _planningAggregator.BuildIntegrationBranchAsync(planningTaskId, targetBranch, CancellationToken.None); } + catch (KeyNotFoundException) { throw new HubException("planning task not found"); } + catch (InvalidOperationException ex) { throw new HubException(ex.Message); } +} + +public async Task MergeAllPlanning(string planningTaskId, string targetBranch) +{ + try { await _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch, CancellationToken.None); } + catch (KeyNotFoundException) { throw new HubException("planning task not found"); } + catch (InvalidOperationException ex) { throw new HubException(ex.Message); } +} + +public async Task ContinuePlanningMerge(string planningTaskId) +{ + try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); } + catch (InvalidOperationException ex) { throw new HubException(ex.Message); } +} + +public async Task AbortPlanningMerge(string planningTaskId) +{ + try { await _planningMergeOrchestrator.AbortAsync(planningTaskId, CancellationToken.None); } + catch (InvalidOperationException ex) { throw new HubException(ex.Message); } +} +``` + +- [ ] **Step 3: Run the full worker test suite — expect pass.** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -v minimal` +Expected: all existing tests still pass. + +- [ ] **Step 4: Build to verify hub compiles and DI resolves** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs +git commit -m "feat(worker): register planning services and add Merge-all hub methods" +``` + +--- + +## Phase 5 — UI Visibility (`TasksIslandViewModel.Regroup`) + +### Task 12: Planning parents roll up their children's statuses + +**Goal:** Subtasks (`ParentTaskId != null`) never appear as standalone rows in virtual lists. A Planning/Planned parent is included in `virtual:queued` if any child is `Queued` and in `virtual:running` if any child is `Running`. Children stay attached under the parent in the task tree. When Planning transitions to `Done`, parent + children move to Completed together. + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` +- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandRegroupTests.cs` (create if absent — mirror the structure of existing ViewModel tests) + +- [ ] **Step 1: Write failing test — queued child with Planning parent is not in virtual:queued standalone** + +(Inspect how the existing test project resolves `TasksIslandViewModel` — it takes DB input. Follow that pattern. Seed a planning task with status `Planning` and a child with status `Queued`.) + +```csharp +[Fact] +public async Task VirtualQueued_QueuedChildOfPlanningParent_IsNotStandaloneRow() +{ + var db = NewDb(); + await SeedPlanningWithQueuedChild(db, parentId: "p1", childId: "c1"); + + var vm = BuildViewModel(db); + await vm.SelectListAsync("virtual:queued"); + + // Child is NOT present as a top-level row. + Assert.DoesNotContain(vm.Items, r => r.Id == "c1" && !r.IsChild); + // Planning parent IS present (roll-up). + Assert.Contains(vm.Items, r => r.Id == "p1" && !r.IsChild); +} + +[Fact] +public async Task VirtualQueued_PlanningParentWithAnyQueuedChild_IsIncluded() +{ + var db = NewDb(); + await SeedPlanningParent(db, "p1", status: TaskStatus.Planned); + await SeedChild(db, parentId: "p1", childId: "c1", status: TaskStatus.Queued); + + var vm = BuildViewModel(db); + await vm.SelectListAsync("virtual:queued"); + + Assert.Contains(vm.Items, r => r.Id == "p1"); +} + +[Fact] +public async Task Regroup_DoneChild_StaysNestedUnderPlanningParent() +{ + var db = NewDb(); + await SeedPlanningParent(db, "p1", status: TaskStatus.Planned); + await SeedChild(db, parentId: "p1", childId: "c1", status: TaskStatus.Done); + + var vm = BuildViewModel(db); + await vm.SelectListAsync("virtual:queued"); // parent is queued-rollup + vm.Items.Single(r => r.Id == "p1").IsExpanded = true; + vm.Regroup(); + + // Done child should NOT be in CompletedItems while parent is Planned. + Assert.DoesNotContain(vm.CompletedItems, r => r.Id == "c1"); + // Should be nested under parent. + Assert.Contains(vm.OpenItems, r => r.Id == "c1" && r.IsChild); +} +``` + +- [ ] **Step 2: Run — expect fail.** + +- [ ] **Step 3: Update virtual-list filter and `Regroup`** + +In `TasksIslandViewModel.cs`, change the filtered switch around line 160: + +```csharp +IEnumerable filtered = list.Kind switch +{ + ListKind.Smart when list.Id == "smart:my-day" => all.Where(t => t.IsMyDay), + ListKind.Smart when list.Id == "smart:important" => all.Where(t => t.IsStarred), + ListKind.Smart when list.Id == "smart:planned" => all.Where(t => t.ScheduledFor != null), + ListKind.Virtual when list.Id == "virtual:queued" => all.Where(t => + t.ParentTaskId == null && + (t.Status == TaskStatus.Queued + || ((t.Status == TaskStatus.Planning || t.Status == TaskStatus.Planned) + && t.Children.Any(c => c.Status == TaskStatus.Queued)))), + ListKind.Virtual when list.Id == "virtual:running" => all.Where(t => + t.ParentTaskId == null && + (t.Status == TaskStatus.Running + || ((t.Status == TaskStatus.Planning || t.Status == TaskStatus.Planned) + && t.Children.Any(c => c.Status == TaskStatus.Running)))), + ListKind.Virtual when list.Id == "virtual:review" => all.Where(t => + t.ParentTaskId == null + && t.Status == TaskStatus.Done + && t.Worktree?.State == WorktreeState.Active), + ListKind.User => all.Where(t => t.ParentTaskId == null && $"user:{t.ListId}" == list.Id), + _ => Enumerable.Empty(), +}; + +// After filtering top-level, also pull in children so Regroup can nest them. +var topIds = filtered.Select(t => t.Id).ToHashSet(); +var childRows = all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId)); +filtered = filtered.Concat(childRows); +``` + +In `Regroup()`, replace the completion classification so Planning children don't get pulled into `CompletedItems` unless their parent is already `Done`: + +```csharp +var today = DateTime.Today; +foreach (var r in flat) +{ + // A child of a still-open Planning parent is grouped with its parent, not moved to Completed. + var underOpenPlanningParent = r.IsChild && + flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done); + + if (r.Done && !underOpenPlanningParent) + CompletedItems.Add(r); + else if (r.ScheduledFor is { } d && d.Date < today) + OverdueItems.Add(r); + else + OpenItems.Add(r); +} +``` + +(If `TaskRowViewModel.IsPlanningParent` currently requires status `Planning`, extend it to also return `true` for `Planned` — inspect the existing getter and adjust.) + +- [ ] **Step 4: Run — expect pass. Run the full UI test suite.** + +Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -v minimal` +Expected: all pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ +git commit -m "fix(ui): planning parents roll up child status; children stay nested until parent Done" +``` + +--- + +## Phase 6 — UI Planning Detail Panel extensions + +### Task 13: Add Merge target dropdown + Review / Merge-all buttons to planning detail + +**Goal:** On the existing detail pane for a Planning task, show a merge target dropdown + `[Review combined diff]` + `[Merge all subtasks]`. Merge-all disabled with tooltip when children aren't all `Done` or any worktree is `Discarded`/`Kept`. + +**Files:** +- Modify: the existing task-detail view + viewmodel that renders a Planning task. Search under `src/ClaudeDo.Ui/Views/` and `src/ClaudeDo.Ui/ViewModels/` for the Planning detail pane (grep for `IsPlanningParent` or `PlanningSession`). + +- [ ] **Step 1: Locate the detail view for Planning tasks.** Add observable properties to its ViewModel: + +```csharp +[ObservableProperty] private ObservableCollection mergeTargetBranches = new(); +[ObservableProperty] private string? selectedMergeTarget; +[ObservableProperty] private bool canMergeAll; +[ObservableProperty] private string? mergeAllDisabledReason; +``` + +- [ ] **Step 2: Populate branches on load** — call `WorkerHubClient.GetMergeTargets()` (reuse existing), fill `MergeTargetBranches`, set `SelectedMergeTarget` to the returned default. + +- [ ] **Step 3: Compute `CanMergeAll` and `MergeAllDisabledReason`** — iterate children: + +```csharp +private void RecomputeCanMergeAll(IEnumerable children) +{ + var notDone = children.Count(c => c.Status != TaskStatus.Done); + if (notDone > 0) { CanMergeAll = false; MergeAllDisabledReason = $"{notDone} subtask(s) not done"; return; } + var badWt = children.FirstOrDefault(c => + c.WorktreeState == WorktreeState.Discarded || c.WorktreeState == WorktreeState.Kept); + if (badWt is not null) { CanMergeAll = false; MergeAllDisabledReason = "at least one worktree was discarded/kept"; return; } + CanMergeAll = true; MergeAllDisabledReason = null; +} +``` + +(If `TaskRowViewModel.WorktreeState` doesn't exist, add it — bind from `TaskEntity.Worktree?.State`.) + +- [ ] **Step 4: Add `[RelayCommand]` for the two buttons** + +```csharp +[RelayCommand(CanExecute = nameof(CanReviewDiff))] +private async Task ReviewCombinedDiffAsync() +{ + // Open PlanningDiffView (Task 14) as a dialog or dedicated window, passing planningTaskId + SelectedMergeTarget. +} + +[RelayCommand(CanExecute = nameof(CanMergeAll))] +private async Task MergeAllAsync() +{ + try { await _hub.MergeAllPlanning(PlanningTaskId, SelectedMergeTarget ?? "main"); } + catch (HubException ex) { /* show inline error */ } +} + +private bool CanReviewDiff() => true; // enabled once any child has a diff +``` + +- [ ] **Step 5: Add XAML controls** to the Planning detail view (follow existing styling). Example: + +```xml + + +