Files
ClaudeDo/docs/superpowers/plans/2026-04-23-planning-sessions-plan-b-worker-mcp.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

57 KiB

Planning Sessions — Plan B: Worker MCP + Launcher 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 MCP server, SignalR endpoints, session-folder management, and Windows Terminal launcher that make planning sessions actually runnable end-to-end.

Architecture: A PlanningSessionManager owns session-directory creation and lifecycle (Start/Resume/Discard/Finalize). A PlanningMcpService hosts MCP tools over streamable HTTP alongside the existing SignalR hub on 127.0.0.1:47821, authenticating each call via a per-session bearer token that maps to a parent task. A WindowsTerminalPlanningLauncher spawns wt.exe with the configured Claude CLI arguments. Hub endpoints coordinate between the UI and these services.

Tech Stack: .NET 8, ASP.NET Core (existing Kestrel host), SignalR, ModelContextProtocol C# SDK, xUnit.

Spec reference: docs/superpowers/specs/2026-04-23-planning-sessions-design.md sections 4, 5, 6.6, 7.


Prerequisite Gate

This plan depends on Plan A being merged to main. Before starting any implementation task, verify the marker file exists:

git fetch origin main
git checkout main
git pull --ff-only
ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs
  • If the file exists → proceed.
  • If it does not exist → Plan A has not merged yet. Wait and re-check periodically:
    while ! ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs >/dev/null 2>&1; do
      echo "Waiting for Plan A to merge..."
      sleep 60
      git fetch origin main && git pull --ff-only
    done
    echo "Plan A merged — proceeding."
    
  • Then create a new branch off main for this plan:
    git checkout -b feat/planning-sessions-worker
    

File Structure

Created:

  • src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs — session lifecycle, file generation, DB orchestration.
  • src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs — DTO with paths to generated files.
  • src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs — DTO returned from Start/Resume (paths + tokens + CWD).
  • src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs — launcher interface.
  • src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cswt.exe invocation.
  • src/ClaudeDo.Worker/Planning/PlanningMcpService.cs — MCP tool class.
  • src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs — middleware resolving bearer token → parent task id.
  • src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs — per-request context carrying parent id + repo handles.
  • tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
  • tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
  • tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs
  • tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs

Modified:

  • src/ClaudeDo.Worker/ClaudeDo.Worker.csproj — add ModelContextProtocol package reference.
  • src/ClaudeDo.Worker/Program.cs (or equivalent Startup/Host composition) — map MCP endpoint, register DI.
  • src/ClaudeDo.Worker/Hub/WorkerHub.cs (or whichever file defines the SignalR hub) — add five new hub methods.

Paths used at runtime: ~/.todo-app/planning-sessions/<parentTaskId>/{mcp.json, system-prompt.md, initial-prompt.txt}.


Task 1: Add ModelContextProtocol NuGet package

Files:

  • Modify: src/ClaudeDo.Worker/ClaudeDo.Worker.csproj

  • Step 1: Add package

Run:

dotnet add src/ClaudeDo.Worker package ModelContextProtocol
dotnet add src/ClaudeDo.Worker package ModelContextProtocol.AspNetCore

If the second package name does not exist in your nuget configuration, try ModelContextProtocol.Server.HttpTransport or the latest canonical ASP.NET Core integration package per NuGet. Check the current package catalog with:

dotnet package search ModelContextProtocol
  • Step 2: Build

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj Expected: builds cleanly.

  • Step 3: Commit
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
git commit -m "chore(worker): add ModelContextProtocol package"

Task 2: Resolve Claude CLI unknowns via Context7

Files: none (research step, output recorded in a comment in Task 8)

  • Step 1: Query Context7 docs for Claude Code CLI

Use the Context7 MCP tool in your session to fetch current docs:

Questions to resolve:

  1. Thinking budget flag: What flag and value set medium extended thinking? (--thinking-budget medium? model suffix? --budget variable?)
  2. Allowed-tools casing: What is the exact form for --allowedTools / --allowed-tools for Read, Grep, Glob, WebFetch, WebSearch, Skill, and mcp__<server>__<tool> wildcards?
  3. System prompt file reference: Does --append-system-prompt accept @path or only inline string?
  4. Session-ID capture: Does --session-id <id> exist to pre-assign? If not, where are session files written and how do we read the ID after launch?

Record answers in an inline comment at the top of WindowsTerminalPlanningLauncher.cs (created in Task 8) like:

// Claude CLI flags (verified <date> via Context7):
//   thinking budget: <flag/value>
//   allowedTools casing: <exact tokens>
//   append-system-prompt: <inline|@file>
//   session id capture: <strategy>
  • Step 2: Update spec §5.8 if answers materially change the design

If the answers contradict section 5.8 of the spec (e.g., --session-id flag exists and can pre-assign), open the spec file and mark the resolved item with the concrete answer.

  • Step 3: No commit needed yet — the research output lives in Task 8's launcher code. Continue to Task 3.

Task 3: PlanningSessionManager — Start

Files:

  • Create: src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs

  • Create: src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs

  • Create: src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs

  • Create: tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs

  • Step 1: Create the DTOs

src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs:

namespace ClaudeDo.Worker.Planning;

public sealed record PlanningSessionFiles(
    string SessionDirectory,
    string McpConfigPath,
    string SystemPromptPath,
    string InitialPromptPath);

src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs:

namespace ClaudeDo.Worker.Planning;

public sealed record PlanningSessionStartContext(
    string ParentTaskId,
    string WorkingDir,
    PlanningSessionFiles Files);

public sealed record PlanningSessionResumeContext(
    string ParentTaskId,
    string WorkingDir,
    string ClaudeSessionId,
    string McpConfigPath);
  • Step 2: Write failing tests for StartAsync

tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs:

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.Planning;

public sealed class PlanningSessionManagerTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly TaskRepository _tasks;
    private readonly ListRepository _lists;
    private readonly string _rootDir;
    private readonly PlanningSessionManager _sut;

    public PlanningSessionManagerTests()
    {
        _ctx = _db.CreateContext();
        _tasks = new TaskRepository(_ctx);
        _lists = new ListRepository(_ctx);
        _rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}");
        _sut = new PlanningSessionManager(_tasks, _rootDir);
    }

    public void Dispose()
    {
        _ctx.Dispose();
        _db.Dispose();
        try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ }
    }

    private async Task<(string listId, string workingDir)> SeedListAsync()
    {
        var listId = Guid.NewGuid().ToString();
        var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
        Directory.CreateDirectory(wd);
        await _lists.AddAsync(new ListEntity
        {
            Id = listId,
            Name = "Test",
            WorkingDir = wd,
            CreatedAt = DateTime.UtcNow,
        });
        return (listId, wd);
    }

    private async Task<TaskEntity> SeedManualTaskAsync(string listId)
    {
        var t = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = "Brainstorm auth",
            Description = "- review tokens\n- plan rollout",
            Status = TaskStatus.Manual,
            CreatedAt = DateTime.UtcNow,
            CommitType = "feat",
        };
        await _tasks.AddAsync(t);
        return t;
    }

    [Fact]
    public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning()
    {
        var (listId, wd) = await SeedListAsync();
        var parent = await SeedManualTaskAsync(listId);

        var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);

        Assert.Equal(parent.Id, ctx.ParentTaskId);
        Assert.Equal(wd, ctx.WorkingDir);
        Assert.True(File.Exists(ctx.Files.McpConfigPath));
        Assert.True(File.Exists(ctx.Files.SystemPromptPath));
        Assert.True(File.Exists(ctx.Files.InitialPromptPath));

        var mcp = await File.ReadAllTextAsync(ctx.Files.McpConfigPath);
        Assert.Contains("\"type\": \"http\"", mcp);
        Assert.Contains("Bearer ", mcp);

        var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath);
        Assert.Contains("Brainstorm auth", initial);
        Assert.Contains("review tokens", initial);

        var loaded = await _tasks.GetByIdAsync(parent.Id);
        Assert.Equal(TaskStatus.Planning, loaded!.Status);
        Assert.NotNull(loaded.PlanningSessionToken);
    }

    [Fact]
    public async Task StartAsync_TaskNotManual_Throws()
    {
        var (listId, _) = await SeedListAsync();
        var queuedTask = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = "x",
            Status = TaskStatus.Queued,
            CreatedAt = DateTime.UtcNow,
            CommitType = "feat",
        };
        await _tasks.AddAsync(queuedTask);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            _sut.StartAsync(queuedTask.Id, CancellationToken.None));
    }

    [Fact]
    public async Task StartAsync_ChildTask_Throws()
    {
        var (listId, _) = await SeedListAsync();
        var parent = await SeedManualTaskAsync(listId);
        await _tasks.SetPlanningStartedAsync(parent.Id, "t");
        var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            _sut.StartAsync(child.Id, CancellationToken.None));
    }
}
  • Step 3: Run; verify fail

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningSessionManagerTests.StartAsync" Expected: FAIL (type not defined).

  • Step 4: Implement PlanningSessionManager.StartAsync

src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs:

using System.Security.Cryptography;
using System.Text.Json;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Planning;

public sealed class PlanningSessionManager
{
    private const string McpServerUrl = "http://127.0.0.1:47821/mcp";

    private readonly TaskRepository _tasks;
    private readonly string _rootDirectory;

    public PlanningSessionManager(TaskRepository tasks, string rootDirectory)
    {
        _tasks = tasks;
        _rootDirectory = rootDirectory;
    }

    public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
    {
        var task = await _tasks.GetByIdAsync(taskId, ct)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (task.ParentTaskId is not null)
            throw new InvalidOperationException("Cannot start a planning session on a child task.");
        if (task.Status != TaskStatus.Manual)
            throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");

        var token = GenerateToken();
        var updated = await _tasks.SetPlanningStartedAsync(taskId, token, ct)
            ?? throw new InvalidOperationException("Failed to transition task to Planning (already Planning?).");

        var sessionDir = Path.Combine(_rootDirectory, taskId);
        Directory.CreateDirectory(sessionDir);

        var files = new PlanningSessionFiles(
            SessionDirectory: sessionDir,
            McpConfigPath: Path.Combine(sessionDir, "mcp.json"),
            SystemPromptPath: Path.Combine(sessionDir, "system-prompt.md"),
            InitialPromptPath: Path.Combine(sessionDir, "initial-prompt.txt"));

        await File.WriteAllTextAsync(files.McpConfigPath, BuildMcpConfigJson(token), ct);
        await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
        await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);

