Files
ClaudeDo/docs/superpowers/plans/2026-07-27-list-handler.md
T

42 KiB
Raw Blame History

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.

    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
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:

            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:

    [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:

            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
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.

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
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.

    [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
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:

    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.

    private const string MergeHelperInitialDefault = """
        # List handler brief

        Scope: {scope}
        Repo: {repo}

        Handle the following tasks. Work Phases 05 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:

    [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
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
  --filter "FullyQualifiedName~PromptFilesTests"

Expected: all pass.

  • Step 7: Commit
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.

    [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
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:

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:

    private string _listId = "";
    [ObservableProperty] private string _scopeLabel = "";

    public bool HasTasks => Tasks.Count > 0;
    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:

        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:

        <!-- 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
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
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.

    [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
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:

    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:

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

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

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

(leave the method body as it is).

  • Step 5: Run the Worker tests
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:

        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:

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

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

    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? listIdstring listId), bodies unchanged.

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

/// <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:

    // 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
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
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

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:

- **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
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.