Files
ClaudeDo/docs/superpowers/plans/2026-04-23-planning-sessions-plan-a-foundation.md
mika kuns 43d517dcfc docs(plans): add planning sessions implementation plans A, B, C
- Plan A (Foundation): schema, enum, repos, auto-status hook
- Plan B (Worker MCP + Launcher): MCP server, SignalR endpoints, wt.exe launcher
- Plan C (UI): context menu, hierarchy rendering, dialog, client methods

Plans B and C depend on Plan A merging first (marker: migration file
AddPlanningSupport). B and C can run in parallel after A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:36:02 +02:00

1429 lines
45 KiB
Markdown

# 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/<timestamp>_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/<timestamp>_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<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
public TaskEntity? Parent { get; set; }
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
```
- [ ] **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/<timestamp>_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 `<timestamp>_AddPlanningSupport.cs` file. Verify the `Up` method contains:
- Four `AddColumn<string>` / `AddColumn<DateTime>` 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<string> 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<List<TaskEntity>> 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<InvalidOperationException>(() =>
_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<TaskEntity> CreateChildAsync(
string parentId,
string title,
string? description,
IReadOnlyList<string>? 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<TaskEntity?> 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<TaskEntity?> 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<int> 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<bool> 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<string> ListAsync()
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
return id;
}
private async Task<TaskEntity> 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<TaskEntity> 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<TaskEntity> 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<Microsoft.EntityFrameworkCore.DbUpdateException>(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.