- 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>
45 KiB
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, extendTaskStatusenum.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— callTryCompleteParentAsyncafterMarkDoneAsync/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:
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:
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
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):
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
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:
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:
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
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:
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 forparent_task_id(nullable string),planning_session_id(nullable string),planning_session_token(nullable string),planning_finalized_at(nullable DateTime). - A
CreateIndexforidx_tasks_parent_task_idonparent_task_id. - An
AddForeignKeycall withonDelete: ReferentialAction.Restrictlinkingparent_task_id→tasks.id.
No data migration needed (all new columns nullable).
- Step 3: Apply to a scratch DB to verify
Run:
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:
rm -f ./scratch_migration_test.db ./scratch_migration_test.db-wal ./scratch_migration_test.db-shm
- Step 4: Commit
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:
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:
[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 }):
#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
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:
[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:
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
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:
[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:
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
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
[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
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
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
[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
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
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
[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
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
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
[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
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
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:
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:
[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:
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
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:
[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
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
[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
ClaudeDoDbContextsetsPRAGMA foreign_keys = ONon connection open. - If not enforced, we need to replace
ExecuteDeleteAsyncinDeleteAsyncwith 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:
optionsBuilder.UseSqlite(conn, opts => opts.CommandTimeout(30));
and ensure the connection string or UseSqlite callback issues PRAGMA foreign_keys = ON.
- Step 3: Commit
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:
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
add:
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):
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.
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
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:
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:
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
TaskStatusvalues (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,--allowedToolscasing, session-ID capture) → Plan B.