Merge branch 'main' into worktree-phase1-reaktivitaet
This commit is contained in:
@@ -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`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,939 @@
|
||||
# Handler-Run Links Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL.
|
||||
|
||||
**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path.
|
||||
|
||||
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modified:**
|
||||
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property
|
||||
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index
|
||||
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — `SetHandlerTaskIdAsync`
|
||||
- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence
|
||||
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border
|
||||
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `HandlerBadgeBrush` + `Border.badge.handler`
|
||||
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `tasks.badgeHandler`, `tasks.handlerTip`
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `HandledTasks` collection, loader, clear, refresh
|
||||
- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel
|
||||
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs
|
||||
|
||||
**Created:**
|
||||
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated
|
||||
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`
|
||||
- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`
|
||||
- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Data — `HandlerTaskId` column and migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`
|
||||
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131`
|
||||
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (generated)
|
||||
|
||||
- [ ] **Step 1: Add the property**
|
||||
|
||||
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add:
|
||||
|
||||
```csharp
|
||||
|
||||
// Id of the "list handler" run task that processed this task ("Let Claude handle it").
|
||||
// 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately
|
||||
// NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering.
|
||||
// No FK: a deleted handler task must not cascade into the tasks it merely touched.
|
||||
public string? HandlerTaskId { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map the column and index it**
|
||||
|
||||
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line
|
||||
`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add:
|
||||
|
||||
```csharp
|
||||
builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id");
|
||||
```
|
||||
|
||||
At the end of `Configure`, after the line
|
||||
`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add:
|
||||
|
||||
```csharp
|
||||
builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id");
|
||||
```
|
||||
|
||||
Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less.
|
||||
|
||||
- [ ] **Step 3: Generate the migration**
|
||||
|
||||
Run from the repo root:
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
||||
```
|
||||
|
||||
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn<string>(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry.
|
||||
|
||||
If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring
|
||||
`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add
|
||||
`Property<string>("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");`
|
||||
plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
|
||||
Expected: `Build succeeded`, 0 errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations
|
||||
git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run"
|
||||
```
|
||||
|
||||
⚠️ Always commit with explicit paths (`git commit -- <paths>`), never a bare `git commit` — the
|
||||
main checkout is shared with concurrent sessions.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Data — `SetHandlerTaskIdAsync` repository method
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`)
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Repositories;
|
||||
|
||||
/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it"
|
||||
/// run processed, so the handler task's detail pane can list them after the run.
|
||||
public sealed class TaskRepositoryHandlerLinkTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
|
||||
public TaskRepositoryHandlerLinkTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private async Task<string> CreateListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId,
|
||||
Name = "Test List",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task<string> AddTaskAsync(string listId)
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await _tasks.AddAsync(new TaskEntity
|
||||
{
|
||||
Id = id,
|
||||
ListId = listId,
|
||||
Title = "T",
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var a = await AddTaskAsync(listId);
|
||||
var b = await AddTaskAsync(listId);
|
||||
var handlerId = await AddTaskAsync(listId);
|
||||
|
||||
var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId);
|
||||
|
||||
Assert.Equal(2, affected);
|
||||
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId);
|
||||
Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var a = await AddTaskAsync(listId);
|
||||
var handlerId = await AddTaskAsync(listId);
|
||||
|
||||
var affected = await _tasks.SetHandlerTaskIdAsync(
|
||||
new[] { a, "does-not-exist" }, handlerId);
|
||||
|
||||
Assert.Equal(1, affected);
|
||||
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetHandlerTaskIdAsync_SecondRunOverwrites()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var a = await AddTaskAsync(listId);
|
||||
var firstHandler = await AddTaskAsync(listId);
|
||||
var secondHandler = await AddTaskAsync(listId);
|
||||
|
||||
await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler);
|
||||
await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler);
|
||||
|
||||
Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp()
|
||||
{
|
||||
var listId = await CreateListAsync();
|
||||
var handlerId = await AddTaskAsync(listId);
|
||||
|
||||
var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty<string>(), handlerId);
|
||||
|
||||
Assert.Equal(0, affected);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
|
||||
Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`.
|
||||
|
||||
- [ ] **Step 3: Implement the method**
|
||||
|
||||
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add:
|
||||
|
||||
```csharp
|
||||
// Links the tasks a "list handler" run processed back to the handler's own task, so the
|
||||
// handler's detail pane can list them after the run. Stamped from the user's selection at
|
||||
// creation time -- that way tasks the handler later cancels as duplicates stay visible.
|
||||
// Unknown ids are silently skipped. Returns the number of rows actually stamped.
|
||||
public async Task<int> SetHandlerTaskIdAsync(
|
||||
IReadOnlyList<string> taskIds,
|
||||
string handlerTaskId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (taskIds.Count == 0) return 0;
|
||||
|
||||
var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList();
|
||||
if (ids.Count == 0) return 0;
|
||||
|
||||
return await _context.Tasks
|
||||
.Where(t => ids.Contains(t.Id))
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.HandlerTaskId, handlerTaskId), ct);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
|
||||
Expected: `Passed! - Failed: 0, Passed: 4`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs
|
||||
git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Worker — stamp the selection when the handler task is created
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`)
|
||||
|
||||
Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList<string> taskIds` — the UI →
|
||||
`IWorkerClient` → `WorkerHub` chain needs **no** change.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
|
||||
var t1 = Guid.NewGuid().ToString();
|
||||
var t2 = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||
|
||||
var svc = BuildService();
|
||||
var handlerId = await svc.CreateMergeHelperTaskAsync(
|
||||
new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
|
||||
|
||||
using var readCtx = _db.CreateContext();
|
||||
var tasks = new TaskRepository(readCtx);
|
||||
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId);
|
||||
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId);
|
||||
// The handler never links to itself.
|
||||
Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"`
|
||||
Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`.
|
||||
|
||||
- [ ] **Step 3: Stamp the selection**
|
||||
|
||||
In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace:
|
||||
|
||||
```csharp
|
||||
await taskRepo.AddAsync(handlerTask, ct);
|
||||
|
||||
return handlerTask.Id;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
await taskRepo.AddAsync(handlerTask, ct);
|
||||
|
||||
// Link the selection back to this run BEFORE the session starts: the handler cancels
|
||||
// duplicates in phase 1, and those still belong in the "what was this run supposed to do"
|
||||
// list. Stamping later (e.g. at handoff) would lose them.
|
||||
await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct);
|
||||
|
||||
return handlerTask.Id;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"`
|
||||
Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
|
||||
git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Ui — HANDLER badge instead of MANUAL
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144`
|
||||
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990`
|
||||
- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on
|
||||
/// it -- the HANDLER badge must win and MANUAL must disappear.
|
||||
public class TaskRowViewModelHandlerBadgeTests
|
||||
{
|
||||
[Fact]
|
||||
public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge()
|
||||
{
|
||||
var row = new TaskRowViewModel { Id = "t1" };
|
||||
row.IsManual = true;
|
||||
row.HandlerBaseCommit = "base123";
|
||||
|
||||
Assert.True(row.IsHandlerRun);
|
||||
Assert.NotNull(row.HandlerBadge);
|
||||
Assert.Null(row.ManualBadge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainManualTask_StillShowsManualBadge()
|
||||
{
|
||||
var row = new TaskRowViewModel { Id = "t2" };
|
||||
row.IsManual = true;
|
||||
|
||||
Assert.False(row.IsHandlerRun);
|
||||
Assert.Null(row.HandlerBadge);
|
||||
Assert.NotNull(row.ManualBadge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateFromEntity_MirrorsHandlerBaseCommit()
|
||||
{
|
||||
var row = new TaskRowViewModel { Id = "t3" };
|
||||
row.UpdateFromEntity(new TaskEntity
|
||||
{
|
||||
Id = "t3",
|
||||
ListId = "l1",
|
||||
Title = "List handler: Alpha",
|
||||
Status = TaskStatus.Idle,
|
||||
IsManual = true,
|
||||
HandlerBaseCommit = "base123",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
Assert.Equal("base123", row.HandlerBaseCommit);
|
||||
Assert.True(row.IsHandlerRun);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
|
||||
Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`.
|
||||
|
||||
- [ ] **Step 3: Add the properties**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field
|
||||
declaration (`[ObservableProperty] private bool _isManual;`), add:
|
||||
|
||||
```csharp
|
||||
// Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run
|
||||
// host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL.
|
||||
[ObservableProperty] private string? _handlerBaseCommit;
|
||||
```
|
||||
|
||||
Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with:
|
||||
|
||||
```csharp
|
||||
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
|
||||
|
||||
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
|
||||
|
||||
// HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and
|
||||
// "MANUAL" would read as a hand-written reminder. The two badges never show together.
|
||||
public bool ShowManualBadge => IsManual && !IsHandlerRun;
|
||||
|
||||
public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null;
|
||||
```
|
||||
|
||||
Add a change hook next to the existing `OnIsManualChanged` partial method:
|
||||
|
||||
```csharp
|
||||
partial void OnHandlerBaseCommitChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHandlerRun));
|
||||
OnPropertyChanged(nameof(HandlerBadge));
|
||||
OnPropertyChanged(nameof(ShowManualBadge));
|
||||
OnPropertyChanged(nameof(ManualBadge));
|
||||
}
|
||||
```
|
||||
|
||||
Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add:
|
||||
|
||||
```csharp
|
||||
OnPropertyChanged(nameof(ShowManualBadge));
|
||||
```
|
||||
|
||||
In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add:
|
||||
|
||||
```csharp
|
||||
HandlerBaseCommit = t.HandlerBaseCommit;
|
||||
```
|
||||
|
||||
Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line:
|
||||
|
||||
```csharp
|
||||
OnPropertyChanged(nameof(HandlerBadge));
|
||||
OnPropertyChanged(nameof(ManualBadge));
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the locale keys**
|
||||
|
||||
In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add:
|
||||
|
||||
```json
|
||||
"badgeHandler": "HANDLER",
|
||||
"handlerTip": "Handler run — see the tasks it processed in the detail pane",
|
||||
```
|
||||
|
||||
In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add:
|
||||
|
||||
```json
|
||||
"badgeHandler": "HANDLER",
|
||||
"handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich",
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the badge style and brush**
|
||||
|
||||
In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line
|
||||
`<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>` add:
|
||||
|
||||
```xml
|
||||
<SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>
|
||||
```
|
||||
|
||||
After the existing `Border.badge.manual` style block add:
|
||||
|
||||
```xml
|
||||
<!-- handler → peat: a "Let Claude handle it" run host, not a hand-written reminder -->
|
||||
<Style Selector="Border.badge.handler">
|
||||
<Setter Property="Background" Value="{DynamicResource HandlerBadgeBrush}"/>
|
||||
</Style>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Render the badge**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144):
|
||||
|
||||
```xml
|
||||
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
|
||||
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||
<TextBlock Text="{Binding ManualBadge}"/>
|
||||
</Border>
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```xml
|
||||
<Border Classes="badge manual" IsVisible="{Binding ShowManualBadge}"
|
||||
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||
<TextBlock Text="{Binding ManualBadge}"/>
|
||||
</Border>
|
||||
<Border Classes="badge handler" IsVisible="{Binding IsHandlerRun}"
|
||||
ToolTip.Tip="{loc:Tr tasks.handlerTip}">
|
||||
<TextBlock Text="{Binding HandlerBadge}"/>
|
||||
</Border>
|
||||
```
|
||||
|
||||
Only the `IsVisible` binding changed on the manual border (`IsManual` → `ShowManualBadge`); the
|
||||
handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`.
|
||||
|
||||
- [ ] **Step 7: Run tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
|
||||
Expected: `Passed! - Failed: 0, Passed: 3`.
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||
Expected: `Passed! - Failed: 0` (en/de key parity).
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: `Build succeeded` — this compiles the AXAML.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs
|
||||
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with
|
||||
/// its id, including ones the handler cancelled as duplicates.
|
||||
public class DetailsIslandHandledTasksTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DetailsIslandHandledTasksTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||
{
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
|
||||
Task.FromResult(new List<DailyNoteDto>());
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
|
||||
Task.FromResult<DailyNoteDto?>(null);
|
||||
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
}
|
||||
|
||||
private DetailsIslandViewModel BuildVm()
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(
|
||||
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus()
|
||||
{
|
||||
const string listId = "list-1";
|
||||
const string handlerId = "handler-task-1";
|
||||
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = handlerId, ListId = listId, Title = "List handler: L",
|
||||
Status = TaskStatus.WaitingForReview, IsManual = true,
|
||||
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = "done-1", ListId = listId, Title = "Merged task",
|
||||
Status = TaskStatus.Done, HandlerTaskId = handlerId,
|
||||
SortOrder = 0, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled",
|
||||
Status = TaskStatus.Cancelled, HandlerTaskId = handlerId,
|
||||
SortOrder = 1, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = "unrelated-1", ListId = listId, Title = "Not part of the run",
|
||||
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = BuildVm();
|
||||
vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview });
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0)
|
||||
await Task.Delay(20);
|
||||
|
||||
Assert.Equal(2, vm.HandledTasks.Count);
|
||||
Assert.True(vm.HasHandledTasks);
|
||||
Assert.Equal("Merged task", vm.HandledTasks[0].Title);
|
||||
Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status);
|
||||
Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status);
|
||||
Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bind_PlainTask_HasNoHandledTasks()
|
||||
{
|
||||
const string listId = "list-1";
|
||||
const string taskId = "plain-1";
|
||||
|
||||
await using (var ctx = NewContext())
|
||||
{
|
||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
ctx.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId, ListId = listId, Title = "Plain",
|
||||
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var vm = BuildVm();
|
||||
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle });
|
||||
await Task.Delay(300);
|
||||
|
||||
Assert.Empty(vm.HandledTasks);
|
||||
Assert.False(vm.HasHandledTasks);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
|
||||
Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`.
|
||||
|
||||
- [ ] **Step 3: Add the collection**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line
|
||||
`public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();` add:
|
||||
|
||||
```csharp
|
||||
// Tasks a "list handler" run processed ("Let Claude handle it"), linked via
|
||||
// TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the
|
||||
// planning/improvement parent's children and feeds the merge card's combined diff, which a
|
||||
// handler run must not touch (it commits straight to the list's working dir).
|
||||
public ObservableCollection<ChildOutcomeRowViewModel> HandledTasks { get; } = new();
|
||||
```
|
||||
|
||||
After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add:
|
||||
|
||||
```csharp
|
||||
public bool HasHandledTasks => HandledTasks.Count > 0;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Clear it on rebind**
|
||||
|
||||
In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add:
|
||||
|
||||
```csharp
|
||||
HandledTasks.Clear();
|
||||
```
|
||||
|
||||
and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add:
|
||||
|
||||
```csharp
|
||||
OnPropertyChanged(nameof(HasHandledTasks));
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Load it on bind**
|
||||
|
||||
In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add:
|
||||
|
||||
```csharp
|
||||
await LoadHandledTasksAsync(row.Id, ct);
|
||||
```
|
||||
|
||||
Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`:
|
||||
|
||||
```csharp
|
||||
// Tasks stamped with this handler run's id. Ordered like the task list itself so the panel
|
||||
// reads in the same order the user picked them.
|
||||
private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var handled = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Worktree)
|
||||
.Where(t => t.HandlerTaskId == handlerTaskId)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (handled.Count == 0) return;
|
||||
|
||||
HandledTasks.Clear();
|
||||
foreach (var h in handled)
|
||||
HandledTasks.Add(new ChildOutcomeRowViewModel
|
||||
{
|
||||
Id = h.Id,
|
||||
Title = h.Title,
|
||||
Status = h.Status,
|
||||
RoadblockCount = h.RoadblockCount,
|
||||
WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
|
||||
});
|
||||
OnPropertyChanged(nameof(HasHandledTasks));
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Keep the rows live**
|
||||
|
||||
In the same file, in `RefreshChildOutcomeAsync`, replace:
|
||||
|
||||
```csharp
|
||||
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
|
||||
if (row is null) return;
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```csharp
|
||||
// The same refresh serves both lists: a planning parent's children and a handler run's
|
||||
// handled tasks. Only one of them can hold a given id.
|
||||
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId)
|
||||
?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId);
|
||||
if (row is null) return;
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Render the panel**
|
||||
|
||||
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing
|
||||
`</StackPanel>` of the existing `<!-- Child outcomes -->` block, add:
|
||||
|
||||
```xml
|
||||
<!-- Handled tasks (list handler run) -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasHandledTasks}">
|
||||
<TextBlock Classes="section-label" Text="HANDLED TASKS" />
|
||||
<ItemsControl ItemsSource="{Binding HandledTasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
|
||||
IsVisible="{Binding HasRoadblock}"
|
||||
Foreground="#E0A030"
|
||||
Margin="8,0" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
|
||||
Opacity="0.75" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run the tests to verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
|
||||
Expected: `Passed! - Failed: 0, Passed: 2`.
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: `Build succeeded`.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs
|
||||
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full verification and docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list)
|
||||
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets)
|
||||
- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section)
|
||||
|
||||
- [ ] **Step 1: Run every affected test project**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||
```
|
||||
|
||||
Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile,
|
||||
it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test.
|
||||
|
||||
- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`**
|
||||
|
||||
In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after
|
||||
`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing
|
||||
`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet:
|
||||
|
||||
```markdown
|
||||
- `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`**
|
||||
|
||||
In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add
|
||||
`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`.
|
||||
|
||||
In the `TaskRowViewModel` sentence, after the `IsManual` clause, add
|
||||
`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`.
|
||||
|
||||
- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`**
|
||||
|
||||
In the "The host task and its commit range" section, add after the existing description:
|
||||
|
||||
```markdown
|
||||
`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task
|
||||
(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail
|
||||
pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates.
|
||||
The handler never links to itself.
|
||||
```
|
||||
|
||||
Bump that note's "verified against" commit line to the current HEAD.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md
|
||||
git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Report the visual-verification gap**
|
||||
|
||||
The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika:
|
||||
|
||||
1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder.
|
||||
2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll).
|
||||
3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate.
|
||||
@@ -0,0 +1,169 @@
|
||||
# Handler-Run: Verknüpfung zu den behandelten Tasks
|
||||
|
||||
**Date:** 2026-08-07
|
||||
**Status:** Design approved (Mika), implementation pending
|
||||
**Verified against:** commit `c792765`
|
||||
|
||||
## Problem
|
||||
|
||||
Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`,
|
||||
`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run
|
||||
behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im
|
||||
Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`)
|
||||
bekommt `survivingTaskIds` als flüchtige Liste.
|
||||
|
||||
Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen,
|
||||
*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler
|
||||
in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld.
|
||||
|
||||
Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt —
|
||||
irreführend, denn es ist kein manueller Reminder.
|
||||
|
||||
## Ist-Zustand
|
||||
|
||||
### Es gibt kein Task-Kind
|
||||
|
||||
`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen:
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|---|---|
|
||||
| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn |
|
||||
| `ParentTaskId` | Kind einer Planning-/Improvement-Session |
|
||||
| `PlanningPhase` | Planning-Parent |
|
||||
| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es |
|
||||
| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) |
|
||||
|
||||
Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert.
|
||||
|
||||
### `ParentTaskId` ist belegt
|
||||
|
||||
`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es
|
||||
für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild`
|
||||
(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile
|
||||
im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler
|
||||
schieben und mit echten Planning-Kindern kollidieren.
|
||||
|
||||
### Badge-Infrastruktur existiert
|
||||
|
||||
`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über
|
||||
`Border Classes="badge <variant>"`. Basis-Style und Varianten liegen in
|
||||
`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in
|
||||
`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164).
|
||||
|
||||
### Kinder-Panel existiert
|
||||
|
||||
`DetailsIslandViewModel.LoadChildOutcomesAsync`
|
||||
(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt
|
||||
`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile
|
||||
Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über
|
||||
`TaskUpdated`/`WorktreeUpdated` (`:814-833`).
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
| Frage | Entscheidung | Begründung |
|
||||
|---|---|---|
|
||||
| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. |
|
||||
| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik |
|
||||
| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. |
|
||||
| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. |
|
||||
| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. |
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Daten
|
||||
|
||||
Neue nullable Spalte auf `TaskEntity`:
|
||||
|
||||
```csharp
|
||||
/// <summary>Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner).</summary>
|
||||
public string? HandlerTaskId { get; set; }
|
||||
```
|
||||
|
||||
Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint
|
||||
(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks
|
||||
nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`.
|
||||
|
||||
Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n).
|
||||
|
||||
### 2. Schreiben
|
||||
|
||||
Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync` →
|
||||
`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) →
|
||||
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt
|
||||
eine neue Repository-Methode die Zuordnung in einem Batch-Update:
|
||||
|
||||
```csharp
|
||||
Task<int> SetHandlerTaskIdAsync(IReadOnlyList<string> taskIds, string handlerTaskId, CancellationToken ct);
|
||||
```
|
||||
|
||||
Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden
|
||||
still übersprungen.
|
||||
|
||||
### 3. Badge
|
||||
|
||||
`TaskRowViewModel`:
|
||||
|
||||
```csharp
|
||||
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
|
||||
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
|
||||
public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null;
|
||||
```
|
||||
|
||||
`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden.
|
||||
HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig.
|
||||
|
||||
In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit
|
||||
`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante
|
||||
mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark.
|
||||
|
||||
Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft):
|
||||
|
||||
- `tasks.badgeHandler` — "HANDLER" / "HANDLER"
|
||||
- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die
|
||||
Tasks, die er bearbeitet hat"
|
||||
|
||||
### 4. Anzeige
|
||||
|
||||
Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden
|
||||
Kinder-Panel:
|
||||
|
||||
- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync`
|
||||
mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste.
|
||||
- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount,
|
||||
WorktreeState) — keine neue Row-Klasse.
|
||||
- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende
|
||||
`RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert.
|
||||
- Sichtbar nur wenn `HandledTasks.Count > 0`.
|
||||
- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige
|
||||
(Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task
|
||||
springen" wäre neues Verhalten und ist hier nicht enthalten.
|
||||
|
||||
### 5. Fehlerfälle
|
||||
|
||||
- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben
|
||||
normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig.
|
||||
- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks).
|
||||
- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar.
|
||||
|
||||
## Tests
|
||||
|
||||
| Ebene | Test |
|
||||
|---|---|
|
||||
| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung |
|
||||
| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst |
|
||||
| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) |
|
||||
| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` |
|
||||
| Localization | Parität en/de — deckt der bestehende Test automatisch ab |
|
||||
|
||||
## Bewusst nicht enthalten
|
||||
|
||||
- Kein `TaskKind`-Enum.
|
||||
- Keine n:m-Historie über mehrere Runs.
|
||||
- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis.
|
||||
- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task.
|
||||
- Kein Badge auf den *behandelten* Tasks.
|
||||
|
||||
## Offen
|
||||
|
||||
- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im
|
||||
Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll).
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||
=> await _agents.ScanAsync(cancellationToken);
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ public sealed class AppSettingsMcpTools
|
||||
|
||||
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> 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<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
|
||||
{
|
||||
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
+11
-12
@@ -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<AttachmentDto> 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<IReadOnlyList<AttachmentDto>> 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<RemoveAttachmentResult> RemoveTaskAttachment(
|
||||
string taskId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
+39
-35
@@ -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<IReadOnlyList<BatchGetTaskResult>> 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<IReadOnlyList<BatchAddTaskResult>> 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<IReadOnlyList<BatchTaskResult>> 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<IReadOnlyList<BatchCancelResult>> 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<IReadOnlyList<BatchTaskResult>> 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<IReadOnlyList<BatchTaskResult>> 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<IReadOnlyList<BatchCleanupResult>> 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));
|
||||
|
||||
|
||||
+11
-13
@@ -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<TaskConfigResult> 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<SetListConfigResult> 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<SetTaskConfigResult> 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<TaskConfigResult> 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<EffectiveRunConfigDto> GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
|
||||
+134
-160
@@ -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<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? 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<IReadOnlyList<TaskListDto>> 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<ListTasksResult> 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<TaskDto> 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<AddTaskResult> 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<TaskRefDto> 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<TaskRefDto> 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<TaskRefDto> 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<ReviewTaskResult> 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<RunTaskNowResult> 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<CancelTaskResult> 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<DeleteTaskResult> 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<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). " +
|
||||
"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<WorktreeInfoDto> 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<TaskDiffDto> 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<MergeTaskResultDto> 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<MergeContinuationResultDto> 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<TaskRefDto> 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<MergePreviewToolDto> 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<MergePreviewSetResultDto> PreviewMergeSet(
|
||||
IReadOnlyList<string> 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<RevertMergeResultDto> 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<IReadOnlyList<WorktreeListItemDto>> 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<CleanupWorktreeResult> 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<string> 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<DailyPrepDataDto> 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<TaskRefDto> 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)
|
||||
{
|
||||
|
||||
+8
-6
@@ -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<HandoffListHandlerResult> HandoffListHandler(
|
||||
string taskId, IReadOnlyList<string> 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<string> survivingTaskIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (survivingTaskIds.Count == 0)
|
||||
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
|
||||
|
||||
+1
-1
@@ -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<ResetFailedTaskResult> ResetFailedTask(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
|
||||
+15
-5
@@ -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<ListSummaryDto> 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<ListSummaryDto> 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<DeleteListResult> DeleteList(string listId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class McpToolDocs
|
||||
{
|
||||
/// <summary>Warns that the payload is the lean reference, not the task's description/result.</summary>
|
||||
public const string LeanTaskRef = " Returns a lean task reference, not the task's description.";
|
||||
|
||||
/// <summary>Batch-size cap shared by every BatchMcpTools entry point.</summary>
|
||||
public const string MaxBatch = " Max 100 per call.";
|
||||
|
||||
/// <summary>Mutations that refuse to touch a task while its agent is running.</summary>
|
||||
public const string NotWhileRunning = " Refused while the task is Running — cancel it first.";
|
||||
}
|
||||
+6
-9
@@ -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<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken);
|
||||
|
||||
+11
-10
@@ -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<IReadOnlyList<RunDto>> 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<RunDto> 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<TaskLogResult> 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)
|
||||
{
|
||||
|
||||
+17
-12
@@ -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<WaitForTaskChangeResult> 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)
|
||||
|
||||
@@ -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<TaskStatus>())
|
||||
Assert.Contains(status.ToString(), names);
|
||||
}
|
||||
|
||||
// ── ListTasks status filter ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user