using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Worktrees; using Microsoft.EntityFrameworkCore; using ModelContextProtocol; using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.External; public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record DeleteTaskResult(bool Deleted, string Id, int Number); public sealed record CancelTaskResult(bool Cancelled, string Id, int? Number = null); // EmptyChildren is non-null only for a parent's approve (unit merge): the Done children whose // review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less // child) contributed nothing, so a reviewer sees them before approving instead of after. public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList? EmptyChildren = null); public sealed record RunTaskNowResult(bool Started, string TaskId, DirtyBaseWarning? BaseDirty = null, int? Number = null); public sealed record TaskDto( string Id, int Number, string ListId, string Title, string? Description, string Status, string? Result, string? CreatedBy, DateTime CreatedAt, DateTime? StartedAt, DateTime? FinishedAt, bool IsMyDay, int SortOrder, // Count of CLAUDEDO_BLOCKED roadblocks the run reported, stamped by TaskRunner on finish. // A planning/improvement child reporting > 0 still goes straight to Done (see // ClaudeDo.Worker/CLAUDE.md → Unified parent model) -- this is the only MCP-visible signal // that it may have delivered nothing despite that Done status. int RoadblockCount = 0, // The three below are non-null only when Status=Failed; stamped by TaskRunner.MarkFailed via // TaskStateService.FailAsync. failureReason is "unknown" for a Failed task that predates this // field. Lets a caller triage without pulling get_task_log's raw NDJSON. string? FailureReason = null, int? FailureTurnsUsed = null, int? FailureMaxTurns = null, // A user/MCP-declared predecessor (set via add_task/update_task), distinct from the // planning chain's own internal BlockedByTaskId link. string? DependsOnTaskId = null, // True only while Status is Queued and the picker will not claim this task yet -- either a // planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason. bool Blocked = false, string? BlockedReason = null); // Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed // without re-sending Description/Result, which the caller just sent or already has. // BaseDirty is populated only where the task just transitioned to Queued (or ran immediately // via run_task_now) and its list's working directory has uncommitted changes -- see // ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null. public sealed record TaskRefDto( string Id, int Number, string ListId, string Title, string Status, int SortOrder, bool IsMyDay, int RoadblockCount = 0, string? FailureReason = null, int? FailureTurnsUsed = null, int? FailureMaxTurns = null, string? DependsOnTaskId = null, bool Blocked = false, string? BlockedReason = null, DirtyBaseWarning? BaseDirty = null); // tasks is populated when includeDescription=false (the default): lean references, no // Description/Result. tasksFull is populated when includeDescription=true: full tasks incl. // Description/Result. Exactly one of the two is non-null per the includeDescription flag — // same "flag alongside nullable payload" idiom as BatchGetTaskResult. public sealed record ListTasksResult( bool IncludeDescription, IReadOnlyList? Tasks, IReadOnlyList? TasksFull); // Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check). public sealed record PossibleDuplicateDto(string TaskId, int Number, string Title, string Status); public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList PossibleDuplicates); public sealed record WorktreeInfoDto( string Path, string Branch, string HeadCommit, string BaseCommit, int Ahead, int Behind, bool IsDirty, string? MergeCommit = null); public sealed record TaskDiffDto( string Content, IReadOnlyList Files, bool Truncated, int TotalBytes); public sealed record MergeTaskResultDto( bool Merged, string? MergeCommit, IReadOnlyList Conflicts, bool ConflictsInTree = false, string? RepoPath = null); public sealed record MergeContinuationResultDto( bool Merged, string TaskStatus, IReadOnlyList Conflicts, string? RepoPath, string? Message); public sealed record ConflictHunkDto( int Index, int StartLine, string Ours, string? Base, string Theirs); public sealed record ConflictFileHunksDto( string Path, bool IsBinary, IReadOnlyList Hunks); public sealed record GetMergeConflictsResultDto( IReadOnlyList Files, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note); public sealed record ResolveConflictHunkResultDto( bool Resolved, int RemainingHunks, string ConflictStyleNote = McpToolDocs.Diff3Note); // IsEmpty = the review range contributed nothing (worktree ahead-of-base is empty, or a // worktree-less handler task's HandlerBaseCommit == HandlerHeadCommit) -- distinguishable from // a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0 // means tiny" when it actually means "nothing to review". // VerifyExitCode/VerifyDurationMs/VerifyOutputTail are null unless a verify run was attempted // (see PreviewMerge/PreviewMergeSet descriptions) -- 0 exit means the merge-tree result built/ // tested clean; a non-zero or -1 (timeout/failed to start) exit means it doesn't, with the tail // of its output in VerifyOutputTail. public sealed record MergePreviewToolDto( string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false, int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null, // Files this branch touches that the target branch ALSO touched since the branch's fork // point -- an honest staleness signal `behind` alone can't give (a branch can be far behind // yet touch nothing the target changed, or close behind yet collide on the one file that // matters). Empty for a worktree-less handler task, which commits straight onto the list's // working dir and has no fork point to compare against. IReadOnlyList? StaleFiles = null); public sealed record MergePreviewSetEntryDto( string TaskId, string Status, IReadOnlyList ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false, int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null, IReadOnlyList? StaleFiles = null, int? Number = null); public sealed record FileOverlapDto(string File, IReadOnlyList TaskIds, IReadOnlyList Numbers); // TaskId's changed-file list is a non-empty PROPER subset of SupersetTaskId's -- e.g. one task // deletes two files and another deletes just one of those same two. Stronger than an Overlaps // entry (which only says some files are shared): a real subset is the best available machine // signal that TaskId's work may already be entirely covered by SupersetTaskId. public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber); public sealed record MergePreviewSetResultDto( IReadOnlyList Tasks, IReadOnlyList Overlaps, IReadOnlyList Subsets); public sealed record WorktreeListItemDto( string? TaskId, int? Number, string Path, string Branch, string HeadCommit, bool IsDirty, bool MergedIntoMain); public sealed record CleanupWorktreeResult( bool Removed, string WorktreePath, bool BranchDeleted, int? Number = null); public sealed record RevertMergeResultDto( bool Reverted, string? RevertCommit, IReadOnlyList Conflicts, string? Message); public sealed record DailyPrepCandidateDto( string Id, int Number, string ListId, string ListName, string Title, string? Description, bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt); public sealed record DailyPrepDataDto( int MaxTasks, IReadOnlyList Candidates, IReadOnlyList CurrentMyDay); [McpServerToolType] public sealed class ExternalMcpService { // Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval: // a separate field (not shared) so shrinking one for a test can't race another's tests. Used // by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare // GitService/WorktreeMaintenanceService call with no natural i/n to report instead. internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30); private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly QueueService _queue; private readonly HubBroadcaster _broadcaster; private readonly ITaskStateService _state; private readonly GitService _git; private readonly IDbContextFactory _dbFactory; private readonly WorktreeMaintenanceService _maintenance; private readonly TaskMergeService _merge; private readonly PlanningMergeOrchestrator _planningMerge; private readonly IBaseDirtyChecker _baseDirtyChecker; public ExternalMcpService( TaskRepository tasks, ListRepository lists, QueueService queue, HubBroadcaster broadcaster, ITaskStateService state, GitService git, IDbContextFactory dbFactory, WorktreeMaintenanceService maintenance, TaskMergeService merge, PlanningMergeOrchestrator planningMerge, IBaseDirtyChecker baseDirtyChecker) { _tasks = tasks; _lists = lists; _queue = queue; _broadcaster = broadcaster; _state = state; _git = git; _dbFactory = dbFactory; _maintenance = maintenance; _merge = merge; _planningMerge = planningMerge; _baseDirtyChecker = baseDirtyChecker; } [McpServerTool, Description( "List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")] public async Task> ListTaskLists(CancellationToken cancellationToken) { var lists = await _lists.GetAllAsync(cancellationToken); return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList(); } [McpServerTool, Description( "List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status." + McpToolDocs.TaskNumberHint)] public async Task ListTasks( string listId, [Description("Only return tasks with this CreatedBy value.")] string? createdBy = null, [Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " + "WaitingForChildren, Done, Failed or Cancelled.")] string? status = null, [Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " + "need the description text, since verbosely-described tasks can blow past the response size " + "limit. true: full tasks in `tasksFull` instead (`tasks` is then null).")] bool includeDescription = false, CancellationToken cancellationToken = default) { TaskStatus? statusFilter = null; if (!string.IsNullOrWhiteSpace(status)) { if (!Enum.TryParse(status, ignoreCase: true, out var parsed)) throw new InvalidOperationException( $"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled."); statusFilter = parsed; } var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken); IEnumerable query = tasks; if (createdBy is not null) query = query.Where(t => t.CreatedBy == createdBy); if (statusFilter is not null) query = query.Where(t => t.Status == statusFilter); var filtered = query.ToList(); var blocked = await ComputeBlockedInfoAsync(filtered, cancellationToken); return includeDescription ? new ListTasksResult(true, null, filtered.Select(t => ToDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList()) : new ListTasksResult(false, filtered.Select(t => ToRefDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList(), null); } // Batch-resolves, for each task, whether the picker is currently skipping it (Queued with // either a planning-chain BlockedByTaskId or an unmet DependsOnTaskId) and why. Only Queued // tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot. private async Task> ComputeBlockedInfoAsync( IReadOnlyList tasks, CancellationToken ct) { var dependencyIds = tasks .Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null && t.DependsOnTaskId is not null) .Select(t => t.DependsOnTaskId!) .Distinct() .ToList(); var dependencyStatuses = new Dictionary(); if (dependencyIds.Count > 0) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); dependencyStatuses = await ctx.Tasks.AsNoTracking() .Where(t => dependencyIds.Contains(t.Id)) .Select(t => new { t.Id, t.Status }) .ToDictionaryAsync(t => t.Id, t => t.Status, ct); } var result = new Dictionary(); foreach (var t in tasks) { if (t.Status != TaskStatus.Queued) { result[t.Id] = (false, null); } else if (t.BlockedByTaskId is not null) { result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}."); } else if (t.DependsOnTaskId is not null) { var known = dependencyStatuses.TryGetValue(t.DependsOnTaskId, out var depStatus); result[t.Id] = known && depStatus == TaskStatus.Done ? (false, null) : (true, $"Blocked: depends on task {t.DependsOnTaskId} (status: " + (known ? depStatus.ToString() : "not found") + ")."); } else { result[t.Id] = (false, null); } } return result; } [McpServerTool, Description( "Get a single task by id, including its current status and result — the canonical reference for what a " + "status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " + "A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " + "Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " + "for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " + "is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" + "error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." + McpToolDocs.TaskNumberHint)] public async Task GetTask(string taskId, CancellationToken cancellationToken) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var blocked = await ComputeBlockedInfoAsync([task], cancellationToken); return ToDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason); } // Lean counterpart to GetTask, used internally by BatchGetTasks' default (includeDescription=false) // path. Not an MCP tool itself — GetTask's own behavior stays untouched. internal async Task GetTaskRefAsync(string taskId, CancellationToken cancellationToken) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var blocked = await ComputeBlockedInfoAsync([task], cancellationToken); return ToRefDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason); } [McpServerTool, Description( "Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " + "heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " + "any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)] public async Task AddTask( string listId, string title, string? description = null, string? createdBy = null, [Description("true: enqueue the task for agent execution right away.")] bool queueImmediately = false, [Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " + "for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " + "list/global default (normally sonnet).")] string? model = null, [Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " + "even once queued. Rejected if it doesn't exist, is this task's own id, or would create a " + "dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " + "stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")] string? dependsOnTaskId = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(listId)) throw new InvalidOperationException("listId is required."); if (string.IsNullOrWhiteSpace(title)) throw new InvalidOperationException("title is required."); var list = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken); var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken); var entity = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Description = description, Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, CommitType = list.DefaultCommitType, CreatedBy = createdBy.NullIfBlank() ?? "mcp", Model = ModelRegistry.NormalizeAlias(model), // A manual list holds reminders, so anything filed there starts out manual. IsManual = list.IsManual, }; await _tasks.AddAsync(entity, cancellationToken); if (dependsOnTaskId is not null) { var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken); if (!dependsResult.Ok) throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId."); entity.DependsOnTaskId = dependsOnTaskId; } if (queueImmediately) { var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); if (!enqueue.Ok) throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task."); entity.Status = TaskStatus.Queued; } await _broadcaster.TaskUpdated(entity.Id); var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken); return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates); } // Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate // of it. Done/Failed/Cancelled tasks are retired and never flagged. private static readonly TaskStatus[] NonTerminalStatuses = [ TaskStatus.Idle, TaskStatus.Queued, TaskStatus.Running, TaskStatus.WaitingForChildren, TaskStatus.WaitingForReview, ]; private const int MaxPossibleDuplicates = 3; // Cheap, dependency-free "near-duplicate title" heuristic — no embeddings, no LLM call, just a // normalized-word overlap coefficient (shared significant words / smaller word-set size, not // Jaccard). Overlap-coefficient beats Jaccard here because titles in this project vary wildly in // length (a terse one-liner vs. a fully-spelled-out German sentence) — dividing by the union // would wash out a real match against a longer title. Threshold tuned against the pair that // motivated this check: "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal" vs. // "Settings: MaxTurnsCeiling editierbar machen" (>=2 shared significant words, >=50% overlap of // the shorter title's words) without over-firing on titles that merely share one generic word. private async Task> FindPossibleDuplicatesAsync( string listId, string title, CancellationToken ct) { var newWords = NormalizeTitleWords(title); if (newWords.Count == 0) return []; var candidates = await _tasks.GetByListIdAsync(listId, ct); return candidates .Where(t => NonTerminalStatuses.Contains(t.Status)) .Select(t => (Task: t, Words: NormalizeTitleWords(t.Title))) .Select(x => (x.Task, Shared: newWords.Count(x.Words.Contains), x.Words)) .Where(x => x.Shared >= 2 && x.Words.Count > 0 && (double)x.Shared / Math.Min(newWords.Count, x.Words.Count) >= 0.5) .OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count)) .ThenByDescending(x => x.Shared) .Take(MaxPossibleDuplicates) .Select(x => new PossibleDuplicateDto(x.Task.Id, x.Task.Number, x.Task.Title, x.Task.Status.ToString())) .ToList(); } private static readonly Regex NonWordChars = new(@"[^a-z0-9]+", RegexOptions.Compiled); // Common structural filler that would otherwise dominate the overlap score across most titles // in this project's lists without indicating a real duplicate (German articles/prepositions // plus the recurring "mcp"/"task(s)"/"cleanup" nouns called out in the task write-up). private static readonly HashSet TitleStopWords = new(StringComparer.Ordinal) { "der", "die", "das", "und", "von", "auf", "mit", "fuer", "ein", "eine", "ist", "sind", "oder", "nicht", "mcp", "task", "tasks", "cleanup", }; // Lowercases, transliterates German umlauts/ß to their digraph spelling (ä→ae, ß→ss, ...) so // "Prüfung" and "Pruefung" normalize to the same token, strips remaining diacritics/punctuation, // then drops short (<3 char) and stopword tokens. private static HashSet NormalizeTitleWords(string title) { var s = title.ToLowerInvariant() .Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss"); s = s.Normalize(NormalizationForm.FormD); var sb = new StringBuilder(s.Length); foreach (var ch in s) if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark) sb.Append(ch); var ascii = NonWordChars.Replace(sb.ToString(), " "); return ascii.Split(' ', StringSplitOptions.RemoveEmptyEntries) .Where(w => w.Length >= 3 && !TitleStopWords.Contains(w)) .ToHashSet(StringComparer.Ordinal); } [McpServerTool, Description( "Update an existing task's title, description, commit type, and/or dependsOn link. Pass null to leave a " + "field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task UpdateTask( string taskId, string? title = null, string? description = null, string? commitType = null, [Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " + "string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " + "is this task's own id, or would create a dependency cycle.")] string? dependsOnTaskId = null, CancellationToken cancellationToken = default) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot update a running task. Cancel it first."); if (title is not null) task.Title = title; if (description is not null) task.Description = description; if (commitType is not null) task.CommitType = commitType; await _tasks.UpdateAsync(task, cancellationToken); if (dependsOnTaskId is not null) { // Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to // clear the link — TaskIdResolver passes it through untouched, then NullIfBlank // below still turns it into the clear signal SetDependsOnAsync expects. dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken); var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken); if (!dependsResult.Ok) throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId."); } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken); return ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason); } [McpServerTool, Description( "Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " + "task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task AddSubtask( string taskId, string title, [Description("Position among the existing steps; defaults to the end.")] int? orderNum = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(title)) throw new InvalidOperationException("title is required."); taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var tasks = new TaskRepository(ctx); var subtasks = new SubtaskRepository(ctx); var task = await tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first."); var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken); var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1); await subtasks.AddAsync(new SubtaskEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, Title = title.Trim(), Completed = false, OrderNum = order, CreatedAt = DateTime.UtcNow, }, cancellationToken); await _broadcaster.TaskUpdated(taskId); return ToRefDto(task); } [McpServerTool, Description( "Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " + "review_task to act on a WaitingForReview task — neither is reachable from here." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)] public async Task UpdateTaskStatus( string taskId, [Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " + "deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " + "active worktree — use review_task to approve and merge that worktree instead). No other " + "value is settable externally.")] string status, CancellationToken cancellationToken) { if (!Enum.TryParse(status, ignoreCase: true, out var target)) throw new InvalidOperationException( $"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled."); taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); DirtyBaseWarning? baseDirty = null; switch (target) { case TaskStatus.Idle: await _tasks.ResetToManualAsync(taskId, cancellationToken); await _broadcaster.TaskUpdated(taskId); break; case TaskStatus.Queued: var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken); if (!enqueueResult.Ok) throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task."); baseDirty = enqueueResult.BaseDirty; break; case TaskStatus.Cancelled: var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken, allowFromIdle: true); if (!cancelResult.Ok) throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task."); break; case TaskStatus.Done: await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken)) { var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken); if (wt is not null && wt.State == WorktreeState.Active) throw new InvalidOperationException( "Task has an active worktree — use review_task to approve and merge instead."); } var doneResult = await _state.ForceSetStatusAsync(taskId, TaskStatus.Done, cancellationToken); if (!doneResult.Ok) throw new InvalidOperationException(doneResult.Reason ?? "Cannot set task to Done."); break; default: throw new InvalidOperationException( $"Status '{target}' is not settable externally. Use run_task_now or review_task."); } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; return ToRefDto(reload) with { BaseDirty = baseDirty }; } [McpServerTool, Description( "Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " + "'approve' is review+merge, exactly like the UI's Approve: a childless task merges its worktree into " + "targetBranch then goes Done; a task with children drives the unit merge (parent worktree if active + each " + "Done child in order); a task without an active worktree approves straight to Done. Fails if the task is " + "not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " + "means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " + "the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " + "reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " + "delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)] public async Task ReviewTask( string taskId, [Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")] string decision, [Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " + "this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " + "just returns the task to Idle for manual editing.")] string? feedback = null, [Description("Branch an approve merges into; defaults to the repo's current branch.")] string? targetBranch = null, [Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " + "no half-merged state, and you resolve in the ClaudeDo UI. true: leave the conflict markers " + "in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " + "or abort_merge to cancel.")] bool leaveConflictsInTree = false, CancellationToken cancellationToken = default, IProgress? progress = null) { // First report fires before any git/verify work starts -- an approve that has to wait on // the per-repo merge gate (another merge/verify already running) must not sit silent long // enough to trip Claude Code's ~300s MCP idle-silence abort before RunVerifyGateAsync's own // periodic reports even begin. progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "review_task started" }); taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); string? mergeStatus = null; IReadOnlyList mergeConflicts = Array.Empty(); string? mergeMessage = null; string? repoPath = null; IReadOnlyList? emptyChildren = null; if (decision.Trim().ToLowerInvariant() == "approve") { // Approve is the single review+merge action — mirror the hub's ApproveReview // routing instead of only flipping the status (which left branches unmerged). bool hasChildren; await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken)) hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, cancellationToken); if (hasChildren) { // Compute before the merge starts -- children are still Done with their own // pre-merge worktree/commit range at this point, so "did it contribute anything" // reflects the review range the reviewer is about to approve. emptyChildren = await GetEmptyDoneChildrenAsync(taskId, cancellationToken); // externallyDriven: true — this call came from an MCP session, not the UI's // Approve button. A unit-merge conflict must not auto-open the in-app resolver; // the driving session resolves it via continue_merge/abort_merge instead. await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress); var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done; mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict; if (!parentDone) { var list = await _lists.GetByIdAsync(task.ListId, cancellationToken); repoPath = list?.WorkingDir; mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " + "resolve them then call continue_merge with the parent task id, or abort_merge to cancel"; } } else { var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress); if (r.Status == TaskMergeService.StatusBlocked) throw new InvalidOperationException(r.ErrorMessage ?? "approve failed"); mergeStatus = r.Status; mergeConflicts = r.ConflictFiles; if (r.Status == TaskMergeService.StatusConflict) { if (leaveConflictsInTree) { var list = await _lists.GetByIdAsync(task.ListId, cancellationToken); repoPath = list?.WorkingDir; mergeMessage = "merge conflict — markers left in the working tree; " + "resolve them then call continue_merge, or abort_merge to cancel"; } else { mergeMessage = "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI"; } } else { mergeMessage = r.ErrorMessage; } } } else { TransitionResult result = decision.Trim().ToLowerInvariant() switch { "reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken), "reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken), "cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken), _ => throw new InvalidOperationException( $"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."), }; if (!result.Ok) throw new InvalidOperationException(result.Reason ?? "Review action failed."); } return new ReviewTaskResult( ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!), mergeStatus, mergeConflicts, mergeMessage, repoPath, emptyChildren); } // Done children about to be unit-merged whose own review range contributed nothing: an // active worktree with zero changed files against its base commit, or a worktree-less // handler child whose HandlerBaseCommit == HandlerHeadCommit. A child with neither (never // committed anything at all) also counts as empty. Best-effort per child -- a diff failure // on one child must not block the caller from seeing the others or from approving. private async Task> GetEmptyDoneChildrenAsync(string parentTaskId, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var children = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) .Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done) .ToListAsync(ct); var empty = new List(); foreach (var child in children) { if (await IsChildEmptyAsync(child, ct)) empty.Add(ToRefDto(child)); } return empty; } private async Task IsChildEmptyAsync(TaskEntity child, CancellationToken ct) { try { if (child.Worktree is not null) { if (!Directory.Exists(child.Worktree.Path)) return false; var files = ParseDiffStatFileNames( await _git.DiffStatAsync(child.Worktree.Path, child.Worktree.BaseCommit, "HEAD", ct: ct)); return files.Count == 0; } if (child.HandlerBaseCommit is { Length: > 0 } handlerBase && child.HandlerHeadCommit is { Length: > 0 } handlerHead) return string.Equals(handlerBase, handlerHead, StringComparison.Ordinal); return true; } catch { return false; } } [McpServerTool, Description( "Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" + "occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " + "enqueue via update_task_status instead of retrying in a loop.")] public async Task RunTaskNow(string taskId, CancellationToken cancellationToken) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); try { await _queue.RunNow(taskId); } catch (InvalidOperationException) { throw new InvalidOperationException("Override slot busy. Try again later."); } catch (KeyNotFoundException) { throw new InvalidOperationException($"Task {taskId} not found."); } await _broadcaster.TaskUpdated(taskId); // Heads-up only, same as update_task_status's Queued path: a worktree forks from the // commit tip, not the working tree, so uncommitted changes in the list's repo right now // won't be included. var task = await _tasks.GetByIdAsync(taskId, cancellationToken); var list = task is not null ? await _lists.GetByIdAsync(task.ListId, cancellationToken) : null; var baseDirty = await _baseDirtyChecker.CheckAsync(list?.WorkingDir, cancellationToken); return new RunTaskNowResult(true, taskId, baseDirty, task?.Number); } [McpServerTool, Description( "Cancel a running task, killing its agent process. cancelled=false means the task was not running.")] public async Task CancelTask(string taskId, CancellationToken cancellationToken) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var cancelled = _queue.CancelTask(taskId); if (cancelled) await _broadcaster.TaskUpdated(taskId); var task = await _tasks.GetByIdAsync(taskId, cancellationToken); return new CancelTaskResult(cancelled, taskId, task?.Number); } [McpServerTool, Description( "Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." + McpToolDocs.NotWhileRunning)] public async Task DeleteTask(string taskId, CancellationToken cancellationToken) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot delete a running task. Cancel it first."); await _tasks.DeleteAsync(taskId, cancellationToken); if (task.ParentTaskId is not null) await _state.TryAdvanceParentAsync(task.ParentTaskId); await _broadcaster.TaskUpdated(taskId); return new DeleteTaskResult(true, taskId, task.Number); } // ── Worktree / git tools ────────────────────────────────────────────────── [McpServerTool, Description( "Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " + "mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " + "read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " + "Throws if the task or its worktree does not exist.")] public async Task GetTaskWorktree(string taskId, CancellationToken cancellationToken) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken); var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit) ? wt.HeadCommit : await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit; var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken); var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken); var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken); return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty, wt.MergeCommit); } [McpServerTool, Description( "Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " + "list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " + "instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " + "totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " + "directory is missing from disk.")] public async Task GetTaskDiff( string taskId, [Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " + "per-file insertion/deletion counts — start here when the diff may be large.")] bool stat = false, [Description("Restrict the diff to these paths (relative to the repo root), e.g. after a --stat pass or a " + "conflict report already narrowed down which files matter. Omit/empty for every changed file " + "— the default and the only prior behavior. Works in both stat and full-diff mode.")] IReadOnlyList? paths = null, CancellationToken cancellationToken = default, IProgress? progress = null) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken); const int maxBytes = 200 * 1024; if (stat) { var diffStat = await ProgressReporter.RunAsync( _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", paths, cancellationToken), ProgressReportInterval, progress, "computing diff stat"); return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length); } var diff = headCommit is null ? await ProgressReporter.RunAsync( _git.GetBranchDiffAsync(repoPath, baseCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff") : await ProgressReporter.RunAsync( _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, paths, cancellationToken), ProgressReportInterval, progress, "computing diff"); var files = ParseDiffFileNames(diff); if (diff.Length <= maxBytes) return new TaskDiffDto(diff, files, false, diff.Length); return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length); } // Resolves where a task's diff lives: a live worktree (repo path = worktree path, diffed // against HEAD) or, for a worktree-less list-handler host task, the fixed // HandlerBaseCommit..HandlerHeadCommit range over the list's working dir (headCommit // non-null signals "fixed range" to the caller instead of "diff against live HEAD"). private async Task<(string RepoPath, string BaseCommit, string? HeadCommit)> LoadDiffRangeAsync( string taskId, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); if (wt is not null) { if (!Directory.Exists(wt.Path)) throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}"); return (wt.Path, wt.BaseCommit, null); } if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead) { var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); if (string.IsNullOrEmpty(list.WorkingDir) || !Directory.Exists(list.WorkingDir)) throw new InvalidOperationException("The list's working directory no longer exists."); return (list.WorkingDir, handlerBase, handlerHead); } throw new InvalidOperationException($"Task {taskId} has no worktree."); } [McpServerTool, Description( "Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " + "review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " + "merged=false and conflicts lists the affected files." + McpToolDocs.ProgressHint)] public async Task MergeTask( string taskId, string targetBranch = "main", [Description("true (default): always create a merge commit (--no-ff).")] bool noFf = true, [Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")] bool dryRun = false, [Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")] bool allowWaitingForReview = false, [Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " + "leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " + "resolve them there and call continue_merge, or abort_merge to cancel.")] bool leaveConflictsInTree = false, CancellationToken cancellationToken = default, IProgress? progress = null) { progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "merge_task started" }); taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var canMerge = task.Status == TaskStatus.Done || (allowWaitingForReview && task.Status == TaskStatus.WaitingForReview); if (!canMerge) throw new InvalidOperationException( $"Task must be Done to merge (current status: {task.Status}). " + "Pass allowWaitingForReview=true to also merge a WaitingForReview task."); var list = await _lists.GetByIdAsync(task.ListId, cancellationToken); if (dryRun) { using var ctx = _dbFactory.CreateDbContext(); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); if (wt.State != WorktreeState.Active) throw new InvalidOperationException( $"Worktree state must be Active to merge (current: {wt.State})."); return new MergeTaskResultDto(false, null, []); } // Blank on purpose: TaskMergeService builds the conventional default message. var result = await _merge.MergeAsync( taskId, targetBranch, removeWorktree: false, commitMessage: "", leaveConflictsInTree, cancellationToken, progress); if (result.Status == TaskMergeService.StatusMerged) { string? mergeCommit = null; try { if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir)) mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken); } catch { /* mergeCommit is optional */ } return new MergeTaskResultDto(true, mergeCommit, []); } if (result.Status == TaskMergeService.StatusConflict) return leaveConflictsInTree ? new MergeTaskResultDto(false, null, result.ConflictFiles, ConflictsInTree: true, RepoPath: list?.WorkingDir) : new MergeTaskResultDto(false, null, result.ConflictFiles); throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}"); } [McpServerTool, Description( "Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " + "(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " + "merged=false with conflicts listed means markers are still present — resolve them and call again. " + "Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead." + McpToolDocs.ProgressHint)] public async Task ContinueMerge( string taskId, CancellationToken cancellationToken = default, IProgress? progress = null) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var task = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var list = await _lists.GetByIdAsync(task.ListId, cancellationToken); var workingDir = list?.WorkingDir; bool merged; IReadOnlyList conflicts = Array.Empty(); string? repoPath = null; string? message = null; if (_planningMerge.HasActiveMerge(taskId)) { await _planningMerge.ContinueAsync(taskId, cancellationToken, progress); var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; if (parent.Status == TaskStatus.Done) { merged = true; } else { var remaining = !string.IsNullOrWhiteSpace(workingDir) ? await _git.ListConflictedFilesAsync(workingDir, cancellationToken) : new List(); merged = false; if (remaining.Count > 0) { conflicts = remaining; repoPath = workingDir; message = "conflicts remain — resolve and call continue_merge again"; } else { message = "unit merge did not complete — the orchestrator aborted or was blocked; " + "check the parent task and approve again to restart the merge"; } } } else { var r = await _merge.ContinueMergeAsync(taskId, cancellationToken, progress); if (r.Status == TaskMergeService.StatusMerged) { merged = true; } else if (r.Status == TaskMergeService.StatusConflict) { merged = false; conflicts = r.ConflictFiles; repoPath = workingDir; message = r.ErrorMessage; } else { throw new InvalidOperationException(r.ErrorMessage ?? "continue failed"); } } var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; await _broadcaster.TaskUpdated(taskId); return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message); } [McpServerTool, Description( "Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " + "Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " + "(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)] public async Task AbortMerge(string taskId, CancellationToken cancellationToken) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); _ = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (_planningMerge.HasActiveMerge(taskId)) { await _planningMerge.AbortAsync(taskId, cancellationToken); } else { var r = await _merge.AbortMergeAsync(taskId, cancellationToken); if (r.Status == TaskMergeService.StatusBlocked) throw new InvalidOperationException(r.ErrorMessage ?? "abort failed"); } await _broadcaster.TaskUpdated(taskId); return ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!); } [McpServerTool, Description( "List every conflict hunk left in a paused merge (started via merge_task/review_task with " + "leaveConflictsInTree=true), so you can resolve them with resolve_conflict_hunk instead of parsing marker " + "text yourself. Each hunk carries the file's path, its index within that file (what resolve_conflict_hunk " + "expects back), and its startLine. remainingHunks is the total across every file — call continue_merge once " + "it reaches 0, or abort_merge to cancel. Throws if the task has no in-progress merge." + " " + McpToolDocs.Diff3Note)] public async Task GetMergeConflicts(string taskId, CancellationToken cancellationToken = default) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); _ = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken); var files = docs.Files.Select(ToConflictFileHunksDto).ToList(); var remaining = files.Sum(f => f.Hunks.Count); return new GetMergeConflictsResultDto(files, remaining); } [McpServerTool, Description( "Resolve exactly one conflict hunk from get_merge_conflicts, identified by file path and its index within " + "that file. Writes only that hunk — every other hunk (in this file or another) is left exactly as it was, " + "so call get_merge_conflicts again afterward: resolved hunks disappear and every remaining hunk's index " + "shifts down to stay zero-based. This never decides FOR you which side wins — resolution must be 'ours', " + "'theirs', 'base' (only valid when that hunk has a diff3 base), or literal replacement text (used exactly " + "as given, including any trailing newline the surrounding file needs). remainingHunks is the total across " + "every file; call continue_merge once it reaches 0. Throws if the file/index don't match a current hunk." + " " + McpToolDocs.Diff3Note)] public async Task ResolveConflictHunk( string taskId, [Description("File path exactly as returned by get_merge_conflicts, relative to the repo root.")] string file, [Description("Zero-based hunk index within that file, as returned by get_merge_conflicts.")] int index, [Description("'ours', 'theirs', 'base', or literal text to use for this hunk.")] string resolution, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(resolution)) throw new InvalidOperationException("resolution is required."); taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var docs = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken); var doc = docs.Files.FirstOrDefault(f => string.Equals(f.Path, file, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"File '{file}' has no conflict for task {taskId}."); if (doc.IsBinary) throw new InvalidOperationException($"File '{file}' is binary; resolve it directly in the working tree."); var hunkCount = doc.Segments.Count(s => s.IsConflict); if (index < 0 || index >= hunkCount) throw new InvalidOperationException( $"Hunk index {index} is out of range for '{file}' ({hunkCount} hunk(s) remaining)."); var counter = -1; var composed = ConflictMarkerParser.Compose(doc.Segments, seg => { counter++; if (counter != index) return ReconstructConflictMarkers(seg); var choice = resolution.Trim().ToLowerInvariant(); return choice switch { "ours" => seg.Ours, "theirs" => seg.Theirs, "base" => seg.Base ?? throw new InvalidOperationException( $"Hunk {index} in '{file}' has no diff3 base to resolve to."), _ => resolution, }; }); await _merge.WriteConflictFileAsync(taskId, file, composed, cancellationToken); var refreshed = await _merge.GetConflictDocumentsAsync(taskId, cancellationToken); var remaining = refreshed.Files.Sum(f => f.Segments.Count(s => s.IsConflict)); return new ResolveConflictHunkResultDto(true, remaining); } private static ConflictFileHunksDto ToConflictFileHunksDto(ConflictDocumentContent f) { var hunks = new List(); foreach (var seg in f.Segments) { if (!seg.IsConflict) continue; hunks.Add(new ConflictHunkDto(hunks.Count, seg.StartLine, seg.Ours, seg.Base, seg.Theirs)); } return new ConflictFileHunksDto(f.Path, f.IsBinary, hunks); } // Re-renders a still-unresolved hunk as valid (unlabeled) conflict markers so it round-trips through // ConflictMarkerParser.Parse again untouched, and git still refuses continue_merge while it stands. The // original marker labels (e.g. "<<<<<<< HEAD") aren't recoverable from a parsed MergeSegment and aren't // needed for either check -- ConflictMarkerParser.IsMarker and ContinueMergeAsync's rescan only match the // marker prefixes. private static string ReconstructConflictMarkers(MergeSegment seg) { var sb = new StringBuilder(); sb.Append("<<<<<<<\n").Append(seg.Ours); if (seg.Base is not null) sb.Append("|||||||\n").Append(seg.Base); sb.Append("=======\n").Append(seg.Theirs); sb.Append(">>>>>>>\n"); return sb.ToString(); } [McpServerTool, Description( "Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " + "working tree, index and HEAD are untouched. status is 'clean', 'conflict' (conflictFiles lists where git " + "would stop), or 'untracked_collision' (conflictFiles lists a path the branch adds that already exists, " + "untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " + "only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " + "silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " + "branch even when the preview is clean. If the list has a verify command configured, a clean preview is " + "additionally built/tested in a scratch worktree (still without touching the real working tree) — " + "verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " + "tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " + "isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " + "small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " + "changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " + "branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " + "file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " + "task has neither an active worktree nor a handler commit range, or the list's working directory is missing " + "from disk." + McpToolDocs.ProgressHint)] public async Task PreviewMerge( string taskId, [Description("Branch to preview against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default, IProgress? progress = null) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var (preview, behind, _, isEmpty, staleFiles, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress); return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty, preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles); } [McpServerTool, Description( "Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " + "check between them. Per entry you get preview_merge's fields, or error instead when that task could not " + "be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " + "touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " + "overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " + "SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " + "worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " + "tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " + "collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " + "runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " + "scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " + "since builds run one at a time." + McpToolDocs.ProgressHint)] public async Task PreviewMergeSet( IReadOnlyList taskIds, [Description("Branch to preview every task against; defaults to the repo's current branch.")] string? targetBranch = null, [Description("true: also run the verify command (if configured) for each task, one build at a time. " + "false (default): no builds, however many tasks are given.")] bool runVerify = false, CancellationToken cancellationToken = default, IProgress? progress = null) { if (taskIds is null || taskIds.Count == 0) throw new InvalidOperationException("taskIds must contain at least one task id."); taskIds = await TaskIdResolver.ResolveManyAsync(_tasks, taskIds, cancellationToken); var entries = new List(); var filesByTask = new Dictionary>(); var numbersByTask = new Dictionary(); for (var i = 0; i < taskIds.Count; i++) { var taskId = taskIds[i]; try { var (preview, behind, changedFiles, isEmpty, staleFiles, number) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress); entries.Add(new MergePreviewSetEntryDto( taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty, preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number)); filesByTask[taskId] = changedFiles; numbersByTask[taskId] = number; } catch (InvalidOperationException ex) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken); entries.Add(new MergePreviewSetEntryDto( taskId, TaskMergeService.PreviewUnavailable, Array.Empty(), 0, 0, ex.Message, Number: task?.Number)); } ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count); } var overlaps = filesByTask .SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key))) .GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase) .Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1) .Select(g => { var ids = g.Select(x => x.TaskId).Distinct().ToList(); return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList()); }) .OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase) .ToList(); var subsets = FindSubsetRelations(filesByTask, numbersByTask); return new MergePreviewSetResultDto(entries, overlaps, subsets); } // A task's changed-file set is flagged only against another task's STRICTLY LARGER set that // contains every one of its files -- an empty set (isEmpty task) never qualifies, since that // case is already surfaced via IsEmpty and would otherwise match every other task trivially. private static List FindSubsetRelations( Dictionary> filesByTask, Dictionary numbersByTask) { var fileSets = filesByTask.ToDictionary( kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.OrdinalIgnoreCase)); var subsets = new List(); foreach (var (taskId, fileSet) in fileSets) { if (fileSet.Count == 0) continue; foreach (var (otherTaskId, otherFileSet) in fileSets) { if (otherTaskId == taskId || otherFileSet.Count <= fileSet.Count) continue; if (fileSet.IsSubsetOf(otherFileSet)) subsets.Add(new SubsetRelationDto(taskId, numbersByTask[taskId], otherTaskId, numbersByTask[otherTaskId])); } } return subsets .OrderBy(s => s.TaskId, StringComparer.OrdinalIgnoreCase) .ThenBy(s => s.SupersetTaskId, StringComparer.OrdinalIgnoreCase) .ToList(); } // Shared core for PreviewMerge/PreviewMergeSet: throws a clear InvalidOperationException instead of // TaskMergeService.PreviewAsync's silent "unavailable" status, and adds `behind` + the task's own // changed-file list (via diff-stat, not the merge-tree preview) for overlap detection, plus // `isEmpty`. A worktree-less list-handler host task has no branch to merge-tree-preview at all // (its commits already sit on the list's working dir) — falls back to the fixed // HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that // range's own diff-stat instead of throwing "has no worktree". private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList ChangedFiles, bool IsEmpty, IReadOnlyList StaleFiles, int Number)> PreviewMergeCoreAsync( string taskId, string? targetBranch, bool runVerify, CancellationToken ct, IProgress? progress = null) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct); if (wt is not null) { if (wt.State != WorktreeState.Active) throw new InvalidOperationException( $"Worktree state must be Active to preview a merge (current: {wt.State})."); if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir)) throw new InvalidOperationException("The list's working directory no longer exists."); var preview = await _merge.PreviewAsync(taskId, targetBranch ?? "", runVerify, ct, progress); if (preview.Status == TaskMergeService.PreviewUnavailable) throw new InvalidOperationException( "Merge preview unavailable for this task (worktree inactive or repo is not a git repository)."); var target = string.IsNullOrWhiteSpace(targetBranch) ? await _git.GetCurrentBranchAsync(list.WorkingDir, ct) : targetBranch; var behind = await GitRevListCountAsync(list.WorkingDir, $"{wt.BranchName}..{target}", ct); var changedFiles = Directory.Exists(wt.Path) ? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct: ct)) : Array.Empty(); // What the target itself picked up since this branch's fork point, so `behind` // (a commit count) doesn't have to stand in for "does this collide" -- a branch can // be far behind but touch nothing the target changed, or close behind and collide on // the one file that matters. var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct); var staleFiles = changedFiles .Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase) .ToList(); return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number); } if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead) { if (string.IsNullOrWhiteSpace(list.WorkingDir) || !Directory.Exists(list.WorkingDir)) throw new InvalidOperationException("The list's working directory no longer exists."); var isEmpty = string.Equals(handlerBase, handlerHead, StringComparison.Ordinal); var changedFiles = isEmpty ? Array.Empty() : ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct: ct)); var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty(), changedFiles.Count); // No fork point to diff against: a handler task commits straight onto the list's // working dir instead of a branch, so there is nothing else that could have "changed // in the target since the fork". return (preview, 0, changedFiles, isEmpty, Array.Empty(), task.Number); } throw new InvalidOperationException($"Task {taskId} has no worktree."); } [McpServerTool, Description( "Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " + "reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " + "Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " + "first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " + "is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " + "repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")] public async Task RevertMerge( string taskId, [Description("Branch carrying the merge commit; defaults to main.")] string targetBranch = "main", CancellationToken cancellationToken = default) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken); if (result.Status == TaskMergeService.StatusReverted) { await _broadcaster.TaskUpdated(taskId); return new RevertMergeResultDto(true, result.RevertCommit, Array.Empty(), null); } if (result.Status == TaskMergeService.StatusConflictAborted) return new RevertMergeResultDto(false, null, result.ConflictFiles, result.ErrorMessage); throw new InvalidOperationException(result.ErrorMessage ?? $"Revert blocked: {result.Status}"); } [McpServerTool, Description( "Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " + "the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk." + McpToolDocs.ProgressHint)] public async Task> ListWorktrees( CancellationToken cancellationToken = default, IProgress? progress = null) { var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); // One git status + one rev-parse per row, run concurrently -- with many tracked worktrees // (this tool's whole point is surfacing leftovers nobody cleaned up) that can still take a // while, so this reports on the same elapsed-time cadence as a single long git call rather // than per-row (rows finish out of order under Task.WhenAll, so there's no natural i/n). var results = await ProgressReporter.RunAsync( Task.WhenAll(rows.Select(async row => { var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken); var headCommit = row.PathExistsOnDisk ? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "") : ""; return new WorktreeListItemDto( row.TaskId, row.TaskNumber, row.Path, row.BranchName, headCommit, isDirty, row.State == WorktreeState.Merged); })), ProgressReportInterval, progress, "surveying worktrees"); return results; } [McpServerTool, Description( "Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " + "went too." + McpToolDocs.NotWhileRunning)] public async Task CleanupTaskWorktree( string taskId, [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " + "those changes.")] bool force = false, CancellationToken cancellationToken = default, IProgress? progress = null) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); if (task.Status == TaskStatus.Running) throw new InvalidOperationException("Cannot remove worktree of a running task."); if (!force && Directory.Exists(wt.Path)) { var isDirty = await ProgressReporter.RunAsync( _git.HasChangesAsync(wt.Path, cancellationToken), ProgressReportInterval, progress, "checking worktree for changes"); if (isDirty) throw new InvalidOperationException( "Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost)."); } var path = wt.Path; var result = await ProgressReporter.RunAsync( _maintenance.ForceRemoveAsync(taskId, cancellationToken), ProgressReportInterval, progress, "removing worktree"); return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted, task.Number); } [McpServerTool, Description( "Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " + "resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " + "same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")] public async Task ContinueTask( string taskId, string followUpPrompt, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(followUpPrompt)) throw new InvalidOperationException("followUpPrompt is required."); taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); string result; try { result = await _queue.ContinueTask(taskId, followUpPrompt); } catch (InvalidOperationException) { throw new InvalidOperationException("Override slot busy. Try again later."); } catch (KeyNotFoundException) { throw new InvalidOperationException($"Task {taskId} not found."); } await _broadcaster.TaskUpdated(taskId); return result; } // ── Daily prep ─────────────────────────────────────────────────────────── [McpServerTool, Description( "Daily prep: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " + "non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " + "flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " + "never exceed maxTasks.")] public async Task GetDailyPrepCandidates(CancellationToken cancellationToken) { await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths); var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; var idle = await ctx.Tasks .AsNoTracking() .Include(t => t.List) .Where(t => t.Status == TaskStatus.Idle) .ToListAsync(cancellationToken); var currentMyDay = idle .Where(t => t.IsMyDay) .OrderBy(t => t.SortOrder) .Select(ToCandidate) .ToList(); var candidates = idle .Where(t => !t.IsMyDay && t.BlockedByTaskId == null // A manual task is a reminder only the user can do — never a Claude candidate. && !t.IsManual && DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes)) .OrderBy(t => t.CreatedAt) .Select(ToCandidate) .ToList(); return new DailyPrepDataDto(maxTasks, candidates, currentMyDay); } [McpServerTool, Description( "Daily prep: set or clear a task's MyDay flag. Setting it is rejected once the MyDay cap " + "(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." + McpToolDocs.LeanTaskRef)] public async Task SetMyDay( string taskId, bool isMyDay, [Description("Position in the MyDay list; use consecutive values to keep related tasks together.")] int? sortOrder = null, CancellationToken cancellationToken = default) { taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (isMyDay && !task.IsMyDay) { var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks; var openMyDay = await ctx.Tasks.CountAsync( t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken); if (openMyDay >= max) throw new InvalidOperationException( $"MyDay limit {max} reached. Clear a task before adding another."); } task.IsMyDay = isMyDay; if (sortOrder is not null) task.SortOrder = sortOrder.Value; await ctx.SaveChangesAsync(cancellationToken); await _broadcaster.TaskUpdated(taskId); return ToRefDto(task); } private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new( t.Id, t.Number, t.ListId, t.List?.Name ?? "", t.Title, t.Description, t.IsStarred, t.ScheduledFor, t.CreatedAt); // ── Private helpers ─────────────────────────────────────────────────────── private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync( string taskId, CancellationToken ct) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException("List not found."); var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} has no worktree."); return (task, list, wt); } private async Task TryGetIsDirtyAsync(string path, CancellationToken ct) { try { return await _git.HasChangesAsync(path, ct); } catch { return false; } } // Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree). private static async Task TryRunGitAsync(string dir, string[] args, CancellationToken ct) { try { var psi = new ProcessStartInfo("git") { UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true, }; psi.ArgumentList.Add("-C"); psi.ArgumentList.Add(dir); foreach (var a in args) psi.ArgumentList.Add(a); using var proc = Process.Start(psi)!; await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } }); var stdout = await proc.StandardOutput.ReadToEndAsync(); await proc.WaitForExitAsync(CancellationToken.None); ct.ThrowIfCancellationRequested(); return proc.ExitCode == 0 ? stdout.Trim() : null; } catch (OperationCanceledException) { throw; } catch { return null; } } private static async Task GitRevListCountAsync(string dir, string range, CancellationToken ct) { var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct); return int.TryParse(result, out var n) ? n : 0; } private static IReadOnlyList ParseDiffFileNames(string diff) { var files = new List(); foreach (var line in diff.Split('\n')) { var s = line.TrimEnd('\r'); if (s.StartsWith("+++ b/", StringComparison.Ordinal)) files.Add(s[6..]); } return files; } private static IReadOnlyList ParseDiffStatFileNames(string stat) { var files = new List(); foreach (var line in stat.Split('\n')) { var idx = line.IndexOf('|'); if (idx > 0) files.Add(line[..idx].Trim()); } return files; } private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( t.Id, t.Number, t.ListId, t.Title, t.Description, t.Status.ToString(), t.Result, t.CreatedBy, t.CreatedAt, t.StartedAt, t.FinishedAt, t.IsMyDay, t.SortOrder, t.RoadblockCount, FailureReasonOf(t), t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null, t.DependsOnTaskId, blocked, blockedReason); private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new( t.Id, t.Number, t.ListId, t.Title, t.Status.ToString(), t.SortOrder, t.IsMyDay, t.RoadblockCount, FailureReasonOf(t), t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null, t.DependsOnTaskId, blocked, blockedReason); // "unknown" covers a Failed task that predates this field (never got a classified reason // stamped) — a defined value rather than null so callers don't have to special-case it. private static string? FailureReasonOf(TaskEntity t) => t.Status == TaskStatus.Failed ? (t.FailureReason ?? "unknown") : null; } internal static class DailyPrepFilter { public static string[] ParseExcludes(string? json) { if (string.IsNullOrWhiteSpace(json)) return []; try { var list = System.Text.Json.JsonSerializer.Deserialize>(json); return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray(); } catch (System.Text.Json.JsonException) { return []; } } public static bool IsIncludedRepo(string? workingDir, string[] excludes) { if (string.IsNullOrWhiteSpace(workingDir)) return false; var norm = Normalize(workingDir); return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase)); } private static string Normalize(string path) => path.Trim().Replace('/', '\\').TrimEnd('\\'); }