# 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: ```bash 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: ```bash 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: ```bash 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.cs` — `wt.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//{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: ```bash 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: ```bash dotnet package search ModelContextProtocol ``` - [ ] **Step 2: Build** Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` Expected: builds cleanly. - [ ] **Step 3: Commit** ```bash 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____` wildcards? 3. **System prompt file reference:** Does `--append-system-prompt` accept `@path` or only inline string? 4. **Session-ID capture:** Does `--session-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: ```csharp // Claude CLI flags (verified via Context7): // thinking budget: // allowedTools casing: // append-system-prompt: // session id capture: ``` - [ ] **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`: ```csharp namespace ClaudeDo.Worker.Planning; public sealed record PlanningSessionFiles( string SessionDirectory, string McpConfigPath, string SystemPromptPath, string InitialPromptPath); ``` `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`: ```csharp 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`: ```csharp 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 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(() => _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(() => _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`: ```csharp 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 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 { ["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 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** ```bash 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** ```csharp [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(() => _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(() => _sut.ResumeAsync(parent.Id, CancellationToken.None)); } ``` - [ ] **Step 2: Run; verify fail** Expected: FAIL (ResumeAsync not defined). - [ ] **Step 3: Implement** In `PlanningSessionManager`: ```csharp public async Task 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** ```bash 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** ```csharp [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** ```csharp 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** ```bash 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** ```csharp [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** ```csharp public Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) => _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); ``` - [ ] **Step 4: Run; verify pass** Expected: PASS. - [ ] **Step 5: Commit** ```bash 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** ```csharp [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** ```csharp public async Task 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** ```bash 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`: ```csharp 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`: ```csharp 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(() => 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(() => 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`: ```csharp // Claude CLI flags (verified via Context7 in Task 2): // thinking budget: // allowedTools casing: // append-system-prompt: // session id capture: 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 { "--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 { "-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(); 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** ```bash 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`: ```csharp 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`: ```csharp 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** ```bash 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`: ```csharp 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 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(() => _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(() => _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`: ```csharp 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 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 CreateChildTask( PlanningMcpContext ctx, string title, string? description, IReadOnlyList? 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> ListChildTasks( PlanningMcpContext ctx, CancellationToken cancellationToken) { var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); var list = new List(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 UpdateChildTask( PlanningMcpContext ctx, string taskId, string? title, string? description, IReadOnlyList? 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** ```bash 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** ```csharp [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`: ```csharp 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 Finalize( PlanningMcpContext ctx, bool queueAgentTasks, CancellationToken cancellationToken) => _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); ``` - [ ] **Step 4: Run; verify pass** Expected: PASS. - [ ] **Step 5: Commit** ```bash 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` and mimic): ```csharp using Microsoft.AspNetCore.SignalR; // Replace WorkerHub with the actual hub class name used in this codebase. public PlanningMcpService(TaskRepository tasks, IHubContext hub) { _tasks = tasks; _hub = hub; } private readonly IHubContext _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: ```csharp using ClaudeDo.Worker.Planning; using ModelContextProtocol.Server; // adjust to actual namespace from Task 1 // After builder.Services... builder.Services.AddSingleton(sp => new PlanningSessionManager( sp.GetRequiredService(), Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".todo-app", "planning-sessions"))); builder.Services.AddSingleton(); builder.Services.AddScoped(); // MCP server mapping — exact call depends on the ModelContextProtocol.AspNetCore API // discovered in Task 1. Typical shape: builder.Services .AddMcpServer() .WithTools(); // attribute-based tool registration // After app = builder.Build(): app.UseMiddleware(); app.MapMcp("/mcp"); // exact endpoint extension depends on SDK ``` Annotate `PlanningMcpService` methods with MCP tool attributes. The SDK typically uses `[McpServerTool]` or similar: ```csharp [McpServerTool, Description("Create a draft child task under this planning session.")] public async Task 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: ```csharp 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** ```bash 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: ```csharp private readonly PlanningSessionManager _planning; private readonly IPlanningTerminalLauncher _launcher; // ... augment the existing constructor to inject these two. public async Task 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 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 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 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`): ```csharp // 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: ```bash dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~PlanningHubTests" ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash 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** ```csharp 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** ```bash 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: ```bash 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("")`. 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: ```csharp // Replace the constructor and LookupWorkingDirAsync with: public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory) { _tasks = tasks; _lists = lists; _rootDirectory = rootDirectory; } // ... private async Task 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: ```bash 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.