        var workingDir = await LookupWorkingDirAsync(task.ListId, ct);
        return new PlanningSessionStartContext(taskId, workingDir, files);
    }

    private static string GenerateToken()
    {
        var bytes = new byte[32];
        RandomNumberGenerator.Fill(bytes);
        return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
    }

    private static string BuildMcpConfigJson(string token)
    {
        var payload = new
        {
            mcpServers = new
            {
                claudedo = new
                {
                    type = "http",
                    url = McpServerUrl,
                    headers = new Dictionary<string, string>
                    {
                        ["Authorization"] = $"Bearer {token}"
                    }
                }
            }
        };
        return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
    }

    private static string BuildSystemPrompt()
    {
        return """
        You are in a ClaudeDo planning session for a task. Your job is to brainstorm with
        the user, then break their rough intent into concrete, independently-executable
        child-tasks. Each child-task should be something a single automated agent can pick
        up and complete autonomously.

        Use the `mcp__claudedo__*` tools to create/update/delete drafts in real time.
        You may read the repository for context (Read/Grep/Glob) but must NOT modify any
        files. Skills you may find useful: `superpowers:writing-plans`,
        `superpowers:writing-clearly-and-concisely`.

        When the user is satisfied, call `finalize` to commit the drafts as regular tasks.
        """;
    }

    private static string BuildInitialPrompt(TaskEntity parent)
    {
        var sb = new System.Text.StringBuilder();
        sb.AppendLine(parent.Title);
        if (!string.IsNullOrWhiteSpace(parent.Description))
        {
            sb.AppendLine();
            sb.AppendLine(parent.Description);
        }
        sb.AppendLine();
        sb.AppendLine("---");
        sb.AppendLine("We're planning this task together. Brainstorm with me, then create concrete child-tasks via the MCP tools. I'll call `finalize` when we're done.");
        return sb.ToString();
    }

    private async Task<string> LookupWorkingDirAsync(string listId, CancellationToken ct)
    {
        // This uses the repo's DbContext indirectly via a fresh query.
        // Alternative: accept ListRepository as a dependency; chose lighter coupling here.
        using var ctx = _tasks.GetType()
            .GetField("_context", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
            .GetValue(_tasks) as ClaudeDo.Data.ClaudeDoDbContext
            ?? throw new InvalidOperationException("Cannot access DbContext.");
        // For production, prefer injecting ListRepository; the reflection shortcut keeps
        // Plan B's surface small but should be cleaned up when ListRepository-injection is added.
        var wd = await Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
            .FirstOrDefaultAsync(ctx.Lists, l => l.Id == listId, ct);
        return wd?.WorkingDir ?? throw new InvalidOperationException($"List {listId} not found.");
    }
}

Note for reviewer: The reflection shortcut in LookupWorkingDirAsync is a smell. Preferred: inject ListRepository into the constructor. If the reviewer flags it, switch to constructor injection of ListRepository and use _lists.GetByIdAsync(listId, ct).WorkingDir.

  • Step 5: Run; verify pass

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningSessionManagerTests.StartAsync" Expected: PASS for all three StartAsync tests.

  • Step 6: Commit
git add src/ClaudeDo.Worker/Planning/ tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "feat(worker): PlanningSessionManager.StartAsync"

Task 4: PlanningSessionManager — Resume

Files:

  • Modify: src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs

  • Modify: tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs

  • Step 1: Write failing test

[Fact]
public async Task ResumeAsync_ReturnsExistingSessionDetails()
{
    var (listId, wd) = await SeedListAsync();
    var parent = await SeedManualTaskAsync(listId);
    var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
    await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42");

    var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);

    Assert.Equal(parent.Id, resumeCtx.ParentTaskId);
    Assert.Equal(wd, resumeCtx.WorkingDir);
    Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId);
    Assert.Equal(startCtx.Files.McpConfigPath, resumeCtx.McpConfigPath);
    Assert.True(File.Exists(resumeCtx.McpConfigPath));
}

[Fact]
public async Task ResumeAsync_NotPlanning_Throws()
{
    var (listId, _) = await SeedListAsync();
    var parent = await SeedManualTaskAsync(listId);
    // did not start
    await Assert.ThrowsAsync<InvalidOperationException>(() =>
        _sut.ResumeAsync(parent.Id, CancellationToken.None));
}

[Fact]
public async Task ResumeAsync_NoClaudeSessionId_Throws()
{
    var (listId, _) = await SeedListAsync();
    var parent = await SeedManualTaskAsync(listId);
    await _sut.StartAsync(parent.Id, CancellationToken.None);
    // UpdatePlanningSessionIdAsync not called

    await Assert.ThrowsAsync<InvalidOperationException>(() =>
        _sut.ResumeAsync(parent.Id, CancellationToken.None));
}
  • Step 2: Run; verify fail

Expected: FAIL (ResumeAsync not defined).

  • Step 3: Implement

In PlanningSessionManager:

public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
{
    var task = await _tasks.GetByIdAsync(taskId, ct)
        ?? throw new InvalidOperationException($"Task {taskId} not found.");
    if (task.Status != TaskStatus.Planning)
        throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
    if (string.IsNullOrEmpty(task.PlanningSessionId))
        throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");

    var sessionDir = Path.Combine(_rootDirectory, taskId);
    var mcpConfigPath = Path.Combine(sessionDir, "mcp.json");
    if (!File.Exists(mcpConfigPath))
        throw new InvalidOperationException($"Session directory missing: {sessionDir}");

    var workingDir = await LookupWorkingDirAsync(task.ListId, ct);
    return new PlanningSessionResumeContext(taskId, workingDir, task.PlanningSessionId, mcpConfigPath);
}
  • Step 4: Run; verify pass

Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "feat(worker): PlanningSessionManager.ResumeAsync"

Task 5: PlanningSessionManager — Discard

Files:

  • Modify: src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs

  • Modify: tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs

  • Step 1: Write failing test

[Fact]
public async Task DiscardAsync_DeletesSessionDirAndResetsTask()
{
    var (listId, _) = await SeedListAsync();
    var parent = await SeedManualTaskAsync(listId);
    var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
    Assert.True(Directory.Exists(startCtx.Files.SessionDirectory));

    await _sut.DiscardAsync(parent.Id, CancellationToken.None);

    Assert.False(Directory.Exists(startCtx.Files.SessionDirectory));
    var loaded = await _tasks.GetByIdAsync(parent.Id);
    Assert.Equal(TaskStatus.Manual, loaded!.Status);
    Assert.Null(loaded.PlanningSessionToken);
}
  • Step 2: Run; verify fail

