- 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>
1645 lines
57 KiB
Markdown
1645 lines
57 KiB
Markdown
# 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/<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:
|
|
```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__<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:
|
|
|
|
```csharp
|
|
// 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`:
|
|
|
|
```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<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`:
|
|
|
|
```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<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**
|
|
|
|
```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<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`:
|
|
|
|
```csharp
|
|
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**
|
|
|
|
```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<int> 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<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**
|
|
|
|
```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<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`:
|
|
|
|
```csharp
|
|
// 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**
|
|
|
|
```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<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`:
|
|
|
|
```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<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**
|
|
|
|
```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<int> 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<WorkerHub>` and mimic):
|
|
|
|
```csharp
|
|
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:
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
[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:
|
|
|
|
```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<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`):
|
|
|
|
```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("<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:
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```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.
|