diff --git a/docs/explore-notes/external-mcp.md b/docs/explore-notes/external-mcp.md index f74131a0..9795f50d 100644 --- a/docs/explore-notes/external-mcp.md +++ b/docs/explore-notes/external-mcp.md @@ -32,7 +32,14 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` `ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a list of verbosely-described tasks from blowing past the response size limit by default. -3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so +3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate + lives there as `const` strings. Rules: the first sentence says what the tool does *and* when + to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind + return-shape prose); parameters are documented with `[Description]` **on the parameter**, not + in the tool description; result fields appear only where the caller must branch on them + before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or + "since this feature was introduced" history. Not test-enforced — review it in PRs. +4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException` / `ArgumentException` messages survive as `McpException` — otherwise the SDK's catch-all replaces any non-`McpException` with a generic *"An error occurred invoking 'X'."* @@ -42,8 +49,9 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` ### `ExternalMcpService` — task CRUD, execution, git Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, -`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, -`CancelTask`, `DeleteTask`. +`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`. +(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s +description is now the canonical place for what each status means.) Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`, `PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`. diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 35fc3ca5..899d5460 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -48,7 +48,7 @@ subfolder within their area; the namespace stays the area namespace. - **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. -- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md). +- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. **Tool-description style** (not test-enforced) is documented in `External/McpToolDocs.cs`, which also holds the shared boilerplate clauses — read it before adding or editing a tool description. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md). ## Status Model diff --git a/src/ClaudeDo.Worker/External/AgentMcpTools.cs b/src/ClaudeDo.Worker/External/AgentMcpTools.cs index 80509602..66502d2e 100644 --- a/src/ClaudeDo.Worker/External/AgentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AgentMcpTools.cs @@ -12,7 +12,7 @@ public sealed class AgentMcpTools public AgentMcpTools(AgentFileService agents) => _agents = agents; - [McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")] + [McpServerTool, Description("List available agent definition files (name, description, path) to pick a value for a task's or list's agentPath override.")] public async Task> ListAgents(CancellationToken cancellationToken) => await _agents.ScanAsync(cancellationToken); } diff --git a/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs index b7c8c72f..82f3ab90 100644 --- a/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs @@ -19,7 +19,7 @@ public sealed class AppSettingsMcpTools public AppSettingsMcpTools(IDbContextFactory dbFactory) => _dbFactory = dbFactory; - [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")] + [McpServerTool, Description("Read the worker's global defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy) that apply when a task/list doesn't override them. Read-only.")] public async Task GetAppSettings(CancellationToken cancellationToken) { using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); diff --git a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs index b387f09a..dd1096c7 100644 --- a/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs +++ b/src/ClaudeDo.Worker/External/AttachmentMcpTools.cs @@ -33,17 +33,15 @@ public sealed class AttachmentMcpTools } [McpServerTool, Description( - "Attach a read-only reference file to a task. These files are handed to the agent at run time, " + - "making them useful to prepare context for a task that will run later (e.g. plans, scripts, specs). " + - "Pass textContent for plain-text files (plans, markdown, scripts). " + - "Pass base64Content only for binary files (images, archives). Exactly one of the two must be provided. " + - "Re-attaching a file with the same fileName overwrites the previous version. " + - "Refuses if the task is currently Running — cancel it first.")] + "Attach a read-only reference file to a task so the agent receives it at run time — use to prepare " + + "context (plans, scripts, specs) for a task that will run later. Exactly one of textContent/" + + "base64Content is required. Re-attaching the same fileName overwrites the previous version." + + McpToolDocs.NotWhileRunning)] public async Task AddTaskAttachment( string taskId, - string fileName, - string? textContent = null, - string? base64Content = null, + [Description("Name to store the attachment under; reusing an existing name overwrites it.")] string fileName, + [Description("Plain-text content (plans, markdown, scripts). Provide this or base64Content, not both.")] string? textContent = null, + [Description("Base64-encoded content for binary files (images, archives). Provide this or textContent, not both.")] string? base64Content = null, CancellationToken ct = default) { var task = await _tasks.GetByIdAsync(taskId, ct) @@ -94,7 +92,8 @@ public sealed class AttachmentMcpTools return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow); } - [McpServerTool, Description("List all attachments on a task (fileName, byteSize, createdAt).")] + [McpServerTool, Description( + "List all attachments on a task — use to check what reference files are already attached before adding more.")] public async Task> ListTaskAttachments( string taskId, CancellationToken ct = default) { @@ -103,8 +102,8 @@ public sealed class AttachmentMcpTools } [McpServerTool, Description( - "Remove a single attachment from a task. Deletes both the file on disk and the database record. " + - "Refuses if the task is currently Running — cancel it first. Returns { removed: true, taskId, fileName } on success.")] + "Remove a single attachment from a task, deleting both the file on disk and its database record." + + McpToolDocs.NotWhileRunning)] public async Task RemoveTaskAttachment( string taskId, string fileName, CancellationToken ct = default) { diff --git a/src/ClaudeDo.Worker/External/BatchMcpTools.cs b/src/ClaudeDo.Worker/External/BatchMcpTools.cs index 7fed1f9f..14ccb54e 100644 --- a/src/ClaudeDo.Worker/External/BatchMcpTools.cs +++ b/src/ClaudeDo.Worker/External/BatchMcpTools.cs @@ -3,8 +3,14 @@ using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; -public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null); -public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null); +public sealed record BatchAddTaskInput( + string Title, + [property: Description("Task description/instructions for the agent.")] string? Description = null, + [property: Description("Model override: haiku|sonnet|opus. Blank inherits the list/global default.")] string? Model = null); +public sealed record BatchSetMyDayInput( + string TaskId, + [property: Description("true to add the task to My Day, false to remove it.")] bool IsMyDay, + [property: Description("Position within My Day; omit to append at the end.")] int? SortOrder = null); // task is populated when found and includeDescription=false (the default, lean reference); // taskFull is populated when found and includeDescription=true (full task incl. @@ -34,14 +40,14 @@ public sealed class BatchMcpTools public BatchMcpTools(ExternalMcpService svc) => _svc = svc; [McpServerTool, Description( - "Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " + - "Returns one result per id: { id, found, task, taskFull, error }. " + - "includeDescription=false (default): found tasks come back in `task` (lean reference, no " + - "Description/Result). includeDescription=true: found tasks come back in `taskFull` (incl. " + - "Description/Result) instead. A missing id is found=false (not an error; task and taskFull both null); " + - "error is only set for an unexpected failure. Max 100 ids.")] + "Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " + + "calling get_task per id. A missing id comes back as found=false, not an error; error is only set " + + "for an unexpected failure." + McpToolDocs.MaxBatch)] public async Task> BatchGetTasks( - string[] taskIds, bool includeDescription = false, CancellationToken cancellationToken = default) + string[] taskIds, + [Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " + + "(default), return a lean reference in `task`.")] bool includeDescription = false, + CancellationToken cancellationToken = default) { EnsureWithinCap(taskIds, nameof(taskIds)); @@ -75,19 +81,15 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Create many tasks in one list at once. Each item: { title, description?, model? } " + - "(model: haiku|sonnet|opus, blank = inherit list/global default). " + - "queueImmediately enqueues every created task. " + - "Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " + - "a lean reference (id, listId, title, status, sortOrder, isMyDay), not the description you just " + - "sent. Each item is always created — possibleDuplicates is a non-blocking heads-up (up to 3 open " + - "tasks in the same list with a strongly overlapping title, id/title/status only); check it and " + - "mention any hit to the caller, but do not treat it as an error. Max 100 items.")] + "Create many tasks in one list at once — use instead of repeated add_task calls when seeding a list. " + + "Every item is still created even if it looks like a duplicate; possibleDuplicates is a non-blocking " + + "heads-up (up to 3 similar open tasks in the list) worth mentioning to the caller, not an error." + + McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch)] public async Task> BatchAddTasks( string listId, BatchAddTaskInput[] tasks, string? createdBy = null, - bool queueImmediately = false, + [Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false, CancellationToken cancellationToken = default) { EnsureWithinCap(tasks, nameof(tasks)); @@ -113,11 +115,13 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " + - "same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " + - "Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + "Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " + + "update_task_status per task. 'Done' is refused per-item for a task with an active worktree." + + McpToolDocs.MaxBatch)] public async Task> BatchUpdateTaskStatus( - string[] taskIds, string status, CancellationToken cancellationToken) + string[] taskIds, + [Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status, + CancellationToken cancellationToken) { EnsureWithinCap(taskIds, nameof(taskIds)); return await RunPerTaskAsync(taskIds, @@ -125,9 +129,8 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Cancel many running tasks at once. Returns one result per id: " + - "{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " + - "Max 100 ids.")] + "Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " + + "ok=true with cancelled=false just means the task wasn't running." + McpToolDocs.MaxBatch)] public async Task> BatchCancelTasks( string[] taskIds, CancellationToken cancellationToken) { @@ -151,8 +154,8 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Delete many tasks at once. A Running task is refused (cancel it first) and reported " + - "as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")] + "Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." + + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)] public async Task> BatchDeleteTasks( string[] taskIds, CancellationToken cancellationToken) { @@ -162,9 +165,9 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " + - "Still cap-guarded — items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + - "(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")] + "Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " + + "Still cap-guarded: items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " + + "(ok=false) without blocking the rest." + McpToolDocs.MaxBatch)] public async Task> BatchSetMyDay( BatchSetMyDayInput[] items, CancellationToken cancellationToken) { @@ -188,12 +191,13 @@ public sealed class BatchMcpTools } [McpServerTool, Description( - "Remove the worktrees of many tasks at once (directory + git branch). " + - "force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " + - "even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " + - "Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")] + "Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " + + "of calling cleanup_task_worktree per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)] public async Task> BatchCleanupTaskWorktrees( - string[] taskIds, bool force = false, CancellationToken cancellationToken = default) + string[] taskIds, + [Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " + + "is still refused either way.")] bool force = false, + CancellationToken cancellationToken = default) { EnsureWithinCap(taskIds, nameof(taskIds)); diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 5556d26c..383a13a1 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -46,7 +46,7 @@ public sealed class ConfigMcpTools _dbFactory = dbFactory; } - [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns { found: false, config: null } if no config is set.")] + [McpServerTool, Description("Read a list's default run config — the fallback used by tasks in this list that don't set their own overrides. Returns { found: false, config: null } if none is set.")] public async Task GetListConfig(string listId, CancellationToken cancellationToken) { var cfg = await _lists.GetConfigAsync(listId, cancellationToken); @@ -56,9 +56,8 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list " + - "config. Returns { ok, listId, config } — config is null when the config was cleared, otherwise it echoes " + - "the fields that were set (a field is null there if it was individually left unset/cleared).")] + "Set a list's default model/system prompt/agent path/max turns — the fallback for tasks in this list " + + "that don't override them. Passing all four as null clears the list config instead of setting one.")] public async Task SetListConfig( string listId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) @@ -90,9 +89,8 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to " + - "clear that override. Returns { ok, taskId, config } — config echoes the resulting overrides (a field is " + - "null there if it was cleared or never set).")] + "Set per-task overrides for model/system prompt/agent path/max turns; these take precedence over the " + + "list's default config for this one task. Pass null for any field to clear that override.")] public async Task SetTaskConfig( string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null, int? maxTurns = null, CancellationToken cancellationToken = default) @@ -109,7 +107,7 @@ public sealed class ConfigMcpTools return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, maxTurns)); } - [McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns { found: false, config: null } if no override is set on this task.")] + [McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")] public async Task GetTaskConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -120,11 +118,11 @@ public sealed class ConfigMcpTools } [McpServerTool, Description( - "Get the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent path, " + - "whether a system prompt is set, and skill names — with each field's source (task/list/preset/global). " + - "Uses the exact same resolution TaskRunner runs with, so this never drifts from get_app_settings/" + - "get_task_config's raw, possibly-unused values. maxTurns also reports the raw requested value and " + - "whether it was clamped to the global ceiling. Read-only, no side effects.")] + "Report the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent " + + "path, whether a system prompt is set, and skill names — each tagged with its source " + + "(task/list/preset/global). Use this over get_task_config/get_app_settings when you need resolved " + + "values, not raw overrides. maxTurns also reports the raw requested value and whether it was clamped " + + "to the global ceiling.")] public async Task GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 00637b00..23fc6319 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -26,7 +26,6 @@ public sealed record CancelTaskResult(bool Cancelled, string Id); // 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 StatusValueDto(string Status, string Meaning); public sealed record RunTaskNowResult(bool Started, string TaskId); public sealed record TaskDto( @@ -161,7 +160,8 @@ public sealed class ExternalMcpService _planningMerge = planningMerge; } - [McpServerTool, Description("List all task lists available in ClaudeDo.")] + [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); @@ -169,17 +169,17 @@ public sealed class ExternalMcpService } [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.")] + "List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status.")] 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) { @@ -206,10 +206,12 @@ public sealed class ExternalMcpService } [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.")] + "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.")] public async Task GetTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -227,21 +229,19 @@ public sealed class ExternalMcpService } [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 { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " + - "sortOrder, isMyDay) — not the description you just sent. 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, id/title/status only); check it and mention any hit to the caller, " + - "but do not treat it as an error.")] + "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)] 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, CancellationToken cancellationToken = default) { @@ -354,9 +354,8 @@ public sealed class ExternalMcpService } [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.")] + "Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." + + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)] public async Task UpdateTask( string taskId, string? title = null, @@ -380,12 +379,12 @@ public sealed class ExternalMcpService } [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.")] + "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) { @@ -419,16 +418,14 @@ public sealed class ExternalMcpService } [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.")] + "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)] 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) { @@ -482,27 +479,29 @@ public sealed class ExternalMcpService } [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. " + - "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.")] + "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)] 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) { @@ -634,7 +633,10 @@ public sealed class ExternalMcpService } } - [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")] + [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) { try @@ -653,7 +655,8 @@ public sealed class ExternalMcpService 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.")] + [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) { var cancelled = _queue.CancelTask(taskId); @@ -661,7 +664,9 @@ public sealed class ExternalMcpService 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.")] + [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) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -676,31 +681,12 @@ public sealed class ExternalMcpService 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> GetTaskStatusValues() => - Task.FromResult>([ - 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). " + + "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) { @@ -718,16 +704,17 @@ public sealed class ExternalMcpService } [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.")] + "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, bool stat = false, CancellationToken cancellationToken = default) + 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, + CancellationToken cancellationToken = default) { var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken); @@ -782,21 +769,21 @@ public sealed class ExternalMcpService } [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.")] + "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.")] 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) { @@ -848,11 +835,9 @@ public sealed class ExternalMcpService } [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. " + + "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.")] public async Task ContinueMerge(string taskId, CancellationToken cancellationToken) { @@ -920,10 +905,8 @@ public sealed class ExternalMcpService [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.")] + "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) { _ = await _tasks.GetByIdAsync(taskId, cancellationToken) @@ -945,21 +928,17 @@ public sealed class ExternalMcpService } [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. " + - "isEmpty=true means the task's review range contributed nothing (no commits ahead of base, or — for a " + - "worktree-less list-handler host task — HandlerBaseCommit == HandlerHeadCommit); do not mistake a small " + - "changedFileCount for an empty one, check isEmpty instead. " + - "Throws a clear error if the task has neither an active worktree nor a handler commit range, or the " + - "list's working directory is missing from disk.")] + "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' or 'conflict' (conflictFiles lists where git " + + "would stop); behind counts commits on targetBranch not yet on this branch, which flags a stale branch even " + + "when the preview is clean. IMPORTANT: a clean preview says nothing about whether the result compiles or " + + "passes tests — git can merge two changes cleanly (one file deleting a symbol another still references) and " + + "still break the build. isEmpty=true means the task's review range contributed nothing; check that flag " + + "rather than reading a small changedFileCount as empty. Throws if the task has neither an active worktree " + + "nor a handler commit range, or the list's working directory is missing from disk.")] public async Task PreviewMerge( string taskId, + [Description("Branch to preview against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default) { @@ -968,18 +947,16 @@ public sealed class ExternalMcpService } [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. " + - "isEmpty=true (per entry) means that task's review range contributed nothing — see preview_merge.")] + "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 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. " + + "IMPORTANT: overlap is a HINT and its absence is not safety — 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.")] public async Task PreviewMergeSet( IReadOnlyList taskIds, + [Description("Branch to preview every task against; defaults to the repo's current branch.")] string? targetBranch = null, CancellationToken cancellationToken = default) { @@ -1076,18 +1053,17 @@ public sealed class ExternalMcpService } [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.")] + "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, string targetBranch = "main", CancellationToken cancellationToken = default) + string taskId, + [Description("Branch carrying the merge commit; defaults to main.")] + string targetBranch = "main", + CancellationToken cancellationToken = default) { var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken); @@ -1104,10 +1080,8 @@ public sealed class ExternalMcpService } [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.")] + "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.")] public async Task> ListWorktrees(CancellationToken cancellationToken) { var rows = await _maintenance.GetOverviewAsync(null, cancellationToken); @@ -1125,12 +1099,14 @@ public sealed class ExternalMcpService } [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.")] + "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, bool force = false, CancellationToken cancellationToken = default) + string taskId, + [Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " + + "those changes.")] + bool force = false, + CancellationToken cancellationToken = default) { using var ctx = _dbFactory.CreateDbContext(); var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken) @@ -1155,10 +1131,9 @@ public sealed class ExternalMcpService } [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.")] + "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, @@ -1187,10 +1162,10 @@ public sealed class ExternalMcpService // ── 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).")] + "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); @@ -1225,14 +1200,13 @@ public sealed class ExternalMcpService } [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.")] + "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) { diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index 6866d7c4..b44d05b7 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -20,13 +20,15 @@ public sealed class HandoffMcpTools } [McpServerTool, Description( - "End of Phase 2 for the list handler (\"Let Claude handle it\"): hand this run off to a fresh " + - "ConPTY session that carries out Phases 3-5, without dragging along this session's dedupe/rewrite " + - "context. taskId is this session's own handler task id; survivingTaskIds are the tasks that made " + - "it past dedupe, in the order to run them. Reuses the SAME handler task -- no new task is created, " + - "and HandlerBaseCommit is untouched. The current tile stays open; end your own turn after calling this.")] + "Call at the end of Phase 2 of the list handler (\"Let Claude handle it\") to hand this run off " + + "to a fresh ConPTY session that carries out Phases 3-5, without dragging along this session's " + + "dedupe/rewrite context. Reuses the SAME handler task -- no new task is created, and " + + "HandlerBaseCommit is untouched. The current tile stays open; you must end your own turn " + + "immediately after calling this.")] public async Task HandoffListHandler( - string taskId, IReadOnlyList survivingTaskIds, CancellationToken cancellationToken) + [Description("This session's own handler task id.")] string taskId, + [Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList survivingTaskIds, + CancellationToken cancellationToken) { if (survivingTaskIds.Count == 0) throw new InvalidOperationException("survivingTaskIds must contain at least one task id."); diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs index aa1ecb7a..3980f51f 100644 --- a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -20,7 +20,7 @@ public sealed class LifecycleMcpTools _reset = reset; } - [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted. Returns { reset: true, taskId } on success.")] + [McpServerTool, Description("Reset a failed task back to Idle so it can be run again, discarding its now-stale worktree. Only tasks with Status=Failed are accepted; other statuses throw.")] public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) { var task = await _tasks.GetByIdAsync(taskId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/ListMcpTools.cs b/src/ClaudeDo.Worker/External/ListMcpTools.cs index 72fe5f2f..5ac26da7 100644 --- a/src/ClaudeDo.Worker/External/ListMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ListMcpTools.cs @@ -21,9 +21,14 @@ public sealed class ListMcpTools _broadcaster = broadcaster; } - [McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")] + [McpServerTool, Description("Create a new task list — the top-level grouping tasks belong to, with its own working dir, commit type, and default run config.")] public async Task CreateList( - string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default) + string name, + [Description("Absolute local path to an existing git repository this list's tasks will run against. Not validated here — the first task run fails if the path isn't an actual git repo. Omit to run this list's tasks in a throwaway sandbox with no worktree.")] + string? workingDir = null, + [Description("Conventional-commit-style type prefix for this list's task commits (e.g. 'feat', 'fix'). Defaults to 'chore'.")] + string? commitType = null, + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException("name is required."); @@ -41,9 +46,14 @@ public sealed class ListMcpTools return ToDto(entity); } - [McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")] + [McpServerTool, Description("Rename a list, or change its working dir / default commit type without recreating it. Pass null for any field to leave it unchanged.")] public async Task UpdateList( - string listId, string? name = null, string? workingDir = null, string? commitType = null, + string listId, + string? name = null, + [Description("Absolute local path to an existing git repository this list's tasks will run against; not validated until the next task runs. Null leaves it unchanged; pass an empty string to clear it and switch this list to sandbox-only task runs.")] + string? workingDir = null, + [Description("New default commit type prefix for this list's task commits. Null leaves it unchanged.")] + string? commitType = null, CancellationToken cancellationToken = default) { var entity = await _lists.GetByIdAsync(listId, cancellationToken) @@ -62,7 +72,7 @@ public sealed class ListMcpTools return ToDto(entity); } - [McpServerTool, Description("Delete a list and its tasks. Irreversible. Returns { deleted: true, id } on success.")] + [McpServerTool, Description("Permanently delete a list and all its tasks — no undo. Only for removing the whole list, not a single task within it.")] public async Task DeleteList(string listId, CancellationToken cancellationToken) { _ = await _lists.GetByIdAsync(listId, cancellationToken) diff --git a/src/ClaudeDo.Worker/External/McpToolDocs.cs b/src/ClaudeDo.Worker/External/McpToolDocs.cs new file mode 100644 index 00000000..46b814af --- /dev/null +++ b/src/ClaudeDo.Worker/External/McpToolDocs.cs @@ -0,0 +1,28 @@ +namespace ClaudeDo.Worker.External; + +/// +/// Boilerplate clauses shared by several external MCP tool descriptions. Every tool description is +/// still emitted in full to the client — these constants only stop the wording from drifting apart +/// across ~50 attributes. +/// +/// Description style (keep new tools in line with it): +/// 1. First sentence says what the tool does AND when to reach for it — MCP clients rank tools by +/// this text, so the trigger must not be buried behind return-shape prose. +/// 2. Then only non-obvious preconditions and refusals. +/// 3. Document parameters with [Description] on the parameter, not in the tool description. +/// 4. Describe result fields only where the caller must branch on them (isEmpty, truncated, +/// conflicts, …). Everything else is visible in the first actual response. +/// 5. No design rationale or "since this feature was introduced" history. +/// Budget: ~400 chars for a simple tool, ~800 for the merge/review family. +/// +internal static class McpToolDocs +{ + /// Warns that the payload is the lean reference, not the task's description/result. + public const string LeanTaskRef = " Returns a lean task reference, not the task's description."; + + /// Batch-size cap shared by every BatchMcpTools entry point. + public const string MaxBatch = " Max 100 per call."; + + /// Mutations that refuse to touch a task while its agent is running. + public const string NotWhileRunning = " Refused while the task is Running — cancel it first."; +} diff --git a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs index fc1e9763..d1d65ca4 100644 --- a/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs +++ b/src/ClaudeDo.Worker/External/QueueStateMcpTools.cs @@ -28,15 +28,12 @@ public sealed class QueueStateMcpTools } [McpServerTool, Description( - "Read-only snapshot of the execution queue -- observe slot occupancy instead of inferring " + - "it from maxParallelExecutions. Result: { configuredSlots, effectiveSlots, activeSlots: " + - "[{ slot, taskId, startedAt }], waitingTaskIds }. configuredSlots is Settings -> " + - "MaxParallelExecutions; effectiveSlots is that value stepped down by the usage throttle " + - "(lower when the 5h/7d usage window is filling up) -- compare the two to see whether " + - "throttling is currently active. activeSlots lists every task presently holding an " + - "execution slot, with slot \"queue\" for a normal queue slot or \"override\" for the single " + - "run_task_now/continue_task slot. waitingTaskIds lists queued, unblocked, non-manual, due " + - "tasks in the order the queue would pick them next.")] + "Read-only snapshot of the execution queue -- call this to observe slot occupancy instead " + + "of inferring it from maxParallelExecutions. effectiveSlots is configuredSlots stepped down " + + "by the usage throttle (lower when the 5h/7d usage window fills up), so comparing the two " + + "shows whether throttling is currently active. Each active slot is \"queue\" (a normal " + + "queue slot) or \"override\" (the single run_task_now/continue_task slot). waitingTaskIds " + + "lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next.")] public async Task GetQueueState(CancellationToken cancellationToken = default) { var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken); diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs index 06e4d799..d334fc7a 100644 --- a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -24,14 +24,17 @@ public sealed class RunHistoryMcpTools public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs; - [McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")] + [McpServerTool, Description( + "List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " + + "oldest to newest by run number, so the last entry is the most recent. Use a run's id from here with " + + "get_run to fetch it individually.")] public async Task> ListRuns(string taskId, CancellationToken cancellationToken) { var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken); return runs.Select(ToDto).ToList(); } - [McpServerTool, Description("Get a single execution run by its run id.")] + [McpServerTool, Description("Get one execution run's full detail by its run id, obtained from list_runs.")] public async Task GetRun(string runId, CancellationToken cancellationToken) { var run = await _runs.GetByIdAsync(runId, cancellationToken) @@ -40,18 +43,16 @@ public sealed class RunHistoryMcpTools } [McpServerTool, Description( - "Fetch log entries from a task's latest run. " + - "Returns { available, entries, totalLines, truncated }. " + - "available=false means no log exists yet (task is queued or just started — not an error). " + - "entries are the individual lines (NDJSON messages) from Claude's streaming output. " + - "Default: returns the last 50 entries (tail=50). " + - "tail: override the number of trailing entries to return. " + - "offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " + - "truncated=true when fewer entries are returned than totalLines.")] + "Fetch NDJSON log lines from a task's latest run — use this to check progress or debug a task without " + + "opening the log file. Defaults to the last 50 lines. available=false means no log exists yet (queued " + + "or just started — not an error); truncated=true when fewer entries are returned than totalLines.")] public async Task GetTaskLog( string taskId, + [Description("Number of trailing entries to return; ignored if offset or limit is set. Default 50.")] int? tail = null, + [Description("0-based entry index to start from; overrides tail when set. Combine with limit to page through the log.")] int? offset = null, + [Description("Max entries to return starting at offset. Omit to return everything from offset to the end.")] int? limit = null, CancellationToken cancellationToken = default) { diff --git a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs index cede7a45..850a05fb 100644 --- a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs +++ b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs @@ -29,19 +29,24 @@ public sealed class TaskWaitMcpTools } [McpServerTool, Description( - "Blocks until at least one of the given tasks leaves Queued/Running, or until timeoutSeconds elapses " + - "(clamped server-side to 900s). Returns immediately if any task is already outside Queued/Running " + - "when called (an unknown id is reported as status \"NotFound\" and counts as changed). Use this instead " + - "of polling get_task in a loop. Pitfall: a planning parent with children goes Running -> " + - "WaitingForChildren while its children are still working, and by default that counts as \"changed\" -- " + - "so waiting on a parent returns immediately even though the work isn't done. Set " + - "treatWaitingForChildrenAsBusy=true to keep waiting through WaitingForChildren; the call then only " + - "returns once the parent reaches WaitingForReview or a terminal status (default: false, unchanged " + - "legacy behavior). Requires the calling claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for " + - "a long wait to actually be held open -- ClaudeDo's own launchers already set this. " + - "Result: { changed: [{ taskId, status }], timedOut }.")] + "Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " + + "polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " + + "(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " + + "goes Running -> WaitingForChildren while its children are still working, so by default " + + "waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Requires the calling " + + "claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " + + "held open -- ClaudeDo's own launchers already set this.")] public async Task WaitForTaskChange( - string[] taskIds, int timeoutSeconds = 60, bool treatWaitingForChildrenAsBusy = false, + string[] taskIds, + [Description( + "How long to wait, in seconds, before giving up. Clamped server-side to 900s (15 min) " + + "regardless of what's passed.")] + int timeoutSeconds = 60, + [Description( + "When true, WaitingForChildren still counts as busy, so waiting on a planning parent " + + "continues until it reaches WaitingForReview or a terminal status instead of returning " + + "as soon as it leaves Running.")] + bool treatWaitingForChildrenAsBusy = false, CancellationToken cancellationToken = default) { if (taskIds.Length == 0) diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index e6d306d3..4f7ef325 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -989,19 +989,6 @@ public sealed class ExternalMcpServiceTests : IDisposable Assert.Equal(10, result.Config.MaxTurns); } - // ── GetTaskStatusValues ─────────────────────────────────────────────────── - - [Fact] - public async Task GetTaskStatusValues_ContainsAllStatuses() - { - var sut = NewService(); - var values = await sut.GetTaskStatusValues(); - var names = values.Select(v => v.Status).ToHashSet(); - - foreach (var status in Enum.GetValues()) - Assert.Contains(status.ToString(), names); - } - // ── ListTasks status filter ─────────────────────────────────────────────── [Fact]