using System.Security.Cryptography; using System.Text; using System.Text.Json; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; public sealed class PlanningSessionManager { private string McpServerUrl => $"http://127.0.0.1:{_cfg.SignalRPort}/mcp"; private readonly IDbContextFactory? _factory; private readonly TaskRepository? _tasksOverride; private readonly ListRepository? _listsOverride; private readonly AppSettingsRepository? _settingsOverride; private readonly GitService _git; private readonly WorkerConfig _cfg; private readonly string _rootDirectory; // DI constructor. public PlanningSessionManager( IDbContextFactory factory, GitService git, WorkerConfig cfg, string rootDirectory) { _factory = factory; _git = git; _cfg = cfg; _rootDirectory = rootDirectory; } // Test constructor. public PlanningSessionManager( TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, GitService git, WorkerConfig cfg, string rootDirectory) { _tasksOverride = tasks; _listsOverride = lists; _settingsOverride = settings; _git = git; _cfg = cfg; _rootDirectory = rootDirectory; } private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos() { if (_tasksOverride is not null) return (_tasksOverride, _listsOverride!, _settingsOverride!, null); var ctx = _factory!.CreateDbContext(); return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx); } public async Task StartAsync(string taskId, CancellationToken ct) { var (tasks, lists, settings, ctx) = CreateRepos(); await using var _ = ctx; 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 list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); var listWorkingDir = list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."); if (!await _git.IsGitRepoAsync(listWorkingDir, ct)) throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}"); var appSettings = await settings.GetAsync(ct); var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir); var branchName = BranchNameFor(taskId); var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct); Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!); try { await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct); } catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)) { // Self-heal: remove phantom worktrees, prune, delete branch, retry once. var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct); foreach (var stale in stalePaths) { try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { } } try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { } try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { } await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct); } // Write .mcp.json and .claude/settings.local.json into the worktree. var mcpPath = Path.Combine(worktreePath, ".mcp.json"); await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct); var claudeDir = Path.Combine(worktreePath, ".claude"); Directory.CreateDirectory(claudeDir); await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct); // Session dir + token + prompt files. var token = GenerateToken(); var started = 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, "system-prompt.md"), Path.Combine(sessionDir, "initial-prompt.txt")); await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct); await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct); await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct); return new PlanningSessionStartContext( ParentTaskId: taskId, WorkingDir: worktreePath, Token: token, WorktreePath: worktreePath, BranchName: branchName, Files: files); } public async Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) { var (tasks, lists, settings, ctx) = CreateRepos(); await using var __ = ctx; var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); // Best-effort cleanup — don't block finalization on git state. await TryCleanupWorktreeAsync(taskId, lists, settings, ct); var sessionDir = Path.Combine(_rootDirectory, taskId); if (Directory.Exists(sessionDir)) { try { Directory.Delete(sessionDir, recursive: true); } catch { } } return count; } public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct) { var (tasks, _, settings, ctx) = CreateRepos(); await using var __ = ctx; var children = await tasks.GetChildrenAsync(taskId, ct); return children.Count(c => c.Status == TaskStatus.Draft); } public async Task DiscardAsync(string taskId, CancellationToken ct) { var (tasks, lists, settings, ctx) = CreateRepos(); await using var __ = ctx; var ok = await tasks.DiscardPlanningAsync(taskId, ct); await TryCleanupWorktreeAsync(taskId, lists, settings, ct); var sessionDir = Path.Combine(_rootDirectory, taskId); if (Directory.Exists(sessionDir)) { try { Directory.Delete(sessionDir, recursive: true); } catch { } } if (!ok) throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard."); } public async Task ResumeAsync(string taskId, CancellationToken ct) { var (tasks, lists, settings, ctx) = CreateRepos(); await using var _ = ctx; 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); if (!Directory.Exists(sessionDir)) throw new InvalidOperationException($"Session directory missing: {sessionDir}"); var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); var listWorkingDir = list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."); var appSettings = await settings.GetAsync(ct); var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir); if (!Directory.Exists(worktreePath)) throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}"); var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct); return new PlanningSessionResumeContext( ParentTaskId: taskId, WorkingDir: worktreePath, ClaudeSessionId: task.PlanningSessionId, Token: token, WorktreePath: worktreePath); } private async Task TryCleanupWorktreeAsync( string taskId, ListRepository lists, AppSettingsRepository settings, CancellationToken ct) { try { var (tasks, _, _, ctx2) = CreateRepos(); await using var __ = ctx2; var task = await tasks.GetByIdAsync(taskId, ct); if (task is null) return; var list = await lists.GetByIdAsync(task.ListId, ct); var listWorkingDir = list?.WorkingDir; if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return; var appSettings = await settings.GetAsync(ct); var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir); var branchName = BranchNameFor(taskId); if (Directory.Exists(worktreePath)) { try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); } catch { /* best effort */ } } try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { } } catch { /* best effort — never block finalize/discard */ } } private static string GenerateToken() { var bytes = RandomNumberGenerator.GetBytes(32); return Convert.ToBase64String(bytes) .Replace('+', '-') .Replace('/', '_') .TrimEnd('='); } private string BuildMcpConfigJson() { var payload = new { mcpServers = new { claudedo = new { type = "http", url = McpServerUrl, headers = new Dictionary { ["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}" } } } }; return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); } private const string SettingsLocalJson = """ { "enableAllProjectMcpServers": 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(); } private static string BranchNameFor(string taskId) => $"claudedo/planning/{taskId.Replace("-", "")}"; private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir) { var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride) ? centralRootOverride! : _cfg.CentralWorktreeRoot; var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase) ? Path.Combine(centralRoot, "planning", taskId) : Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId); return Path.GetFullPath(raw); } private static string TokenFilePathFor(string sessionDir) => Path.Combine(sessionDir, "token"); private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct) { await File.WriteAllTextAsync(path, token, ct); // Best-effort current-user-only ACL on Windows. On non-Windows the inherited // perms from the parent dir apply; acceptable because sessionDir is already // under the user's home (~/.todo-app/sessions/). if (OperatingSystem.IsWindows()) { try { var fi = new FileInfo(path); var ac = fi.GetAccessControl(); ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!; ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule( me, System.Security.AccessControl.FileSystemRights.FullControl, System.Security.AccessControl.AccessControlType.Allow)); fi.SetAccessControl(ac); } catch { /* ACL hardening is best-effort */ } } } private static async Task ReadTokenFileAsync(string path, CancellationToken ct) { if (!File.Exists(path)) throw new InvalidOperationException($"Token file missing: {path}"); return (await File.ReadAllTextAsync(path, ct)).Trim(); } }