# Planning Sessions — Plan A: Foundation 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:** Add the database, enum, repository, and auto-status infrastructure that enables planning sessions — no UI, no terminal, no MCP. Everything on top of this plan depends on the schema and repo methods landing cleanly. **Architecture:** Extend `TaskEntity` with four new columns (`ParentTaskId`, `PlanningSessionId`, `PlanningSessionToken`, `PlanningFinalizedAt`), add three enum values (`Planning`, `Planned`, `Draft`), configure a self-referential FK with `DeleteBehavior.Restrict`, generate an EF migration, add seven new repository methods for the planning lifecycle, and wire a parent-auto-completion hook into the TaskRunner so that when a child reaches a terminal state the parent's status updates accordingly. **Tech Stack:** .NET 8, EF Core (SQLite provider), xUnit. Uses existing `DbFixture` (real SQLite per test) and `TaskRepository` sealed-class pattern. **Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` sections 2, 3, 7.1, 7.2, 8.1. --- ## File Structure **Modified:** - `src/ClaudeDo.Data/Models/TaskEntity.cs` — add four columns + navigation properties, extend `TaskStatus` enum. - `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — map new columns, configure self-ref FK, extend status converter. - `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — add seven planning methods. - `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — call `TryCompleteParentAsync` after `MarkDoneAsync`/`MarkFailedAsync`. **Created:** - `src/ClaudeDo.Data/Migrations/_AddPlanningSupport.cs` — EF migration. - `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` — new test class for planning methods. - `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs` — tests for auto-status hook. **No interface extraction:** existing code uses `TaskRepository` sealed class directly (no `ITaskRepository`); we follow that pattern. **Marker for parallel plans B and C:** the file `src/ClaudeDo.Data/Migrations/_AddPlanningSupport.cs` existing on `main` is the signal that Plan A is merged. Plans B and C should poll for it before starting. --- ## Task 1: Extend `TaskStatus` enum **Files:** - Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` - Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` - [ ] **Step 1: Add enum values** In `src/ClaudeDo.Data/Models/TaskEntity.cs`, extend the enum: ```csharp public enum TaskStatus { Manual, Queued, Running, Done, Failed, Planning, Planned, Draft, } ``` - [ ] **Step 2: Extend status converter** In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, extend both converter methods to handle the three new values: ```csharp private static string StatusToString(TaskStatus v) => v == TaskStatus.Manual ? "manual" : v == TaskStatus.Queued ? "queued" : v == TaskStatus.Running ? "running" : v == TaskStatus.Done ? "done" : v == TaskStatus.Failed ? "failed" : v == TaskStatus.Planning ? "planning" : v == TaskStatus.Planned ? "planned" : v == TaskStatus.Draft ? "draft" : throw new ArgumentOutOfRangeException(nameof(v)); private static TaskStatus StatusFromString(string v) => v == "manual" ? TaskStatus.Manual : v == "queued" ? TaskStatus.Queued : v == "running" ? TaskStatus.Running : v == "done" ? TaskStatus.Done : v == "failed" ? TaskStatus.Failed : v == "planning" ? TaskStatus.Planning : v == "planned" ? TaskStatus.Planned : v == "draft" ? TaskStatus.Draft : throw new ArgumentOutOfRangeException(nameof(v)); ``` - [ ] **Step 3: Build** Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs git commit -m "feat(data): add Planning, Planned, Draft task statuses" ``` --- ## Task 2: Extend `TaskEntity` with planning columns and navigations **Files:** - Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` - [ ] **Step 1: Add four properties + navigations** In `TaskEntity` class, add (placement: after `SortOrder`, before navigation section): ```csharp public int SortOrder { get; set; } public string? ParentTaskId { get; set; } public string? PlanningSessionId { get; set; } public string? PlanningSessionToken { get; set; } public DateTime? PlanningFinalizedAt { get; set; } // Navigation properties public ListEntity List { get; set; } = null!; public WorktreeEntity? Worktree { get; set; } public ICollection Tags { get; set; } = new List(); public ICollection Runs { get; set; } = new List(); public ICollection Subtasks { get; set; } = new List(); public TaskEntity? Parent { get; set; } public ICollection Children { get; set; } = new List(); ``` - [ ] **Step 2: Build** Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` Expected: build succeeds. - [ ] **Step 3: Commit** ```bash git add src/ClaudeDo.Data/Models/TaskEntity.cs git commit -m "feat(data): add planning columns and self-ref navigations to TaskEntity" ``` --- ## Task 3: Configure self-ref FK and new columns in `TaskEntityConfiguration` **Files:** - Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` - [ ] **Step 1: Map the four new columns + configure self-ref FK** After the existing `SortOrder` property mapping (currently ends with `.HasDefaultValue(0);`), insert: ```csharp builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id"); builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id"); builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token"); builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at"); builder.HasOne(t => t.Parent) .WithMany(t => t.Children) .HasForeignKey(t => t.ParentTaskId) .OnDelete(DeleteBehavior.Restrict); builder.HasOne(t => t.List) ``` (The existing `builder.HasOne(t => t.List)` block immediately follows; do not duplicate it — just insert the new block above it.) - [ ] **Step 2: Add index on `ParentTaskId`** At the end of `Configure`, after the existing `HasIndex` calls: ```csharp builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); ``` - [ ] **Step 3: Build** Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs git commit -m "feat(data): configure planning columns and self-ref FK with Restrict" ``` --- ## Task 4: Generate EF migration **Files:** - Create: `src/ClaudeDo.Data/Migrations/_AddPlanningSupport.cs` (auto-generated, then reviewed) - [ ] **Step 1: Scaffold the migration** Run from project root: ```bash dotnet ef migrations add AddPlanningSupport \ --project src/ClaudeDo.Data \ --startup-project src/ClaudeDo.Worker ``` Expected: three new files under `src/ClaudeDo.Data/Migrations/` (the migration, its designer file, and an updated model snapshot). - [ ] **Step 2: Review the generated migration** Open the new `_AddPlanningSupport.cs` file. Verify the `Up` method contains: - Four `AddColumn` / `AddColumn` calls for `parent_task_id` (nullable string), `planning_session_id` (nullable string), `planning_session_token` (nullable string), `planning_finalized_at` (nullable DateTime). - A `CreateIndex` for `idx_tasks_parent_task_id` on `parent_task_id`. - An `AddForeignKey` call with `onDelete: ReferentialAction.Restrict` linking `parent_task_id` → `tasks.id`. No data migration needed (all new columns nullable). - [ ] **Step 3: Apply to a scratch DB to verify** Run: ```bash dotnet ef database update \ --project src/ClaudeDo.Data \ --startup-project src/ClaudeDo.Worker \ --connection "Data Source=./scratch_migration_test.db" ``` Expected: no errors. Then delete the scratch DB file: ```bash rm -f ./scratch_migration_test.db ./scratch_migration_test.db-wal ./scratch_migration_test.db-shm ``` - [ ] **Step 4: Commit** ```bash git add src/ClaudeDo.Data/Migrations/ git commit -m "feat(data): migration AddPlanningSupport" ``` --- ## Task 5: Repository method `GetChildrenAsync` **Files:** - Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` (create) - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Create the test class skeleton** Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`: ```csharp using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Repositories; public sealed class TaskRepositoryPlanningTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly TagRepository _tags; public TaskRepositoryPlanningTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _tags = new TagRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task CreateListAsync(string? id = null) { var listId = id ?? Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "Test List", CreatedAt = DateTime.UtcNow, }); return listId; } private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Manual, string? parentId = null) => new() { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = status, CreatedAt = DateTime.UtcNow, CommitType = "feat", ParentTaskId = parentId, }; } ``` - [ ] **Step 2: Write the failing test for `GetChildrenAsync`** Add inside the class: ```csharp [Fact] public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted() { var listId = await CreateListAsync(); var parent = MakeTask(listId, TaskStatus.Planning); parent.Title = "parent"; await _tasks.AddAsync(parent); var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); childA.Title = "a"; childA.SortOrder = 1; await _tasks.AddAsync(childA); var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id); childB.Title = "b"; childB.SortOrder = 0; await _tasks.AddAsync(childB); var unrelated = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(unrelated); var children = await _tasks.GetChildrenAsync(parent.Id); Assert.Equal(2, children.Count); Assert.Equal("b", children[0].Title); Assert.Equal("a", children[1].Title); } ``` - [ ] **Step 3: Run the test; verify it fails** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetChildrenAsync"` Expected: FAIL — `TaskRepository` has no method `GetChildrenAsync`. - [ ] **Step 4: Implement the method** In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, add a new region at the end of the class (before the final `}`): ```csharp #region Planning public async Task> GetChildrenAsync(string parentId, CancellationToken ct = default) { return await _context.Tasks .AsNoTracking() .Where(t => t.ParentTaskId == parentId) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); } #endregion ``` - [ ] **Step 5: Run the test; verify it passes** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetChildrenAsync"` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.GetChildrenAsync" ``` --- ## Task 6: Repository method `CreateChildAsync` **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Write the failing test** Append to the planning test class: ```csharp [Fact] public async Task CreateChildAsync_CreatesDraftUnderParent() { var listId = await CreateListAsync(); var parent = MakeTask(listId, TaskStatus.Planning); await _tasks.AddAsync(parent); var child = await _tasks.CreateChildAsync( parent.Id, title: "child title", description: "child desc", tagNames: new[] { "agent" }, commitType: "feat"); Assert.Equal(TaskStatus.Draft, child.Status); Assert.Equal(parent.Id, child.ParentTaskId); Assert.Equal(listId, child.ListId); Assert.Equal("child title", child.Title); Assert.Equal("child desc", child.Description); Assert.Equal("feat", child.CommitType); var loaded = await _tasks.GetByIdAsync(child.Id); Assert.NotNull(loaded); Assert.Equal(TaskStatus.Draft, loaded!.Status); var tags = await _tasks.GetTagsAsync(child.Id); Assert.Contains(tags, t => t.Name == "agent"); } [Fact] public async Task CreateChildAsync_ThrowsIfParentNotFound() { var listId = await CreateListAsync(); _ = listId; // just to create the DB await Assert.ThrowsAsync(() => _tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null)); } ``` - [ ] **Step 2: Run; verify fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.CreateChildAsync"` Expected: FAIL (method not defined). - [ ] **Step 3: Implement** In the `#region Planning` section of `TaskRepository.cs`, add: ```csharp public async Task CreateChildAsync( string parentId, string title, string? description, IReadOnlyList? tagNames, string? commitType, CancellationToken ct = default) { var parent = await _context.Tasks.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.Draft, CreatedAt = DateTime.UtcNow, CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, ParentTaskId = parentId, SortOrder = (maxSort ?? -1) + 1, }; _context.Tasks.Add(child); if (tagNames is not null && tagNames.Count > 0) { foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase)) { var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct); if (tag is null) { tag = new TagEntity { Name = tagName }; _context.Tags.Add(tag); await _context.SaveChangesAsync(ct); } child.Tags.Add(tag); } } await _context.SaveChangesAsync(ct); return child; } ``` - [ ] **Step 4: Run; verify pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.CreateChildAsync"` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.CreateChildAsync" ``` --- ## Task 7: Repository method `SetPlanningStartedAsync` **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Write the failing test** Append: ```csharp [Fact] public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning() { var listId = await CreateListAsync(); var task = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(task); var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc"); Assert.NotNull(result); Assert.Equal(TaskStatus.Planning, result!.Status); Assert.Equal("tok-abc", result.PlanningSessionToken); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Planning, loaded!.Status); Assert.Equal("tok-abc", loaded.PlanningSessionToken); } [Fact] public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull() { var listId = await CreateListAsync(); var task = MakeTask(listId, TaskStatus.Queued); await _tasks.AddAsync(task); var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-xyz"); Assert.Null(result); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Queued, loaded!.Status); Assert.Null(loaded.PlanningSessionToken); } ``` - [ ] **Step 2: Run; verify fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.SetPlanningStarted"` Expected: FAIL. - [ ] **Step 3: Implement** In `#region Planning`: ```csharp public async Task SetPlanningStartedAsync( string taskId, string sessionToken, CancellationToken ct = default) { var affected = await _context.Tasks .Where(t => t.Id == taskId && t.Status == TaskStatus.Manual) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Planning) .SetProperty(t => t.PlanningSessionToken, sessionToken), ct); if (affected == 0) return null; return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); } ``` - [ ] **Step 4: Run; verify pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.SetPlanningStarted"` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.SetPlanningStartedAsync" ``` --- ## Task 8: Repository method `UpdatePlanningSessionIdAsync` **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Write the failing test** ```csharp [Fact] public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId() { var listId = await CreateListAsync(); var task = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(task); await _tasks.SetPlanningStartedAsync(task.Id, "tok"); await _tasks.UpdatePlanningSessionIdAsync(task.Id, "claude-session-42"); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("claude-session-42", loaded!.PlanningSessionId); } ``` - [ ] **Step 2: Run; verify fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.UpdatePlanningSessionId"` Expected: FAIL. - [ ] **Step 3: Implement** ```csharp public async Task UpdatePlanningSessionIdAsync( string parentId, string sessionId, CancellationToken ct = default) { await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.PlanningSessionId, sessionId), ct); } ``` - [ ] **Step 4: Run; verify pass** Expected: PASS. - [ ] **Step 5: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.UpdatePlanningSessionIdAsync" ``` --- ## Task 9: Repository method `FindByPlanningTokenAsync` **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Write the failing test** ```csharp [Fact] public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches() { var listId = await CreateListAsync(); var task = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(task); await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123"); var found = await _tasks.FindByPlanningTokenAsync("unique-token-123"); Assert.NotNull(found); Assert.Equal(task.Id, found!.Id); } [Fact] public async Task FindByPlanningTokenAsync_ReturnsNull_WhenTokenUnknown() { var found = await _tasks.FindByPlanningTokenAsync("no-such-token"); Assert.Null(found); } ``` - [ ] **Step 2: Run; verify fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.FindByPlanningToken"` Expected: FAIL. - [ ] **Step 3: Implement** ```csharp public async Task FindByPlanningTokenAsync( string token, CancellationToken ct = default) { if (string.IsNullOrEmpty(token)) return null; return await _context.Tasks .AsNoTracking() .FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct); } ``` - [ ] **Step 4: Run; verify pass** Expected: PASS. - [ ] **Step 5: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.FindByPlanningTokenAsync" ``` --- ## Task 10: Repository method `FinalizePlanningAsync` **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Write the failing test** ```csharp [Fact] public async Task FinalizePlanningAsync_TransitionsDraftsAndParent() { var listId = await CreateListAsync(); var parent = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(parent); await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null); var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null); var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true); Assert.Equal(2, count); var c1Loaded = await _tasks.GetByIdAsync(c1.Id); var c2Loaded = await _tasks.GetByIdAsync(c2.Id); var parentLoaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Queued, c1Loaded!.Status); Assert.Equal(TaskStatus.Manual, c2Loaded!.Status); Assert.Equal(TaskStatus.Planned, parentLoaded!.Status); Assert.NotNull(parentLoaded.PlanningFinalizedAt); Assert.Null(parentLoaded.PlanningSessionToken); } [Fact] public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual() { var listId = await CreateListAsync(); var parent = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(parent); await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null); await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); var cLoaded = await _tasks.GetByIdAsync(c.Id); Assert.Equal(TaskStatus.Manual, cLoaded!.Status); } [Fact] public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued() { var listId = await CreateListAsync(); var agentTag = await _tags.GetOrCreateAsync("agent"); // Attach "agent" tag to the list (list-level tags propagate to its tasks). await _lists.AddTagAsync(listId, agentTag.Id); var parent = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(parent); await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null); await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true); var cLoaded = await _tasks.GetByIdAsync(c.Id); Assert.Equal(TaskStatus.Queued, cLoaded!.Status); } ``` - [ ] **Step 2: Run; verify fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.FinalizePlanning"` Expected: FAIL. - [ ] **Step 3: Implement** ```csharp public async Task FinalizePlanningAsync( string parentId, bool queueAgentTasks, CancellationToken ct = default) { using var tx = await _context.Database.BeginTransactionAsync(ct); var parent = await _context.Tasks .Include(t => t.List).ThenInclude(l => l.Tags) .FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null || parent.Status != TaskStatus.Planning) throw new InvalidOperationException($"Task {parentId} is not in Planning state."); var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent"); var drafts = await _context.Tasks .Include(t => t.Tags) .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) .ToListAsync(ct); int count = 0; foreach (var draft in drafts) { var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent"); var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag); draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual; count++; } parent.Status = TaskStatus.Planned; parent.PlanningFinalizedAt = DateTime.UtcNow; parent.PlanningSessionToken = null; await _context.SaveChangesAsync(ct); await tx.CommitAsync(ct); return count; } ``` - [ ] **Step 4: Run; verify pass** Expected: PASS for all three finalize tests. - [ ] **Step 5: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.FinalizePlanningAsync" ``` --- ## Task 11: Repository method `DiscardPlanningAsync` **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Write the failing test** ```csharp [Fact] public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent() { var listId = await CreateListAsync(); var parent = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(parent); await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42"); var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); var ok = await _tasks.DiscardPlanningAsync(parent.Id); Assert.True(ok); Assert.Null(await _tasks.GetByIdAsync(c1.Id)); Assert.Null(await _tasks.GetByIdAsync(c2.Id)); var parentLoaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Manual, parentLoaded!.Status); Assert.Null(parentLoaded.PlanningSessionId); Assert.Null(parentLoaded.PlanningSessionToken); Assert.Null(parentLoaded.PlanningFinalizedAt); } [Fact] public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse() { var listId = await CreateListAsync(); var task = MakeTask(listId, TaskStatus.Manual); await _tasks.AddAsync(task); var ok = await _tasks.DiscardPlanningAsync(task.Id); Assert.False(ok); } ``` - [ ] **Step 2: Run; verify fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.DiscardPlanning"` Expected: FAIL. - [ ] **Step 3: Implement** ```csharp public async Task DiscardPlanningAsync( string parentId, CancellationToken ct = default) { using var tx = await _context.Database.BeginTransactionAsync(ct); var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null || parent.Status != TaskStatus.Planning) { await tx.RollbackAsync(ct); return false; } await _context.Tasks .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) .ExecuteDeleteAsync(ct); parent.Status = TaskStatus.Manual; parent.PlanningSessionId = null; parent.PlanningSessionToken = null; parent.PlanningFinalizedAt = null; await _context.SaveChangesAsync(ct); await tx.CommitAsync(ct); return true; } ``` - [ ] **Step 4: Run; verify pass** Expected: PASS. - [ ] **Step 5: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.DiscardPlanningAsync" ``` --- ## Task 12: Repository method `TryCompleteParentAsync` (auto-status hook) **Files:** - Create: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs` - Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` - [ ] **Step 1: Create the test class** Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs`: ```csharp using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Repositories; public sealed class TaskRepositoryParentCompletionTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; public TaskRepositoryParentCompletionTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task ListAsync() { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); return id; } private async Task PlannedParentAsync(string listId) { var parent = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "p", Status = TaskStatus.Planned, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(parent); return parent; } private async Task ChildAsync(string listId, string parentId, TaskStatus status) { var child = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "c", Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", ParentTaskId = parentId, }; await _tasks.AddAsync(child); return child; } } ``` - [ ] **Step 2: Write failing tests** Append inside the class: ```csharp [Fact] public async Task TryCompleteParentAsync_AllChildrenDone_ParentBecomesDone() { var listId = await ListAsync(); var parent = await PlannedParentAsync(listId); await ChildAsync(listId, parent.Id, TaskStatus.Done); await ChildAsync(listId, parent.Id, TaskStatus.Done); await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Done, loaded!.Status); Assert.NotNull(loaded.FinishedAt); } [Fact] public async Task TryCompleteParentAsync_OneFailedRestDone_ParentBecomesFailed() { var listId = await ListAsync(); var parent = await PlannedParentAsync(listId); await ChildAsync(listId, parent.Id, TaskStatus.Done); await ChildAsync(listId, parent.Id, TaskStatus.Failed); await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Failed, loaded!.Status); Assert.NotNull(loaded.FinishedAt); } [Fact] public async Task TryCompleteParentAsync_OneStillRunning_ParentStaysPlanned() { var listId = await ListAsync(); var parent = await PlannedParentAsync(listId); await ChildAsync(listId, parent.Id, TaskStatus.Done); await ChildAsync(listId, parent.Id, TaskStatus.Running); await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Planned, loaded!.Status); Assert.Null(loaded.FinishedAt); } [Fact] public async Task TryCompleteParentAsync_ChildStillDraft_ParentStaysPlanned() { var listId = await ListAsync(); var parent = await PlannedParentAsync(listId); await ChildAsync(listId, parent.Id, TaskStatus.Done); await ChildAsync(listId, parent.Id, TaskStatus.Draft); await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Planned, loaded!.Status); } [Fact] public async Task TryCompleteParentAsync_ParentIsNotPlanned_NoChange() { var listId = await ListAsync(); var parent = await PlannedParentAsync(listId); await _ctx.Database.ExecuteSqlRawAsync("UPDATE tasks SET status = 'planning' WHERE id = {0}", parent.Id); await ChildAsync(listId, parent.Id, TaskStatus.Done); await _tasks.TryCompleteParentAsync(parent.Id); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Planning, loaded!.Status); } ``` - [ ] **Step 3: Run; verify fail** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryParentCompletionTests"` Expected: FAIL. - [ ] **Step 4: Implement** In `#region Planning` of `TaskRepository.cs`: ```csharp public async Task TryCompleteParentAsync( string parentId, CancellationToken ct = default) { var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null || parent.Status != TaskStatus.Planned) return; var children = await _context.Tasks .Where(t => t.ParentTaskId == parentId) .Select(t => t.Status) .ToListAsync(ct); if (children.Count == 0) return; bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed); if (!allTerminal) return; bool anyFailed = children.Any(s => s == TaskStatus.Failed); parent.Status = anyFailed ? TaskStatus.Failed : TaskStatus.Done; parent.FinishedAt = DateTime.UtcNow; await _context.SaveChangesAsync(ct); } ``` - [ ] **Step 5: Run; verify pass** Expected: PASS. - [ ] **Step 6: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs git commit -m "feat(data): TaskRepository.TryCompleteParentAsync" ``` --- ## Task 13: Regression test — queue skips Drafts/Planning/Planned **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - [ ] **Step 1: Write the test** Append to planning test class: ```csharp [Fact] public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned() { var listId = await CreateListAsync(); var agent = await _tags.GetOrCreateAsync("agent"); async Task T(TaskStatus s, bool withTag, string? parent = null) { var t = MakeTask(listId, s, parentId: parent); await _tasks.AddAsync(t); if (withTag) await _tasks.AddTagAsync(t.Id, agent.Id); return t; } var planning = await T(TaskStatus.Planning, withTag: true); var planned = await T(TaskStatus.Planned, withTag: true); var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id); var queued = await T(TaskStatus.Queued, withTag: true); var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); Assert.NotNull(picked); Assert.Equal(queued.Id, picked!.Id); } ``` - [ ] **Step 2: Run** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetNextQueuedAgentTask_SkipsDraftPlanningPlanned"` Expected: PASS immediately (the existing query filters on `status = 'queued'`, so Planning/Planned/Draft are already excluded). If this test fails, something is wrong with the queue filter — investigate before continuing. - [ ] **Step 3: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs git commit -m "test(data): queue skips Planning/Planned/Draft" ``` --- ## Task 14: Regression test — Restrict cascade on parent delete **Files:** - Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` - [ ] **Step 1: Write the test** ```csharp [Fact] public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete() { var listId = await CreateListAsync(); var parent = MakeTask(listId, TaskStatus.Planning); await _tasks.AddAsync(parent); await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); // ExecuteDelete bypasses the FK check in EF but SQLite enforces it at the DB level // when foreign_keys = ON (which ClaudeDoDbContext enables). await Assert.ThrowsAsync(async () => { await _tasks.DeleteAsync(parent.Id); }); var stillThere = await _tasks.GetByIdAsync(parent.Id); Assert.NotNull(stillThere); } ``` - [ ] **Step 2: Run** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.DeleteAsync_ParentWithChildren"` Expected: PASS. **If this test fails** because `ExecuteDeleteAsync` succeeds: SQLite may not be enforcing FKs in the test environment. Diagnose: - Confirm `ClaudeDoDbContext` sets `PRAGMA foreign_keys = ON` on connection open. - If not enforced, we need to replace `ExecuteDeleteAsync` in `DeleteAsync` with an EF-tracked delete (`_context.Tasks.Remove(...); SaveChangesAsync()`) so EF enforces Restrict before SQL is sent. If FK enforcement is the issue, add this helper to `ClaudeDoDbContext.OnConfiguring` (if not already there) or verify the existing pragma: ```csharp optionsBuilder.UseSqlite(conn, opts => opts.CommandTimeout(30)); ``` and ensure the connection string or `UseSqlite` callback issues `PRAGMA foreign_keys = ON`. - [ ] **Step 3: Commit** ```bash git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs # include any DbContext fix if needed git commit -m "test(data): parent delete with children is restricted" ``` --- ## Task 15: Wire `TryCompleteParentAsync` into `TaskRunner` **Files:** - Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` - Create: `tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs` (integration) - [ ] **Step 1: Locate the call-sites** Open `src/ClaudeDo.Worker/Runner/TaskRunner.cs`. Three locations already call `MarkDoneAsync` / `MarkFailedAsync` (line numbers per spec reference: ~333, ~348, ~362). At each location, the variable `task` (or `taskId` in the last two) is in scope. For the one at line ~333 `task` is the full `TaskEntity`; for the two failure paths, only `taskId` — but we can load the parent id from the repo. - [ ] **Step 2: Update the Done-path (around line 333)** Right after: ```csharp await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None); ``` add: ```csharp if (task.ParentTaskId is not null) await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None); ``` - [ ] **Step 3: Update the two Failed-paths (around lines 348, 362)** For each, right after the `MarkFailedAsync` call, add a block that re-reads the task to get `ParentTaskId` (the scope has `taskId` not the entity): ```csharp await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None); var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None); if (justFailed?.ParentTaskId is not null) await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None); ``` Do the same for the second failure path at line ~362. - [ ] **Step 4: Build** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: builds. - [ ] **Step 5: Write integration test** Create `tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs`: The intent is to exercise the full path: child task ends → parent updates. Rather than invoking the full `TaskRunner` (which requires Claude CLI), we test the repository integration by calling `MarkDoneAsync` + `TryCompleteParentAsync` in the same sequence the runner now does. ```csharp using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Runner; public sealed class TaskRunnerParentCompletionTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; public TaskRunnerParentCompletionTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } [Fact] public async Task ChildMarkedDone_LastOne_ParentFinalized() { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); var parent = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "p", Status = TaskStatus.Planned, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(parent); var c1 = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "c1", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore", ParentTaskId = parent.Id, }; var c2 = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "c2", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, CommitType = "chore", ParentTaskId = parent.Id, }; await _tasks.AddAsync(c1); await _tasks.AddAsync(c2); // Simulate the runner finishing the second child: await _tasks.MarkDoneAsync(c2.Id, DateTime.UtcNow, "done"); if (c2.ParentTaskId is not null) await _tasks.TryCompleteParentAsync(c2.ParentTaskId); var parentLoaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal(TaskStatus.Done, parentLoaded!.Status); Assert.NotNull(parentLoaded.FinishedAt); } } ``` - [ ] **Step 6: Run; verify pass** Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunnerParentCompletionTests"` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs git commit -m "feat(worker): hook TryCompleteParentAsync after MarkDone/MarkFailed" ``` --- ## Task 16: Full test run and merge preparation **Files:** none (verification step) - [ ] **Step 1: Run the full test suite** Run: `dotnet test tests/ClaudeDo.Worker.Tests` Expected: all tests pass, no regressions. - [ ] **Step 2: Verify build of every project** Run: ```bash dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj ``` Expected: all succeed. - [ ] **Step 3: Verify the migration file is present for parallel-plan detection** Run: ```bash ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs ``` Expected: exactly one file matches (the migration + designer file). This is the marker that Plans B and C wait for. - [ ] **Step 4: Summarize what's done** Plan A now delivers: - Three new `TaskStatus` values (Planning/Planned/Draft) with persistence. - Four new columns on `tasks` (`parent_task_id`, `planning_session_id`, `planning_session_token`, `planning_finalized_at`). - Self-ref FK with `DeleteBehavior.Restrict`. - Seven new repository methods. - Auto-parent-completion hook wired into `TaskRunner`. - Regression test that the queue still only picks `Queued`. - Regression test that parent delete with children fails. Nothing UI-visible changes yet; users notice only that deleting a task that has children now fails with a DB error (Plan C will fix the UX by adding a confirmation dialog). --- ## Out of scope for Plan A - Any MCP server, SignalR endpoints, or terminal launcher → Plan B. - Any UI rendering of hierarchy, context menu entries, or draft styling → Plan C. - Session-folder file management (`~/.todo-app/planning-sessions/`) → Plan B. - Resolving unknowns around Claude CLI flags (`--thinking-budget`, `--allowedTools` casing, session-ID capture) → Plan B.