diff --git a/docs/superpowers/plans/2026-06-04-child-tasks-and-improvement-loop.md b/docs/superpowers/plans/2026-06-04-child-tasks-and-improvement-loop.md new file mode 100644 index 0000000..7c50e37 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-child-tasks-and-improvement-loop.md @@ -0,0 +1,2017 @@ +# Reusable Child Tasks + Agent Improvement Loop — 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:** Let an executing task agent file out-of-scope work as child tasks (via a narrow `SuggestImprovement` MCP tool) that auto-run, then surface the parent for review once all children finish — generalizing the planning parent/child machinery into a reusable subsystem. + +**Architecture:** Add a `WaitingForChildren` status (enum-only, no DB column). A standalone task whose run succeeds with ≥1 child goes `WaitingForChildren`, auto-queues its children (each worktree based off the parent's HEAD), and advances to `WaitingForReview` when all children are terminal. A per-run MCP token (mirroring planning's per-session token) lets the server stamp the caller's id onto suggested children. The planning merge orchestrator is generalized to fold the parent's own branch ahead of the children. + +**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core, ModelContextProtocol.AspNetCore, Avalonia (UI phase), xUnit. + +**Build/test reminder:** build individual csproj with `-c Release` (`.slnx` needs .NET 9; a running Worker locks Debug). Stage files by explicit path — never `git add -A`. Use the `sonnet` model for subagents. + +**Coordination note:** The UI phase (Phase G) is BLOCKED until the `claudedo-ux` session lands its changes on main (review actions move to the detail panel, a `⚠` roadblock badge is added to `TaskRowView`, and a `RoadblockCount` int column + ONE migration are added to `TaskEntity`). Do NOT add a competing migration — `WaitingForChildren` is enum-only. Phases A–F + the prompt task are independent of that work and can proceed immediately. Rebase onto updated main before starting Phase G. + +--- + +## File Structure + +**Created:** +- `src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs` — in-memory token→taskId map (singleton), the per-run MCP identity store. +- `src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs` — request-scoped caller context + `TaskRunMcpContextAccessor` (small single-consumer types live together). +- `src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs` — the `SuggestImprovement` MCP tool. +- `tests/ClaudeDo.Data.Tests/CreateChildTests.cs`, `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs`, `tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs`, `tests/ClaudeDo.Worker.Tests/ChildWorktreeBaseTests.cs`, `tests/ClaudeDo.Worker.Tests/TreeMergeTests.cs` — new test files. + +**Modified:** +- `src/ClaudeDo.Data/Models/TaskEntity.cs` — add `WaitingForChildren` enum value. +- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — converter arms. +- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — generalize `CreateChildAsync`; remove `TryCompleteParentAsync` (logic moves to `TaskStateService`). +- `src/ClaudeDo.Worker/State/ITaskStateService.cs` + `TaskStateService.cs` — `SubmitForChildrenAsync`; generalized terminal-child parent advancement. +- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — route to `WaitingForChildren` + enqueue children; mint per-run token + write `--mcp-config`. +- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — base improvement-child worktree on parent HEAD. +- `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — `--mcp-config` + `--allowedTools` flags. +- `src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs` — resolve task-run tokens too. +- `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` + `PlanningAggregator.cs` — fold the parent's own branch; relax child validation to skip non-mergeable children. +- `src/ClaudeDo.Worker/Program.cs` — DI for the registry/accessor/service + `.WithTools()`. +- `src/ClaudeDo.Data/PromptFiles.cs` — `## Out-of-scope improvements` section in `SystemDefault`. +- UI (Phase G): `StatusColorConverter`, task tree grouping, parent review card. + +--- + +## Phase A — Data layer (no migration) + +### Task 1: Add `WaitingForChildren` status value + +**Files:** +- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:3-12` +- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:11-38` +- Test: `tests/ClaudeDo.Data.Tests/CreateChildTests.cs` (round-trip test added here; file reused by Task 2) + +- [ ] **Step 1: Write the failing test** + +Read an existing sibling test in `tests/ClaudeDo.Data.Tests/` first to copy the SQLite fixture pattern (temp file DB + `MigrateAndConfigure`). Then create `tests/ClaudeDo.Data.Tests/CreateChildTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests; + +public class CreateChildTests +{ + private static ClaudeDoDbContext NewDb() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source=file:{Guid.NewGuid():N}?mode=memory&cache=shared") + .Options; + var ctx = new ClaudeDoDbContext(opts); + ctx.Database.OpenConnection(); + ctx.Database.EnsureCreated(); + return ctx; + } + + [Fact] + public async Task WaitingForChildren_roundtrips_through_value_converter() + { + using var ctx = NewDb(); + var list = new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }; + ctx.Lists.Add(list); + ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T", + Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + ctx.ChangeTracker.Clear(); + + var reloaded = await ctx.Tasks.SingleAsync(t => t.Id == "t1"); + Assert.Equal(TaskStatus.WaitingForChildren, reloaded.Status); + } +} +``` + +> If `EnsureCreated()` doesn't match the project's fixture convention, copy the exact setup from a sibling test instead (e.g. temp-file DB + `ClaudeDoDbContext.MigrateAndConfigure`). The assertion is what matters. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter WaitingForChildren_roundtrips_through_value_converter` +Expected: FAIL — `WaitingForChildren` is not a member of `TaskStatus` (compile error). + +- [ ] **Step 3: Add the enum value** + +In `src/ClaudeDo.Data/Models/TaskEntity.cs`, add `WaitingForChildren` to the enum after `WaitingForReview`: + +```csharp +public enum TaskStatus +{ + Idle, + Queued, + Running, + WaitingForReview, + WaitingForChildren, + Done, + Failed, + Cancelled, +} +``` + +- [ ] **Step 4: Add converter arms** + +In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, add the arm to BOTH switch expressions: + +```csharp +// in StatusToString: +TaskStatus.WaitingForChildren => "waiting_for_children", +// in StatusFromString: +"waiting_for_children" => TaskStatus.WaitingForChildren, +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter WaitingForChildren_roundtrips_through_value_converter` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs tests/ClaudeDo.Data.Tests/CreateChildTests.cs +git commit -m "feat(status): add WaitingForChildren task status value" +``` + +--- + +### Task 2: Generalize `TaskRepository.CreateChildAsync` + +Drop the planning-phase guard and let the caller stamp `CreatedBy`. Planning callers pass `createdBy: null` (unchanged behavior); the improvement tool passes the caller's task id. + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:187-224` +- Modify: `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs:48` (caller passes `createdBy: null`) +- Test: `tests/ClaudeDo.Data.Tests/CreateChildTests.cs` (add cases) + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/ClaudeDo.Data.Tests/CreateChildTests.cs`: + +```csharp +[Fact] +public async Task CreateChild_attaches_to_non_planning_parent_and_stamps_createdBy() +{ + using var ctx = NewDb(); + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent", + Status = TaskStatus.Running, PlanningPhase = PlanningPhase.None, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + + var repo = new ClaudeDo.Data.Repositories.TaskRepository(ctx); + var child = await repo.CreateChildAsync("p1", "Refactor X", "desc", commitType: null, createdBy: "p1"); + + Assert.Equal("p1", child.ParentTaskId); + Assert.Equal("p1", child.CreatedBy); + Assert.Equal(TaskStatus.Idle, child.Status); + Assert.Equal("l1", child.ListId); +} + +[Fact] +public async Task CreateChild_throws_when_parent_missing() +{ + using var ctx = NewDb(); + var repo = new ClaudeDo.Data.Repositories.TaskRepository(ctx); + await Assert.ThrowsAsync( + () => repo.CreateChildAsync("nope", "T", null, null, null)); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter CreateChildTests` +Expected: FAIL — `CreateChildAsync` has no `createdBy` parameter (compile error). + +- [ ] **Step 3: Generalize the method** + +Replace `CreateChildAsync` in `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (lines 187-224) with: + +```csharp +public async Task CreateChildAsync( + string parentId, + string title, + string? description, + string? commitType, + string? createdBy = null, + CancellationToken ct = default) +{ + var parent = await _context.Tasks.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == parentId, ct); + if (parent is null) + throw new InvalidOperationException($"Parent task {parentId} not found."); + + var maxSort = await _context.Tasks + .Where(t => t.ListId == parent.ListId) + .Select(t => (int?)t.SortOrder) + .MaxAsync(ct); + + var child = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = parent.ListId, + Title = title, + Description = description, + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, + ParentTaskId = parentId, + CreatedBy = createdBy, + SortOrder = (maxSort ?? -1) + 1, + }; + _context.Tasks.Add(child); + await _context.SaveChangesAsync(ct); + return child; +} +``` + +(The `parent.PlanningPhase == PlanningPhase.None` guard is removed.) + +- [ ] **Step 4: Fix the planning caller** + +In `src/ClaudeDo.Worker/Planning/PlanningMcpService.cs:48`, update the call to pass `createdBy: null`: + +```csharp +var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, cancellationToken); +``` + +- [ ] **Step 5: Run to verify pass + build Worker** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter CreateChildTests` +Expected: PASS. +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: Build succeeded (the caller change compiles). + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRepository.cs src/ClaudeDo.Worker/Planning/PlanningMcpService.cs tests/ClaudeDo.Data.Tests/CreateChildTests.cs +git commit -m "feat(children): generalize CreateChildAsync for any parent + CreatedBy stamp" +``` + +--- + +## Phase B — State transitions & generalized completion + +**Scoping decision (deliberate):** planning-parent completion stays in `TaskRepository.TryCompleteParentAsync` (already covered by `TaskRepositoryParentCompletionTests`, only acts on `PlanningPhase == Finalized`). The NEW improvement-parent transition (`WaitingForChildren → WaitingForReview`) is written by `TaskStateService` (the sole writer of that transition). `OnChildTerminalAsync` dispatches by context: planning → `TryCompleteParentAsync`; improvement → `TryAdvanceImprovementParentAsync`. This is the "per-context completion policy" with minimal regression risk. + +### Task 3: `SubmitForChildrenAsync` transition (Running → WaitingForChildren) + +**Files:** +- Modify: `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs` +- Modify: `src/ClaudeDo.Worker/State/TaskStateService.cs` (add method after `SubmitForReviewAsync`, ~line 108) +- Test: `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests; + +public sealed class WaitingForChildrenLifecycleTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly TestDbContextFactory _factory; + private readonly TaskStateServiceBuilder.Built _built; + + public WaitingForChildrenLifecycleTests() + { + _factory = _db.CreateFactory(); + _built = TaskStateServiceBuilder.Build(_factory); + } + public void Dispose() => _db.Dispose(); + + private async Task SeedRunningStandaloneAsync() + { + using var ctx = _db.CreateContext(); + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent", + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + return "p1"; + } + + [Fact] + public async Task SubmitForChildren_moves_running_task_to_WaitingForChildren() + { + var id = await SeedRunningStandaloneAsync(); + var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default); + Assert.True(result.Ok); + + using var ctx = _db.CreateContext(); + var t = await new TaskRepository(ctx).GetByIdAsync(id); + Assert.Equal(TaskStatus.WaitingForChildren, t!.Status); + Assert.Equal("ran ok", t.Result); + Assert.NotNull(t.FinishedAt); + } + + [Fact] + public async Task SubmitForChildren_rejects_when_not_running() + { + var id = await SeedRunningStandaloneAsync(); + await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default); + var second = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default); + Assert.False(second.Ok); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WaitingForChildrenLifecycleTests` +Expected: FAIL — `SubmitForChildrenAsync` does not exist (compile error). + +- [ ] **Step 3: Add interface member** + +In `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs`, add next to `SubmitForReviewAsync`: + +```csharp +Task SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); +``` + +- [ ] **Step 4: Implement in TaskStateService** + +In `src/ClaudeDo.Worker/State/TaskStateService.cs`, add after `SubmitForReviewAsync` (after line 108): + +```csharp +public async Task SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct) +{ + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var affected = await ctx.Tasks + .Where(t => t.Id == taskId && t.Status == TaskStatus.Running) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.WaitingForChildren) + .SetProperty(t => t.FinishedAt, finishedAt) + .SetProperty(t => t.Result, result), ct); + + if (affected == 0) + return new TransitionResult(false, "Task not running; cannot submit for children."); + + await _broadcaster.TaskUpdated(taskId); + return new TransitionResult(true, null); +} +``` + +- [ ] **Step 5: Run to verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WaitingForChildrenLifecycleTests` +Expected: PASS (both cases). + +> Note: adding a member to `ITaskStateService` may break hand-rolled fakes that implement it. Search `: ITaskStateService` across both test projects; add the new method to any fake (return `new TransitionResult(true, null)`). Build both test projects to confirm. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs src/ClaudeDo.Worker/State/TaskStateService.cs tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs +git commit -m "feat(state): add SubmitForChildrenAsync (Running -> WaitingForChildren)" +``` + +--- + +### Task 4: Improvement-parent advancement on child-terminal + +When a child reaches a terminal state, if the parent is `WaitingForChildren` and ALL children are terminal (`Done`/`Failed`/`Cancelled`), advance the parent to `WaitingForReview`, flagging any failed/cancelled children in `Result`. + +**Files:** +- Modify: `src/ClaudeDo.Worker/State/TaskStateService.cs` (`OnChildTerminalAsync` ~line 345; add private `TryAdvanceImprovementParentAsync`) +- Test: `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs` (add cases) + +- [ ] **Step 1: Write the failing tests** + +Add to `WaitingForChildrenLifecycleTests`: + +```csharp +private async Task SeedParentWithChildrenAsync(string parentStatus, params TaskStatus[] childStatuses) +{ + using var ctx = _db.CreateContext(); + if (!ctx.Lists.Any()) + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent", + Status = Enum.Parse(parentStatus), Result = "parent ran", + CreatedAt = DateTime.UtcNow }); + int i = 0; + foreach (var cs in childStatuses) + ctx.Tasks.Add(new TaskEntity { Id = $"c{i++}", ListId = "l1", Title = "Child", + Status = cs, ParentTaskId = "par", CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); +} + +[Fact] +public async Task LastChildDone_advances_WaitingForChildren_parent_to_WaitingForReview() +{ + await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running); + await _built.State.CompleteAsync("c1", DateTime.UtcNow, "child ok", default); + + using var ctx = _db.CreateContext(); + var parent = await new TaskRepository(ctx).GetByIdAsync("par"); + Assert.Equal(TaskStatus.WaitingForReview, parent!.Status); +} + +[Fact] +public async Task NonLastChild_leaves_parent_in_WaitingForChildren() +{ + await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Running, TaskStatus.Idle); + await _built.State.CompleteAsync("c0", DateTime.UtcNow, "ok", default); + + using var ctx = _db.CreateContext(); + var parent = await new TaskRepository(ctx).GetByIdAsync("par"); + Assert.Equal(TaskStatus.WaitingForChildren, parent!.Status); +} + +[Fact] +public async Task FailedChild_still_advances_parent_and_flags_failure() +{ + await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running); + await _built.State.FailAsync("c1", DateTime.UtcNow, "boom", default); + + using var ctx = _db.CreateContext(); + var parent = await new TaskRepository(ctx).GetByIdAsync("par"); + Assert.Equal(TaskStatus.WaitingForReview, parent!.Status); + Assert.Contains("1 failed", parent.Result!); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WaitingForChildrenLifecycleTests` +Expected: FAIL — parent stays `WaitingForChildren`. + +- [ ] **Step 3: Wire dispatch in `OnChildTerminalAsync`** + +In `src/ClaudeDo.Worker/State/TaskStateService.cs`, inside `OnChildTerminalAsync`, AFTER the existing `TryCompleteParentAsync` try/catch (after line 377), add: + +```csharp + try + { + await TryAdvanceImprovementParentAsync(parentId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Improvement-parent advance failed for {ParentId}", parentId); + } +``` + +- [ ] **Step 4: Implement `TryAdvanceImprovementParentAsync`** + +Add this private method at the end of the class (before the final closing brace): + +```csharp + // Improvement parents sit in WaitingForChildren while their suggested children run. + // Once every child is terminal (Done/Failed/Cancelled) the parent surfaces for review; + // a failed or cancelled child does not wedge the parent — it is flagged on the result. + private async Task TryAdvanceImprovementParentAsync(string parentId) + { + string? parentResult; + List childStatuses; + await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None)) + { + var parent = await ctx.Tasks.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == parentId, CancellationToken.None); + if (parent is null || parent.Status != TaskStatus.WaitingForChildren) return; + parentResult = parent.Result; + childStatuses = await ctx.Tasks + .Where(t => t.ParentTaskId == parentId) + .Select(t => t.Status) + .ToListAsync(CancellationToken.None); + } + if (childStatuses.Count == 0) return; + + bool allTerminal = childStatuses.All(s => + s == TaskStatus.Done || s == TaskStatus.Failed || s == TaskStatus.Cancelled); + if (!allTerminal) return; + + int failed = childStatuses.Count(s => s == TaskStatus.Failed); + int cancelled = childStatuses.Count(s => s == TaskStatus.Cancelled); + var newResult = parentResult; + if (failed + cancelled > 0) + { + var note = $"⚠ Children: {failed} failed, {cancelled} cancelled."; + newResult = string.IsNullOrWhiteSpace(parentResult) ? note : $"{parentResult}\n\n{note}"; + } + + await using var writeCtx = await _dbFactory.CreateDbContextAsync(CancellationToken.None); + await writeCtx.Tasks + .Where(t => t.Id == parentId && t.Status == TaskStatus.WaitingForChildren) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.WaitingForReview) + .SetProperty(t => t.Result, newResult), CancellationToken.None); + await _broadcaster.TaskUpdated(parentId); + } +``` + +- [ ] **Step 5: Run to verify pass + planning regression** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "WaitingForChildrenLifecycleTests|TaskRepositoryParentCompletionTests|PlanningChainCoordinatorTests|PlanningEndToEndTests"` +Expected: PASS — improvement cases pass AND planning completion unaffected. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/State/TaskStateService.cs tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs +git commit -m "feat(state): advance WaitingForChildren parent to review when children terminal" +``` + +--- + +## Phase C — Runner routing & child auto-queue + +### Task 5: Fix the draft-child guard for improvement children + +`IsDraftChildAsync` currently treats ANY child whose parent isn't `Finalized` as a non-runnable "draft". Improvement children have a parent with `PlanningPhase == None`, so they'd be wrongly rejected by `EnqueueAsync`/`StartRunningAsync`. A child is only a draft while its parent's planning session is still **open** (`Active`). + +**Files:** +- Modify: `src/ClaudeDo.Worker/State/TaskStateService.cs:332-343` (`IsDraftChildAsync`) +- Test: `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs` (add cases) + +- [ ] **Step 1: Write the failing tests** + +Add to `WaitingForChildrenLifecycleTests`: + +```csharp +private async Task SeedParentChildPairAsync(PlanningPhase parentPhase) +{ + using var ctx = _db.CreateContext(); + if (!ctx.Lists.Any()) + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent", + Status = TaskStatus.WaitingForChildren, PlanningPhase = parentPhase, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "kid", ListId = "l1", Title = "Child", + Status = TaskStatus.Idle, ParentTaskId = "par", CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); +} + +[Fact] +public async Task ImprovementChild_can_be_enqueued() +{ + await SeedParentChildPairAsync(PlanningPhase.None); + var result = await _built.State.EnqueueAsync("kid", default); + Assert.True(result.Ok); + + using var ctx = _db.CreateContext(); + var kid = await new TaskRepository(ctx).GetByIdAsync("kid"); + Assert.Equal(TaskStatus.Queued, kid!.Status); +} + +[Fact] +public async Task PlanningDraftChild_cannot_be_enqueued() +{ + await SeedParentChildPairAsync(PlanningPhase.Active); + var result = await _built.State.EnqueueAsync("kid", default); + Assert.False(result.Ok); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "ImprovementChild_can_be_enqueued|PlanningDraftChild_cannot_be_enqueued"` +Expected: FAIL — `ImprovementChild_can_be_enqueued` fails (guard rejects it as draft). + +- [ ] **Step 3: Fix the guard** + +In `src/ClaudeDo.Worker/State/TaskStateService.cs`, replace the body of `IsDraftChildAsync` (lines 332-343) with: + +```csharp + // A subtask is "draft" only while its parent's planning session is still OPEN + // (PlanningPhase.Active) — those children must not run until the plan is finalized. + // Finalized-planning children and improvement children (parent PlanningPhase.None) + // are runnable. Standalone tasks (no parent) are never draft. + private static async Task IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct) + { + var parentId = await ctx.Tasks.AsNoTracking() + .Where(t => t.Id == taskId) + .Select(t => t.ParentTaskId) + .FirstOrDefaultAsync(ct); + if (parentId is null) return false; + + return await ctx.Tasks.AsNoTracking() + .AnyAsync(p => p.Id == parentId && p.PlanningPhase == PlanningPhase.Active, ct); + } +``` + +- [ ] **Step 4: Run to verify pass + planning regression** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "WaitingForChildrenLifecycleTests|PlanningEndToEndTests|PlanningMcpServiceTests"` +Expected: PASS — improvement children enqueue; planning drafts still blocked. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/State/TaskStateService.cs tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs +git commit -m "fix(state): only planning-active children are drafts; allow improvement children to queue" +``` + +--- + +### Task 6: Route a standalone success with children to `WaitingForChildren` and enqueue them + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess`, lines 319-353) +- Test: `tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs`. Mirror the `CreateService` harness from `Services/QueueServiceTests.cs` (FakeClaudeProcess + real `WorktreeManager` + `TaskStateServiceBuilder`). Use a list with `WorkingDir = null` so the run uses a sandbox dir (no git needed): + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +public sealed class StandaloneChildrenRoutingTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly WorkerConfig _cfg; + private readonly string _tempDir; + + public StandaloneChildrenRoutingTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"cd_routing_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir }; + } + public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } } + + [Fact] + public async Task StandaloneSuccess_withChild_goesWaitingForChildren_andEnqueuesChild() + { + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent", + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "kid", ListId = "l1", Title = "Improve", + Status = TaskStatus.Idle, ParentTaskId = "p1", CreatedBy = "p1", CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" })); + var broadcaster = new HubBroadcaster(new FakeHubContext()); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); + var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, + NullLogger.Instance, state); + + using (var ctx = _db.CreateContext()) + { + var task = await new TaskRepository(ctx).GetByIdAsync("p1"); + await runner.RunAsync(task!, "slot-1", default); + } + + using var verify = _db.CreateContext(); + var repo = new TaskRepository(verify); + Assert.Equal(TaskStatus.WaitingForChildren, (await repo.GetByIdAsync("p1"))!.Status); + Assert.Equal(TaskStatus.Queued, (await repo.GetByIdAsync("kid"))!.Status); + } + + [Fact] + public async Task StandaloneSuccess_noChildren_goesWaitingForReview() + { + var dbFactory = _db.CreateFactory(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = "solo", ListId = "l1", Title = "Solo", + Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" })); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); + var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default); + + using var verify = _db.CreateContext(); + Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(verify).GetByIdAsync("solo"))!.Status); + } +} +``` + +> `FakeClaudeProcess` is declared `internal` in `Services/QueueServiceTests.cs`. If it isn't accessible, move it to `tests/ClaudeDo.Worker.Tests/Infrastructure/FakeClaudeProcess.cs` (same `internal` modifier, namespace `ClaudeDo.Worker.Tests.Infrastructure`) and add the `using` — do that as the first step and re-point `QueueServiceTests` to the shared type. + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StandaloneChildrenRoutingTests` +Expected: FAIL — parent goes `WaitingForReview` (routing not implemented), child stays `Idle`. + +- [ ] **Step 3: Implement the routing** + +In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, replace the status-setting block in `HandleSuccess` (lines 337-350, the `if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)` … `else` … block) with: + +```csharp + var finishedAt = DateTime.UtcNow; + var reviewResult = ComposeReviewResult(result.ResultMarkdown, result.Blocks); + bool isStandalone = task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None; + + List pendingChildren = new(); + if (isStandalone) + { + using var ctx = _dbFactory.CreateDbContext(); + var children = await new TaskRepository(ctx).GetChildrenAsync(task.Id, CancellationToken.None); + pendingChildren = children + .Where(c => c.Status is TaskStatus.Idle or TaskStatus.Queued) + .ToList(); + } + + if (isStandalone && pendingChildren.Count > 0) + { + // Suggested improvements exist: hold for children, queue them (they branch off this + // task's worktree HEAD and run under the normal queue). + await _state.SubmitForChildrenAsync(task.Id, finishedAt, reviewResult, CancellationToken.None); + foreach (var child in pendingChildren) + await _state.EnqueueAsync(child.Id, CancellationToken.None); + await _broadcaster.WorkerLog( + $"Finished \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))", + WorkerLogLevel.Success, DateTime.UtcNow); + await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt); + } + else if (isStandalone) + { + await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None); + await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow); + await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt); + } + else + { + await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None); + await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow); + await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); + } +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StandaloneChildrenRoutingTests` +Expected: PASS (both cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs +git commit -m "feat(runner): route standalone success with children to WaitingForChildren + enqueue them" +``` + +--- + +## Phase D — Base improvement-child worktrees on the parent's HEAD + +An improvement child must branch from the parent's worktree HEAD so it sees the code the parent just wrote (the parent's work lives only on `claudedo/{parentId}` until merged). Planning children keep basing off the list HEAD. + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:27-57` (`CreateAsync` base-commit selection) +- Test: `tests/ClaudeDo.Worker.Tests/Runner/ChildWorktreeBaseTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Runner/ChildWorktreeBaseTests.cs`: + +```csharp +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +public sealed class ChildWorktreeBaseTests : IDisposable +{ + private readonly List _repos = new(); + private readonly List _dbs = new(); + private readonly List<(string repoDir, string wtPath)> _cleanups = new(); + private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); + + [Fact] + public async Task ImprovementChild_basesOff_parentWorktreeHead() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); _repos.Add(repo); + var db = new DbFixture(); _dbs.Add(db); + var listId = Guid.NewGuid().ToString(); + var parentId = Guid.NewGuid().ToString(); + var childId = Guid.NewGuid().ToString(); + var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; + + var parent = new TaskEntity { Id = parentId, ListId = listId, Title = "Parent", + CommitType = "chore", PlanningPhase = PlanningPhase.None, CreatedAt = DateTime.UtcNow }; + var child = new TaskEntity { Id = childId, ListId = listId, Title = "Improve", + CommitType = "chore", ParentTaskId = parentId, CreatedBy = parentId, CreatedAt = DateTime.UtcNow }; + + using (var seed = db.CreateContext()) + { + await new ListRepository(seed).AddAsync(list); + await new TaskRepository(seed).AddAsync(parent); + await new TaskRepository(seed).AddAsync(child); + } + + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), db.CreateFactory(), cfg, NullLogger.Instance); + + // Parent worktree gets a commit so its HeadCommit advances past the list HEAD. + var parentCtx = await mgr.CreateAsync(parent, list, CancellationToken.None); + _cleanups.Add((repo.RepoDir, parentCtx.WorktreePath)); + File.WriteAllText(Path.Combine(parentCtx.WorktreePath, "parent.txt"), "parent work"); + await mgr.CommitIfChangedAsync(parentCtx, parent, list, CancellationToken.None); + + string parentHead; + using (var read = db.CreateContext()) + parentHead = (await new WorktreeRepository(read).GetByTaskIdAsync(parentId))!.HeadCommit!; + + // Child worktree must base off the parent's HEAD, not the list HEAD. + var childCtx = await mgr.CreateAsync(child, list, CancellationToken.None); + _cleanups.Add((repo.RepoDir, childCtx.WorktreePath)); + + Assert.Equal(parentHead, childCtx.BaseCommit); + Assert.NotEqual(repo.BaseCommit, childCtx.BaseCommit); + Assert.True(File.Exists(Path.Combine(childCtx.WorktreePath, "parent.txt")), + "child worktree should contain the parent's committed file"); + } + + public void Dispose() + { + foreach (var (repoDir, wtPath) in _cleanups) + try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { } + foreach (var r in _repos) r.Dispose(); + foreach (var d in _dbs) d.Dispose(); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ChildWorktreeBaseTests` +Expected: FAIL — child bases off the list HEAD (`repo.BaseCommit`), so `parent.txt` is absent. + +- [ ] **Step 3: Implement base-commit resolution** + +In `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`, replace line 35: + +```csharp + var baseCommit = await _git.RevParseHeadAsync(workingDir, ct); +``` + +with: + +```csharp + var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct); +``` + +Then add this private method to the class (e.g. after `CreateAsync`): + +```csharp + // Improvement children (parent is a non-planning task with its own worktree) branch + // from the parent's recorded HEAD so they build on the parent's not-yet-merged work. + // Planning children and standalone tasks base off the list's current HEAD. + private async Task ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct) + { + if (task.ParentTaskId is not null) + { + using var ctx = _dbFactory.CreateDbContext(); + var parent = await ctx.Tasks.AsNoTracking() + .FirstOrDefaultAsync(t => t.Id == task.ParentTaskId, ct); + if (parent is not null && parent.PlanningPhase == PlanningPhase.None) + { + var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct); + var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit; + if (parentHead is not null) + return parentHead; + } + } + return await _git.RevParseHeadAsync(workingDir, ct); + } +``` + +(`Microsoft.EntityFrameworkCore` and `ClaudeDo.Data.Models` are already imported in this file.) + +- [ ] **Step 4: Run to verify pass + no regression** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "ChildWorktreeBaseTests|WorktreeManagerTests"` +Expected: PASS — child bases off parent HEAD; existing worktree tests unaffected (they have no `ParentTaskId`). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/ChildWorktreeBaseTests.cs +git commit -m "feat(worktree): base improvement-child worktree on parent HEAD" +``` + +--- + +## Phase E — The `SuggestImprovement` MCP tool + per-run identity + +Mirrors planning's per-session token. The Worker mints a per-run token, registers `token → taskId` in an in-memory singleton (no DB column), and writes a run-scoped `--mcp-config` file OUTSIDE the worktree (so it is never committed). The shared `/mcp` endpoint's auth middleware resolves both planning tokens and task-run tokens; the `SuggestImprovement` tool reads the caller's id from the request context and stamps the child server-side. + +### Task 7: `TaskRunTokenRegistry` + +**Files:** +- Create: `src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/TaskRunTokenRegistryTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Runner/TaskRunTokenRegistryTests.cs`: + +```csharp +using ClaudeDo.Worker.Runner; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Runner; + +public sealed class TaskRunTokenRegistryTests +{ + [Fact] + public void Register_then_resolve_returns_taskId() + { + var reg = new TaskRunTokenRegistry(); + reg.Register("tok", "task-1"); + Assert.True(reg.TryResolve("tok", out var id)); + Assert.Equal("task-1", id); + } + + [Fact] + public void Unregister_removes_token() + { + var reg = new TaskRunTokenRegistry(); + reg.Register("tok", "task-1"); + reg.Unregister("tok"); + Assert.False(reg.TryResolve("tok", out _)); + } + + [Fact] + public void GenerateToken_is_urlsafe_and_unique() + { + var a = TaskRunTokenRegistry.GenerateToken(); + var b = TaskRunTokenRegistry.GenerateToken(); + Assert.NotEqual(a, b); + Assert.DoesNotContain('+', a); + Assert.DoesNotContain('/', a); + Assert.DoesNotContain('=', a); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TaskRunTokenRegistryTests` +Expected: FAIL — `TaskRunTokenRegistry` does not exist. + +- [ ] **Step 3: Implement the registry** + +Create `src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs`: + +```csharp +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace ClaudeDo.Worker.Runner; + +// In-memory per-run MCP identity store. A task run mints a token, registers it here, +// and tears it down when the run ends. Kept out of the DB on purpose: a run that +// outlives a Worker restart is already dead (StaleTaskRecovery flips it to Failed). +public sealed class TaskRunTokenRegistry +{ + private readonly ConcurrentDictionary _tokenToTaskId = new(); + + public void Register(string token, string taskId) => _tokenToTaskId[token] = taskId; + + public bool TryResolve(string token, out string taskId) + { + if (_tokenToTaskId.TryGetValue(token, out var id)) { taskId = id; return true; } + taskId = string.Empty; + return false; + } + + public void Unregister(string token) => _tokenToTaskId.TryRemove(token, out _); + + public static string GenerateToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TaskRunTokenRegistryTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs tests/ClaudeDo.Worker.Tests/Runner/TaskRunTokenRegistryTests.cs +git commit -m "feat(mcp): add per-run TaskRunTokenRegistry" +``` + +--- + +### Task 8: `TaskRunMcpContext` + accessor + +**Files:** +- Create: `src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs` + +(No standalone test — exercised by Task 9.) + +- [ ] **Step 1: Create the context + accessor** + +Create `src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs`: + +```csharp +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.Runner; + +// Per-request caller identity for the task-run MCP surface, populated by the auth +// middleware from the run's token (mirrors PlanningMcpContext). +public sealed class TaskRunMcpContext +{ + public required string CallerTaskId { get; init; } +} + +public sealed class TaskRunMcpContextAccessor +{ + private readonly IHttpContextAccessor _http; + public TaskRunMcpContextAccessor(IHttpContextAccessor http) => _http = http; + + public TaskRunMcpContext Current => + (_http.HttpContext?.Items["TaskRunContext"] as TaskRunMcpContext) + ?? throw new InvalidOperationException("No task-run context on request."); +} +``` + +- [ ] **Step 2: Build to verify it compiles** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs +git commit -m "feat(mcp): add TaskRunMcpContext + accessor" +``` + +--- + +### Task 9: `TaskRunMcpService.SuggestImprovement` + +**Files:** +- Create: `src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs` (new) + +- [ ] **Step 1: Write the failing tests** + +Create `tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs`: + +```csharp +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests; + +public sealed class SuggestImprovementTests : IDisposable +{ + private readonly DbFixture _db = new(); + public void Dispose() => _db.Dispose(); + + private static TaskRunMcpContextAccessor AccessorFor(string callerTaskId) + { + var http = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + http.HttpContext!.Items["TaskRunContext"] = new TaskRunMcpContext { CallerTaskId = callerTaskId }; + return new TaskRunMcpContextAccessor(http); + } + + private async Task SeedCallerAsync(string id, string? parentId) + { + using var ctx = _db.CreateContext(); + if (!ctx.Lists.Any()) + ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = id, ListId = "l1", Title = "Caller", + Status = TaskStatus.Running, ParentTaskId = parentId, CommitType = "feat", + CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task SuggestImprovement_stamps_parent_createdBy_status_and_list() + { + await SeedCallerAsync("caller", parentId: null); + using var ctx = _db.CreateContext(); + var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"), + new HubBroadcaster(new FakeHubContext())); + + var dto = await svc.SuggestImprovement("Refactor X", "details", default); + + var child = await new TaskRepository(ctx).GetByIdAsync(dto.ChildTaskId); + Assert.Equal("caller", child!.ParentTaskId); + Assert.Equal("caller", child.CreatedBy); + Assert.Equal(TaskStatus.Idle, child.Status); + Assert.Equal("l1", child.ListId); + } + + [Fact] + public async Task SuggestImprovement_rejects_when_caller_is_a_child() + { + await SeedCallerAsync("parent", parentId: null); + await SeedCallerAsync("child", parentId: "parent"); + using var ctx = _db.CreateContext(); + var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("child"), + new HubBroadcaster(new FakeHubContext())); + + await Assert.ThrowsAsync( + () => svc.SuggestImprovement("nested", "x", default)); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter SuggestImprovementTests` +Expected: FAIL — `TaskRunMcpService` does not exist. + +- [ ] **Step 3: Implement the tool** + +Create `src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs`: + +```csharp +using System.ComponentModel; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.Runner; + +public sealed record SuggestedImprovementDto(string ChildTaskId); + +[McpServerToolType] +public sealed class TaskRunMcpService +{ + private readonly TaskRepository _tasks; + private readonly TaskRunMcpContextAccessor _ctx; + private readonly HubBroadcaster _broadcaster; + + public TaskRunMcpService(TaskRepository tasks, TaskRunMcpContextAccessor ctx, HubBroadcaster broadcaster) + { + _tasks = tasks; + _ctx = ctx; + _broadcaster = broadcaster; + } + + [McpServerTool, Description( + "File an out-of-scope improvement as a child task of the current task. The child runs " + + "automatically after this task finishes and is surfaced for review alongside it. Use ONLY " + + "for work that is genuinely outside this task's scope (a refactor, follow-up, or tech debt) " + + "— never for work that belongs to the current task.")] + public async Task SuggestImprovement( + string title, + string description, + CancellationToken cancellationToken) + { + var callerId = _ctx.Current.CallerTaskId; + var caller = await _tasks.GetByIdAsync(callerId, cancellationToken) + ?? throw new InvalidOperationException("Calling task not found."); + if (caller.ParentTaskId is not null) + throw new InvalidOperationException( + "A child task cannot suggest further improvements (improvements are one layer deep)."); + + var child = await _tasks.CreateChildAsync( + callerId, title, description, commitType: null, createdBy: callerId, cancellationToken); + await _broadcaster.TaskUpdated(child.Id); + await _broadcaster.TaskUpdated(callerId); + return new SuggestedImprovementDto(child.Id); + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter SuggestImprovementTests` +Expected: PASS (both cases). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs +git commit -m "feat(mcp): add SuggestImprovement tool (server-stamped, one layer deep)" +``` + +--- + +### Task 10: Resolve task-run tokens in the MCP auth middleware + wire DI + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs` +- Modify: `src/ClaudeDo.Worker/Program.cs` (DI + `.WithTools()`) +- Test: `tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs`: + +```csharp +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +public sealed class TaskRunTokenAuthTests : IDisposable +{ + private readonly DbFixture _db = new(); + public void Dispose() => _db.Dispose(); + + [Fact] + public async Task Valid_taskRun_token_populates_TaskRunContext_and_calls_next() + { + var reg = new TaskRunTokenRegistry(); + reg.Register("run-token", "task-1"); + + bool nextCalled = false; + var mw = new PlanningTokenAuthMiddleware(_ => { nextCalled = true; return Task.CompletedTask; }); + + var ctx = new DefaultHttpContext(); + ctx.Request.Path = "/mcp"; + ctx.Request.Headers["Authorization"] = "Bearer run-token"; + + using var db = _db.CreateContext(); + await mw.InvokeAsync(ctx, new TaskRepository(db), reg); + + Assert.True(nextCalled); + var resolved = ctx.Items["TaskRunContext"] as TaskRunMcpContext; + Assert.NotNull(resolved); + Assert.Equal("task-1", resolved!.CallerTaskId); + } + + [Fact] + public async Task Unknown_token_returns_401() + { + var mw = new PlanningTokenAuthMiddleware(_ => Task.CompletedTask); + var ctx = new DefaultHttpContext(); + ctx.Request.Path = "/mcp"; + ctx.Request.Headers["Authorization"] = "Bearer nope"; + + using var db = _db.CreateContext(); + await mw.InvokeAsync(ctx, new TaskRepository(db), new TaskRunTokenRegistry()); + + Assert.Equal(401, ctx.Response.StatusCode); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TaskRunTokenAuthTests` +Expected: FAIL — `InvokeAsync` has no `TaskRunTokenRegistry` parameter (compile error). + +- [ ] **Step 3: Extend the middleware** + +In `src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs`, add `using ClaudeDo.Worker.Runner;` at the top, then replace `InvokeAsync` with: + +```csharp + public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks, TaskRunTokenRegistry runTokens) + { + if (!ctx.Request.Path.StartsWithSegments("/mcp")) + { + await _next(ctx); + return; + } + + var auth = ctx.Request.Headers["Authorization"].ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Missing bearer token"); + return; + } + + var token = auth.Substring("Bearer ".Length).Trim(); + + // Planning session token (long-lived, DB-backed). + var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted); + if (parent is not null && parent.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.Active) + { + ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; + await _next(ctx); + return; + } + + // Per-run task token (in-memory, scoped to a live run). + if (runTokens.TryResolve(token, out var callerTaskId)) + { + ctx.Items["TaskRunContext"] = new TaskRunMcpContext { CallerTaskId = callerTaskId }; + await _next(ctx); + return; + } + + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Invalid or expired token"); + } +``` + +- [ ] **Step 4: Wire DI in `Program.cs`** + +In `src/ClaudeDo.Worker/Program.cs`, add the registry singleton near the runner stack (after line 59, `AddSingleton()`): + +```csharp +builder.Services.AddSingleton(); +``` + +In the planning-session DI block (after line 133, `AddScoped()`), add: + +```csharp +builder.Services.AddScoped(); +builder.Services.AddScoped(); +``` + +And extend the MCP tool registration (lines 139-141): + +```csharp +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools() + .WithTools(); +``` + +(Add `using ClaudeDo.Worker.Runner;` to `Program.cs` if not already present — it is, via the runner stack.) + +- [ ] **Step 5: Run to verify pass + build Worker** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "TaskRunTokenAuthTests|PlanningHubTests|PlanningMcpServiceTests"` +Expected: PASS — task-run tokens resolve; planning auth still works. +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs src/ClaudeDo.Worker/Program.cs tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs +git commit -m "feat(mcp): resolve per-run tokens in MCP auth + register TaskRunMcpService" +``` + +--- + +### Task 11: TaskRunner mints the per-run token + emits `--mcp-config` + +Provision the MCP only for improvement-eligible runs (`ParentTaskId == null && PlanningPhase == None`). Write the config OUTSIDE the worktree (under `LogRoot`) so the run never commits it. Tear the token + file down in a `finally`. + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` (add `McpConfigPath`, `AllowedTools`) +- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (ctor + `RunAsync`) +- Modify: all test sites constructing `new TaskRunner(...)` — `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs`, `Services/QueueServiceSlotGuardTests.cs`, `External/ExternalMcpServiceTests.cs`, and `Runner/StandaloneChildrenRoutingTests.cs` (Task 6). +- Test: `tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs` (add case) + +- [ ] **Step 1: Write the failing args test** + +Add to `tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs` (match the file's existing construction style): + +```csharp +[Fact] +public void Build_emits_mcpConfig_and_allowedTools_when_set() +{ + var args = new ClaudeArgsBuilder().Build(new ClaudeRunConfig( + Model: null, SystemPrompt: null, AgentPath: null, ResumeSessionId: null, + McpConfigPath: "C:\\tmp\\t_mcp.json", + AllowedTools: "mcp__claudedo_run__SuggestImprovement")); + + Assert.Contains("--mcp-config", args); + Assert.Contains("t_mcp.json", args); + Assert.Contains("--allowedTools mcp__claudedo_run__SuggestImprovement", args); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter Build_emits_mcpConfig_and_allowedTools_when_set` +Expected: FAIL — `ClaudeRunConfig` has no `McpConfigPath`/`AllowedTools` (compile error). + +- [ ] **Step 3: Extend `ClaudeRunConfig` + `Build`** + +In `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs`, extend the record: + +```csharp +public sealed record ClaudeRunConfig( + string? Model, + string? SystemPrompt, + string? AgentPath, + string? ResumeSessionId, + int? MaxTurns = null, + string? PermissionMode = null, + string? McpConfigPath = null, + string? AllowedTools = null +); +``` + +In `Build`, before the `--resume` block (after line 58, the `--json-schema` add): + +```csharp + if (config.McpConfigPath is not null) + args.Add($"--mcp-config {Escape(config.McpConfigPath)}"); + + if (config.AllowedTools is not null) + args.Add($"--allowedTools {config.AllowedTools}"); +``` + +- [ ] **Step 4: Run the args test to verify pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter Build_emits_mcpConfig_and_allowedTools_when_set` +Expected: PASS. + +- [ ] **Step 5: Add the registry to `TaskRunner` + provision in `RunAsync`** + +In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`: + +(a) Add `using System.Text.Json;` to the usings. + +(b) Add a field + ctor parameter: + +```csharp + private readonly TaskRunTokenRegistry _tokens; +``` + +In the constructor signature add `TaskRunTokenRegistry tokens,` (after `ITaskStateService state`) and assign `_tokens = tokens;`. + +(c) In `RunAsync`, change the body so the provisioning is visible to a `finally`. Declare the token/path BEFORE the existing `try` (replace the `try` opening at line 45): + +```csharp + string? mcpToken = null; + string? mcpConfigPath = null; + try + { +``` + +Then, right after `var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct);` (line 76), insert: + +```csharp + // Improvement-eligible runs get a per-run MCP identity so the agent can file + // out-of-scope follow-ups via SuggestImprovement. Children and planning runs do not. + if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None) + { + mcpToken = TaskRunTokenRegistry.GenerateToken(); + _tokens.Register(mcpToken, task.Id); + mcpConfigPath = Path.Combine(_cfg.LogRoot, $"{task.Id}_mcp.json"); + await File.WriteAllTextAsync(mcpConfigPath, BuildRunMcpConfigJson(mcpToken), ct); + resolvedConfig = resolvedConfig with + { + McpConfigPath = mcpConfigPath, + AllowedTools = "mcp__claudedo_run__SuggestImprovement", + }; + } +``` + +(d) Add a `finally` to the existing try/catch (after the `catch (Exception ex)` block that ends at line 137): + +```csharp + finally + { + if (mcpToken is not null) + { + _tokens.Unregister(mcpToken); + if (mcpConfigPath is not null) + try { File.Delete(mcpConfigPath); } catch { /* best effort */ } + } + } +``` + +(e) Add the config-builder helper method to the class: + +```csharp + private string BuildRunMcpConfigJson(string token) + { + var payload = new + { + mcpServers = new + { + claudedo_run = new + { + type = "http", + url = $"http://127.0.0.1:{_cfg.SignalRPort}/mcp", + headers = new Dictionary + { + ["Authorization"] = $"Bearer {token}", + }, + }, + }, + }; + return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + } +``` + +- [ ] **Step 6: Update all `new TaskRunner(...)` call sites** + +Search the test projects for `new TaskRunner(` and add `new TaskRunTokenRegistry()` as the final argument (after the `state` argument). Sites: `QueueServiceTests.cs:58`, `QueueServiceSlotGuardTests.cs`, `ExternalMcpServiceTests.cs`, and `StandaloneChildrenRoutingTests.cs`. Example for `QueueServiceTests`: + +```csharp +var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, + NullLogger.Instance, state, new TaskRunTokenRegistry()); +``` + +(`Program.cs` resolves `TaskRunner` from DI — the singleton `TaskRunTokenRegistry` registered in Task 10 is injected automatically; no change needed there.) + +- [ ] **Step 7: Run to verify pass + build** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release` +Expected: Build succeeded. +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "ClaudeArgsBuilderTests|QueueServiceTests|StandaloneChildrenRoutingTests"` +Expected: PASS — all TaskRunner constructions compile and run. + +- [ ] **Step 8: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs +git commit -m "feat(runner): mint per-run MCP token + emit run-scoped --mcp-config" +``` + +--- + +## Phase F — Generalize the merge into a tree-merge that folds the parent's branch + +`TaskMergeService.MergeAsync(taskId, …)` already merges any task's `claudedo/{id}` branch onto a target. So generalizing `PlanningMergeOrchestrator` is mostly: when the parent is a non-planning (improvement) parent that has its own Active worktree, prepend the parent's id to the merge queue (so its commits land first and the children — which descend from the parent's HEAD — merge cleanly on top). Planning parents are unchanged (no worktree → not prepended; strict child validation preserved). The conflict pause/continue/abort machinery is reused as-is. The existing hub methods (`MergeAllPlanning` / `ContinuePlanningMerge` / `AbortPlanningMerge`) and `PlanningMerge*` broadcast events serve both parent kinds — they are NOT renamed (the UI listens on those event names). + +### Task 12: Fold the parent branch into the merge queue + lenient improvement-child validation + +**Files:** +- Modify: `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Planning; + +file sealed class TreeMergeHubClients : IHubClients +{ + public TreeMergeClientProxy Proxy { get; } = new(); + public IClientProxy All => Proxy; + public IClientProxy AllExcept(IReadOnlyList e) => Proxy; + public IClientProxy Client(string c) => Proxy; + public IClientProxy Clients(IReadOnlyList c) => Proxy; + public IClientProxy Group(string g) => Proxy; + public IClientProxy GroupExcept(string g, IReadOnlyList e) => Proxy; + public IClientProxy Groups(IReadOnlyList g) => Proxy; + public IClientProxy User(string u) => Proxy; + public IClientProxy Users(IReadOnlyList u) => Proxy; +} +file sealed class TreeMergeClientProxy : IClientProxy +{ + public List<(string Method, object?[] Args)> Calls { get; } = new(); + public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) { Calls.Add((m, a)); return Task.CompletedTask; } +} +file sealed class TreeMergeHubContext : IHubContext +{ + public TreeMergeHubClients RecordingClients { get; } = new(); + public IHubClients Clients => RecordingClients; + public IGroupManager Groups => throw new NotImplementedException(); +} + +public sealed class TreeMergeTests : IDisposable +{ + private readonly List _dbs = new(); + private readonly List _repos = new(); + private readonly List<(string repo, string wt)> _cleanups = 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 _cleanups) 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 ImprovementParent_foldsOwnBranch_thenChild_andMarksDone() + { + if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available"); return; } + + var db = NewDb(); + var repo = NewRepo(); + GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main"); + + var listId = Guid.NewGuid().ToString(); + var parentId = Guid.NewGuid().ToString(); + var childId = Guid.NewGuid().ToString(); + + using (var ctx = db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "Parent", + Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.None, SortOrder = 0, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "Child", + Status = TaskStatus.Done, ParentTaskId = parentId, SortOrder = 1, CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + + // Parent worktree: commit parent.txt off main. + var parentWt = SeedWorktree(repo, parentId, repo.BaseCommit, "parent.txt", "parent work"); + // Child worktree: branch off the PARENT's head, add child.txt. + var childWt = SeedWorktree(repo, childId, parentWt.head, "child.txt", "child work"); + + ctx.Worktrees.Add(MakeRow(parentId, parentWt)); + ctx.Worktrees.Add(MakeRow(childId, childWt)); + await ctx.SaveChangesAsync(); + } + + var (orch, calls) = BuildOrchestrator(db); + await orch.StartAsync(parentId, "main", CancellationToken.None); + + using var verify = db.CreateContext(); + Assert.Equal(TaskStatus.Done, verify.Tasks.Single(t => t.Id == parentId).Status); + Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == parentId).State); + Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State); + Assert.Contains(calls, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == parentId); + Assert.Contains(calls, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == childId); + Assert.Contains(calls, c => c.Method == "PlanningCompleted"); + + // main now contains both files. + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "parent.txt"))); + Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt"))); + } + + private (string path, string branch, string head) SeedWorktree( + GitRepoFixture repo, string taskId, string baseCommit, string file, string content) + { + var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}"); + _cleanups.Add((repo.RepoDir, wtPath)); + var branch = $"claudedo/{taskId.Replace("-", "")}"; + GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit); + File.WriteAllText(Path.Combine(wtPath, file), content); + GitRepoFixture.RunGit(wtPath, "add", file); + GitRepoFixture.RunGit(wtPath, "commit", "-m", $"add {file}"); + var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(); + return (wtPath, branch, head); + } + + private static WorktreeEntity MakeRow(string taskId, (string path, string branch, string head) wt) + => new() { TaskId = taskId, Path = wt.path, BranchName = wt.branch, BaseCommit = "x", + HeadCommit = wt.head, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow }; + + private (PlanningMergeOrchestrator orch, List<(string Method, object?[] Args)> calls) BuildOrchestrator(DbFixture db) + { + var fakeHub = new TreeMergeHubContext(); + var broadcaster = new HubBroadcaster(fakeHub); + var git = new GitService(); + var factory = db.CreateFactory(); + var merge = new TaskMergeService(factory, git, broadcaster, NullLogger.Instance); + var aggregator = new PlanningAggregator(factory, git, NullLogger.Instance); + var orch = new PlanningMergeOrchestrator(factory, merge, aggregator, broadcaster, git, + NullLogger.Instance); + return (orch, fakeHub.RecordingClients.Proxy.Calls); + } +} +``` + +> The `BaseCommit = "x"` placeholder is fine — `TaskMergeService` merges by `BranchName`, not `BaseCommit`. + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TreeMergeTests` +Expected: FAIL — the parent's own branch is never merged (`parent.txt` is merged only transitively if at all; parent worktree stays `Active`, and no `PlanningSubtaskMerged` is emitted for `parentId`). + +- [ ] **Step 3: Generalize the orchestrator** + +In `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`: + +(a) Add `IsPlanning` to the `State` record (after `CurrentSubtaskId`): + +```csharp + private sealed class State + { + public required string TargetBranch { get; init; } + public required Queue RemainingSubtaskIds { get; init; } + public required bool IsPlanning { get; init; } + public string? CurrentSubtaskId { get; set; } + } +``` + +(b) Replace the whole `StartAsync` method (lines 46-90) with: + +```csharp + public async Task StartAsync(string parentTaskId, string targetBranch, CancellationToken ct) + { + string workingDir; + bool isPlanning; + bool parentHasWorktree; + List children; + + using (var ctx = _dbFactory.CreateDbContext()) + { + var parent = await ctx.Tasks + .Include(t => t.List) + .Include(t => t.Worktree) + .Include(t => t.Children).ThenInclude(c => c.Worktree) + .SingleOrDefaultAsync(t => t.Id == parentTaskId, ct) + ?? throw new KeyNotFoundException($"Parent task '{parentTaskId}' not found."); + workingDir = parent.List.WorkingDir + ?? throw new InvalidOperationException("List has no working directory."); + isPlanning = parent.PlanningPhase != PlanningPhase.None; + parentHasWorktree = parent.Worktree is { State: WorktreeState.Active }; + children = parent.Children.OrderBy(c => c.SortOrder).ToList(); + } + + // Planning chains require every child Done with a usable worktree (unchanged). + // Improvement parents are lenient: failed/cancelled children are simply skipped. + if (isPlanning) + { + 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}"); + } + } + + 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 idsToMerge = new List(); + // Improvement parents carry their own code branch — fold it in first so the + // children (which descend from the parent HEAD) merge cleanly on top. + if (!isPlanning && parentHasWorktree) + idsToMerge.Add(parentTaskId); + idsToMerge.AddRange(children + .Where(c => c.Status == TaskStatus.Done && c.Worktree is { State: WorktreeState.Active }) + .Select(c => c.Id)); + + var queue = new Queue(idsToMerge); + + var state = new State + { + TargetBranch = targetBranch, + RemainingSubtaskIds = queue, + IsPlanning = isPlanning, + }; + if (!_states.TryAdd(parentTaskId, state)) + throw new InvalidOperationException($"Merge already in progress for {parentTaskId}."); + + await _broadcaster.PlanningMergeStarted(parentTaskId, targetBranch); + await DrainAsync(parentTaskId, ct); + } +``` + +(c) In `DrainAsync`, replace the success-path call `await FinalizePlanningDoneAsync(planningTaskId, ct);` (line 170) with: + +```csharp + await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct); +``` + +(d) Replace `FinalizePlanningDoneAsync` (lines 179-190) with: + +```csharp + private async Task FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct) + { + using var ctx = _dbFactory.CreateDbContext(); + var parent = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct); + if (parent is null) return; + parent.Status = TaskStatus.Done; + parent.FinishedAt = DateTime.UtcNow; + await ctx.SaveChangesAsync(ct); + + // Only planning builds an integration branch via the aggregator; skip cleanup otherwise. + if (isPlanning) + { + try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); } + catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); } + } + } +``` + +- [ ] **Step 4: Run to verify pass + planning regression** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "TreeMergeTests|PlanningMergeOrchestratorTests"` +Expected: PASS — improvement parent folds its own branch + child and marks Done; ALL existing planning merge tests still pass (planning parents have no worktree, so behavior is byte-identical). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs +git commit -m "feat(merge): fold parent branch into tree-merge for improvement parents" +``` + +--- + +## Phase F2 — Prompt: teach the agent to offload + +### Task 13: Add the "Out-of-scope improvements" section to `SystemDefault` + +**Files:** +- Modify: `src/ClaudeDo.Data/PromptFiles.cs` (`SystemDefault` const, after the `## Scope` block) +- Test: `tests/ClaudeDo.Data.Tests/SystemPromptTests.cs` (new) + +- [ ] **Step 1: Write the failing test** + +Create `tests/ClaudeDo.Data.Tests/SystemPromptTests.cs`: + +```csharp +using ClaudeDo.Data; +using Xunit; + +namespace ClaudeDo.Data.Tests; + +public class SystemPromptTests +{ + [Fact] + public void SystemDefault_mentions_SuggestImprovement_offload() + { + var prompt = PromptFiles.DefaultFor(PromptKind.System); + Assert.Contains("Out-of-scope improvements", prompt); + Assert.Contains("SuggestImprovement", prompt); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter SystemPromptTests` +Expected: FAIL — the marker is absent. + +- [ ] **Step 3: Add the section** + +In `src/ClaudeDo.Data/PromptFiles.cs`, inside the `SystemDefault` raw string literal, insert this block immediately AFTER the `## Scope` bullet list (after the line `hypothetical future needs.` and before `## Working in the repo`): + +``` +## Out-of-scope improvements +If you notice worthwhile work that is genuinely outside this task's scope +(a refactor, a follow-up, tech debt), do NOT do it here. File it with +SuggestImprovement(title, description) and stay focused on the task at hand. + +``` + +(Keep the surrounding blank lines so the markdown sections stay separated. Preserve the raw-string indentation used by the `"""` literal.) + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter SystemPromptTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/SystemPromptTests.cs +git commit -m "feat(prompt): instruct agents to offload out-of-scope work via SuggestImprovement" +``` + +--- + +## Phase G — UI (BLOCKED on `claudedo-ux`; rebase first) + +> **Do not start Phase G until `claudedo-ux` confirms its commits are on main.** Then: commit/stash Phases A–F2, `git rebase main` (or `git merge main`) this worktree, resolve, re-run the full build + test suite, and only then implement below — building on the detail-panel review structure UX added (review actions live in `DetailsIslandViewModel`/`DetailsIslandView`, the `⚠` badge lives in `TaskRowView`, and `RoadblockCount` + its migration already exist). Coordinate via mailbox before editing the shared files. Re-read each file at the start of each task — line numbers below are pre-rebase estimates and will have shifted. + +### Task 14: `WaitingForChildren` status chip + color + +**Files:** +- Modify: `src/ClaudeDo.Ui/Converters/StatusColorConverter.cs` (add a `WaitingForChildren` arm — amber) +- Modify: the status-label/localization source for status display text — add `WaitingForChildren` to `locales/en.json` AND `locales/de.json` in parity (Localization.Tests enforces parity). +- Modify: wherever status chips are rendered (the row/detail status badge) to map `WaitingForChildren`. +- Test: rely on `Localization.Tests` for key parity; build the UI project. + +- [ ] **Step 1: Locate the converter + status display** + +Run: `dotnet build src/ClaudeDo.Ui` is not standalone — read `src/ClaudeDo.Ui/Converters/StatusColorConverter.cs` and grep for where `WaitingForReview` is handled in converters/labels: +`grep -rn "WaitingForReview" src/ClaudeDo.Ui` +Mirror every site for `WaitingForChildren`. + +- [ ] **Step 2: Add the color arm** + +In `StatusColorConverter`, add a `WaitingForChildren` arm returning an amber brush (distinct from `WaitingForReview`). Snap to an existing color token if the project tokenizes brushes — do not hardcode a new hex if a token scale exists (see memory: "UI work — single source of truth"). + +- [ ] **Step 3: Add localization keys (parity)** + +Add the `WaitingForChildren` status label key to BOTH `locales/en.json` (e.g. `"Waiting on improvements"`) and `locales/de.json` (e.g. `"Wartet auf Verbesserungen"`). Keys must match exactly (Localization.Tests fails on drift). + +- [ ] **Step 4: Build + localization test** + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release` +Expected: Build + localization parity pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/Converters/StatusColorConverter.cs locales/en.json locales/de.json +git commit -m "feat(ui): WaitingForChildren status chip + color" +``` + +### Task 15: Child tree grouping with agent-suggested marker + +**Files:** +- Modify: the task-list view/viewmodel that groups children under parents by `ParentTaskId`. +- Modify: `TaskRowView.axaml` (rebased) to mark improvement children (`CreatedBy == ParentTaskId`) distinctly from planning children. + +- [ ] **Step 1: Find the existing parent/child grouping** + +Planning already groups children under a parent. Read the list/tree viewmodel that builds that grouping (`grep -rn "ParentTaskId" src/ClaudeDo.Ui`). Improvement children attach to a non-planning parent — confirm the grouping keys on `ParentTaskId` (not on `PlanningPhase`) so improvement children also nest. If it gates on planning, generalize it to any parent. + +- [ ] **Step 2: Add the agent-suggested marker** + +Expose `IsAgentSuggested = (CreatedBy != null && CreatedBy == ParentTaskId)` on the row viewmodel and show a small "agent" glyph/badge on those rows. If using `PathIcon`, author the glyph as FILLED geometry (memory: PathIcon fills geometry — stroke-only renders invisible). + +- [ ] **Step 3: Build + visually verify** + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Then run the app, create a task, let it suggest an improvement (or seed a child with `CreatedBy == ParentTaskId`), and confirm the child nests under the parent with the agent marker. +**Visual-verification gap:** flag to the user that the grouping/marker needs a human visual pass (cannot be asserted in tests). + +- [ ] **Step 4: Commit** + +```bash +git add +git commit -m "feat(ui): nest improvement children under parent with agent-suggested marker" +``` + +### Task 16: Parent review card — child outcomes, rolled-up roadblocks, tree-merge + +**Files:** +- Modify: `DetailsIslandViewModel.cs` (rebased — review actions already hosted here) + `DetailsIslandView`. +- Reuse hub methods: `MergeAllPlanning(parentId, targetBranch)`, `ContinuePlanningMerge`, `AbortPlanningMerge`, and the `PlanningMerge*` client events (already wired in `WorkerClient`/`IslandsShellViewModel`/`ConflictResolutionViewModel`). + +- [ ] **Step 1: Show child outcomes + rolled-up roadblocks** + +When the selected task is a parent in `WaitingForReview` with children, the detail review card lists each child's outcome (Done/Failed/Cancelled) and surfaces each child's `CLAUDEDO_BLOCKED` problems. Child roadblocks live in the child's `Result` (the runner appends them via `ComposeReviewResult`) and/or the rebased `RoadblockCount` column UX added — read the children and render their roadblock reasons under the parent. Load children via the existing data path (the SignalR client already fetches tasks; filter by `ParentTaskId == parent.Id`). + +- [ ] **Step 2: Drive the tree-merge from "approve"** + +For a parent that has children (improvement parent), the review "approve" action should trigger the guided tree-merge instead of a plain `ApproveReview`: call `MergeAllPlanning(parent.Id, targetBranch)` and reuse the existing planning conflict UI (`ConflictResolutionViewModel` + `ContinuePlanningMerge`/`AbortPlanningMerge`). For a parent with NO children, keep the existing plain approve→Done path. Pick the target branch the same way the planning merge UI does (read `GetMergeTargets`/the planning target selection). + +> If `DetailsIslandViewModel`'s constructor changes, update the hand-rolled fakes in BOTH test projects (memory + CLAUDE.md: changing `IWorkerClient`/`WorkerHub`/`DetailsIslandViewModel` ctors breaks fakes). + +- [ ] **Step 3: Build + visually verify** + +Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` +Run the app end-to-end: task → suggests improvement → both run → parent shows `WaitingForChildren` then `WaitingForReview` with child outcomes → approve → tree-merge folds parent + child to the target (exercise a conflict to confirm pause/continue/abort). +**Visual-verification gap:** flag the full review-card + merge flow for a human visual pass. + +- [ ] **Step 4: Commit** + +```bash +git add +git commit -m "feat(ui): parent review card with child outcomes, rolled-up roadblocks, tree-merge" +``` + +--- + +## Final verification + +- [ ] Build everything: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release` and `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`. +- [ ] Run the full suites: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`, `tests/ClaudeDo.Data.Tests`, `tests/ClaudeDo.Localization.Tests`. +- [ ] Manual smoke (no real-`claude` tests — memory): start the Worker + UI, create a task whose run calls `SuggestImprovement`, confirm parent → `WaitingForChildren` → children auto-run off the parent's HEAD → parent → `WaitingForReview`, approve → tree-merge folds parent + children. +- [ ] Coordinate the worktree merge-to-main with `claudedo-ux` before integrating (mailbox `claudedo-ux`). +