diff --git a/docs/superpowers/specs/2026-07-27-list-handler-design.md b/docs/superpowers/specs/2026-07-27-list-handler-design.md new file mode 100644 index 00000000..76281350 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-list-handler-design.md @@ -0,0 +1,191 @@ +# "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-dir` set 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-210` and `LetClaudeHandleAllCommand`. +- `IsGlobal` on `MergeHelperSelectionModalViewModel` and 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 `InvalidOperationException` if it has no existing `WorkingDir`. +- `cwd` = that directory; `--add-dir` = the session dir + that one directory. +- The per-task brief line drops the now-constant `list:` and `repo:` 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: + +```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; +``` + +`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`, then + `update_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 -- ` 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.