Files
ClaudeDo/src/ClaudeDo.Worker/External/ExternalMcpService.cs
T
mika kuns f8c48e2ed7 fix(worker): make list_tasks/batch_get_tasks lean by default
list_tasks on a list of ~100 verbosely-described tasks could return
390k+ characters in one call, blowing past the caller's token limit.
Both tools now default to lean TaskRefDto references (no
Description/Result) and take an includeDescription flag to opt back
into the full TaskDto payload — same flag-alongside-nullable-payload
idiom already used by BatchGetTaskResult/TaskConfigResult. get_task is
unchanged.
2026-08-06 11:25:32 +02:00

1202 lines
59 KiB
C#

using System.ComponentModel;
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
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.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);
public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null);
public sealed record StatusValueDto(string Status, string Meaning);
public sealed record RunTaskNowResult(bool Started, string TaskId);
public sealed record TaskDto(
string Id,
string ListId,
string Title,
string? Description,
string Status,
string? Result,
string? CreatedBy,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? FinishedAt,
bool IsMyDay,
int SortOrder);
// 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.
public sealed record TaskRefDto(
string Id,
string ListId,
string Title,
string Status,
int SortOrder,
bool IsMyDay);
// 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<TaskRefDto>? Tasks,
IReadOnlyList<TaskDto>? TasksFull);
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<string> Files, bool Truncated, int TotalBytes);
public sealed record MergeTaskResultDto(
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
bool ConflictsInTree = false, string? RepoPath = null);
public sealed record MergeContinuationResultDto(
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
string? RepoPath, string? Message);
public sealed record MergePreviewToolDto(
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind);
public sealed record MergePreviewSetEntryDto(
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted);
public sealed record RevertMergeResultDto(
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
public sealed record DailyPrepCandidateDto(
string Id, string ListId, string ListName, string Title, string? Description,
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
public sealed record DailyPrepDataDto(
int MaxTasks,
IReadOnlyList<DailyPrepCandidateDto> Candidates,
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
[McpServerToolType]
public sealed class ExternalMcpService
{
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<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _maintenance;
private readonly TaskMergeService _merge;
private readonly PlanningMergeOrchestrator _planningMerge;
public ExternalMcpService(
TaskRepository tasks,
ListRepository lists,
QueueService queue,
HubBroadcaster broadcaster,
ITaskStateService state,
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance,
TaskMergeService merge,
PlanningMergeOrchestrator planningMerge)
{
_tasks = tasks;
_lists = lists;
_queue = queue;
_broadcaster = broadcaster;
_state = state;
_git = git;
_dbFactory = dbFactory;
_maintenance = maintenance;
_merge = merge;
_planningMerge = planningMerge;
}
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
public async Task<IReadOnlyList<TaskListDto>> 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 tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled. " +
"includeDescription=false (default): returns lean task references in `tasks` (no Description/Result) — " +
"use this unless you actually need the description text, since a list of verbosely-described tasks can " +
"otherwise blow past the response size limit. " +
"includeDescription=true: returns full tasks (incl. Description/Result) in `tasksFull` instead; `tasks` is " +
"null in that case.")]
public async Task<ListTasksResult> ListTasks(
string listId,
string? createdBy = null,
string? status = null,
bool includeDescription = false,
CancellationToken cancellationToken = default)
{
TaskStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse<TaskStatus>(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<TaskEntity> 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();
return includeDescription
? new ListTasksResult(true, null, filtered.Select(ToDto).ToList())
: new ListTasksResult(false, filtered.Select(ToRefDto).ToList(), null);
}
[McpServerTool, Description(
"Get a single task by id, including its current status and result. " +
"Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
"A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
return ToDto(task);
}
// 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<TaskRefDto> GetTaskRefAsync(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
return ToRefDto(task);
}
[McpServerTool, Description(
"Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
"Leave model null to inherit the list/global default. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay) — not the description you just sent.")]
public async Task<TaskRefDto> AddTask(
string listId,
string title,
string? description = null,
string? createdBy = null,
bool queueImmediately = false,
string? model = 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.");
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 (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);
return ToRefDto(entity);
}
[McpServerTool, Description(
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. " +
"Refuses if the task is currently Running. Returns a lean task reference (id, listId, title, status, " +
"sortOrder, isMyDay) — not the description you just sent.")]
public async Task<TaskRefDto> UpdateTask(
string taskId,
string? title = null,
string? description = null,
string? commitType = null,
CancellationToken cancellationToken = default)
{
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);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToRefDto(reload);
}
[McpServerTool, Description(
"Append a subtask (step) to a task. orderNum defaults to the end. " +
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> AddSubtask(
string taskId,
string title,
int? orderNum = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
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(
"Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " +
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
"Cancelled (retire the task without deleting it; it can be reset to Idle later), " +
"Done (mark complete; refused if the task has an active worktree — use review_task to approve " +
"and merge that worktree instead). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> UpdateTaskStatus(
string taskId,
string status,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
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.");
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);
}
[McpServerTool, Description(
"Review a task that is WaitingForReview. " +
"decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
"targetBranch (default: the repo's current branch) 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. " +
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " +
"aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead 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. " +
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
"decision='cancel' → Cancelled. " +
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued). " +
"The result's task field is a lean reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<ReviewTaskResult> ReviewTask(
string taskId,
string decision,
string? feedback = null,
string? targetBranch = null,
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
string? mergeStatus = null;
IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
string? mergeMessage = null;
string? repoPath = 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)
{
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken);
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);
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);
}
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")]
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken 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);
return new RunTaskNowResult(true, taskId);
}
[McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")]
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
{
var cancelled = _queue.CancelTask(taskId);
if (cancelled) await _broadcaster.TaskUpdated(taskId);
return new CancelTaskResult(cancelled, taskId);
}
[McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")]
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken 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);
}
// ── Status reference ─────────────────────────────────────────────────────
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
Task.FromResult<IReadOnlyList<StatusValueDto>>([
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
]);
// ── Worktree / git tools ──────────────────────────────────────────────────
[McpServerTool, Description(
"Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
"baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
"behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
"isDirty (has uncommitted changes in the worktree directory), " +
"mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " +
"if it has been merged and that succeeded after this field was introduced; null otherwise — " +
"required by revert_merge). " +
"Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken 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(
"Get the diff for a task's worktree relative to its base commit. For a worktree-less " +
"list-handler host task (Mission Control's \"Let Claude handle it\"), returns the fixed " +
"HandlerBaseCommit..HandlerHeadCommit range over the list's working dir instead. " +
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
"files always lists the changed file paths regardless of stat mode. " +
"totalBytes is the uncapped diff size (useful when truncated=true). " +
"Throws if the task has no worktree/review range, or the relevant directory is missing from disk.")]
public async Task<TaskDiffDto> GetTaskDiff(
string taskId, bool stat = false, CancellationToken cancellationToken = default)
{
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
const int maxBytes = 200 * 1024;
if (stat)
{
var diffStat = await _git.DiffStatAsync(repoPath, baseCommit, headCommit ?? "HEAD", cancellationToken);
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
}
var diff = headCommit is null
? await _git.GetBranchDiffAsync(repoPath, baseCommit, cancellationToken)
: await _git.GetCommitRangeDiffAsync(repoPath, baseCommit, headCommit, cancellationToken);
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 task's worktree branch into targetBranch (default: main). " +
"noFf=true (default): always creates a merge commit (--no-ff). " +
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
"On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " +
"leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " +
"tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " +
"or abort_merge to cancel.")]
public async Task<MergeTaskResultDto> MergeTask(
string taskId,
string targetBranch = "main",
bool noFf = true,
bool dryRun = false,
bool allowWaitingForReview = false,
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
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, []);
}
var commitMessage = $"Merge task branch for: {task.Title}";
var result = await _merge.MergeAsync(
taskId, targetBranch, removeWorktree: false, commitMessage, leaveConflictsInTree, cancellationToken);
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 after the conflict markers in the working tree (repoPath from " +
"merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " +
"merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " +
"post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " +
"lists the affected files — resolve them and call continue_merge again. " +
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken 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<string> conflicts = Array.Empty<string>();
string? repoPath = null;
string? message = null;
if (_planningMerge.HasActiveMerge(taskId))
{
await _planningMerge.ContinueAsync(taskId, cancellationToken);
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<string>();
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);
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. " +
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
"Throws if there is no in-progress merge for the task. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken 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(
"Non-destructive merge preview for a task's worktree branch against targetBranch (default: the repo's " +
"current branch), via `git merge-tree --write-tree` — does NOT touch the working tree, index, or HEAD. " +
"status: 'clean' (mergeable; changedFileCount is the size of that merge) or 'conflict' (conflictFiles " +
"lists the paths git would stop on). behind = commits on targetBranch not yet on this task's branch, so " +
"you can spot a stale branch even when the preview itself is clean. " +
"IMPORTANT: a clean preview says nothing about whether the merged result compiles or passes tests — git " +
"can merge two changes cleanly (e.g. one file deletes a symbol another file still references) and still " +
"break the build. " +
"Throws a clear error if the task has no worktree, the worktree is not Active, or the list's working " +
"directory is missing from disk.")]
public async Task<MergePreviewToolDto> PreviewMerge(
string taskId,
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
var (preview, behind, _) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind);
}
[McpServerTool, Description(
"Merge preview plus file-overlap check across several tasks at once, all previewed against the same " +
"targetBranch (default: the repo's current branch). For each taskId returns the same fields as " +
"preview_merge (status/conflictFiles/changedFileCount/behind; error is set instead if that task could not " +
"be previewed, and it is then excluded from the overlap computation). overlaps lists, for each file " +
"touched by MORE THAN ONE of the given tasks (via each task's own diff, not the merge preview itself), " +
"which tasks touch it — passing a single taskId always yields an empty overlaps list. " +
"IMPORTANT: file-name overlap is a HINT, not a guarantee of a real collision, and its absence is not a " +
"guarantee of safety — two tasks touching different files entirely (e.g. one deletes a symbol, another " +
"still references it elsewhere) can still collide, and this tool will not flag that case.")]
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
IReadOnlyList<string> taskIds,
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
if (taskIds is null || taskIds.Count == 0)
throw new InvalidOperationException("taskIds must contain at least one task id.");
var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
foreach (var taskId in taskIds)
{
try
{
var (preview, behind, changedFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null));
filesByTask[taskId] = changedFiles;
}
catch (InvalidOperationException ex)
{
entries.Add(new MergePreviewSetEntryDto(
taskId, TaskMergeService.PreviewUnavailable, Array.Empty<string>(), 0, 0, ex.Message));
}
}
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 => new FileOverlapDto(g.Key, g.Select(x => x.TaskId).Distinct().ToList()))
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList();
return new MergePreviewSetResultDto(entries, overlaps);
}
// 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.
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, CancellationToken ct)
{
var (_, list, wt) = await LoadWorktreeContextAsync(taskId, ct);
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 ?? "", ct);
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))
: Array.Empty<string>();
return (preview, behind, changedFiles);
}
[McpServerTool, Description(
"Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " +
"a new commit, never a reset/rewrite (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; tasks merged " +
"before this feature existed have no recorded commit and are refused rather than guessed via git log. " +
"On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " +
"WaitingForReview so it can be reconsidered. " +
"On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " +
"left in the tree) and conflicts lists the files that would have conflicted. " +
"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<RevertMergeResultDto> RevertMerge(
string taskId, string targetBranch = "main", CancellationToken cancellationToken = default)
{
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<string>(), 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(
"List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
"isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
"Only worktrees recorded in the ClaudeDo database are returned.")]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
{
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
var results = await 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.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
}));
return results;
}
[McpServerTool, Description(
"Remove a task's worktree directory and delete its git branch. " +
"force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
"force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
"Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
string taskId, bool force = false, CancellationToken cancellationToken = default)
{
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 _git.HasChangesAsync(wt.Path, cancellationToken);
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 _maintenance.ForceRemoveAsync(taskId, cancellationToken);
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted);
}
[McpServerTool, Description(
"Send a follow-up prompt to an existing Claude session (multi-turn continuation). " +
"The agent resumes using --resume with the session ID from the task's last run. " +
"Runs in the override execution slot; throws if the slot is busy — try again later. " +
"Returns a status string from the execution slot.")]
public async Task<string> ContinueTask(
string taskId,
string followUpPrompt,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(followUpPrompt))
throw new InvalidOperationException("followUpPrompt is required.");
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: returns the open tasks eligible for today's MyDay selection. " +
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
public async Task<DailyPrepDataDto> 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, optionally setting its sortOrder " +
"(use consecutive sortOrder values to keep related tasks together). " +
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
"clearing (isMyDay=false) is always allowed. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
public async Task<TaskRefDto> SetMyDay(
string taskId,
bool isMyDay,
int? sortOrder = null,
CancellationToken cancellationToken = default)
{
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.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<bool> 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<string?> 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<int> 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<string> ParseDiffFileNames(string diff)
{
var files = new List<string>();
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<string> ParseDiffStatFileNames(string stat)
{
var files = new List<string>();
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) => new(
t.Id,
t.ListId,
t.Title,
t.Description,
t.Status.ToString(),
t.Result,
t.CreatedBy,
t.CreatedAt,
t.StartedAt,
t.FinishedAt,
t.IsMyDay,
t.SortOrder);
private static TaskRefDto ToRefDto(TaskEntity t) => new(
t.Id,
t.ListId,
t.Title,
t.Status.ToString(),
t.SortOrder,
t.IsMyDay);
}
internal static class DailyPrepFilter
{
public static string[] ParseExcludes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(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('\\');
}