9.1 KiB
"Let Claude handle it" — per-list handler with read / dedupe / enhance / run / merge
Date: 2026-07-27
Supersedes parts of: 2026-07-24-merge-helper-design.md (global scope, run-then-merge-only prompt)
1. Problem
The merge helper shipped in v2.3.0 with two entry points — a per-list context-menu item and a global footer Broom button — and a prompt that only runs and merges the selected tasks.
Two things are wrong with that:
- The global scope is unwanted. A run spanning several lists spans several repos, which
makes
cwd, the--add-dirset and the merge order ambiguous for no benefit. The user works one list (= one repo) at a time. - The helper starts too late. It takes the task list as given: it never reads the tasks as a set, so duplicates run twice and produce conflicting worktrees, and vague tasks go into an autonomous run under-specified and come back wrong.
2. Goal
One entry point, on a user list. It opens an interactive ConPTY session that takes the selected tasks through five phases: read them all, dedupe them, sharpen them for autonomous execution, run them, then review and merge each worktree.
Non-goals: no change to the autonomous queue path, no change to the ConPTY tile plumbing,
no rename of the MergeHelper* identifiers (the user-facing label stays "Let Claude handle it").
3. Scope becomes list-only
listId becomes non-nullable across the whole chain:
| Layer | Change |
|---|---|
ListsIslandViewModel |
MergeHelperRequest(string ListId, …); LetClaudeHandleAllAsync deleted |
IslandsShellViewModel:241 |
unchanged (already forwards req.ListId) |
MissionControlViewModel:327 |
OpenMergeHelperConPtySessionAsync(string listId, …) |
IWorkerClient:90 / WorkerClient:525 |
GetMergeHelperLaunchSpecAsync(taskIds, string listId, ct) |
WorkerHub:682 |
GetMergeHelperLaunchSpec(string[] taskIds, string listId) |
IInteractiveLaunchSpecService:36 |
BuildForMergeHelperAsync(taskIds, string listId, ct) |
Deleted UI surface:
- Broom button
ListsIslandView.axaml:205-210andLetClaudeHandleAllCommand. IsGlobalonMergeHelperSelectionModalViewModeland the LIST column (MergeHelperSelectionModal.axaml:71) — with a single list the column is constant.- Localization keys
lists.letClaudeAllTip,modals.mergeHelper.scopeAll,modals.mergeHelper.columnList(en + de, parity test enforces both).
Configure(string listId, string listName) loses its nullable overload; ScopeLabel always
renders modals.mergeHelper.scopeList.
3.1 Single repo in the launch spec
BuildForMergeHelperAsync currently collects a distinct repoDirs set across the selected
tasks and picks cwd per scope. With a list scope every task shares the list's WorkingDir,
so this collapses to:
- Load the list; throw
InvalidOperationExceptionif it has no existingWorkingDir. cwd= that directory;--add-dir= the session dir + that one directory.- The per-task brief line drops the now-constant
list:andrepo:fields.
The context-menu item is already hidden when WorkingDir is empty, so the throw is a guard,
not a normal path.
3.2 Entry point visibility
The item stays in the list row's context menu, next to "List settings", "Worktrees overview", "Open in Explorer" and "Open in Terminal". That is the established place for list-scoped actions; a second always-visible button in the row would break the pattern.
4. Worker: Cancelled becomes externally settable
ExternalMcpService.UpdateTaskStatus accepts only Idle and Queued and throws
"Status '{target}' is not settable externally. Use run_task_now or cancel_task." for the
rest — but neither escape hatch reaches an Idle task: cancel_task only cancels a
running task, and review_task(decision="cancel") requires WaitingForReview/Running/Queued.
The dedupe phase needs exactly that: retire an Idle duplicate without destroying it.
Add to the switch:
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
TaskStateService.CancelAsync (State/TaskStateService.cs:244) already owns the transition
and its worktree/parent side effects. The existing error message in UpdateTaskStatus already
lists Cancelled as valid, so this also removes a lie. A cancelled task stays visible and can
be reset to Idle — nothing is lost, unlike delete_task.
5. The five-phase prompt
PromptFiles.MergeHelperDefault is rewritten. The session stays interactive and the helper is
told to ask whenever unsure — that is the point of a watched ConPTY session.
Phase 0 — Read
batch_get_tasks over every id in the brief before touching anything: title, description,
status, parent/child links. The helper must hold the whole set in mind before acting on any
single task.
Phase 1 — Dedupe
Compare the tasks pairwise for overlap. Emit a table of candidate pairs with the reason each pair looks like a duplicate, then ask per pair:
- merge → fold the loser's unique content into the survivor via
update_task, thenupdate_task_status(loserId, "Cancelled"); - keep both → note why and move on.
Nothing is cancelled without an explicit answer.
Phase 2 — Enhance
For each surviving task, sharpen title and description for autonomous execution:
- concrete acceptance criteria,
- the files/areas actually involved — grounded in the repo via Read/Grep/Glob, not guessed,
- explicit out-of-scope.
Write back with update_task (title / description / commitType are the settable fields; it
refuses while Running, which cannot happen this early). Rules: do not change intent, do not
invent requirements. A task too vague to sharpen safely gets a question, not a guess.
Phase 3 — Run
run_task_now cannot be used for a batch: OverrideSlotService.StartInSlot
(Queue/OverrideSlotService.cs:65-70) holds a single slot and throws "override slot busy"
on the second concurrent call. The queue picker is the only parallel path.
So: read get_app_settings, tell the user how many parallel slots are configured
(MaxParallelExecutions, default 1 — AppSettingsEntity.cs:14), then
update_task_status(id, "Queued") for every surviving task, then poll get_task until each
has left Queued/Running — WaitingForReview on success, Failed on error. Announcing the
slot count up front stops the user wondering why "run them all" executes one at a time.
A task already Running or WaitingForChildren when the session starts is not re-queued, only
polled. A task already WaitingForReview skips straight to Phase 4.
Phase 4 — Review and merge
Sequential, in list order. A task that came back Failed needs human judgement — ask whether
to reset_failed_task and re-queue it, or skip it. Otherwise, unchanged from the shipped
prompt: get_task_diff (stat first,
full diff when non-trivial), sanity-check against the task's intent, ask before merging
anything that looks wrong, then review_task(taskId, decision="approve", leaveConflictsInTree=true) and the conflict loop (continue_merge / abort_merge, parent id
for unit merges, hand-resolution only where MCP cannot reach, always
git commit -- <paths> and never git add -A because the checkout is shared).
New in this phase: the prompt states that because Phase 3 branches all fork from the same base, conflicts are the normal case, not an exception — resolve them rather than bailing out of the run.
Phase 5 — Summary
One line per task: title — dedupe action — enhanced? — final status — merge commit — conflicts resolved. Then anything skipped and why, then follow-ups.
Brief template
MergeHelperInitialDefault names the list and repo once in the header and drops the per-task
list:/repo: fields, leaving - [{status}] {title} (id: {id}). Descriptions stay out of the
brief; Phase 0 fetches them.
6. Testing
| Test | Change |
|---|---|
MergeHelperSelectionModalViewModelTests |
drop IsGlobal, list-scoped Configure |
InteractiveLaunchSpecServiceTests |
three null-listId cases → non-null; drop the two-repo global-scope test; add "list without WorkingDir throws" |
MissionControlViewModelTests |
four OpenMergeHelperConPtySessionAsync(null, …) calls |
StubWorkerClient, TasksIslandViewModelPlanningTests fake |
signature |
PromptFilesTests |
assert the five phase markers in the default prompt |
Localization.Tests |
parity after removing three keys |
new: ExternalMcpService / worker test |
UpdateTaskStatus(id, "Cancelled") cancels an Idle task; unknown status still throws |
No test spawns the real claude CLI — the prompt content is asserted as text, the session
itself is a manual smoke step.
7. Verification left to the user
- The list context menu shows "Let Claude handle it" only for lists with a working dir, and the Broom button is gone from the footer row.
- The selection dialog has no LIST column and reads "List: ".
- A real ConPTY run: dedupe questions appear, enhancements land in the task descriptions, queued tasks execute, merges complete or hand off to conflict resolution.