Compare commits

...
11 Commits
31 changed files with 1682 additions and 34 deletions
+68
View File
@@ -1,5 +1,73 @@
# Changelog # Changelog
## v2.2.0 — 2026-07-24
### Features
- submit interactive (ConPTY) work for review (109a35c)
- gate Approve & Merge behind opening the diff (2aaaa23)
- run interactive planning sessions via embedded ConPTY (ef285b2)
- AskUser question banner in the detail island (798d100)
- conflict resolver shows why Continue is disabled (da6a70a)
- session-skills empty-state + neutral subtask terminology (85d0f9d)
### Fixes
- use default permission mode so MCP planning tools don't prompt (624ec7a)
- hide misleading Idle chip on planning parents (e8f7e3a)
- show structured-output summary instead of raw JSON in OUTCOME (8a7275a)
- live-refresh child rows on parent planning transitions (f4dd67d)
- restore turn/token counts on task reload (0226c98)
- clearer rename display in diff viewer (b9b3053)
- diagnostic error surfacing on attachment drop (671c886)
- surface resume-planning-session failures (ffff1ee)
- render Plus and agent-settings gear icons (ad2acdd)
- kill cancelled runs' processes and make MCP approve actually merge (fee6999)
- make external MCP filter params optional, surface tool errors (d7ebafd)
- merge preflights ignore untracked files in target working tree (14e4c08)
- UnifiedDiffParser mishandles paths with spaces and git-quoted paths (0f2d202)
- validate conflict markers before staging in ContinueMergeAsync (377409e)
- cascade cancel of a WaitingForChildren parent to its non-terminal children (941c8b9)
- advance parent when the last non-terminal child is deleted (2452e39)
- keep planning-chain cascade moving past an Idle middle link (816f247)
### Documentation
- spec for ConPTY planning sessions (2612831)
- record session progress (A/B done, C#9+#11, D#13+#14; C#10/#12 + group E deferred) (04044bd)
- add fix-plan for fresh session (findings grouped by fixability); defer §10, mark §11 OK per Mika (efd7cc9)
- §3 UnfinishedPlanning modal (Finalize/Discard PASS, Resume BUG); generalize child-row live-refresh finding; edges done (75a6e0e)
- finding — Resume planning session is broken (session_id never captured) + error swallowed by empty catch (9efc5c9)
- §1 DiffModal error-state resolved via code analysis (defensive/unreachable, gates prevent it) (6ca8cac)
- §4 merge-editor Abort PASS (tree clean, task stays WaitingForReview) (9a1fa3d)
- §9 attachments drag&drop UI PASS (overlay/drop/picker/remove); finding — intermittent first-drop error (9b4d343)
- §8 Session Skills complete (Remove PASS); refresh handoff summary + fixture state (a1ba3b6)
- §8 per-task activation + no-leak counterprobe PASS; UI partial (cards/general-tab open) (9b041ba)
- finding — agent-settings gear uses Unicode glyph, not Icon.Settings PathIcon (inconsistent) (39fc594)
- §8 skill install PASS (6 skills, commit-pinned); finding — Skills tab has no empty-state (b353ed6)
- note Mika explicitly wants AskUser interaction in detail island (416e47e)
- §7 AskUser complete — timeout UI-cleanup visually verified (banner clears) (e8b5e97)
- §7 AskUser PASS (happy-path + backend timeout); finding — banner only in Mission Control, absent in detail island (d19ef54)
- note fixture cleanup (verif tasks/worktrees removed, ClaudeDoTests reset) (ded068c)
- refresh handoff for next session — progress, remaining (§7-§11+edges), gotchas, fixture state (9556e0e)
- trim to actively-verified 2026-07-24 findings; drop stale manual-verif/historical blocks (9d39a8f)
- §5 ConPTY/Mission Control PASS (prompt-send, close kills proc); ad-hoc icon invisible + re-open re-sends noted (b536b6f)
- §5 findings — invisible New-session icon (Icon.Plus stroke-only), re-open re-sends prompt (1b80bb0)
- §3 PASS end-to-end (+§1 children-band, +§4 planning-conflict); dequeue-X UX nit (4394623)
- §3 finalize findings — improvements-mislabel, child-badge live-refresh, chain not visualized (f0b0582)
- planning session permission-prompt bug + planning-active parent shows Idle (UX) (07de897)
- clean additive approve PASS (2101228)
- §1 commit-range-after-merge PASS; blocked-merge silent-fail confirmed on clean path too (8241bf8)
- §4 merge editor PASS end-to-end + UX findings (continue-btn, multi-file, blocked-merge) (255705d)
- Approve & Merge silently swallows a blocked merge (no footer error) (3dfd75f)
- §1 findings — raw-JSON outcome bug, rename/turns nits, session-tab expected (26c03a5)
- correct permission finding — auto+haiku denies writes (not a CLI regression), §2 happy-path PASS (3211bfc)
- log autonomous-batch results (§2/§6/§9/§12) (79ce7af)
- track CLI 2.1.207 --permission-mode auto write-denial regression (0ad93f4)
- remove chain-cascade bug bullet (fixed in 110364a) (3d668da)
- explore-notes convention + verification handoff for manual checks (d6891b8)
- update for v2.1.0 (ad58129)
## v2.1.0 — 2026-07-23 ## v2.1.0 — 2026-07-23
### Features ### Features
@@ -0,0 +1,79 @@
# Merge Helper — Implementation Plan
Spec: `docs/superpowers/specs/2026-07-24-merge-helper-design.md`
Approach: subagent-driven (one subagent per task, `sonnet`, TDD, stage files by path — never `git add -A`). Build with `-c Release` per-csproj (a running Worker locks `Debug`). Commit per task, Conventional Commits.
---
## Phase A — Worker MCP conflict tools
Independently useful; merges first. All in `src/ClaudeDo.Worker/External/ExternalMcpService.cs` + tests in `tests/ClaudeDo.Worker.Tests/`.
### A1 — Verify engine surface (spike, no commit)
Read `TaskMergeService.MergeAsync` / `ContinueMergeAsync` / `AbortMergeAsync` and the hub conflict flow (`WorkerHub.StartConflictMerge`/`ContinueConflictMerge`/`AbortConflictMerge`). Pin down:
- exact `ContinueMergeAsync` / `AbortMergeAsync` signatures and how in-progress-merge state is located (repo + target branch from task/list, not shared hub state);
- how the childless approve path (`ApproveAndMergeAsync`) threads `leaveConflictsInTree`.
Record findings in the task notes; feeds A2/A3.
### A2 — `leaveConflictsInTree` on review_task / merge_task
- TDD: tests in `Worker.Tests` (real git) — clean merge → Done; conflict + flag → `conflict_in_tree`, markers present, task stays `WaitingForReview`, `repoPath` returned.
- Add optional param `leaveConflictsInTree = false` to `MergeTask` and `ReviewTask` (approve branch). When true, call the `leaveConflictsInTree:true` engine path and map the conflict result to `{ mergeStatus/merged, conflicts, repoPath }`.
- Keep default behaviour (abort-on-conflict) byte-identical when the flag is absent/false.
- Commit: `feat(worker): let review_task/merge_task leave conflicts in tree via MCP`
### A3 — `continue_merge` + `abort_merge` MCP tools
- TDD: continue after on-disk resolution → committed, task Done, worktree merged; continue with markers remaining → returns conflicts; abort → markers gone, task `WaitingForReview`; both on no-active-merge → clean MCP error; `TaskUpdated` fired.
- Add `[McpServerTool] continue_merge(taskId)``ContinueMergeAsync`; `abort_merge(taskId)``AbortMergeAsync`. Locate the merge from the task's repo/target. Emit `TaskUpdated`.
- **Route both single-task and orchestrated (parent/children) in-progress merges** where locatable from the task (per A1 findings): detect the kind and call the matching engine continue/abort (`TaskMergeService` vs `PlanningMergeOrchestrator.Continue/Abort`). If the orchestrated path can't be located without hub UI state, leave it to the manual fallback (documented in the B1 prompt) and note the gap in `docs/open.md`.
- Commit: `feat(worker): add continue_merge and abort_merge MCP tools`
---
## Phase B — Worker launch for the merge-helper session
### B1 — Prompt templates
- Add `PromptKind.MergeHelper` + `PromptKind.MergeHelperInitial` to `ClaudeDo.Data/PromptFiles.cs` (file names `merge-helper-system.md` / `merge-helper-initial.md`, built-in `DefaultFor`, `Render` tokens for the initial brief).
- System prompt encodes §7 behaviour (per-status algorithm, ask-on-uncertainty, summary format). Merge-state rule: **prefer MCP tools whenever they apply**; hand-merge (Edit + `git commit -- <paths>`) is an accepted fallback only for merges the MCP tools can't reach (§5.3), never a shortcut around them.
- Initial brief renders a task table `{id,title,status,list,repo}` + scope label.
- TDD: `PromptFiles` tests — kinds resolve, defaults non-empty, `Render` substitutes brief tokens.
- Commit: `feat(data): add merge-helper prompt templates`
### B2 — `BuildForMergeHelper` launch spec
- TDD (`Worker.Tests`): distinct-repo `--add-dir` set computed from selected tasks; correct cwd per scope (per-list repo vs first repo global); brief file written to `~/.todo-app/merge-helper-sessions/<guid>/brief.md`; allowed-tools + `--permission-mode default` + `MCP_TOOL_TIMEOUT` env correct; single-line kickoff points at the brief.
- Implement `InteractiveLaunchSpecService.BuildForMergeHelper(IReadOnlyList<string> taskIds, MergeHelperScope scope, ct)`. Reuse the planning brief-file/kickoff pattern.
- Commit: `feat(worker): build merge-helper interactive launch spec`
### B3 — Hub endpoint + client method
- `WorkerHub.GetMergeHelperLaunchSpec(string[] taskIds, string? listId)`; `IWorkerClient.GetMergeHelperLaunchSpecAsync(...)` + `WorkerClient` impl.
- Update hand-rolled `IWorkerClient` fakes in **both** test projects (see gotcha memory).
- Commit: `feat(worker): expose merge-helper launch spec over the hub`
---
## Phase C — UI
### C1 — Selection dialog (View + VM)
- New `MergeHelperSelectionViewModel` + `MergeHelperSelectionDialog.axaml` (compiled bindings, `TaskCompletionSource<T>` pattern). Checkbox rows (title, status badge, list/repo), grouping in global mode, default ticks per §4, select-all/none, confirm disabled when empty.
- Candidates via existing `list_tasks`/worker client; filter client-side.
- TDD (`Ui.Tests`): default-tick logic, empty→confirm-disabled, returns ordered selected IDs + list mapping.
- Commit: `feat(ui): add merge-helper task selection dialog`
### C2 — Entry points + event plumbing
- Per-list context-menu item **"Let Claude handle it"** in `ListsIslandView.axaml` (user-list rows) + one global entry in the footer. Bind to `LetClaudeHandleCommand` on `ListsIslandViewModel` (param = `ListNavItemViewModel` or a global sentinel).
- VM raises `LetClaudeHandleRequested(MergeHelperScope)`; `IslandsShellViewModel` forwards to Mission Control.
- Commit: `feat(ui): add "Let Claude handle it" entry points`
### C3 — Mission Control wiring
- `MissionControlViewModel.OpenMergeHelperConPtySessionAsync(scope)`: open selection dialog → on confirm, `GetMergeHelperLaunchSpecAsync` → wrap in `TerminalLaunchDescriptor` → new `ConPtyPaneViewModel` (never deduped) → add to `ConPtySessions`/`Panes`.
- Commit: `feat(ui): open merge-helper ConPTY tile from selection`
---
## Verify (per task + at the end)
- Read each subagent diff; build the touched csproj `-c Release`; run the relevant test project.
- `locales/en.json` + `de.json` parity for any new UI strings (Localization.Tests enforces it).
- Flag visual-verification gaps (dialog layout, tile) for the user — never claim UI works without a run.
- End-to-end ConPTY smoke (real Claude) is a manual item in `docs/open.md`.
## Commit docs first
`docs(merge-helper): spec + implementation plan` (this file + the spec).
@@ -0,0 +1,182 @@
# Merge Helper ("Let Claude handle it") — Design
**Status:** Proposed — awaiting approval
**Date:** 2026-07-24
**Scope:** Feature — a per-list and global button that opens an **interactive ConPTY Claude session** pre-loaded with a set of user-selected tasks. The session (the "Merge Helper") drives each selected task to completion and merge autonomously via `mcp__claudedo__*` tools, asks the user interactively (in the ConPTY terminal) only when uncertain, resolves merge conflicts itself, and ends with a written summary of everything that changed.
---
## 1. Goal
Collapse the repetitive per-task review→merge clicking into a single "Let Claude handle it" action. The user picks the tasks; an embedded Claude session babysits them — running the ones that still need running, reviewing diffs, merging the clean ones, resolving conflicts, and reporting back — while remaining fully interactive so the user can answer questions mid-run.
This reuses the existing ConPTY infrastructure (UI-process embedded terminal) and the globally-registered `claudedo` MCP server. The only genuinely new worker capability is **MCP-driven conflict resolution** (§5), which today exists only in the UI hub.
---
## 2. Decisions (locked with user, 2026-07-24)
| Question | Decision |
|---|---|
| Which tasks does the helper handle? | **Free choice, any status.** User hand-picks; helper acts per-status. |
| How are tasks selected for a run? | **Checkbox dialog before launch** (candidates listed, user ticks). |
| Merge authority / review-gate | **Auto-merge; asks interactively on uncertainty.** The app's per-task diff-gate is intentionally bypassed for helper-driven merges. |
| Conflict handling | **Build MCP conflict tools** so the helper resolves conflicts in the working tree itself, asking only when unsure (Option B). The helper must handle **all** cases including parent/children unit merges; where the MCP path doesn't reach, **manual resolution by hand (Edit + git) is an accepted fallback** (user-confirmed 2026-07-24). |
---
## 3. UX Flow
1. **Entry points**
- **Per-list:** context-menu item **"Let Claude handle it"** on each user-list row in `ListsIslandView.axaml` (alongside Settings / Worktrees / Open in Explorer).
- **Global:** one entry (footer of the lists island) that spans *all* lists/repos.
2. Click opens the **Merge Helper selection dialog** (§4): a checkbox list of candidate tasks, grouped by list/repo, pre-filtered to tasks worth acting on but freely overridable.
3. User ticks tasks → **"Let Claude handle it"** confirm button.
4. UI asks the worker for a `MergeHelperLaunchSpec`, opens a **ConPTY tile in Mission Control** running the real `claude` TUI with the merge-helper prompt.
5. The session works through the tasks, printing progress and asking questions inline; the user answers directly in the terminal.
6. On completion Claude prints a **summary** (merged / skipped / conflicted / follow-ups). The tile stays open for review.
---
## 4. Selection Dialog
New modal `MergeHelperSelectionDialog` (View + VM), built with the existing `TaskCompletionSource<T>` dialog pattern used by other modals.
**Contents:**
- Title: *"Let Claude handle it"* + subtitle naming the scope ("List: <name>" or "All lists").
- A scrollable checkbox list of **candidate tasks**. Per row: checkbox, title, status badge, list/repo name (in global mode).
- Grouping: by list/repo in global mode; flat in per-list mode.
- Default selection: all **actionable** tasks pre-ticked — actionable = `WaitingForReview`, `Idle`, `Queued`, `Failed` (resettable). `Running` / `WaitingForChildren` shown but unticked (helper will poll them). Terminal `Done`/`Cancelled` excluded from the list entirely.
- Footer: **"Let Claude handle it"** (disabled when nothing ticked) + **Cancel**. A "select all / none" affordance.
**Candidate source:** `list_tasks` via the existing worker client (per list, or across all lists for global). No new query needed; the VM filters client-side by status.
**Output:** an ordered `IReadOnlyList<string>` of selected task IDs (+ their list/repo mapping), passed to the launch request.
---
## 5. New Worker Capability — MCP Conflict Resolution
Today (verified): `merge_task` / `review_task approve` call `TaskMergeService.MergeAsync(..., leaveConflictsInTree:false)` — on conflict they run `git merge --abort` (clean rollback, no markers) and return `mergeStatus="conflict"`; the task stays `WaitingForReview`. Continue/abort/write-resolution exist **only** on the SignalR hub (Rider merge editor). An MCP agent therefore cannot resolve conflicts. This section adds that.
### 5.1 Approach
Reuse the *exact* engine methods the UI already uses — `TaskMergeService.MergeAsync(leaveConflictsInTree:true)`, `ContinueMergeAsync`, `AbortMergeAsync` — and expose them over MCP. The helper resolves conflict markers on disk (it has filesystem access to the repo checkouts via `--add-dir`, see §6.3) and drives the merge state exclusively through MCP tools so the engine stays authoritative.
### 5.2 MCP surface changes (`ExternalMcpService.cs`)
1. **`review_task` / `merge_task` — new optional param `leaveConflictsInTree: bool = false`.**
When `true` and the merge conflicts: leave markers in the checkout instead of aborting, and return
`{ mergeStatus: "conflict_in_tree", conflicts: string[], repoPath: string }`
where `repoPath` is the checkout holding the markers. Task stays `WaitingForReview`, merge is in progress. Clean-merge behaviour is unchanged, so the helper can always pass `true`.
2. **New tool `continue_merge(taskId)`**`TaskMergeService.ContinueMergeAsync`.
Stages the resolved files and commits the merge; on success the task goes to `Done` and the worktree is marked merged, returning `{ merged: true, mergeCommit }`. If markers remain, returns `{ merged: false, conflicts: string[] }`.
3. **New tool `abort_merge(taskId)`**`TaskMergeService.AbortMergeAsync`.
Aborts the in-progress merge; task stays `WaitingForReview`. Returns `{ aborted: true }`.
**Implementation notes (verify against `TaskMergeService.cs` during the plan):**
- Confirm the exact signatures of `ContinueMergeAsync` / `AbortMergeAsync` and how in-progress-merge state is keyed. The hub tracks a single active conflict merge; the MCP variants must locate the merge from `taskId` (target branch + repo from the task/list), not shared hub state.
- Emit the existing `TaskUpdated` event after continue/abort so the UI list re-buckets live.
- Guard against a repo already mid-merge (`Blocked`) — surface it to the agent rather than clobbering.
### 5.3 Parent/children unit merges
`review_task approve` on a task **with children** drives `PlanningMergeOrchestrator` (a multi-step unit merge with its own continue/abort on the hub). The helper must handle these too. Two paths, tried in order:
1. **MCP (preferred):** `continue_merge` / `abort_merge` detect *which* kind of in-progress merge the task has (single-task `TaskMergeService` vs orchestrated `PlanningMergeOrchestrator`) and route to the matching engine continue/abort. This keeps the orchestrated path engine-mediated over MCP too. Implement if the orchestrator's continue/abort can be located from the task without shared hub UI state (verify in A1).
2. **Manual fallback (accepted):** where the MCP path genuinely can't reach an in-progress merge, the helper resolves the conflict markers on disk (Read/Edit) and completes the merge by hand (`git add <paths>` + `git commit`, or `git merge --continue`). The user has explicitly accepted hand-merging as a fallback. The system prompt still mandates: **prefer the MCP tools whenever they apply**; only drop to raw git for cases the MCP tools don't cover, and honour the shared-checkout rule (`git commit -- <paths>`, never a bare commit that sweeps peers' index).
---
## 6. Launch — Worker + Wiring
### 6.1 Prompt templates
Add `PromptKind.MergeHelper` (system) and `PromptKind.MergeHelperInitial` (brief) to `ClaudeDo.Data/PromptFiles.cs`, with built-in defaults and `{{token}}` rendering, mirroring `Planning` / `PlanningInitial`.
- **System prompt** (`merge-helper-system.md`): defines the role and the per-status algorithm (§7), the merge/conflict rules, the "ask on uncertainty" posture, and the required final summary format.
- **Initial brief** (`merge-helper-initial.md`): rendered with the selected tasks — a table of `{id, title, status, list, repo}` plus the scope label. Written to a session-brief file on disk; the positional prompt is a **single-line kickoff** pointing at that file via `--add-dir` (planning pattern — a multi-line positional prompt truncates at the first newline).
### 6.2 Session files
Path: `~/.todo-app/merge-helper-sessions/<sessionId>/` (a fresh GUID per run — these sessions are ephemeral and never resumed):
- `brief.md` — rendered task list + scope + instructions.
- No per-session MCP config: the session uses the **globally-registered `claudedo` MCP server** (same as task/ad-hoc sessions), so no token is needed.
Cleanup: prune session dirs older than N days on app start (best-effort; same posture as planning dirs).
### 6.3 Launch spec
New `InteractiveLaunchSpecService.BuildForMergeHelper(selectedTaskIds, scope, ct)` returning a `LaunchSpec`:
- **Cwd:** per-list → the list's repo working dir; global → the first selected task's repo (any valid repo; the agent works cross-repo via MCP).
- **`--add-dir`:** the session-brief dir **plus every distinct repo checkout** among the selected tasks (so the agent can read/resolve conflict markers in each repo). Computed from each task's list working dir.
- **Args:** `--permission-mode default`, `--allowedTools mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`, `--append-system-prompt-file <merge-helper-system.md>`, `--add-dir ...`, then the single-line kickoff prompt.
- `Edit` is required for conflict resolution; `Bash` is allowed for **read-only** git inspection (`git status`/`diff`) — the system prompt mandates that all merge *state changes* go through MCP tools, never raw `git merge/commit`, to keep the engine authoritative and honour the user's rejection of the "raw git" option.
- **Env:** `MCP_TOOL_TIMEOUT=200000` (as task/ad-hoc sessions set).
### 6.4 Hub + client + Mission Control
- **Hub:** `WorkerHub.GetMergeHelperLaunchSpec(string[] taskIds, string? listId)``_launchSpecService.BuildForMergeHelper(...)`. Sibling to `GetAdHocLaunchSpec` / `GetPlanningStartLaunchSpec`.
- **Client:** `IWorkerClient.GetMergeHelperLaunchSpecAsync(...)` + `WorkerClient` impl.
- **Mission Control:** `MissionControlViewModel.OpenMergeHelperConPtySessionAsync(spec)` — wraps the spec in a `TerminalLaunchDescriptor`, creates a `ConPtyPaneViewModel` (ad-hoc style, **never deduped** — each run is its own tile), adds it to `ConPtySessions`.
- **Event plumbing:** `ListsIslandViewModel` raises `LetClaudeHandleRequested(scope)`; `IslandsShellViewModel` forwards to Mission Control, which opens the selection dialog, then (on confirm) fetches the spec and opens the tile.
---
## 7. Helper Behaviour (encoded in the system prompt)
For each selected task, act by status:
- **Idle / Queued:** `run_task_now`; poll `get_task` until terminal or `WaitingForReview`.
- **Failed:** `reset_failed_task` then run, *or* ask the user — failures often need a human call; default to asking briefly.
- **Running / WaitingForChildren:** poll `get_task` until it surfaces for review.
- **WaitingForReview:** `get_task_diff` (stat first, then full if needed), sanity-check the change against the task's intent, then `review_task approve` with `leaveConflictsInTree:true`.
- **Clean →** merged, task Done.
- **Conflict (`conflict_in_tree`) →** open the conflicted files under `repoPath` (Read/Edit), resolve the markers guided by both sides' intent, then `continue_merge`. If the resolution is non-obvious or risky, **ask the user in the terminal** before continuing. `abort_merge` if the user declines or it's unsafe.
- **Parent with children:** clean unit merge proceeds; on conflict, resolve via the MCP tools if they reach the orchestrated merge, else hand-merge the markers and complete it (§5.3) — asking the user first when the resolution is non-obvious.
Cross-cutting rules (in the prompt):
- Ask the user interactively for anything ambiguous, risky, or destructive — that is the point of the ConPTY session.
- Never use raw `git merge/commit/reset`; drive all merge state through the MCP tools.
- Keep a running tally; at the end print a **summary**: per task — final status, merge commit (if any), conflicts resolved, anything skipped, and suggested follow-ups.
---
## 8. Testing
**Automated (`ClaudeDo.Worker.Tests`, real SQLite + real git):**
- `review_task`/`merge_task` with `leaveConflictsInTree:true`: clean merge → Done; conflicting merge → `conflict_in_tree`, markers present in the checkout, task stays `WaitingForReview`.
- `continue_merge`: after markers resolved on disk → commits, task Done, worktree merged; with markers still present → returns remaining conflicts.
- `abort_merge`: in-progress merge aborted, markers gone, task stays `WaitingForReview`.
- `continue_merge`/`abort_merge` on a task with no in-progress merge → clean MCP error, no clobber.
- `TaskUpdated` fired after continue/abort.
- `BuildForMergeHelper`: computes distinct repo `--add-dir` set, correct cwd per scope, brief file rendered with all selected tasks, allowed-tools string correct.
**No real-Claude tests** (per project convention) — the end-to-end ConPTY run is a manual smoke item.
**Manual (add to `docs/open.md`):**
- ConPTY tile launches with the brief; MCP tools reachable; a clean multi-task run merges all and prints a summary.
- A seeded conflict is resolved autonomously via `continue_merge`.
- Interactive question round-trip (helper asks, user answers in terminal).
- Global (multi-repo) run with `--add-dir` for each repo.
- Selection dialog: grouping, default ticks, select-all/none, per-list vs global scope.
---
## 9. Phasing
Delivered as one plan with three phases (see the plan doc). Phase A is independently useful and merges first.
- **Phase A — Worker MCP conflict tools** (§5): `leaveConflictsInTree` param + `continue_merge` + `abort_merge` + tests. No UI.
- **Phase B — Worker launch** (§6.16.4 worker side): prompt templates, `BuildForMergeHelper`, hub endpoint, session-file/brief generation, client method. Contract for C locked here.
- **Phase C — UI**: selection dialog (View+VM), per-list + global entries, event plumbing, `OpenMergeHelperConPtySessionAsync`.
---
## 10. Out of scope (v1)
- Resuming a merge-helper session (`--resume`); sessions are ephemeral.
- A non-interactive/headless merge-helper (this is deliberately a ConPTY interactive session).
- Cross-list *batching* semantics beyond "act on each selected task independently."
- Any change to the existing per-task Approve/diff-gate flow.
+1
View File
@@ -125,6 +125,7 @@ sealed class Program
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>()); sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>(); sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>()); sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddTransient<MergeHelperSelectionModalViewModel>();
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>(); sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddSingleton<INotesApi, WorkerNotesApi>(); sc.AddSingleton<INotesApi, WorkerNotesApi>();
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>(); sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
+51 -1
View File
@@ -2,7 +2,7 @@ using System.Text;
namespace ClaudeDo.Data; namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine } public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
public static class PromptFiles public static class PromptFiles
{ {
@@ -18,6 +18,8 @@ public static class PromptFiles
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"), PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"), PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
PromptKind.Refine => Path.Combine(Root, "refine.md"), PromptKind.Refine => Path.Combine(Root, "refine.md"),
PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind)) _ => throw new ArgumentOutOfRangeException(nameof(kind))
}; };
@@ -63,6 +65,8 @@ public static class PromptFiles
PromptKind.WeeklyReport => WeeklyReportDefault, PromptKind.WeeklyReport => WeeklyReportDefault,
PromptKind.ImprovementChild => ImprovementChildDefault, PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault, PromptKind.Refine => RefineDefault,
PromptKind.MergeHelper => MergeHelperDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
_ => "" _ => ""
}; };
@@ -224,6 +228,52 @@ public static class PromptFiles
task, stop. task, stop.
"""; """;
private const string MergeHelperDefault = """
You are the ClaudeDo Merge Helper, 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 each one to a merged, Done state, then print a summary of everything that changed. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it lists each task's id, title, status, list and repo.
Handle the tasks in the order listed, one at a time. For each task, act on its current status:
- Idle or Queued: run it with run_task_now, then poll get_task until it leaves Running (it lands in WaitingForReview on success, or Failed).
- Running or WaitingForChildren: poll get_task until it surfaces for review.
- Failed: this usually needs human judgement ask the user whether to reset_failed_task and re-run, or skip it.
- WaitingForReview: review, then merge it (below).
Reviewing and merging a WaitingForReview task:
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/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 it 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 (below).
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:
- 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.
When every task is handled, print a SUMMARY:
- one line per task: title final status merge commit (if any) conflicts resolved (if any)
- anything you skipped or left for the user, and why
- suggested follow-ups, if any.
""";
private const string MergeHelperInitialDefault = """
# Merge Helper brief
Scope: {scope}
Handle the following tasks, in order. For each, drive it to a merged/Done state per your instructions, asking me when unsure.
{tasks}
When every task is handled, print the summary.
""";
private const string WeeklyReportDefault = """ private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer, You are generating a concise weekly standup report for a software developer,
covering {start} to {end}. covering {start} to {end}.
+18 -1
View File
@@ -173,8 +173,10 @@
"contextWorktrees": "Worktrees…", "contextWorktrees": "Worktrees…",
"contextOpenExplorer": "Im Explorer öffnen", "contextOpenExplorer": "Im Explorer öffnen",
"contextOpenTerminal": "Im Terminal öffnen", "contextOpenTerminal": "Im Terminal öffnen",
"contextLetClaude": "Claude machen lassen",
"newList": "Neue Liste", "newList": "Neue Liste",
"addReposTip": "Repos als Listen hinzufügen" "addReposTip": "Repos als Listen hinzufügen",
"letClaudeAllTip": "Claude machen lassen (alle Listen)"
}, },
"details": { "details": {
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen", "pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
@@ -266,6 +268,7 @@
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung schließen", "closeSession": "Sitzung schließen",
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}", "conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
"mergeHelperTitle": "Merge-Helfer",
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}", "submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
"submitForReview": "Zum Review einreichen", "submitForReview": "Zum Review einreichen",
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann", "submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
@@ -365,6 +368,20 @@
"forgetFolders": "Ordner vergessen", "forgetFolders": "Ordner vergessen",
"alreadyAdded": "(bereits hinzugefügt)" "alreadyAdded": "(bereits hinzugefügt)"
}, },
"mergeHelper": {
"title": "CLAUDE MACHEN LASSEN",
"windowTitle": "Claude machen lassen",
"scopeAll": "Alle Listen",
"scopeList": "Liste: {0}",
"confirm": "Claude machen lassen",
"cancel": "Abbrechen",
"selectAll": "Alle auswählen",
"selectNone": "Keine auswählen",
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
"columnTask": "AUFGABE",
"columnStatus": "STATUS",
"columnList": "LISTE"
},
"unfinishedPlanning": { "unfinishedPlanning": {
"title": "UNVOLLENDETE PLANUNGSSITZUNG", "title": "UNVOLLENDETE PLANUNGSSITZUNG",
"windowTitle": "Unvollendete Planungssitzung", "windowTitle": "Unvollendete Planungssitzung",
+18 -1
View File
@@ -173,8 +173,10 @@
"contextWorktrees": "Worktrees…", "contextWorktrees": "Worktrees…",
"contextOpenExplorer": "Open in Explorer", "contextOpenExplorer": "Open in Explorer",
"contextOpenTerminal": "Open in Terminal", "contextOpenTerminal": "Open in Terminal",
"contextLetClaude": "Let Claude handle it",
"newList": "New list", "newList": "New list",
"addReposTip": "Add repos as lists" "addReposTip": "Add repos as lists",
"letClaudeAllTip": "Let Claude handle it (all lists)"
}, },
"details": { "details": {
"pickUpInTerminalTip": "Pick up this session in a terminal", "pickUpInTerminalTip": "Pick up this session in a terminal",
@@ -266,6 +268,7 @@
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Close session", "closeSession": "Close session",
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}", "conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
"mergeHelperTitle": "Merge Helper",
"submitForReviewFailed": "Couldn't submit for review: {0}", "submitForReviewFailed": "Couldn't submit for review: {0}",
"submitForReview": "Submit for review", "submitForReview": "Submit for review",
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged", "submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
@@ -365,6 +368,20 @@
"forgetFolders": "Forget folders", "forgetFolders": "Forget folders",
"alreadyAdded": "(already added)" "alreadyAdded": "(already added)"
}, },
"mergeHelper": {
"title": "LET CLAUDE HANDLE IT",
"windowTitle": "Let Claude handle it",
"scopeAll": "All lists",
"scopeList": "List: {0}",
"confirm": "Let Claude handle it",
"cancel": "Cancel",
"selectAll": "Select all",
"selectNone": "Select none",
"empty": "No tasks to hand over — everything is done or cancelled.",
"columnTask": "TASK",
"columnStatus": "STATUS",
"columnList": "LIST"
},
"unfinishedPlanning": { "unfinishedPlanning": {
"title": "UNFINISHED PLANNING SESSION", "title": "UNFINISHED PLANNING SESSION",
"windowTitle": "Unfinished planning session", "windowTitle": "Unfinished planning session",
@@ -22,6 +22,9 @@ public interface IDialogService
Task ShowListSettingsAsync(ListSettingsModalViewModel vm); Task ShowListSettingsAsync(ListSettingsModalViewModel vm);
Task ShowRepoImportAsync(RepoImportModalViewModel vm); Task ShowRepoImportAsync(RepoImportModalViewModel vm);
Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm); Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm);
/// <summary>Merge-helper task picker. Returns the ordered selected task ids, or null on cancel.</summary>
Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm);
Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm); Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm);
Task ShowConflictResolverAsync(ConflictResolverViewModel vm); Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm); Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
@@ -85,6 +85,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory -- /// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree.</summary> /// no task, no worktree.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default); Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
/// tasks to a merged/Done state. listId scopes the session to that list; null = all lists.</summary>
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY /// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary> /// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default); Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+3
View File
@@ -522,6 +522,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct); => await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct); => await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
@@ -16,6 +16,9 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User } public enum ListKind { Smart, Virtual, User }
/// <summary>Confirmed merge-helper run: the scope list (null = all lists) and the ordered selected task ids.</summary>
public sealed record MergeHelperRequest(string? ListId, IReadOnlyList<string> TaskIds);
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -80,6 +83,35 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
finally { _worktreesOverviewOpen = false; } finally { _worktreesOverviewOpen = false; }
} }
/// <summary>Raised after the merge-helper selection dialog is confirmed; the shell opens the ConPTY tile.</summary>
public event Action<MergeHelperRequest>? LetClaudeHandleRequested;
[RelayCommand]
private async Task LetClaudeHandleListAsync(ListNavItemViewModel? row)
{
if (row is null || Dialogs is null || _services is null) return;
if (row.Kind != ListKind.User || string.IsNullOrWhiteSpace(row.WorkingDir)) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
vm.Configure(rawId, row.Name);
await vm.LoadAsync();
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
if (ids is { Count: > 0 })
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
}
[RelayCommand]
private async Task LetClaudeHandleAllAsync()
{
if (Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
vm.Configure(null, null);
await vm.LoadAsync();
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
if (ids is { Count: > 0 })
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(null, ids));
}
[RelayCommand] [RelayCommand]
private void OpenInExplorer(ListNavItemViewModel? row) private void OpenInExplorer(ListNavItemViewModel? row)
{ {
@@ -238,6 +238,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
OpenMissionControl(); OpenMissionControl();
_ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume); _ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
}; };
Lists.LetClaudeHandleRequested += req =>
{
OpenMissionControl();
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
};
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync(); Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
Tasks.OpenListSettingsRequested += (_, _) => Tasks.OpenListSettingsRequested += (_, _) =>
{ {
@@ -322,6 +322,40 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
// Merge-helper 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");
if (listId is not null)
{
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 */ }
}
try
{
var spec = await _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId);
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
}
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message); private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive // Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
@@ -0,0 +1,124 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Ui.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
public required string Title { get; init; }
public required string StatusText { get; init; }
public required string ListName { get; init; }
[ObservableProperty] private bool _isSelected;
}
/// <summary>
/// "Let Claude handle it" task picker: lists the non-terminal tasks of a list (or all lists),
/// pre-ticks the actionable ones, and resolves <see cref="Result"/> with the ordered selected
/// task ids (null on cancel).
/// </summary>
public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private string? _listId;
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
[ObservableProperty] private string _scopeLabel = "";
[ObservableProperty] private bool _isGlobal;
public bool HasTasks => Tasks.Count > 0;
public bool CanConfirm => Tasks.Any(t => t.IsSelected);
public TaskCompletionSource<IReadOnlyList<string>?> Result { get; } = new();
public Action? CloseAction { get; set; }
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
=> _dbFactory = dbFactory;
public void Configure(string? listId, string? listName)
{
_listId = listId;
IsGlobal = listId is null;
ScopeLabel = listId is null
? Loc.T("modals.mergeHelper.scopeAll")
: Loc.T("modals.mergeHelper.scopeList", listName ?? "");
}
public async Task LoadAsync(CancellationToken ct = default)
{
foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged;
Tasks.Clear();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var query = ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled);
if (_listId is not null)
query = query.Where(t => t.ListId == _listId);
var candidates = await query
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.Status, ListName = t.List.Name })
.ToListAsync(ct);
foreach (var c in candidates)
{
var row = new MergeHelperTaskRowViewModel
{
Id = c.Id,
Title = c.Title,
StatusText = c.Status.ToString(),
ListName = c.ListName,
IsSelected = IsTickedByDefault(c.Status),
};
row.PropertyChanged += OnRowChanged;
Tasks.Add(row);
}
OnPropertyChanged(nameof(HasTasks));
OnPropertyChanged(nameof(CanConfirm));
}
// Actionable statuses are pre-ticked; Running/WaitingForChildren are listed but unticked
// (the helper would only poll them). Done/Cancelled never make it into the list.
internal static bool IsTickedByDefault(TaskStatus status) => status
is TaskStatus.Idle or TaskStatus.Queued or TaskStatus.WaitingForReview or TaskStatus.Failed;
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MergeHelperTaskRowViewModel.IsSelected))
OnPropertyChanged(nameof(CanConfirm));
}
[RelayCommand]
private void SelectAll()
{
foreach (var t in Tasks) t.IsSelected = true;
}
[RelayCommand]
private void SelectNone()
{
foreach (var t in Tasks) t.IsSelected = false;
}
[RelayCommand]
private void Confirm()
{
Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList());
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result.TrySetResult(null);
CloseAction?.Invoke();
}
}
@@ -137,6 +137,11 @@
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}" Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/> CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="{loc:Tr lists.contextLetClaude}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).LetClaudeHandleListCommand}"
CommandParameter="{Binding}"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto"> <Grid ColumnDefinitions="20,*,Auto">
@@ -176,8 +181,8 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<!-- New list + import row --> <!-- New list + import + merge-helper row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn" <Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}"> Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
@@ -198,6 +203,13 @@
Width="14" Height="14" Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/> Foreground="{DynamicResource TextMuteBrush}"/>
</Button> </Button>
<Button Grid.Column="2" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding LetClaudeHandleAllCommand}"
ToolTip.Tip="{loc:Tr lists.letClaudeAllTip}">
<PathIcon Data="{StaticResource Icon.Broom}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid> </Grid>
</StackPanel> </StackPanel>
@@ -0,0 +1,81 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Modals.MergeHelperSelectionModal"
x:DataType="vm:MergeHelperSelectionModalViewModel"
Title="{loc:Tr modals.mergeHelper.windowTitle}"
Width="560" Height="480" MinWidth="420" MinHeight="320"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.mergeHelper.title}" CloseCommand="{Binding CancelCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr modals.mergeHelper.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{loc:Tr modals.mergeHelper.confirm}" Command="{Binding ConfirmCommand}"
IsEnabled="{Binding CanConfirm}" MinWidth="140" Classes="primary"/>
</StackPanel>
</ctl:ModalShell.Footer>
<!-- Body: scope + select-all/none toolbar, headers, checkbox rows -->
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="20,12,20,6">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ScopeLabel}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}"
Command="{Binding SelectAllCommand}" IsEnabled="{Binding HasTasks}"/>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}"
Command="{Binding SelectNoneCommand}" IsEnabled="{Binding HasTasks}" Margin="8,0,0,0"/>
</Grid>
<!-- Column headers -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="32,*,120,120" Margin="20,0,20,4"
IsVisible="{Binding HasTasks}">
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnTask}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnList}"
IsVisible="{Binding IsGlobal}"/>
</Grid>
<ScrollViewer Padding="20,2,20,8">
<StackPanel>
<TextBlock Classes="meta" Margin="0,8"
Text="{loc:Tr modals.mergeHelper.empty}"
IsVisible="{Binding !HasTasks}"/>
<ItemsControl ItemsSource="{Binding Tasks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
<Grid ColumnDefinitions="32,*,120,120" Margin="0,1">
<CheckBox Grid.Column="0" MinWidth="0"
IsChecked="{Binding IsSelected, Mode=TwoWay}"
VerticalAlignment="Center"/>
<TextBlock Classes="body" Grid.Column="1" Text="{Binding Title}"
VerticalAlignment="Center" Margin="4,0,0,0"
TextTrimming="CharacterEllipsis"/>
<Border Grid.Column="2" Classes="chip"
HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding StatusText}"/>
</Border>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding ListName}"
VerticalAlignment="Center" Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding $parent[Window].((vm:MergeHelperSelectionModalViewModel)DataContext).IsGlobal}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</DockPanel>
</ctl:ModalShell>
</Window>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Modals;
public partial class MergeHelperSelectionModal : Window
{
public MergeHelperSelectionModal()
{
InitializeComponent();
}
}
@@ -81,6 +81,15 @@ public sealed class WindowDialogService : IDialogService
await dlg.ShowDialog(_owner); await dlg.ShowDialog(_owner);
} }
public async Task<System.Collections.Generic.IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm)
{
var dlg = new MergeHelperSelectionModal { DataContext = vm };
vm.CloseAction = () => dlg.Close();
dlg.Closed += (_, _) => vm.Result.TrySetResult(null); // native close counts as cancel
await dlg.ShowDialog(_owner);
return await vm.Result.Task;
}
public async Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) public async Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm)
{ {
var dlg = new WorktreesOverviewModalView { DataContext = vm }; var dlg = new WorktreesOverviewModalView { DataContext = vm };
+146 -13
View File
@@ -19,7 +19,7 @@ namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id); public sealed record DeleteTaskResult(bool Deleted, string Id);
public sealed record CancelTaskResult(bool Cancelled, string Id); public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage); public sealed record ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null);
public sealed record StatusValueDto(string Status, string Meaning); public sealed record StatusValueDto(string Status, string Meaning);
public sealed record TaskDto( public sealed record TaskDto(
@@ -44,7 +44,12 @@ public sealed record TaskDiffDto(
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes); string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
public sealed record MergeTaskResultDto( public sealed record MergeTaskResultDto(
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts); bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
bool ConflictsInTree = false, string? RepoPath = null);
public sealed record MergeContinuationResultDto(
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
string? RepoPath, string? Message);
public sealed record WorktreeListItemDto( public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch, string? TaskId, string Path, string Branch,
@@ -300,7 +305,10 @@ public sealed class ExternalMcpService
"decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " + "decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
"targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " + "targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " +
"(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " + "(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " +
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — resolve them in the ClaudeDo UI. " + "mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " +
"aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " +
"markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
"or abort_merge to cancel. " +
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " + "decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
"decision='reject_park' → Idle for manual editing (feedback ignored). " + "decision='reject_park' → Idle for manual editing (feedback ignored). " +
"decision='cancel' → Cancelled. " + "decision='cancel' → Cancelled. " +
@@ -310,14 +318,16 @@ public sealed class ExternalMcpService
string decision, string decision,
string? feedback = null, string? feedback = null,
string? targetBranch = null, string? targetBranch = null,
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_ = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
string? mergeStatus = null; string? mergeStatus = null;
IReadOnlyList<string> mergeConflicts = Array.Empty<string>(); IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
string? mergeMessage = null; string? mergeMessage = null;
string? repoPath = null;
if (decision.Trim().ToLowerInvariant() == "approve") if (decision.Trim().ToLowerInvariant() == "approve")
{ {
@@ -333,18 +343,38 @@ public sealed class ExternalMcpService
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done; var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict; mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
if (!parentDone) if (!parentDone)
mergeMessage = "unit merge paused on a conflict — resolve and continue it in the ClaudeDo UI"; {
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
repoPath = list?.WorkingDir;
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
}
} }
else else
{ {
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", cancellationToken); var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken);
if (r.Status == TaskMergeService.StatusBlocked) if (r.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed"); throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
mergeStatus = r.Status; mergeStatus = r.Status;
mergeConflicts = r.ConflictFiles; mergeConflicts = r.ConflictFiles;
mergeMessage = r.Status == TaskMergeService.StatusConflict if (r.Status == TaskMergeService.StatusConflict)
? "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI" {
: r.ErrorMessage; if (leaveConflictsInTree)
{
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
repoPath = list?.WorkingDir;
mergeMessage = "merge conflict — markers left in the working tree; " +
"resolve them then call continue_merge, or abort_merge to cancel";
}
else
{
mergeMessage = "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI";
}
}
else
{
mergeMessage = r.ErrorMessage;
}
} }
} }
else else
@@ -364,7 +394,7 @@ public sealed class ExternalMcpService
return new ReviewTaskResult( return new ReviewTaskResult(
ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!), ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
mergeStatus, mergeConflicts, mergeMessage); mergeStatus, mergeConflicts, mergeMessage, repoPath);
} }
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")] [McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
@@ -484,13 +514,17 @@ public sealed class ExternalMcpService
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " + "dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " + "allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
"On success: merged=true, mergeCommit contains the new merge commit SHA. " + "On success: merged=true, mergeCommit contains the new merge commit SHA. " +
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")] "On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " +
"leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " +
"tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " +
"or abort_merge to cancel.")]
public async Task<MergeTaskResultDto> MergeTask( public async Task<MergeTaskResultDto> MergeTask(
string taskId, string taskId,
string targetBranch = "main", string targetBranch = "main",
bool noFf = true, bool noFf = true,
bool dryRun = false, bool dryRun = false,
bool allowWaitingForReview = false, bool allowWaitingForReview = false,
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -517,7 +551,7 @@ public sealed class ExternalMcpService
var commitMessage = $"Merge task branch for: {task.Title}"; var commitMessage = $"Merge task branch for: {task.Title}";
var result = await _merge.MergeAsync( var result = await _merge.MergeAsync(
taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken); taskId, targetBranch, removeWorktree: false, commitMessage, leaveConflictsInTree, cancellationToken);
if (result.Status == TaskMergeService.StatusMerged) if (result.Status == TaskMergeService.StatusMerged)
{ {
@@ -532,11 +566,110 @@ public sealed class ExternalMcpService
} }
if (result.Status == TaskMergeService.StatusConflict) if (result.Status == TaskMergeService.StatusConflict)
return new MergeTaskResultDto(false, null, result.ConflictFiles); return leaveConflictsInTree
? new MergeTaskResultDto(false, null, result.ConflictFiles,
ConflictsInTree: true, RepoPath: list?.WorkingDir)
: new MergeTaskResultDto(false, null, result.ConflictFiles);
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}"); throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
} }
[McpServerTool, Description(
"Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " +
"merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " +
"merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " +
"post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " +
"lists the affected files — resolve them and call continue_merge again. " +
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
var workingDir = list?.WorkingDir;
bool merged;
IReadOnlyList<string> conflicts = Array.Empty<string>();
string? repoPath = null;
string? message = null;
if (_planningMerge.HasActiveMerge(taskId))
{
await _planningMerge.ContinueAsync(taskId, cancellationToken);
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
if (parent.Status == TaskStatus.Done)
{
merged = true;
}
else
{
var remaining = !string.IsNullOrWhiteSpace(workingDir)
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
: new List<string>();
merged = false;
if (remaining.Count > 0)
{
conflicts = remaining;
repoPath = workingDir;
message = "conflicts remain — resolve and call continue_merge again";
}
else
{
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
"check the parent task and approve again to restart the merge";
}
}
}
else
{
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken);
if (r.Status == TaskMergeService.StatusMerged)
{
merged = true;
}
else if (r.Status == TaskMergeService.StatusConflict)
{
merged = false;
conflicts = r.ConflictFiles;
repoPath = workingDir;
message = r.ErrorMessage;
}
else
{
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
}
}
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
}
[McpServerTool, Description(
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
"Throws if there is no in-progress merge for the task.")]
public async Task<TaskDto> AbortMerge(string taskId, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (_planningMerge.HasActiveMerge(taskId))
{
await _planningMerge.AbortAsync(taskId, cancellationToken);
}
else
{
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
if (r.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
}
await _broadcaster.TaskUpdated(taskId);
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
[McpServerTool, Description( [McpServerTool, Description(
"List all ClaudeDo-tracked worktrees. " + "List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " + "Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
+10
View File
@@ -676,6 +676,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted); return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
}); });
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
// and cwd to that list; null means all lists.
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string? listId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, Context.ConnectionAborted);
});
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns // Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for // the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started // StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
@@ -342,7 +342,11 @@ public sealed class TaskMergeService
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count); return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
} }
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct) public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
public async Task<MergeResult> ApproveAndMergeAsync(
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct)
{ {
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct); var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
@@ -367,7 +371,7 @@ public sealed class TaskMergeService
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge. // MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
// Remove the worktree on approve (matching the unit-merge path) so merged // Remove the worktree on approve (matching the unit-merge path) so merged
// worktrees don't pile up; the merge commit on the target branch is the record. // worktrees don't pile up; the merge commit on the target branch is the record.
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", ct); return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", leaveConflictsInTree, ct);
} }
private static MergeResult Blocked(string reason) => private static MergeResult Blocked(string reason) =>
@@ -108,6 +108,10 @@ public sealed class PlanningMergeOrchestrator
await DrainAsync(parentTaskId, ct); await DrainAsync(parentTaskId, ct);
} }
/// <summary>True when a unit merge for this parent is paused on a conflict (in-memory state).</summary>
public bool HasActiveMerge(string parentTaskId) =>
_states.TryGetValue(parentTaskId, out var s) && s.CurrentSubtaskId is not null;
public async Task ContinueAsync(string planningTaskId, CancellationToken ct) public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
{ {
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null) if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
@@ -157,6 +157,101 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty<string>(), env)); return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty<string>(), env));
} }
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
// diff, review/merge, continue/abort merge), read/search, Edit + Bash for hand-resolving
// conflict markers the MCP tools left behind, and web/skill lookups.
private const string MergeHelperAllowedTools =
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill";
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct)
{
if (taskIds.Count == 0)
throw new InvalidOperationException("No tasks selected for the merge helper.");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var taskRepo = new TaskRepository(ctx);
var listRepo = new ListRepository(ctx);
var listsById = new Dictionary<string, ListEntity?>();
var briefLines = new List<string>();
var repoDirs = new List<string>(); // distinct, existing, in first-seen order
foreach (var id in taskIds)
{
var task = await taskRepo.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"Task not found: {id}");
if (!listsById.TryGetValue(task.ListId, out var list))
listsById[task.ListId] = list = await listRepo.GetByIdAsync(task.ListId, ct);
var workingDir = list?.WorkingDir;
if (!string.IsNullOrEmpty(workingDir) && Directory.Exists(workingDir) && !repoDirs.Contains(workingDir))
repoDirs.Add(workingDir);
briefLines.Add(
$"- [{task.Status}] {task.Title} (id: {task.Id}, list: {list?.Name ?? ""}, " +
$"repo: {(string.IsNullOrEmpty(workingDir) ? "" : workingDir)})");
}
if (repoDirs.Count == 0)
throw new InvalidOperationException("none of the selected tasks are in a working directory");
string scopeLabel;
string cwd;
if (listId is not null)
{
var scopeList = await listRepo.GetByIdAsync(listId, ct)
?? throw new KeyNotFoundException($"List not found: {listId}");
scopeLabel = $"List: {scopeList.Name}";
cwd = scopeList.WorkingDir is { Length: > 0 } wd && Directory.Exists(wd) ? wd : repoDirs[0];
}
else
{
scopeLabel = "All lists";
cwd = repoDirs[0];
}
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"] = scopeLabel,
["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,
};
args.AddRange(repoDirs);
args.Add("--append-system-prompt-file");
args.Add(systemPromptPath);
args.Add(
$"Read the file {briefPath} first. It lists the tasks you must merge and their status. " +
"After reading it, begin the merge-helper session as your instructions describe.");
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = "200000",
};
return new LaunchSpec(cwd, resolvedClaude, args, env);
}
// The positional prompt claude opens the interactive session on. Empty (no positional arg) // The positional prompt claude opens the interactive session on. Empty (no positional arg)
// if the task has neither a title nor a description. // if the task has neither a title nor a description.
private static IReadOnlyList<string> BuildFreshPromptArgs(TaskEntity task) private static IReadOnlyList<string> BuildFreshPromptArgs(TaskEntity task)
@@ -26,4 +26,12 @@ public interface IInteractiveLaunchSpecService
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the /// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
/// directory doesn't exist.</summary> /// directory doesn't exist.</summary>
Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct); Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct);
/// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/&lt;guid&gt; and exposes
/// that dir plus every distinct existing repo dir via --add-dir. listId scopes the brief label
/// and the cwd to that list; null means all lists (cwd = first existing repo dir). Throws
/// InvalidOperationException if taskIds is empty or no task has an existing working directory.</summary>
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct);
} }
@@ -43,4 +43,41 @@ public class PromptFilesTests
{ {
Assert.EndsWith("planning-system.md", PromptFiles.PathFor(PromptKind.Planning)); Assert.EndsWith("planning-system.md", PromptFiles.PathFor(PromptKind.Planning));
} }
[Fact]
public void PathFor_merge_helper_kinds_map_to_their_files()
{
Assert.EndsWith("merge-helper-system.md", PromptFiles.PathFor(PromptKind.MergeHelper));
Assert.EndsWith("merge-helper-initial.md", PromptFiles.PathFor(PromptKind.MergeHelperInitial));
}
[Fact]
public void DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.False(string.IsNullOrWhiteSpace(d));
Assert.Contains("review_task", d);
Assert.Contains("continue_merge", d);
}
[Fact]
public void DefaultFor_merge_helper_initial_has_scope_and_tasks_tokens()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
Assert.False(string.IsNullOrWhiteSpace(d));
Assert.Contains("{scope}", d);
Assert.Contains("{tasks}", d);
}
[Fact]
public void RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks()
{
var outp = PromptFiles.RenderTemplate(
PromptFiles.DefaultFor(PromptKind.MergeHelperInitial),
new Dictionary<string, string> { ["scope"] = "All lists", ["tasks"] = "- [Idle] T1" });
Assert.Contains("Scope: All lists", outp);
Assert.Contains("- [Idle] T1", outp);
Assert.DoesNotContain("{scope}", outp);
Assert.DoesNotContain("{tasks}", outp);
}
} }
@@ -100,6 +100,8 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default) public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
@@ -0,0 +1,183 @@
using System.Linq;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class MergeHelperSelectionModalViewModelTests : IDisposable
{
private readonly string _dbPath;
public MergeHelperSelectionModalViewModelTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mh_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private MergeHelperSelectionModalViewModel BuildVm()
=> new(new TestDbFactory(NewContext));
private async Task SeedAllStatusesAsync()
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "L2", Name = "Home", CreatedAt = DateTime.UtcNow });
var order = 0;
void Add(string id, string listId, TaskStatus status) =>
db.Tasks.Add(new TaskEntity
{
Id = id, ListId = listId, Title = $"task {id}",
Status = status, CreatedAt = DateTime.UtcNow, SortOrder = order++,
});
Add("t-idle", "L1", TaskStatus.Idle);
Add("t-queued", "L1", TaskStatus.Queued);
Add("t-running", "L1", TaskStatus.Running);
Add("t-review", "L1", TaskStatus.WaitingForReview);
Add("t-children", "L1", TaskStatus.WaitingForChildren);
Add("t-failed", "L1", TaskStatus.Failed);
Add("t-done", "L1", TaskStatus.Done);
Add("t-cancelled", "L1", TaskStatus.Cancelled);
Add("t-other-list", "L2", TaskStatus.Idle);
await db.SaveChangesAsync();
}
[Fact]
public async Task Load_ExcludesTerminalStatuses_AndTicksActionableByDefault()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure(null, null);
await vm.LoadAsync();
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
Assert.Equal(7, 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.Equal("Home", vm.Tasks[0].ListName);
Assert.False(vm.IsGlobal);
Assert.Contains("Home", vm.ScopeLabel);
}
[Fact]
public async Task CanConfirm_FollowsRowSelection()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure(null, null);
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(null, null);
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);
}
[Fact]
public async Task Cancel_ReturnsNull()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure(null, null);
await vm.LoadAsync();
var closed = false;
vm.CloseAction = () => closed = true;
vm.CancelCommand.Execute(null);
Assert.Null(await vm.Result.Task);
Assert.True(closed);
}
[Fact]
public async Task Load_NoCandidates_HasTasksFalse_CannotConfirm()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "t-done", ListId = "L1", Title = "done", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
Assert.False(vm.HasTasks);
Assert.False(vm.CanConfirm);
}
}
@@ -406,6 +406,65 @@ public class MissionControlViewModelTests : IDisposable
Assert.Empty(vm.Panes); Assert.Empty(vm.Panes);
} }
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
=> throw new InvalidOperationException("spec failed");
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_AddsAdHocPane()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1", "t2" });
Assert.Single(vm.ConPtySessions);
Assert.Null(vm.ConPtySessions[0].TaskId);
Assert.Single(vm.Panes);
Assert.Same(vm.ConPtySessions[0], vm.Panes[0]);
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_TwoRuns_NeverDeduped()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
Assert.Equal(2, vm.ConPtySessions.Count);
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_EmptySelection_NoPane()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync(null, Array.Empty<string>());
Assert.Empty(vm.ConPtySessions);
Assert.Empty(vm.Panes);
}
[Fact]
public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
{
var worker = new ThrowingMergeHelperLaunchSpecWorker();
using var vm = BuildVm(worker);
string? error = null;
vm.ErrorReported += msg => error = msg;
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
Assert.Empty(vm.ConPtySessions);
Assert.Empty(vm.Panes);
Assert.NotNull(error);
}
[Fact] [Fact]
public void ToggleLayoutCommand_FlipsIsFocusMode() public void ToggleLayoutCommand_FlipsIsFocusMode()
{ {
@@ -216,7 +216,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(task.Id, "approve", null, null, CancellationToken.None); var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
Assert.Equal("Done", result.Task.Status); Assert.Equal("Done", result.Task.Status);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
@@ -230,7 +230,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "reject_rerun", null, null, CancellationToken.None)); sut.ReviewTask(task.Id, "reject_rerun", null, null, cancellationToken: CancellationToken.None));
} }
[Fact] [Fact]
@@ -240,7 +240,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview); var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, CancellationToken.None); var result = await sut.ReviewTask(task.Id, "reject_rerun", "fix it", null, cancellationToken: CancellationToken.None);
Assert.Equal("Queued", result.Task.Status); Assert.Equal("Queued", result.Task.Status);
var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); var loaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
@@ -255,7 +255,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReviewTask(task.Id, "bogus", null, null, CancellationToken.None)); sut.ReviewTask(task.Id, "bogus", null, null, cancellationToken: CancellationToken.None));
} }
[Fact] [Fact]
@@ -275,7 +275,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
} }
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(task.Id, "approve", null, null, CancellationToken.None); var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status); Assert.Equal("Done", result.Task.Status);
@@ -321,7 +321,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
} }
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(parentId, "approve", null, "main", CancellationToken.None); var result = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus); Assert.Equal(TaskMergeService.StatusMerged, result.MergeStatus);
Assert.Equal("Done", result.Task.Status); Assert.Equal("Done", result.Task.Status);
@@ -610,7 +610,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.MergeTask(task.Id, "main", true, false, false, CancellationToken.None)); () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
Assert.Contains("Done", ex.Message); Assert.Contains("Done", ex.Message);
} }
@@ -785,7 +785,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.MergeTask(task.Id, "main", true, false, false, CancellationToken.None)); () => sut.MergeTask(task.Id, "main", true, false, false, cancellationToken: CancellationToken.None));
Assert.Contains("Done", ex.Message); Assert.Contains("Done", ex.Message);
} }
@@ -797,7 +797,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview); var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, CancellationToken.None); var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
Assert.False(result.Merged); Assert.False(result.Merged);
Assert.Null(result.MergeCommit); Assert.Null(result.MergeCommit);
@@ -817,13 +817,264 @@ public sealed class ExternalMcpServiceTests : IDisposable
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None); var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None);
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, CancellationToken.None); var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, cancellationToken: CancellationToken.None);
Assert.True(result.Merged); Assert.True(result.Merged);
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id); var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, reloaded!.Status); Assert.Equal(TaskStatus.Done, reloaded!.Status);
} }
// ── leaveConflictsInTree ──────────────────────────────────────────────────
private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedConflictingWorktreeAsync(
TaskStatus status = TaskStatus.WaitingForReview)
{
var (task, list, wt) = await SeedWorktreeAsync(status);
File.WriteAllText(Path.Combine(wt.WorktreePath, "README.md"), "# from worktree\n");
GitRepoFixture.RunGit(wt.WorktreePath, "add", "README.md");
GitRepoFixture.RunGit(wt.WorktreePath, "commit", "-m", "worktree edit");
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# from main\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
GitRepoFixture.RunGit(list.WorkingDir!, "commit", "-m", "main edit");
return (task, list, wt);
}
[Fact]
public async Task MergeTask_LeaveConflictsInTree_LeavesMarkersAndKeepsRepoMidMerge()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
allowWaitingForReview: true, leaveConflictsInTree: true, CancellationToken.None);
Assert.False(result.Merged);
Assert.True(result.ConflictsInTree);
Assert.Equal(list.WorkingDir, result.RepoPath);
Assert.Contains("README.md", result.Conflicts);
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status);
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
}
[Fact]
public async Task MergeTask_ConflictWithoutLeaveFlag_AbortsCleanly()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!);
var sut = BuildSut(CreateQueue());
var result = await sut.MergeTask(task.Id, target, true, dryRun: false,
allowWaitingForReview: true, leaveConflictsInTree: false, CancellationToken.None);
Assert.False(result.Merged);
Assert.False(result.ConflictsInTree);
Assert.Null(result.RepoPath);
Assert.Contains("README.md", result.Conflicts);
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
}
[Fact]
public async Task ReviewTask_Approve_LeaveConflictsInTree_LeavesMarkersAndReportsRepoPath()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
Assert.Equal(list.WorkingDir, result.RepoPath);
Assert.Contains("continue_merge", result.MergeMessage);
Assert.Contains("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
Assert.Equal("WaitingForReview", result.Task.Status);
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
}
[Fact]
public async Task ReviewTask_Approve_ConflictWithoutLeaveFlag_KeepsOldBehaviour()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var result = await sut.ReviewTask(task.Id, "approve", null, null, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.MergeStatus);
Assert.Null(result.RepoPath);
Assert.Contains("ClaudeDo UI", result.MergeMessage);
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
Assert.Equal("WaitingForReview", result.Task.Status);
}
// ── continue_merge / abort_merge ──────────────────────────────────────────
[Fact]
public async Task ContinueMerge_AfterResolvingMarkers_MergesAndSetsDone()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
// Resolve the conflict on disk and stage it.
File.WriteAllText(Path.Combine(list.WorkingDir!, "README.md"), "# resolved\n");
GitRepoFixture.RunGit(list.WorkingDir!, "add", "README.md");
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
Assert.True(result.Merged);
Assert.Equal("Done", result.TaskStatus);
Assert.Empty(result.Conflicts);
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
}
[Fact]
public async Task ContinueMerge_MarkersStillPresent_ReportsConflicts()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
// Markers never resolved — continue must refuse and list the files.
var result = await sut.ContinueMerge(task.Id, CancellationToken.None);
Assert.False(result.Merged);
Assert.Contains("README.md", result.Conflicts);
Assert.Equal(list.WorkingDir, result.RepoPath);
Assert.Equal("WaitingForReview", result.TaskStatus);
Assert.True(await new GitService().IsMidMergeAsync(list.WorkingDir!));
GitRepoFixture.RunGit(list.WorkingDir!, "merge", "--abort");
}
[Fact]
public async Task AbortMerge_RestoresCleanTreeAndKeepsWaitingForReview()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, _) = await SeedConflictingWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
await sut.ReviewTask(task.Id, "approve", null, null,
leaveConflictsInTree: true, CancellationToken.None);
var dto = await sut.AbortMerge(task.Id, CancellationToken.None);
Assert.Equal("WaitingForReview", dto.Status);
Assert.DoesNotContain("<<<<<<<", File.ReadAllText(Path.Combine(list.WorkingDir!, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(list.WorkingDir!));
}
[Fact]
public async Task ContinueMerge_NoMergeInProgress_Throws()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.ContinueMerge(task.Id, CancellationToken.None));
Assert.Contains("mid-merge", ex.Message);
}
[Fact]
public async Task AbortMerge_NoMergeInProgress_Throws()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.AbortMerge(task.Id, CancellationToken.None));
Assert.Contains("mid-merge", ex.Message);
}
[Fact]
public async Task ContinueMerge_ParentUnitMergeConflict_RoutesToOrchestratorAndCompletes()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
// main edits README.md so the child's edit conflicts.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "README.md");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var listId = Guid.NewGuid().ToString();
var parentId = Guid.NewGuid().ToString();
var childId = Guid.NewGuid().ToString();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "plan", CreatedAt = DateTime.UtcNow,
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.Finalized });
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "child", CreatedAt = DateTime.UtcNow,
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1 });
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{childId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# child change\n");
GitRepoFixture.RunGit(wtPath, "add", "README.md");
GitRepoFixture.RunGit(wtPath, "commit", "-m", "child edit");
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = childId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var sut = BuildSut(CreateQueue());
var review = await sut.ReviewTask(parentId, "approve", null, "main", cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, review.MergeStatus);
Assert.Equal(repo.RepoDir, review.RepoPath);
Assert.Contains("continue_merge", review.MergeMessage);
// Resolve the conflict on disk.
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# resolved\n");
var result = await sut.ContinueMerge(parentId, CancellationToken.None);
Assert.True(result.Merged);
Assert.Equal("Done", result.TaskStatus);
Assert.Empty(result.Conflicts);
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
using var verify = _db.CreateContext();
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
}
// ── AddTask model override ──────────────────────────────────────────────── // ── AddTask model override ────────────────────────────────────────────────
[Fact] [Fact]
@@ -52,6 +52,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
_db.Dispose(); _db.Dispose();
foreach (var f in _gitFixtures) f.Dispose(); foreach (var f in _gitFixtures) f.Dispose();
try { Directory.Delete(_tempDir, true); } catch { /* best effort */ } try { Directory.Delete(_tempDir, true); } catch { /* best effort */ }
foreach (var d in _mergeHelperSessionDirs)
try { Directory.Delete(d, true); } catch { /* best effort */ }
} }
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
@@ -70,23 +72,23 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance), new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new WorkerConfig { ClaudeBin = _claudeStubPath }); new WorkerConfig { ClaudeBin = _claudeStubPath });
private async Task<string> SeedListAsync(string? workingDir = null) private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
{ {
var listId = Guid.NewGuid().ToString(); var listId = Guid.NewGuid().ToString();
using var ctx = _db.CreateContext(); using var ctx = _db.CreateContext();
await new ListRepository(ctx).AddAsync(new ListEntity await new ListRepository(ctx).AddAsync(new ListEntity
{ {
Id = listId, Name = "L", WorkingDir = workingDir ?? _tempDir, CreatedAt = DateTime.UtcNow, Id = listId, Name = name, WorkingDir = workingDir ?? _tempDir, CreatedAt = DateTime.UtcNow,
}); });
return listId; return listId;
} }
private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null) private async Task SeedTaskAsync(string taskId, string listId, TaskStatus status, string? sessionSkillsJson = null, string title = "T")
{ {
using var ctx = _db.CreateContext(); using var ctx = _db.CreateContext();
await new TaskRepository(ctx).AddAsync(new TaskEntity await new TaskRepository(ctx).AddAsync(new TaskEntity
{ {
Id = taskId, ListId = listId, Title = "T", Status = status, Id = taskId, ListId = listId, Title = title, Status = status,
CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson, CreatedAt = DateTime.UtcNow, SessionSkills = sessionSkillsJson,
}); });
} }
@@ -344,6 +346,133 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None)); () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
} }
// ── Merge helper ──
private readonly List<string> _mergeHelperSessionDirs = new();
/// The session dir is the value right after --add-dir; register it for cleanup
/// (BuildForMergeHelperAsync writes under the real ~/.todo-app).
private string TrackSessionDir(LaunchSpec spec)
{
var args = spec.Args.ToList();
var dir = args[args.IndexOf("--add-dir") + 1];
_mergeHelperSessionDirs.Add(dir);
return dir;
}
[Fact]
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
{
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), null, CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperAsync_NoExistingWorkingDirs_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 }, null, CancellationToken.None));
Assert.Contains("working directory", ex.Message);
}
[Fact]
public async Task BuildForMergeHelperAsync_TasksAcrossTwoRepos_BuildsGlobalScopeSpec()
{
var repoA = Path.Combine(_tempDir, "repoA");
var repoB = Path.Combine(_tempDir, "repoB");
Directory.CreateDirectory(repoA);
Directory.CreateDirectory(repoB);
var listA = await SeedListAsync(workingDir: repoA, name: "Alpha");
var listB = await SeedListAsync(workingDir: repoB, name: "Beta");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
var t3 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listA, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listB, TaskStatus.Idle, title: "Second task");
await SeedTaskAsync(t3, listA, TaskStatus.Failed, title: "Third task"); // same repo as t1 -> distinct
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2, t3 }, null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
// cwd: no listId -> first existing repo dir.
Assert.Equal(repoA, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
var args = spec.Args.ToList();
// --permission-mode default
var pmIdx = args.IndexOf("--permission-mode");
Assert.True(pmIdx >= 0);
Assert.Equal("default", args[pmIdx + 1]);
// allowedTools string for the merge helper
var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
// --add-dir: session dir + BOTH distinct repo dirs (repoA only once)
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, repoA, repoB }, addDirs);
// system prompt file follows --append-system-prompt-file
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);
// brief exists, carries the scope label and every task's title
Assert.True(File.Exists(briefPath));
var brief = File.ReadAllText(briefPath);
Assert.Contains("Scope: All lists", brief);
Assert.Contains("First task", brief);
Assert.Contains("Second task", brief);
Assert.Contains("Third task", brief);
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
public async Task BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope()
{
var repoA = Path.Combine(_tempDir, "repoA2");
var repoB = Path.Combine(_tempDir, "repoB2");
Directory.CreateDirectory(repoA);
Directory.CreateDirectory(repoB);
var listA = await SeedListAsync(workingDir: repoA, name: "Alpha");
var listB = await SeedListAsync(workingDir: repoB, name: "Beta");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listA, TaskStatus.WaitingForReview, title: "Task in Alpha");
await SeedTaskAsync(t2, listB, TaskStatus.WaitingForReview, title: "Task in Beta");
var svc = BuildService();
// Scope on listB even though listA's task comes first: cwd must be listB's repo.
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listB, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
Assert.Equal(repoB, spec.Cwd);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
Assert.Contains("Scope: List: Beta", brief);
Assert.Contains("Task in Alpha", brief);
Assert.Contains("Task in Beta", brief);
}
[Fact] [Fact]
public void BuildPlanningStart_MapsPlanningArgsAndEnv() public void BuildPlanningStart_MapsPlanningArgsAndEnv()
{ {
@@ -75,6 +75,8 @@ sealed class FakeWorkerClient : IWorkerClient
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public int PlanningStartSpecCalls { get; private set; } public int PlanningStartSpecCalls { get; private set; }