Expected: FAIL.

  • Step 3: Implement
public async Task DiscardAsync(string taskId, CancellationToken ct)
{
    var ok = await _tasks.DiscardPlanningAsync(taskId, ct);
    var sessionDir = Path.Combine(_rootDirectory, taskId);
    if (Directory.Exists(sessionDir))
    {
        try { Directory.Delete(sessionDir, recursive: true); }
        catch { /* best effort */ }
    }
    if (!ok)
        throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
}
  • Step 4: Run; verify pass

Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "feat(worker): PlanningSessionManager.DiscardAsync"

Task 6: PlanningSessionManager — Finalize

Files:

  • Modify: src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs

  • Modify: tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs

  • Step 1: Write failing test

[Fact]
public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned()
{
    var (listId, _) = await SeedListAsync();
    var parent = await SeedManualTaskAsync(listId);
    await _sut.StartAsync(parent.Id, CancellationToken.None);
    await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
    await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);

    var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);

    Assert.Equal(2, count);
    var loaded = await _tasks.GetByIdAsync(parent.Id);
    Assert.Equal(TaskStatus.Planned, loaded!.Status);
}
  • Step 2: Run; verify fail

Expected: FAIL.

  • Step 3: Implement
public Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
    => _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
  • Step 4: Run; verify pass

Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "feat(worker): PlanningSessionManager.FinalizeAsync"

Task 7: PlanningSessionManager — GetPendingDraftCount

Files:

  • Modify: src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs

  • Modify: tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs

  • Step 1: Write failing test

[Fact]
public async Task GetPendingDraftCountAsync_ReturnsDraftCount()
{
    var (listId, _) = await SeedListAsync();
    var parent = await SeedManualTaskAsync(listId);
    await _sut.StartAsync(parent.Id, CancellationToken.None);
    await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
    await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
    await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null);

    var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None);

    Assert.Equal(3, n);
}
  • Step 2: Run; verify fail

Expected: FAIL.

  • Step 3: Implement
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)
{
    var children = await _tasks.GetChildrenAsync(taskId, ct);
    return children.Count(c => c.Status == TaskStatus.Draft);
}
  • Step 4: Run; verify pass

Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "feat(worker): PlanningSessionManager.GetPendingDraftCountAsync"

Task 8: Terminal launcher

Files:

  • Create: src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs

  • Create: src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs

  • Create: tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs

  • Step 1: Define the interface

src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs:

namespace ClaudeDo.Worker.Planning;

public interface IPlanningTerminalLauncher
{
    Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken);
    Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken);
}

public sealed class PlanningLaunchException : Exception
{
    public PlanningLaunchException(string message) : base(message) { }
}
  • Step 2: Write failing tests for pre-flight checks

tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs:

using ClaudeDo.Worker.Planning;

namespace ClaudeDo.Worker.Tests.Planning;

public sealed class WindowsTerminalPlanningLauncherTests
{
    private static PlanningSessionStartContext MakeStartCtx(string? wd = null)
    {
        var workingDir = wd ?? Path.GetTempPath();
        var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
        Directory.CreateDirectory(dir);
        return new PlanningSessionStartContext(
            ParentTaskId: "task-1",
            WorkingDir: workingDir,
            Files: new PlanningSessionFiles(
                SessionDirectory: dir,
                McpConfigPath: Path.Combine(dir, "mcp.json"),
                SystemPromptPath: Path.Combine(dir, "system-prompt.md"),
                InitialPromptPath: Path.Combine(dir, "initial-prompt.txt")));
    }

    [Fact]
    public async Task LaunchStartAsync_WorkingDirMissing_Throws()
    {
        var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid()));
        var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude");
        var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
            sut.LaunchStartAsync(ctx, CancellationToken.None));
        Assert.Contains("Working directory", ex.Message);
    }

    [Fact]
    public async Task LaunchStartAsync_WtMissing_Throws()
    {
        var ctx = MakeStartCtx();
        File.WriteAllText(ctx.Files.McpConfigPath, "{}");
        File.WriteAllText(ctx.Files.SystemPromptPath, "sp");
        File.WriteAllText(ctx.Files.InitialPromptPath, "ip");

        var sut = new WindowsTerminalPlanningLauncher(
            wtPath: "C:/no/such/wt.exe",
            claudePath: "claude");
        var ex = await Assert.ThrowsAsync<PlanningLaunchException>(() =>
            sut.LaunchStartAsync(ctx, CancellationToken.None));
        Assert.Contains("Windows Terminal", ex.Message);
    }
}
  • Step 3: Run; verify fail

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WindowsTerminalPlanningLauncherTests" Expected: FAIL.

  • Step 4: Implement the launcher

src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs:

// Claude CLI flags (verified <date> via Context7 in Task 2):
//   thinking budget: <fill in from Task 2 research>
//   allowedTools casing: <fill in from Task 2>
//   append-system-prompt: <inline|@file — fill in>
//   session id capture: <strategy from Task 2>

using System.Diagnostics;

namespace ClaudeDo.Worker.Planning;

