using System.Security.Cryptography; using System.Text.Json; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.State; 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; private readonly string _projectsRoot; private readonly ITaskStateService? _state; private readonly PlanningChainCoordinator? _chain; // DI constructor. public PlanningSessionManager( IDbContextFactory factory, GitService git, WorkerConfig cfg, ITaskStateService state, PlanningChainCoordinator chain, string rootDirectory, string? projectsRoot = null) { _factory = factory; _git = git; _cfg = cfg; _state = state; _chain = chain; _rootDirectory = rootDirectory; _projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects"); } // Test constructor. public PlanningSessionManager( TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, GitService git, WorkerConfig cfg, string rootDirectory, ITaskStateService? state = null, PlanningChainCoordinator? chain = null, string? projectsRoot = null) { _tasksOverride = tasks; _listsOverride = lists; _settingsOverride = settings; _git = git; _cfg = cfg; _state = state; _chain = chain; _rootDirectory = rootDirectory; _projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects"); } 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.Idle || task.PlanningPhase != PlanningPhase.None) throw new InvalidOperationException( $"Task is in status {task.Status}/{task.PlanningPhase}; only Idle+None 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(); if (_state is not null) { var startResult = await _state.StartPlanningAsync(taskId, ct); if (!startResult.Ok) throw new InvalidOperationException(startResult.Reason ?? "Failed to transition task to Planning."); await tasks.SetPlanningSessionTokenAsync(taskId, token, ct); } else { // Test fallback when no state-service is provided. if (await tasks.SetPlanningStartedAsync(taskId, token, ct) is null) 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; if (_state is null || _chain is null) throw new InvalidOperationException( "PlanningSessionManager.FinalizeAsync requires ITaskStateService and PlanningChainCoordinator."); var finalizeResult = await _state.FinalizePlanningAsync(taskId, ct); if (!finalizeResult.Ok) throw new InvalidOperationException( finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}."); // Establish the blocked-by chain but leave children Idle; queueing is a // deliberate user action ("Queue plan"), never an automatic finalize step. // queueAgentTasks is accepted for compatibility but no longer auto-queues. var children = await tasks.GetChildrenAsync(taskId, ct); int count = children.Count; if (children.Count > 0) count = await _chain.SetupChainAsync(taskId, enqueue: false, 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.Idle); } public async Task DiscardAsync( string taskId, bool dequeueQueuedChildren, CancellationToken ct) { var (tasks, lists, settings, ctx) = CreateRepos(); await using var __ = ctx; var outcome = await tasks.DiscardPlanningAsync(taskId, dequeueQueuedChildren, ct); if (outcome.Result != DiscardPlanningResult.Discarded) return outcome; await TryCleanupWorktreeAsync(taskId, lists, settings, ct); var sessionDir = Path.Combine(_rootDirectory, taskId); if (Directory.Exists(sessionDir)) { try { Directory.Delete(sessionDir, recursive: true); } catch { } } return outcome; } 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.PlanningPhase != PlanningPhase.Active) throw new InvalidOperationException( $"Task planning phase is {task.PlanningPhase}; resume requires Active planning."); 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}"); // The planning session runs in an interactive TUI, which never reports its session id // back to us — so nothing ever wrote planning_session_id. Recover it from the transcript // Claude Code leaves on disk for the planning worktree, and persist it so the next // resume is a plain DB read. var sessionId = task.PlanningSessionId; if (string.IsNullOrEmpty(sessionId)) { sessionId = PlanningTranscriptLocator.FindSessionId(worktreePath, _projectsRoot) ?? throw new InvalidOperationException( "No Claude session transcript found for this planning worktree; cannot resume. " + "Finalize or discard the planning session instead."); await tasks.UpdatePlanningSessionIdAsync(taskId, sessionId, ct); } var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct); return new PlanningSessionResumeContext( ParentTaskId: taskId, WorkingDir: worktreePath, ClaudeSessionId: sessionId, 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() => PromptFiles.ReadOrDefault(PromptKind.Planning); private static string BuildInitialPrompt(TaskEntity task) => PromptFiles.Render(PromptKind.PlanningInitial, new Dictionary { ["title"] = task.Title, ["description"] = task.Description ?? "", }); 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(WorktreeRootResolver.ResolveSiblingRoot(listWorkingDir), ".claudedo-worktrees", "planning", taskId); var worktreePath = Path.GetFullPath(raw); WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, listWorkingDir); return worktreePath; } 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(); } } /// /// Recovers the Claude session id of an interactive session from the transcript Claude Code /// writes to ~/.claude/projects/<encoded cwd>/<sessionId>.jsonl. A planning /// worktree is used by exactly one session, so the newest transcript in that folder is it. /// /// /// The folder-name encoding is undocumented CLI behaviour (every non-alphanumeric character in /// the absolute path becomes '-'). If it ever changes, returns null /// and the caller reports "cannot resume" instead of resuming the wrong session. /// public static class PlanningTranscriptLocator { public static string? FindSessionId(string worktreePath, string projectsRoot) { var dir = Path.Combine(projectsRoot, EncodeCwd(worktreePath)); if (!Directory.Exists(dir)) return null; var newest = new DirectoryInfo(dir) .EnumerateFiles("*.jsonl") .OrderByDescending(f => f.LastWriteTimeUtc) .FirstOrDefault(); return newest is null ? null : Path.GetFileNameWithoutExtension(newest.Name); } private static string EncodeCwd(string path) { var full = Path.GetFullPath(path); return string.Create(full.Length, full, static (span, src) => { for (var i = 0; i < src.Length; i++) span[i] = char.IsAsciiLetterOrDigit(src[i]) ? src[i] : '-'; }); } }