# 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`).