public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher
{
    private readonly string _wtPath;
    private readonly string _claudePath;

    public WindowsTerminalPlanningLauncher(string wtPath = "wt", string claudePath = "claude")
    {
        _wtPath = wtPath;
        _claudePath = claudePath;
    }

    public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
    {
        if (!Directory.Exists(ctx.WorkingDir))
            throw new PlanningLaunchException($"Working directory not found: {ctx.WorkingDir}");
        if (!File.Exists(ctx.Files.McpConfigPath))
            throw new PlanningLaunchException($"MCP config missing: {ctx.Files.McpConfigPath}");
        if (!IsResolvable(_wtPath))
            throw new PlanningLaunchException("Windows Terminal (wt.exe) not found in PATH.");
        if (!IsResolvable(_claudePath))
            throw new PlanningLaunchException("Claude CLI (claude) not found in PATH.");

        var systemPrompt = File.ReadAllText(ctx.Files.SystemPromptPath);
        var initialPrompt = File.ReadAllText(ctx.Files.InitialPromptPath);

        // NOTE: Fill in the exact flags verified in Task 2.
        var claudeArgs = new List<string>
        {
            "--model", "claude-sonnet-4-6",
            // "--thinking-budget", "medium",   // UNCOMMENT and adjust per Task 2
            "--append-system-prompt", systemPrompt,
            "--mcp-config", ctx.Files.McpConfigPath,
            "--allowedTools", "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill",
            initialPrompt,
        };

        var wtArgs = new List<string> { "-d", ctx.WorkingDir, "cmd", "/k", _claudePath };
        wtArgs.AddRange(claudeArgs);

        var psi = new ProcessStartInfo
        {
            FileName = _wtPath,
            UseShellExecute = true,
        };
        foreach (var a in wtArgs) psi.ArgumentList.Add(a);

        Process.Start(psi);
        return Task.CompletedTask;
    }

    public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
    {
        if (!Directory.Exists(ctx.WorkingDir))
            throw new PlanningLaunchException($"Working directory not found: {ctx.WorkingDir}");
        if (!IsResolvable(_wtPath))
            throw new PlanningLaunchException("Windows Terminal (wt.exe) not found in PATH.");
        if (!IsResolvable(_claudePath))
            throw new PlanningLaunchException("Claude CLI (claude) not found in PATH.");

        var psi = new ProcessStartInfo
        {
            FileName = _wtPath,
            UseShellExecute = true,
        };
        psi.ArgumentList.Add("-d");
        psi.ArgumentList.Add(ctx.WorkingDir);
        psi.ArgumentList.Add("cmd");
        psi.ArgumentList.Add("/k");
        psi.ArgumentList.Add(_claudePath);
        psi.ArgumentList.Add("--resume");
        psi.ArgumentList.Add(ctx.ClaudeSessionId);
        psi.ArgumentList.Add("--mcp-config");
        psi.ArgumentList.Add(ctx.McpConfigPath);

        Process.Start(psi);
        return Task.CompletedTask;
    }

    private static bool IsResolvable(string pathOrName)
    {
        if (Path.IsPathRooted(pathOrName))
            return File.Exists(pathOrName);

        var paths = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty<string>();
        var extensions = OperatingSystem.IsWindows()
            ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT").Split(';')
            : new[] { "" };
        foreach (var p in paths)
        {
            foreach (var ext in extensions)
            {
                var candidate = Path.Combine(p, pathOrName + ext);
                if (File.Exists(candidate)) return true;
            }
        }
        return false;
    }
}
  • Step 5: Run; verify pass

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~WindowsTerminalPlanningLauncherTests" Expected: PASS.

  • Step 6: Commit
git add src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs
git commit -m "feat(worker): WindowsTerminalPlanningLauncher with pre-flight checks"

Task 9: MCP token-auth middleware + context

Files:

  • Create: src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs

  • Create: src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs

  • Step 1: Create context

src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs:

namespace ClaudeDo.Worker.Planning;

public sealed class PlanningMcpContext
{
    public required string ParentTaskId { get; init; }
}
  • Step 2: Create auth middleware

src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs:

using ClaudeDo.Data.Repositories;
using Microsoft.AspNetCore.Http;

namespace ClaudeDo.Worker.Planning;

public sealed class PlanningTokenAuthMiddleware
{
    private readonly RequestDelegate _next;

    public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks)
    {
        if (!ctx.Request.Path.StartsWithSegments("/mcp"))
        {
            await _next(ctx);
            return;
        }

        var auth = ctx.Request.Headers["Authorization"].ToString();
        if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
        {
            ctx.Response.StatusCode = 401;
            await ctx.Response.WriteAsync("Missing bearer token");
            return;
        }

        var token = auth.Substring("Bearer ".Length).Trim();
        var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
        if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning)
        {
            ctx.Response.StatusCode = 401;
            await ctx.Response.WriteAsync("Invalid or expired planning token");
            return;
        }

        ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
        await _next(ctx);
    }
}
  • Step 3: Commit
git add src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs
git commit -m "feat(worker): MCP bearer-token auth middleware"

Task 10: MCP tools — create/list/update/delete child

Files:

  • Create: src/ClaudeDo.Worker/Planning/PlanningMcpService.cs

  • Create: tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs

  • Step 1: Write failing tests

tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs:

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.Planning;

