# 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 -- <exact paths>` and `git commit -- <exact paths>`. 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<TaskEntity> 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<InvalidOperationException>(
            () => 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 -- <the resolved paths>` 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
        <!-- New list + import row -->
        <Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
```

and delete the whole `<Button Grid.Column="2" … LetClaudeHandleAllCommand … />` element
(lines 206-210) including its `<PathIcon>` child.

- [ ] **Step 6: Remove the three dead localization keys**

Delete from **both** `src/ClaudeDo.Localization/locales/en.json` and
`src/ClaudeDo.Localization/locales/de.json`:

- `lists.letClaudeAllTip` (and the trailing comma on the preceding key, so the object stays valid JSON)
- `modals.mergeHelper.scopeAll`
- `modals.mergeHelper.columnList` (and the trailing comma on the preceding key)

Keep `lists.contextLetClaude` and `modals.mergeHelper.scopeList`.

- [ ] **Step 7: Build and run the tests**

```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```

Expected: build succeeds, all tests pass. The localization parity test is the one that catches a
key removed from only one of the two JSON files.

- [ ] **Step 8: Commit**

```bash
git add -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
git commit -m "refactor(ui): scope \"Let Claude handle it\" to a single list" -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
```

---

### Task 4: Non-nullable `listId` and a single-repo launch spec

One atomic commit across Worker and Ui. Splitting it would leave one side passing `string?` into a
`string` parameter — nullable warnings strewn across a commit boundary, and a launch spec that
still carries a dead multi-repo path.

**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:166-253`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:682-687`
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:525-526`
- Modify: `src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-357`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103`
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs:363-472`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs:409-463`

- [ ] **Step 1: Rewrite the launch-spec tests**

In `InteractiveLaunchSpecServiceTests.cs`, replace the four merge-helper `[Fact]`s (from
`BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation` through
`BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope`) with these five. Keep
the `_mergeHelperSessionDirs` field and `TrackSessionDir` helper above them exactly as they are.

```csharp
    [Fact]
    public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var svc = BuildService();
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
    {
        var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);

        var svc = BuildService();
        var ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
        Assert.Contains("working directory", ex.Message);
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_UnknownList_Throws()
    {
        var listId = await SeedListAsync(workingDir: _tempDir);
        var taskId = Guid.NewGuid().ToString();
        await SeedTaskAsync(taskId, listId, TaskStatus.Idle);

        var svc = BuildService();
        await Assert.ThrowsAsync<KeyNotFoundException>(
            () => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
    {
        var repo = Path.Combine(_tempDir, "repoOnly");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, 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 spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        Assert.Equal(repo, spec.Cwd);
        Assert.Equal(_claudeStubPath, spec.Exe);

        var args = spec.Args.ToList();

        var pmIdx = args.IndexOf("--permission-mode");
        Assert.True(pmIdx >= 0);
        Assert.Equal("default", args[pmIdx + 1]);

        var atIdx = args.IndexOf("--allowedTools");
        Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);

        // --add-dir: session dir + the list's single repo dir
        var addIdx = args.IndexOf("--add-dir");
        var appendIdx = args.IndexOf("--append-system-prompt-file");
        var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
        Assert.Equal(new[] { sessionDir, repo }, addDirs);

        var systemPromptPath = args[appendIdx + 1];
        Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
        Assert.True(File.Exists(systemPromptPath));

        // kickoff is the LAST arg (positional), single line, points at brief.md
        var kickoff = args[^1];
        var briefPath = Path.Combine(sessionDir, "brief.md");
        Assert.Contains(briefPath, kickoff);
        Assert.DoesNotContain('\n', kickoff);

        Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
    }

    [Fact]
    public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
    {
        var repo = Path.Combine(_tempDir, "repoBrief");
        Directory.CreateDirectory(repo);

        var listId = await SeedListAsync(workingDir: repo, 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 spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
        var sessionDir = TrackSessionDir(spec);

        var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
        Assert.Contains("Scope: List: Alpha", brief);
        Assert.Contains($"Repo: {repo}", brief);
        Assert.Contains("First task", brief);
        Assert.Contains("Second task", brief);
        Assert.Contains(t1, brief);
        Assert.Contains(t2, brief);
    }
```

- [ ] **Step 2: Run the launch-spec tests to verify they fail**

```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
  --filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests.BuildForMergeHelper"
```

Expected: FAIL. `BuildForMergeHelperAsync_UnknownList_Throws` fails because the current code
resolves the repo from the tasks and never validates the list; the brief test fails on the missing
`Repo:` line.

- [ ] **Step 3: Rewrite `BuildForMergeHelperAsync`**

Replace the method body (`InteractiveLaunchSpecService.cs:166-253`) with:

```csharp
    public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
    {
        if (taskIds.Count == 0)
            throw new InvalidOperationException("No tasks selected for the list handler.");

        await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
        var taskRepo = new TaskRepository(ctx);
        var listRepo = new ListRepository(ctx);

        var list = await listRepo.GetByIdAsync(listId, ct)
            ?? throw new KeyNotFoundException($"List not found: {listId}");

        var repoDir = list.WorkingDir;
        if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
            throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");

        var briefLines = new List<string>();
        foreach (var id in taskIds)
        {
            var task = await taskRepo.GetByIdAsync(id, ct)
                ?? throw new KeyNotFoundException($"Task not found: {id}");
            briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
        }

        var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
        Directory.CreateDirectory(sessionDir);

        var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
        await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);

        var briefPath = Path.Combine(sessionDir, "brief.md");
        await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
            new Dictionary<string, string>
            {
                ["scope"] = $"List: {list.Name}",
                ["repo"] = repoDir,
                ["tasks"] = string.Join("\n", briefLines),
            }), ct);

        var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
            ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");

        // Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags
        // (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
        // positional kickoff LAST — a multi-line positional prompt truncates at the first
        // newline, so the full multi-line brief travels via the file exposed through --add-dir.
        var args = new List<string>
        {
            "--permission-mode", "default",
            "--allowedTools", MergeHelperAllowedTools,
            "--add-dir", sessionDir, repoDir,
            "--append-system-prompt-file", systemPromptPath,
            $"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
            "After reading it, begin the session as your instructions describe.",
        };

        var env = new Dictionary<string, string>
        {
            ["MCP_TOOL_TIMEOUT"] = "200000",
        };

        return new LaunchSpec(cwd: repoDir, resolvedClaude, args, env);
    }
```

- [ ] **Step 4: Flip the interface and hub signatures**

`src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`:

```csharp
    Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
```

`src/ClaudeDo.Worker/Hub/WorkerHub.cs:682`:

```csharp
    public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
```

(leave the method body as it is).

- [ ] **Step 5: Run the Worker tests**

```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
  --filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests"
```

Expected: build succeeds, all pass. `TasksIslandViewModelPlanningTests.cs:78` holds a fake
implementing `IWorkerClient` — its signature is flipped in Step 7; if the Worker.Tests build fails
there, do Step 7 first and re-run.

- [ ] **Step 6: Update the Mission Control tests**

In `MissionControlViewModelTests.cs`, the four `OpenMergeHelperConPtySessionAsync` calls pass
`null` as the list id. Replace `null` with `"L1"` on lines 421, 435, 436 and 461, and change the
`ThrowingMergeHelperLaunchSpecWorker` override signature at line 411 to:

```csharp
        public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
```

The list-title lookup in `OpenMergeHelperConPtySessionAsync` is wrapped in a `try/catch` and falls
back to the plain title, so an unseeded `"L1"` is harmless.

- [ ] **Step 7: Flip the UI signatures**

`src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`:

```csharp
    Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default);
```

`src/ClaudeDo.Ui/Services/WorkerClient.cs:525`:

```csharp
    public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
        => await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
```

`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103` and
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78` — same parameter change
(`string? listId` → `string listId`), bodies unchanged.

`src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`:

```csharp
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
```

`src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-341` — the `listId is not null` guard is
now dead:

```csharp
    // List-handler session over a hand-picked set of tasks ("Let Claude handle it").
    // Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
    public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
    {
        if (taskIds is not { Count: > 0 }) return;

        var title = Loc.T("missionControl.mergeHelperTitle");
        try
        {
            await using var ctx = await _dbFactory.CreateDbContextAsync();
            var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
            if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
        }
        catch { /* best-effort title lookup */ }
```

Leave the rest of the method (the `try` block that fetches the spec and adds the pane) unchanged.

- [ ] **Step 8: Build everything and run the full suite**

```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
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: both builds succeed with no `CS8600`/`CS8604` nullability warnings on the touched files,
and every test passes.

- [ ] **Step 9: Commit**

```bash
git add -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
git commit -m "refactor(worker): make the list-handler launch spec single-list and single-repo" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
```

---

### Task 5: Update the project docs

**Files:**
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
- Modify: `docs/open.md`

- [ ] **Step 1: Check what the CLAUDE.md files claim**

```bash
grep -n "merge.helper\|Let Claude handle\|MergeHelper" src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
```

- [ ] **Step 2: Correct any stale claim**

Where those files describe the merge helper as having a global scope, or describe the prompt as
run-and-merge only, update them to: list-scoped only, single repo, five phases (read, dedupe,
enhance, queue, review+merge). Note in `src/ClaudeDo.Worker/CLAUDE.md` that
`update_task_status` now also accepts `Cancelled`. Do not restructure the files beyond that.

- [ ] **Step 3: Add the open verification items**

Append to the open-items section of `docs/open.md`:

```markdown
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
  the context-menu item appears only on lists with a working dir, and the selection dialog has no
  LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
  landing in task descriptions, queued execution, merges).
```

- [ ] **Step 4: Commit**

```bash
git add -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
git commit -m "docs: describe the list-scoped five-phase handler" -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
```

---

## Verification left to the user

None of this can be confirmed from tests alone:

- The lists footer no longer shows the Broom button, and the row context menu still offers
  "Let Claude handle it" for lists with a working dir.
- The selection dialog shows TASK and STATUS only, and the scope line reads `List: <name>`.
- A real ConPTY run: Phase 1 asks about duplicates, Phase 2's enhancements are visible in the task
  descriptions afterwards, Phase 3 reports the slot count and the tasks execute, Phase 4 merges or
  hands off to conflict resolution.
