diff --git a/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
index 301b931..5d0f072 100644
--- a/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
+++ b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs
@@ -1,4 +1,6 @@
using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -8,6 +10,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260416064948_InitialCreate")]
public partial class InitialCreate : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs b/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs
index 79ba1ac..27b2fe8 100644
--- a/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs
+++ b/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs
@@ -1,10 +1,14 @@
-using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260420075929_AddTaskFlagsAndNotes")]
public partial class AddTaskFlagsAndNotes : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs
index 083c4cb..99eef30 100644
--- a/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs
+++ b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs
@@ -1,10 +1,14 @@
-using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260421113614_AddAppSettings")]
public partial class AddAppSettings : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs
index 6918635..3f275bd 100644
--- a/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs
+++ b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs
@@ -1,3 +1,5 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260422120000_AddTaskSortOrder")]
public partial class AddTaskSortOrder : Migration
{
///
diff --git a/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs
index ef24f78..9efb30a 100644
--- a/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs
+++ b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs
@@ -1,4 +1,6 @@
using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -6,6 +8,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations
{
///
+ [DbContext(typeof(ClaudeDoDbContext))]
+ [Migration("20260423154708_AddPlanningSupport")]
public partial class AddPlanningSupport : Migration
{
///
diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
new file mode 100644
index 0000000..ea1b7fc
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
@@ -0,0 +1,12 @@
+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);
\ No newline at end of file
diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
new file mode 100644
index 0000000..ffe4390
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
@@ -0,0 +1,7 @@
+namespace ClaudeDo.Worker.Planning;
+
+public sealed record PlanningSessionFiles(
+ string SessionDirectory,
+ string McpConfigPath,
+ string SystemPromptPath,
+ string InitialPromptPath);
\ No newline at end of file
diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
new file mode 100644
index 0000000..7080960
--- /dev/null
+++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
@@ -0,0 +1,111 @@
+using System.Security.Cryptography;
+using System.Text;
+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 ListRepository _lists;
+ private readonly string _rootDirectory;
+
+ public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory)
+ {
+ _tasks = tasks;
+ _lists = lists;
+ _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();
+ _ = await _tasks.SetPlanningStartedAsync(taskId, token, ct)
+ ?? throw new InvalidOperationException("Failed to transition task to Planning.");
+
+ var sessionDir = Path.Combine(_rootDirectory, taskId);
+ Directory.CreateDirectory(sessionDir);
+
+ var files = new PlanningSessionFiles(
+ sessionDir,
+ Path.Combine(sessionDir, "mcp.json"),
+ Path.Combine(sessionDir, "system-prompt.md"),
+ 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 list = await _lists.GetByIdAsync(task.ListId, ct)
+ ?? throw new InvalidOperationException($"List {task.ListId} not found.");
+ return new PlanningSessionStartContext(taskId, list.WorkingDir, files);
+ }
+
+ private static string GenerateToken()
+ {
+ var bytes = RandomNumberGenerator.GetBytes(32);
+ return Convert.ToBase64String(bytes)
+ .Replace('+', '-')
+ .Replace('/', '_')
+ .TrimEnd('=');
+ }
+
+ 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() =>
+ """
+ You are a planning assistant for ClaudeDo.
+ Your role is to help break down a task into smaller, actionable subtasks.
+
+ Use the available MCP tools (mcp__claudedo__*) to create child tasks.
+ When you are done planning, finalize the session.
+
+ Be concise and focused. Each subtask should be independently executable.
+ """;
+
+ private static string BuildInitialPrompt(TaskEntity task)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine($"# Task: {task.Title}");
+ if (!string.IsNullOrWhiteSpace(task.Description))
+ {
+ sb.AppendLine();
+ sb.AppendLine(task.Description);
+ }
+ sb.AppendLine();
+ sb.AppendLine("---");
+ sb.AppendLine();
+ sb.AppendLine("Please analyze this task and break it down into concrete subtasks.");
+ return sb.ToString();
+ }
+}
\ No newline at end of file
diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs
index d15c574..d47a976 100644
--- a/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs
+++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs
@@ -10,9 +10,9 @@ public sealed class DbFixture : IDisposable
public DbFixture()
{
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
- // Apply migrations so the schema is created.
+ // EnsureCreated uses the current model directly — no Designer.cs needed.
using var ctx = CreateContext();
- ctx.Database.Migrate();
+ ctx.Database.EnsureCreated();
}
public ClaudeDoDbContext CreateContext()
diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
new file mode 100644
index 0000000..e9c16c7
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
@@ -0,0 +1,123 @@
+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, _lists, _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));
+ }
+}
\ No newline at end of file