public sealed class PlanningMcpServiceTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly TaskRepository _tasks;
    private readonly ListRepository _lists;
    private readonly PlanningMcpService _sut;

    public PlanningMcpServiceTests()
    {
        _ctx = _db.CreateContext();
        _tasks = new TaskRepository(_ctx);
        _lists = new ListRepository(_ctx);
        _sut = new PlanningMcpService(_tasks);
    }

    public void Dispose() { _ctx.Dispose(); _db.Dispose(); }

    private async Task<TaskEntity> SeedPlanningParentAsync()
    {
        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.Manual,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
        };
        await _tasks.AddAsync(parent);
        await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
        return (await _tasks.GetByIdAsync(parent.Id))!;
    }

    private static PlanningMcpContext Ctx(string parentId) => new() { ParentTaskId = parentId };

    [Fact]
    public async Task CreateChildTask_CreatesDraft()
    {
        var parent = await SeedPlanningParentAsync();

        var result = await _sut.CreateChildTask(Ctx(parent.Id), "My child", "desc", null, null, CancellationToken.None);

        Assert.Equal("Draft", result.Status);
        var child = await _tasks.GetByIdAsync(result.TaskId);
        Assert.Equal("My child", child!.Title);
        Assert.Equal(TaskStatus.Draft, child.Status);
    }

    [Fact]
    public async Task ListChildTasks_ReturnsOnlyThisParentsChildren()
    {
        var parent = await SeedPlanningParentAsync();
        var other = await SeedPlanningParentAsync();

        await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
        await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);

        var list = await _sut.ListChildTasks(Ctx(parent.Id), CancellationToken.None);
        Assert.Single(list);
        Assert.Equal("mine", list[0].Title);
    }

    [Fact]
    public async Task UpdateChildTask_NotAChild_Throws()
    {
        var parent = await SeedPlanningParentAsync();
        var other = await SeedPlanningParentAsync();
        var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            _sut.UpdateChildTask(Ctx(parent.Id), otherChild.Id, "new", null, null, null, CancellationToken.None));
    }

    [Fact]
    public async Task UpdateChildTask_NotDraft_Throws()
    {
        var parent = await SeedPlanningParentAsync();
        var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
        await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);

        await Assert.ThrowsAsync<InvalidOperationException>(() =>
            _sut.UpdateChildTask(Ctx(parent.Id), c.Id, "new", null, null, null, CancellationToken.None));
    }

    [Fact]
    public async Task DeleteChildTask_RemovesDraft()
    {
        var parent = await SeedPlanningParentAsync();
        var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);

        await _sut.DeleteChildTask(Ctx(parent.Id), c.Id, CancellationToken.None);

        Assert.Null(await _tasks.GetByIdAsync(c.Id));
    }
}
  • Step 2: Run; verify fail

Expected: FAIL.

  • Step 3: Implement service

src/ClaudeDo.Worker/Planning/PlanningMcpService.cs:

using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Planning;

public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList<string> Tags);
public sealed record CreatedChildDto(string TaskId, string Status);

public sealed class PlanningMcpService
{
    private readonly TaskRepository _tasks;

    public PlanningMcpService(TaskRepository tasks) => _tasks = tasks;

    public async Task<CreatedChildDto> CreateChildTask(
        PlanningMcpContext ctx,
        string title,
        string? description,
        IReadOnlyList<string>? tags,
        string? commitType,
        CancellationToken cancellationToken)
    {
        var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
        return new CreatedChildDto(child.Id, "Draft");
    }

    public async Task<IReadOnlyList<ChildTaskDto>> ListChildTasks(
        PlanningMcpContext ctx,
        CancellationToken cancellationToken)
    {
        var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
        var list = new List<ChildTaskDto>(children.Count);
        foreach (var c in children)
        {
            var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken);
            list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList()));
        }
        return list;
    }

    public async Task<ChildTaskDto> UpdateChildTask(
        PlanningMcpContext ctx,
        string taskId,
        string? title,
        string? description,
        IReadOnlyList<string>? tags,
        string? commitType,
        CancellationToken cancellationToken)
    {
        var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (child.ParentTaskId != ctx.ParentTaskId)
            throw new InvalidOperationException("Task is not a child of this planning session.");
        if (child.Status != TaskStatus.Draft)
            throw new InvalidOperationException("Cannot modify a finalized task.");

        if (title is not null) child.Title = title;
        if (description is not null) child.Description = description;
        if (commitType is not null) child.CommitType = commitType;
        await _tasks.UpdateAsync(child, cancellationToken);

        // Tag handling omitted for v1 simplicity — tags set at create time.
        // If Claude asks to update tags, it can delete and re-create.

        var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
        var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
        return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
    }

    public async Task DeleteChildTask(
        PlanningMcpContext ctx,
        string taskId,
        CancellationToken cancellationToken)
    {
        var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
            ?? throw new InvalidOperationException($"Task {taskId} not found.");
        if (child.ParentTaskId != ctx.ParentTaskId)
            throw new InvalidOperationException("Task is not a child of this planning session.");
        if (child.Status != TaskStatus.Draft)
            throw new InvalidOperationException("Cannot delete a finalized task.");

        await _tasks.DeleteAsync(taskId, cancellationToken);
    }
}
  • Step 4: Run; verify pass

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningMcpServiceTests" Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Planning/PlanningMcpService.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
git commit -m "feat(worker): MCP tools for child-task CRUD"

Task 11: MCP tools — update_planning_task + finalize

Files:

  • Modify: src/ClaudeDo.Worker/Planning/PlanningMcpService.cs

  • Modify: tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs

  • Step 1: Write failing tests

[Fact]
public async Task UpdatePlanningTask_SetsTitleAndDescription()
{
    var parent = await SeedPlanningParentAsync();

    await _sut.UpdatePlanningTask(Ctx(parent.Id), "new title", "new desc", CancellationToken.None);

    var loaded = await _tasks.GetByIdAsync(parent.Id);
    Assert.Equal("new title", loaded!.Title);
    Assert.Equal("new desc", loaded.Description);
}

[Fact]
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
{
    var parent = await SeedPlanningParentAsync();
    await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
    await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);

    var count = await _sut.Finalize(Ctx(parent.Id), true, CancellationToken.None);

    Assert.Equal(2, count);
    var loaded = await _tasks.GetByIdAsync(parent.Id);
    Assert.Equal(TaskStatus.Planned, loaded!.Status);
    Assert.Null(loaded.PlanningSessionToken);
}
  • Step 2: Run; verify fail

