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

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, 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:

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 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_idtasks.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 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:

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 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.