# Per-List Task Handler 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:** Make "Let Claude handle it" list-scoped only, and turn its prompt into a five-phase run — read all tasks, dedupe, enhance, queue, review+merge. **Architecture:** Four independent commits. Two touch only leaf code (the MCP status tool, the prompt templates). One strips the global UI entry point. The last is an atomic sweep that makes `listId` non-nullable end to end and collapses the launch spec to a single repo — atomic because a half-flipped signature chain leaves nullable warnings scattered across a commit boundary. **Tech Stack:** .NET 8, xUnit, Avalonia 12, EF Core + SQLite, CommunityToolkit.Mvvm. **Spec:** `docs/superpowers/specs/2026-07-27-list-handler-design.md` **Build note:** `dotnet build ClaudeDo.slnx` needs .NET 9 — build individual csproj with `-c Release` (a running Worker locks `Debug` output). **Staging note:** the checkout is shared with parallel sessions. Always `git add -- ` and `git commit -- `. Never `git add -A`, never a bare `git commit`. --- ### Task 1: `update_task_status` accepts `Cancelled` Dedupe needs to retire an **Idle** duplicate. Today nothing can: `UpdateTaskStatus` allows only `Idle`/`Queued`, `cancel_task` only cancels a *running* task, and `review_task(decision="cancel")` requires WaitingForReview/Running/Queued. `TaskStateService.CancelAsync` already owns the transition and its side effects. `BatchMcpTools.BatchUpdateTaskStatus` delegates to this same method, so batch cancel comes free. **Files:** - Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs:264-300` - Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` - [ ] **Step 1: Write the failing tests** Append inside the `ExternalMcpServiceTests` class. `SeedTaskAsync` does not exist in this class — seed inline the way the existing tests do, via `_lists` / `_tasks`. ```csharp private async Task SeedPlainTaskAsync(TaskStatus status) { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } [Fact] public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask() { var task = await SeedPlainTaskAsync(TaskStatus.Idle); var queue = CreateQueue(); var sut = BuildSut(queue); var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None); Assert.Equal("Cancelled", dto.Status); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Cancelled, loaded!.Status); } [Fact] public async Task UpdateTaskStatus_Done_StillRejected() { var task = await SeedPlainTaskAsync(TaskStatus.Idle); var queue = CreateQueue(); var sut = BuildSut(queue); var ex = await Assert.ThrowsAsync( () => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None)); Assert.Contains("not settable externally", ex.Message); } ``` - [ ] **Step 2: Run the tests to verify they fail** ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \ --filter "FullyQualifiedName~ExternalMcpServiceTests.UpdateTaskStatus" ``` Expected: `UpdateTaskStatus_Cancelled_CancelsAnIdleTask` FAILS with `Status 'Cancelled' is not settable externally.`; `UpdateTaskStatus_Done_StillRejected` passes. - [ ] **Step 3: Add the `Cancelled` branch** In `ExternalMcpService.UpdateTaskStatus`, insert between the `Queued` case and `default`: ```csharp case TaskStatus.Cancelled: var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken); if (!cancelResult.Ok) throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task."); break; ``` Then update the `[McpServerTool, Description(...)]` text directly above the method — it currently claims only Idle and Queued are permitted. Replace the whole attribute with: ```csharp [McpServerTool, Description( "Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' 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). " + "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")] ``` Also fix the `default` branch message, which still points at `cancel_task`: ```csharp default: throw new InvalidOperationException( $"Status '{target}' is not settable externally. Use run_task_now or review_task."); ``` - [ ] **Step 4: Run the tests to verify they pass** ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \ --filter "FullyQualifiedName~ExternalMcpServiceTests" ``` Expected: all pass. - [ ] **Step 5: Run the MCP schema test** `ExternalMcpToolSchemaTests` asserts over tool descriptions and may pin the old text. ```bash dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \ --filter "FullyQualifiedName~ExternalMcpToolSchemaTests" ``` Expected: PASS. If it fails on the changed description, update the assertion to match the new text — do not revert the description. - [ ] **Step 6: Commit** ```bash git add -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs git commit -m "feat(worker): allow update_task_status to set Cancelled" -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs ``` (If Step 5 required a schema-test edit, add that path to both commands too.) --- ### Task 2: Five-phase helper prompt **Files:** - Modify: `src/ClaudeDo.Data/PromptFiles.cs:231-276` (`MergeHelperDefault`, `MergeHelperInitialDefault`) - Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs:54-80` - [ ] **Step 1: Write the failing tests** Replace the existing `DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools` test with the two below, and keep the other merge-helper tests as they are. ```csharp [Fact] public void DefaultFor_merge_helper_covers_all_five_phases() { var d = PromptFiles.DefaultFor(PromptKind.MergeHelper); Assert.False(string.IsNullOrWhiteSpace(d)); Assert.Contains("Phase 0", d); Assert.Contains("Phase 1", d); Assert.Contains("Phase 2", d); Assert.Contains("Phase 3", d); Assert.Contains("Phase 4", d); Assert.Contains("Phase 5", d); } [Fact] public void DefaultFor_merge_helper_names_the_tools_each_phase_needs() { var d = PromptFiles.DefaultFor(PromptKind.MergeHelper); Assert.Contains("batch_get_tasks", d); // phase 0 Assert.Contains("update_task", d); // phase 1 + 2 Assert.Contains("get_app_settings", d); // phase 3 Assert.Contains("update_task_status", d); // phase 3 Assert.Contains("review_task", d); // phase 4 Assert.Contains("continue_merge", d); // phase 4 Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start } ``` - [ ] **Step 2: Run the tests to verify they fail** ```bash dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \ --filter "FullyQualifiedName~PromptFilesTests" ``` Expected: both new tests FAIL (no "Phase 0", no `batch_get_tasks`). - [ ] **Step 3: Replace `MergeHelperDefault`** Replace the whole `private const string MergeHelperDefault = """ … """;` block with: ```csharp private const string MergeHelperDefault = """ You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure — that is the point of this session. Your job: take the tasks listed in the brief and drive the whole set to merged, Done work — reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo. Work the five phases in order. Do not start a phase before the previous one is finished. ## Phase 0 — Read everything Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view. ## Phase 1 — Dedupe Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason. Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time: - merge → fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this. - keep both → note why and move on. Cancel nothing without an explicit answer. If there are no duplicates, say so and go on. ## Phase 2 — Enhance for execution Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry: - concrete acceptance criteria — what must be true when it is done, - the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up. - what is explicitly out of scope. Write it back with update_task (title, description and commitType are the settable fields). Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task. ## Phase 3 — Run Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy". Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings. Then, for each surviving task: - Idle or Failed → update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it. - Queued → leave it; it is already waiting for a slot. - Running or WaitingForChildren → leave it; only poll. - WaitingForReview → leave it; it goes straight to Phase 4. Poll get_task until every task has left Queued and Running — WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes. ## Phase 4 — Review and merge One task at a time, in the order the brief lists them. 1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description. 2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip. 3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true). - Clean merge → the task is Done; move on. - Conflict (markers left in the working tree, repoPath returned) → resolve it. Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted. Resolving a conflict: - Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve. - For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge. - If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing. - Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- ` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions. Rules for the whole session: - Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish. - Ask the user for anything ambiguous, risky, or destructive. ## Phase 5 — Summary Print one line per task from the original brief: title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no) — final status — merge commit (if any) — conflicts resolved (if any). Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks. """; ``` - [ ] **Step 4: Replace `MergeHelperInitialDefault`** The scope is now always one list with one repo, so the header states it once and the task lines drop the constant `list:` / `repo:` fields. ```csharp private const string MergeHelperInitialDefault = """ # List handler brief Scope: {scope} Repo: {repo} Handle the following tasks. Work Phases 0–5 as your instructions describe, asking me whenever you are unsure. {tasks} When every task is handled, print the summary. """; ``` - [ ] **Step 5: Add the `{repo}` token test** `{repo}` is a new token — Task 4 will pass it. Add to `PromptFilesTests`: ```csharp [Fact] public void DefaultFor_merge_helper_initial_has_repo_token() { var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial); Assert.Contains("{repo}", d); } ``` The existing `RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks` test passes only `scope` and `tasks`. `RenderTemplate` leaves unknown tokens alone, so its two `Assert.Contains` still hold and its `Assert.DoesNotContain("{scope}", outp)` still holds. Leave it unchanged. - [ ] **Step 6: Run the tests to verify they pass** ```bash dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \ --filter "FullyQualifiedName~PromptFilesTests" ``` Expected: all pass. - [ ] **Step 7: Commit** ```bash git add -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs git commit -m "feat(data): five-phase list-handler prompt with dedupe and enhance" -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs ``` --- ### Task 3: Drop the global entry point and the LIST column This removes every caller that passes a null `listId`, clearing the way for Task 4's signature sweep. Types stay nullable here; only callers and UI go. **Files:** - Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:103-113` - Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml:184,206-210` - Modify: `src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs:14-53` - Modify: `src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml:41-72` - Modify: `src/ClaudeDo.Localization/locales/en.json`, `src/ClaudeDo.Localization/locales/de.json` - Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs` - [ ] **Step 1: Update the dialog tests to the list-only API** `Configure` becomes `Configure(string listId, string listName)` and `IsGlobal` and `ListName` are gone. Rewrite the affected tests. `Load_ExcludesTerminalStatuses_AndTicksActionableByDefault`, `CanConfirm_FollowsRowSelection` and `Confirm_ReturnsSelectedIds_InRowOrder` all used `Configure(null, null)` to see every seeded task — point them at `"L1"` instead, which holds all eight seeded statuses (`t-other-list` lives in `L2` and drops out). Replace the four tests below; leave `Load_NoCandidates_HasTasksFalse_CannotConfirm` untouched. ```csharp [Fact] public async Task Load_ExcludesTerminalStatuses_AndTicksActionableByDefault() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled"); Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list"); Assert.Equal(6, vm.Tasks.Count); Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-review").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-failed").IsSelected); Assert.False(vm.Tasks.Single(t => t.Id == "t-running").IsSelected); Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected); } [Fact] public async Task Load_PerListScope_FiltersToThatList() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L2", "Home"); await vm.LoadAsync(); Assert.Single(vm.Tasks); Assert.Equal("t-other-list", vm.Tasks[0].Id); Assert.Contains("Home", vm.ScopeLabel); } [Fact] public async Task CanConfirm_FollowsRowSelection() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); Assert.True(vm.CanConfirm); vm.SelectNoneCommand.Execute(null); Assert.False(vm.CanConfirm); Assert.All(vm.Tasks, t => Assert.False(t.IsSelected)); vm.Tasks[0].IsSelected = true; // single row re-enables via PropertyChanged hook Assert.True(vm.CanConfirm); vm.SelectAllCommand.Execute(null); Assert.All(vm.Tasks, t => Assert.True(t.IsSelected)); } [Fact] public async Task Confirm_ReturnsSelectedIds_InRowOrder() { await SeedAllStatusesAsync(); var vm = BuildVm(); vm.Configure("L1", "Work"); await vm.LoadAsync(); vm.SelectNoneCommand.Execute(null); vm.Tasks.Single(t => t.Id == "t-review").IsSelected = true; vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = true; var closed = false; vm.CloseAction = () => closed = true; vm.ConfirmCommand.Execute(null); var result = await vm.Result.Task; Assert.NotNull(result); // Row order (SortOrder): t-idle was seeded before t-review. Assert.Equal(new[] { "t-idle", "t-review" }, result); Assert.True(closed); } ``` Also change `Cancel_ReturnsNull`'s `vm.Configure(null, null);` to `vm.Configure("L1", "Work");`. - [ ] **Step 2: Run the tests to verify they fail** ```bash dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release \ --filter "FullyQualifiedName~MergeHelperSelectionModalViewModelTests" ``` Expected: FAIL — the project does not compile, because `Configure(string, string)` does not exist yet and `IsGlobal` was removed from an assertion that still compiles against it. Compilation failure is the expected "red" here. - [ ] **Step 3: Make the dialog VM list-only** In `MergeHelperSelectionModalViewModel.cs`: Remove the `ListName` property from `MergeHelperTaskRowViewModel`: ```csharp public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase { public required string Id { get; init; } public required string Title { get; init; } public required string StatusText { get; init; } [ObservableProperty] private bool _isSelected; } ``` Change the field to non-nullable, drop `IsGlobal`, and make `Configure` list-only: ```csharp private string _listId = ""; ``` ```csharp [ObservableProperty] private string _scopeLabel = ""; public bool HasTasks => Tasks.Count > 0; ``` ```csharp public void Configure(string listId, string listName) { _listId = listId; ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName); } ``` In `LoadAsync`, the list filter is now unconditional and `ListName` is no longer selected: ```csharp await using var ctx = await _dbFactory.CreateDbContextAsync(ct); var candidates = await ctx.Tasks.AsNoTracking() .Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled) .Where(t => t.ListId == _listId) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .Select(t => new { t.Id, t.Title, t.Status }) .ToListAsync(ct); foreach (var c in candidates) { var row = new MergeHelperTaskRowViewModel { Id = c.Id, Title = c.Title, StatusText = c.Status.ToString(), IsSelected = IsTickedByDefault(c.Status), }; row.PropertyChanged += OnRowChanged; Tasks.Add(row); } ``` - [ ] **Step 4: Drop the LIST column from the dialog view** In `MergeHelperSelectionModal.axaml`, change both `ColumnDefinitions="32,*,120,120"` (lines 41 and 57) to `ColumnDefinitions="32,*,120"`, and delete the two `Grid.Column="3"` elements — the header `TextBlock` bound to `modals.mergeHelper.columnList` (lines 45-46) and the row `TextBlock` bound to `ListName` (lines 68-71). - [ ] **Step 5: Remove the global command and the Broom button** In `ListsIslandViewModel.cs`, delete the whole `LetClaudeHandleAllAsync` method including its `[RelayCommand]` attribute (lines 103-113). Leave `LetClaudeHandleListAsync` and the `MergeHelperRequest` record as they are — Task 4 changes those. In `ListsIslandView.axaml`, revert the button row to two columns: ```xml ``` and delete the whole `