Expected: FAIL.

  • Step 3: Implement

In PlanningMcpService:

public async Task UpdatePlanningTask(
    PlanningMcpContext ctx,
    string? title,
    string? description,
    CancellationToken cancellationToken)
{
    var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
        ?? throw new InvalidOperationException("Planning task not found.");
    if (title is not null) parent.Title = title;
    if (description is not null) parent.Description = description;
    await _tasks.UpdateAsync(parent, cancellationToken);
}

public Task<int> Finalize(
    PlanningMcpContext ctx,
    bool queueAgentTasks,
    CancellationToken cancellationToken)
    => _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
  • Step 4: Run; verify pass

Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Planning/PlanningMcpService.cs tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
git commit -m "feat(worker): MCP tools update_planning_task and finalize"

Task 12: MCP HTTP endpoint + broadcast on mutations

Files:

  • Modify: src/ClaudeDo.Worker/Program.cs (or whichever file configures the ASP.NET host)

  • Modify: src/ClaudeDo.Worker/Planning/PlanningMcpService.cs

  • Step 1: Add SignalR-broadcast dependency to service

In PlanningMcpService, add constructor dependency on the hub context (existing pattern in the worker — examine how QueueService or TaskRunner emit TaskUpdated via IHubContext<WorkerHub> and mimic):

using Microsoft.AspNetCore.SignalR;
// Replace WorkerHub with the actual hub class name used in this codebase.

public PlanningMcpService(TaskRepository tasks, IHubContext<WorkerHub> hub)
{
    _tasks = tasks;
    _hub = hub;
}

private readonly IHubContext<WorkerHub> _hub;

private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
    => _hub.Clients.All.SendAsync("TaskUpdated", taskId, ct);

Call BroadcastTaskUpdatedAsync at the end of each mutation method (CreateChildTask, UpdateChildTask, DeleteChildTask, UpdatePlanningTask, Finalize). For Finalize, broadcast once for the parent — the UI will refetch and get all children's new statuses in one reload (existing pattern).

If the existing hub name is not WorkerHub, replace accordingly. Inspect src/ClaudeDo.Worker/Hub/ to find it.

  • Step 2: Wire MCP in Program.cs

Add:

using ClaudeDo.Worker.Planning;
using ModelContextProtocol.Server; // adjust to actual namespace from Task 1

// After builder.Services...
builder.Services.AddSingleton<PlanningSessionManager>(sp =>
    new PlanningSessionManager(
        sp.GetRequiredService<TaskRepository>(),
        Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
            ".todo-app", "planning-sessions")));
builder.Services.AddSingleton<IPlanningTerminalLauncher, WindowsTerminalPlanningLauncher>();
builder.Services.AddScoped<PlanningMcpService>();

// MCP server mapping — exact call depends on the ModelContextProtocol.AspNetCore API
// discovered in Task 1. Typical shape:
builder.Services
    .AddMcpServer()
    .WithTools<PlanningMcpService>();   // attribute-based tool registration

// After app = builder.Build():
app.UseMiddleware<PlanningTokenAuthMiddleware>();
app.MapMcp("/mcp"); // exact endpoint extension depends on SDK

Annotate PlanningMcpService methods with MCP tool attributes. The SDK typically uses [McpServerTool] or similar:

[McpServerTool, Description("Create a draft child task under this planning session.")]
public async Task<CreatedChildDto> CreateChildTask(...)

Consult Context7 docs again here (Task 2 research) for the exact attribute names, endpoint-mapping extension method, and how to inject PlanningMcpContext per request (likely via HttpContext.Items["PlanningContext"] in a factory). If the ambient-context injection pattern isn't obvious, use a lightweight accessor:

public sealed class PlanningMcpContextAccessor
{
    private readonly IHttpContextAccessor _http;
    public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http;
    public PlanningMcpContext Current =>
        (_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext)
        ?? throw new InvalidOperationException("No planning context on request.");
}

and inject PlanningMcpContextAccessor into PlanningMcpService constructor, replacing the explicit PlanningMcpContext ctx parameter on each tool method with _contextAccessor.Current.

Important: Keep the existing test signatures (Ctx(parentId) passed explicitly) if you refactor tools to pull from an accessor — update the tests accordingly to set up an HttpContextAccessor with Items["PlanningContext"] populated.

  • Step 3: Build

Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj Expected: build succeeds.

  • Step 4: Commit
git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
git commit -m "feat(worker): map MCP HTTP endpoint and broadcast TaskUpdated"

Task 13: SignalR hub endpoints

Files:

  • Modify: the worker hub file (find via grep -r "class.*Hub.*:.*Hub" src/ClaudeDo.Worker)

  • Create: tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs

  • Step 1: Locate the hub

Run: grep -rn "class.*WorkerHub\|: Hub\b" src/ClaudeDo.Worker/Hub/

The existing hub class (commonly WorkerHub) handles methods like RunNowAsync, CancelTaskAsync. Extend it — do not create a new hub.

  • Step 2: Add hub methods

In the existing hub class, add:

private readonly PlanningSessionManager _planning;
private readonly IPlanningTerminalLauncher _launcher;

// ... augment the existing constructor to inject these two.

public async Task<PlanningSessionStartContext> StartPlanningSessionAsync(string taskId)
{
    var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
    try
    {
        await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted);
    }
    catch (PlanningLaunchException)
    {
        await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
        throw;
    }
    await Clients.All.SendAsync("TaskUpdated", taskId);
    return ctx;
}

public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
{
    var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
    await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted);
    return ctx;
}

public async Task DiscardPlanningSessionAsync(string taskId)
{
    await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
    await Clients.All.SendAsync("TaskUpdated", taskId);
}

public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
{
    var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
    await Clients.All.SendAsync("TaskUpdated", taskId);
    return count;
}

public Task<int> GetPendingDraftCountAsync(string taskId)
    => _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
  • Step 3: Hub tests

tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs — exercise the hub methods via the TestServer/in-process SignalR pattern already used by AgentSettingsHubTests.cs. Mirror its setup (look at that file for the pattern) and write tests for each of the five methods. Use a fake IPlanningTerminalLauncher that records calls but does not spawn processes.

Example skeleton (adapt to the real hub bootstrapping pattern observed in AgentSettingsHubTests):

// Setup similar to AgentSettingsHubTests:
// - TestWebHost with real DB and real TaskRepository
// - Register a FakeTerminalLauncher that captures calls
// - Connect a HubConnection client
// Tests:
// - StartPlanningSession changes parent status to Planning and invokes launcher once
// - Discard resets parent to Manual and removes session dir
// - Finalize promotes drafts and broadcasts TaskUpdated
// - GetPendingDraftCount returns the correct count
  • Step 4: Build + test

Run:

dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningHubTests"

Expected: PASS.

  • Step 5: Commit
git add src/ClaudeDo.Worker/Hub/ tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs
git commit -m "feat(worker): SignalR hub endpoints for planning sessions"

Task 14: End-to-end smoke test

Files:

  • Create: tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs

  • Step 1: Write an end-to-end test using a fake launcher

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;

namespace ClaudeDo.Worker.Tests.Planning;

public sealed class PlanningEndToEndTests : IDisposable
{
    private readonly DbFixture _db = new();
    private readonly ClaudeDoDbContext _ctx;
    private readonly TaskRepository _tasks;
    private readonly ListRepository _lists;
    private readonly PlanningSessionManager _manager;
    private readonly PlanningMcpService _svc;

    public PlanningEndToEndTests()
    {
        _ctx = _db.CreateContext();
        _tasks = new TaskRepository(_ctx);
        _lists = new ListRepository(_ctx);
        var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}");
        _manager = new PlanningSessionManager(_tasks, root);
        _svc = new PlanningMcpService(_tasks /*, fake hub context */);
    }

    public void Dispose() { _ctx.Dispose(); _db.Dispose(); }

    [Fact]
    public async Task StartThenCreateThenFinalize_FullFlow()
    {
        var listId = Guid.NewGuid().ToString();
        var wd = Path.GetTempPath();
        await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow });

        var parent = new TaskEntity
        {
            Id = Guid.NewGuid().ToString(),
            ListId = listId,
            Title = "Big Task",
            Status = TaskStatus.Manual,
            CreatedAt = DateTime.UtcNow,
            CommitType = "chore",
        };
        await _tasks.AddAsync(parent);

        var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None);
        Assert.True(File.Exists(startCtx.Files.McpConfigPath));

        var pCtx = new PlanningMcpContext { ParentTaskId = parent.Id };
        await _svc.CreateChildTask(pCtx, "sub 1", null, null, null, CancellationToken.None);
        await _svc.CreateChildTask(pCtx, "sub 2", null, null, null, CancellationToken.None);

        var count = await _svc.Finalize(pCtx, true, CancellationToken.None);
        Assert.Equal(2, count);

        var reload = await _tasks.GetByIdAsync(parent.Id);
        Assert.Equal(TaskStatus.Planned, reload!.Status);
        var kids = await _tasks.GetChildrenAsync(parent.Id);
        Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status));
    }
}

If PlanningMcpService now requires an IHubContext<...> constructor arg, supply a fake mirroring FakeHubContext that already exists in the test project.

  • Step 2: Run

Run: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningEndToEndTests" Expected: PASS.

  • Step 3: Commit
git add tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs
git commit -m "test(worker): planning session end-to-end"

Task 15: Verify full build + test and clean up

Files: none

  • Step 1: Full test run

Run: dotnet test tests/ClaudeDo.Worker.Tests Expected: all green.

  • Step 2: Build all

Run:

dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj

Expected: all succeed.

  • Step 3: Manual smoke test (one-liner, only works if wt + claude installed)

Launch the worker locally, then from a second terminal connect with a SignalR client (or use the UI from Plan C if available) and call StartPlanningSessionAsync("<some-manual-task-id>"). A Windows Terminal window should open with Claude running.

If the UI is not yet built (Plan C in progress), this is manual-only — skip to final commit.

  • Step 4: Final commit and PR-ready

Ensure the reflection shortcut in PlanningSessionManager.LookupWorkingDirAsync has been replaced with proper ListRepository injection (flagged in Task 3 Step 4). If not:

// Replace the constructor and LookupWorkingDirAsync with:
public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory)
{
    _tasks = tasks;
    _lists = lists;
    _rootDirectory = rootDirectory;
}
// ...
private async Task<string> LookupWorkingDirAsync(string listId, CancellationToken ct)
{
    var list = await _lists.GetByIdAsync(listId, ct)
        ?? throw new InvalidOperationException($"List {listId} not found.");
    return list.WorkingDir ?? throw new InvalidOperationException($"List {listId} has no WorkingDir.");
}

Update DI in Program.cs and tests to match. Commit:

git add -A
git commit -m "refactor(worker): inject ListRepository into PlanningSessionManager"

Out of scope for Plan B

  • All UI work (context menu, hierarchy rendering, draft styling, unfinished-session dialog) → Plan C.
  • WorkerClient extensions (Plan C will add the client-side methods that call these hub endpoints).
  • Avalonia-side live-refresh on TaskUpdated for drafts — the event is broadcast; Plan C wires the handler.