Compare commits

..
1016 Commits
Author SHA1 Message Date
mika kuns f9a8ed761a docs: record the operation-feedback state before the v2.10.0 cut
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
Plan checkboxes stood at zero although P0, A, B, C1 and D are all merged --
anyone reading it would have assumed nothing was built. Ticks the merged
packages, records A5's cancellation and the visual pass, and names the two
leftovers (the three unused ops.* keys from A3/A4, C2-C5 as the only open
group). Also corrects the batch_* tool count from eight to seven and puts the
OperationTiming kill-switch note in the Ui CLAUDE.md back into English.
2026-08-17 11:45:03 +02:00
mika kuns 0e314e44dd Merge remote-tracking branch 'origin/main' 2026-08-17 11:35:02 +02:00
Mika Kuns 9c4f0d1872 feat(claude-do): merge [D4] Restliche MCP-Long-Runner versorgen + Progress-Regel im
ClaudeDo-Task: 8e7f70d8-e821-45c8-835b-7743c201f599
2026-08-17 10:00:35 +02:00
Mika Kuns 23aab26daf feat(worker): report progress for continue_merge and unit-merge verify gate
continue_merge and the parent/children unit-merge drain (PlanningMergeOrchestrator)
re-run the post-merge verify gate but never forwarded their IProgress token into it,
so a slow verify command on either path went silent past Claude Code's 300s MCP
idle-abort even though D1-D3 already fixed this for merge_task/review_task's
childless path. list_worktrees also gets elapsed-time progress: many tracked
worktrees means many concurrent git subprocess spawns.

Worker CLAUDE.md's existing progress rule now points at ProgressReporter as the
one implementation instead of a fresh polling loop.
2026-08-17 09:56:59 +02:00
Mika Kuns 1341c5f4f3 feat(claude-do): merge [D3] Worktree- und Diff-MCP-Tools mit Progress (3 Tools in E
ClaudeDo-Task: cc93dd2d-f870-4820-9f69-5388372ee73b
2026-08-17 09:32:40 +02:00
Mika Kuns aeb1a5eb82 feat(worker): report progress for cleanup_task_worktree, get_task_diff, preview_merge_set
Wraps the single-element git/worktree long-runners in ProgressReporter.RunAsync
(time-based, ExternalMcpService.ProgressReportInterval) and adds i/n reporting
to preview_merge_set's per-task loop, so these MCP calls survive Claude Code's
~300s idle-silence abort instead of leaving the caller with no signal that work
is still happening.
2026-08-17 09:29:51 +02:00
Mika Kuns 0de3816fd0 feat(claude-do): merge [D2] Die 7 batch_*-MCP-Tools mit Element-Fortschritt (i/n)
ClaudeDo-Task: 286efaaf-b38e-47d4-9655-0c2d87e4b329
2026-08-17 09:06:27 +02:00
Mika Kuns 53e26f0340 feat(worker): report per-item progress on all 7 batch_* MCP tools
Each batch tool now sends an i/n progress ping via ProgressReporter.ReportItem
after processing every item, so a waiting agent doesn't see the MCP client's
300s idle-silence abort while the worker keeps looping. Covers BatchGetTasks,
BatchAddTasks, BatchUpdateTaskStatus, BatchCancelTasks, BatchDeleteTasks,
BatchSetMyDay, and BatchCleanupTaskWorktrees (the slowest of the seven, since
it does git work per task).
2026-08-17 08:56:32 +02:00
Mika Kuns 9099b22eea refactor(claude-do): merge OperationTiming hinter Kill-Switch, Default aus (vor dem näc
ClaudeDo-Task: ae170c8b-33cf-4cf3-932e-865fbdd2afa6
2026-08-17 08:52:57 +02:00
Mika Kuns ce1a26d407 refactor(claude-do): merge [D1] ProgressReporter aus TaskMergeService extrahieren (+ i/
ClaudeDo-Task: 47c76093-633e-4f3b-b49b-b34873de0469
2026-08-17 08:39:06 +02:00
Mika Kuns 555933a93c refactor(worker): extract ProgressReporter from TaskMergeService
Pulls the MCP idle-timeout progress loop out of TaskMergeService into a
standalone ProgressReporter (Lifecycle namespace) shared by the verify
gate and preview-verify call sites, and adds a per-item i/n overload
(ReportItem) for upcoming batch progress in D2/D3.
2026-08-17 08:36:21 +02:00
Mika Kuns 43fbfd3fa2 feat(ui): gate OperationTiming.Shared behind a kill switch, off by default
Its job (surfacing the DetailsIsland.BindAsync churn) is done; leave the
InvokeTimedAsync wrappers and Island Record call sites in place as a
chokepoint for next time, but stop writing by default. Shared now only
writes when CLAUDEDO_OP_TIMING=1 is set; the constructor keeps enabled=true
so both TestSetup redirects and OperationTimingTests are unaffected.
2026-08-17 08:30:02 +02:00
Mika Kuns ec9ad1b0e1 chore(claude-do): merge Bind-Churn: DetailsIsland.BindAsync feuert ~1900x/Tag, 91-96
ClaudeDo-Task: 351cacc4-3e11-4b3e-a89e-4efc4bd0448e
2026-08-13 09:00:32 +02:00
Mika Kuns 8a1e2c9698 chore(claude-do): merge [C1] Generischer OperationProgress-Kanal, MergeProgress dara
ClaudeDo-Task: 543947ed-b501-4df8-b30d-1c14804244fd
2026-08-13 08:53:17 +02:00
Mika Kuns c1ec05a437 fix(ui): stop tests from polluting the live operation-timing.ndjson
DetailsIsland.BindAsync churn (~1900 binds/day, 91-96% cancelled) was not a
UI reactivity bug: OperationTiming.Shared is a static singleton hardcoded to
~/.todo-app/logs/operation-timing.ndjson, and Ui.Tests/Worker.Tests both
construct real DetailsIslandViewModel/TasksIslandViewModel instances that
call Shared.Record directly. Every dotnet test run appended 50-100 lines
straight into the live app's log — pid-burst analysis showed 13 distinct
test-run pids in the ":?" bucket (tests never pass a source) plus a
no-suffix bucket with no pid field at all (pre-dating the pid-per-line
feature). Real user-driven binds that day: 4.

Made Shared settable and added a [ModuleInitializer]-based TestSetup in
both test projects that redirects it to a per-process temp file before any
test runs.
2026-08-13 08:35:09 +02:00
Mika Kuns b8caa27027 feat(worker): add generic OperationProgress channel, port MergeProgress onto it
Merge/verify phases now broadcast over a generic (opKey, phase, current,
total) Hub event instead of a merge-specific one, so future producers
(worktree cleanup, startup recovery, planning integration) can reuse it.
IWorkerClient.MergeProgressEvent stays as a thin forwarder for existing
consumers (elapsed seconds riding in the generic "current" slot).
2026-08-13 08:31:23 +02:00
Mika Kuns db32e5307f chore(claude-do): merge [B3] Guard-Test: großer Diff blockiert den Dispatcher nicht
ClaudeDo-Task: e1edd8cd-9392-4616-b2e2-e0511ff59568
2026-08-12 15:00:14 +02:00
Mika Kuns c795827162 test(ui): [B3] guard against a synchronous diff parse blocking the dispatcher
Adds DiffDispatcherGuardTests, the one blockade-regression test in the
operation-feedback effort. Uses a queueing SynchronizationContext (Option B —
this project has no bootstrapped Avalonia dispatcher anywhere) to prove
DiffViewerViewModel's UnifiedDiffParser.Parse offload ([B1]) truly defers past
the calling thread rather than racing on timing. [B2]'s DiffAlignment.Build
offload in DiffTextView is deliberately not covered — three escalating
attempts to construct that control safely in-process each broke under the
full test suite (dispatcher thread-affinity crashes, then process-wide
Application state polluting an unrelated test), and Avalonia.Headless.XUnit
collides with this project's xUnit v2 stack. Verified the test fails against
the pre-[B1] code and passes with it; full suite green aside from a
pre-existing, unrelated flaky test.
2026-08-12 14:55:37 +02:00
Mika Kuns a9af650407 chore(claude-do): merge [B2] DiffAlignment.Build — begründete Grenze gegen unbegrenz
ClaudeDo-Task: ee8117fa-283a-4470-a823-ce57f52a40be
2026-08-12 14:11:41 +02:00
Mika Kuns c9afc81f0a fix(ui): offload DiffAlignment.Build off the UI thread above a line threshold
DiffTextView.ReloadFile ran DiffAlignment.Build synchronously for both the
constructor and the File-property path, so a large diff (or several at once
in Planning mode's per-file ItemsControl) could freeze the UI thread.
Diffs at or under 500 raw lines still build inline (no placeholder frame);
larger ones build via Task.Run and a generation counter discards the result
if File changed again before the build finished.
2026-08-12 14:07:06 +02:00
Mika Kuns ea4db9d0bd chore(claude-do): merge [B1] UnifiedDiffParser.Parse vom UI-Thread nehmen (DiffViewe
ClaudeDo-Task: e2003fc8-f029-44d0-a66a-51b57a95583f
2026-08-12 13:51:18 +02:00
mika kuns 589b9e75f3 fix(ui): unfreeze the operation spinner and attribute rebind churn
Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
2026-08-12 13:50:05 +02:00
Mika Kuns f6994f32d5 fix(ui): show ParseOp indicator on the planning-mode diff parse too
ParseOp only covered LoadFilesAsync; OnDisplayedDiffChanged's parse (driven
by ToggleCombinedAsync and OnSelectedSubtaskChanged) had no visible
feedback even though the offload was already correct. Both call sites now
go through one ParseOffUiThreadAsync helper (Begin on the UI thread before
Task.Run, End via `using` regardless of a stale generation), so the
already-visible toolbar indicator covers the planning path without
duplicating the begin/offload/end ceremony.
2026-08-12 13:41:05 +02:00
Mika Kuns 9fc8ed391e fix(ui): offload UnifiedDiffParser.Parse off the UI thread in DiffViewerViewModel
Both call sites (LoadFilesAsync, OnDisplayedDiffChanged) ran the parser
synchronously on the dispatcher, freezing the window on a large diff. The
Files-mode parse+tree-build now runs via Task.Run behind a ParseOp
OperationStatus wired to the toolbar; the planning-mode parse is
fire-and-forget with a generation counter so a fast second DisplayedDiff
change can't write stale PlanningFiles.
2026-08-12 13:19:20 +02:00
mika kuns b9827eac01 fix(ui): connection pill opens worker help only when disconnected
The footer pill fired OpenWorkerConnectionHelpCommand unconditionally, so
clicking it on a connected worker showed the "WORKER NOT REACHABLE" dialog.
Gate the command on !IsConnected (not IsOffline — the retry loop stays in
"connecting" forever while the worker is down, which is exactly when the
dialog's "Start Worker" is needed) and re-evaluate CanExecute on connection
state changes.
2026-08-12 10:58:10 +02:00
Mika Kuns 69b07fff26 chore(claude-do): merge [A4] WeeklyReport, DailyPrep und Planning-Aktionen auf Opera
ClaudeDo-Task: 7c6f2f66-8cd7-4abb-8151-aed42ae739b5
2026-08-12 10:10:57 +02:00
Mika Kuns 030b3ceb6c chore(claude-do): merge [A3] Settings + Update-Check: OperationStatus für Skill-Inst
ClaudeDo-Task: 2356806f-f16e-4652-b615-4562e95c3a65
2026-08-12 10:02:35 +02:00
Mika Kuns ec4cbcbaf9 chore(claude-do): [A3] Settings + Update-Check: OperationStatus für Skill-Inst
Vorgaben: `docs/superpowers/plans/2026-08-11-operation-feedback.md`, Gruppe A, Entwurf A3. P0-1 ist gemergt: `src/ClaudeDo.Ui/Services/OperationStatus.cs` + `src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml`.

SCOPE-ÄNDERUNG (Nutzer, 2026-08-12): OnlineInbox Sign-In/Sign-Out ist AUS DEM SCOPE GENOMMEN — vorerst unwichtig. `ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs` und die

ClaudeDo-Task: 2356806f-f16e-4652-b615-4562e95c3a65
2026-08-12 09:55:04 +02:00
Mika Kuns 5fca459579 chore(claude-do): merge [A2] WorktreesOverview + Reset-All: getrennte OperationStatu
ClaudeDo-Task: cec07e2c-2814-4a43-898c-a6be07a13f01
2026-08-12 09:54:58 +02:00
Mika Kuns 5a74a67660 chore(claude-do): merge [A1] Detail-Pane: Approve, Submit, Reject, Park, Merge-Previ
ClaudeDo-Task: 120cb6a4-ad0d-426a-bb72-e3c508698b8f
2026-08-12 09:46:14 +02:00
Mika Kuns bea85a9ee5 feat(ui): route detail-pane review actions through OperationStatus
Approve, Submit, Reject, and Park each get their own OperationStatus so the
button locks and shows an OperationIndicator for the duration of the call;
Approve additionally scopes a MergeProgressEvent subscription to its own
task id to sharpen the label from "merging" to "verifying" mid-flight.
MergeSectionViewModel's preview refresh gets the same treatment for display
only, since it has no command button to gate.
2026-08-12 09:42:15 +02:00
Mika Kuns bbd6151ba6 feat(claude-do): [A4] WeeklyReport, DailyPrep und Planning-Aktionen auf OperationStatus umstellen
WeeklyReportModalViewModel.Generate, PrepPanelViewModel.PlanDayAsync und die beiden
Planning-Aktionen (QueuePlanningSubtasksAsync, FinalizePlanningSessionAsync) laufen jetzt
durch je eine OperationStatus + OperationIndicator statt handgebauter Spinner. Die beiden
Planning-Aktionen teilen sich eine Instanz, angezeigt im Tasks-Island-Header, weil sie aus
einem sofort schliessenden Kontextmenue ausgeloest werden und es keine dauerhafte
Pro-Zeilen-Flaeche gibt, an die ein Indikator gehaengt werden koennte.
2026-08-12 09:39:42 +02:00
Mika Kuns a0d5db0db0 feat(ui): separate OperationStatus per worktree action (refresh/cleanup/reset/force-remove/batch-merge)
Replaces the shared IsBusy/IsMerging flags in WorktreesOverviewModalViewModel and the
reset flow in WorktreesSettingsTabViewModel with dedicated OperationStatus instances
shown via OperationIndicator, so a running Refresh no longer blocks the Cleanup
indicator. ForceRemove gains a CanExecute guard against re-entrancy while it's running,
and the reconcile tick's busy guard now checks all four action statuses instead of the
old IsBusy||IsMerging pair.
2026-08-12 09:39:10 +02:00
Mika Kuns d3abd4b88b docs(claude-do): merge [P0-3] Feedback-Regel in Ui- und Worker-CLAUDE.md
ClaudeDo-Task: 771e3484-cd9f-4d81-a890-82c336d1fee4
2026-08-12 08:50:59 +02:00
Mika Kuns 8098b7f4ec docs(claude-do): document OperationStatus and MCP progress-reporting rules 2026-08-12 08:44:30 +02:00
Mika Kuns 3e3f44bfb9 feat(claude-do): merge [P0-2] Timing-Hook für Hub-Invokes und Island-DB-Pfad
ClaudeDo-Task: a96c2c16-62e0-48f2-b80d-d98b113c7583
2026-08-12 08:43:09 +02:00
Mika Kuns 5353e5c39c feat(claude-do): merge [P0-1] OperationStatus + OperationIndicator + ops-Locale-Key
ClaudeDo-Task: 8e3167b1-dc9d-4f65-bbfb-90a3b181ddcc
2026-08-12 08:36:20 +02:00
Mika Kuns ca65849280 feat(ui): add OperationStatus feedback primitive and OperationIndicator control
Foundation for long-running-operation feedback (P0-1). OperationStatus tracks
IsRunning/ShowIndicator(300ms grace)/Elapsed/IsStalled(60s since last Report)
via an injectable TimeProvider, no static timer. OperationIndicator binds a
status to the shared spinner style. Pre-provisions ops.* locale keys for
groups A and C; no ViewModel wired up yet.
2026-08-12 08:31:38 +02:00
Mika Kuns a614feb96d feat(ui): add operation-timing NDJSON sink for hub invokes and bulk DB paths
Two chokepoints per P0-2: WorkerClient.InvokeTimedAsync(<T>) wraps all 63
_hub.InvokeAsync call sites (plus TryInvokeAsync), and Stopwatch blocks
around the Island VMs' bulk DB load/write paths (list load, task list load,
reorders, batch delete, reconcile tick, count refresh). Single-row reads
stay untouched per the plan's rule of thumb. No display, no behavior change.
2026-08-12 08:31:35 +02:00
mika kuns eca16a3506 docs(plans): grobe Task-Entwürfe je Gruppe und Test-Kommandos ergänzen 2026-08-11 19:32:41 +02:00
mika kuns 260cb5a89c docs(plans): Vorgaben je Merge-Helper-Gruppe für Operations-Feedback 2026-08-11 19:29:04 +02:00
mika kuns 72d46af6e5 docs(specs): Peer-Commit als aufgelöste Abhängigkeit festhalten 2026-08-11 19:17:28 +02:00
mika kuns ba1a745e09 docs(specs): Design für Feedback bei langlaufenden Operationen 2026-08-11 19:16:55 +02:00
mika kuns cad0582b37 fix(merge): conventional merge-commit default and live verify progress
The merge commit message was hand-rolled per caller ("Merge task: <title>",
"Merge <branch>", "Merge subtask") and ignored the task's commit type. Every
caller now passes a blank message and TaskMergeService fills in
CommitMessageBuilder.BuildMerge -> {commitType}(list-slug): merge <title> plus the
ClaudeDo-Task trailer; the merge modal prefills it from GetMergeTargets.

A merge whose list has a verify command holds the MergeTask call for minutes (5m46s
on this repo), during which the modal only disabled its button - no spinner, no
message, so a landed merge looked like a dead app. TaskMergeService now broadcasts
MergeProgress(taskId, phase, elapsedSeconds) for the merging and verifying phases
(re-reported every 30s) plus a WorkerLog line when verify starts; the modal shows a
spinner and the localized phase.
2026-08-11 19:14:36 +02:00
mika kuns fc9df7f9ac Merge task: UI polish: ListBox Padding scroll clip, 4s log rebuild, ClassifyItems side effects 2026-08-11 18:34:36 +02:00
mika kuns e55c84cda4 fix(ui): stop ClearCompletedAsync from mutating chain-display fields
ClassifyItems() layers ApplyChainGrouping on top of partitioning, which
writes ShowAsChainMember/ChainStep/ChainAfterLabel on every row.
ClearCompletedAsync only needed the completed-section count/rows, so it
got that mutation as an unwanted side effect of counting.

Split the pure overdue/open/completed partition into PartitionItems();
ClassifyItems() now composes it with the chain-grouping mutation for
Regroup's actual render path. ClearCompletedAsync uses PartitionItems
directly. Regroup's behavior is unchanged.
2026-08-11 18:32:59 +02:00
mika kuns d327a1007e fix(ui): reconcile the log visualizer tick instead of clearing Rows
The 4s reconcile tick called Apply(), which did Rows.Clear() + refill on
every tick -- resetting the user's scroll position and selection while
they're mid-read, exactly what the overlay exists to avoid.

Reconcile granularly instead: find the overlap between the previous and
latest snapshot (the ring buffer is append-mostly, evicting from the
front as entries age out of the 30-min window), trim rows for evicted
entries off the tail, and insert new entries at the head. A tick with no
change now leaves Rows untouched. The WarnErrorOnly toggle still goes
through the full Apply() rebuild, unchanged.
2026-08-11 18:32:39 +02:00
mika kuns 1861832b99 fix(ui): move the tasks list inset off the ListBox onto the panel
Padding on RowsListBox mirrored the ScrollViewer.Padding-excluded-from-
Extent trap fixed elsewhere today, so move the 10,4 inset onto the
VirtualizingStackPanel's Margin instead, matching the other scrollables.

Headless measurement showed Avalonia's Fluent ListBox template actually
forwards Padding onto PART_ItemsPresenter's margin already (content
side), so this particular control was never affected -- both variants
produce an identical Extent and a fully visible last row at max scroll.
Kept the change for consistency and corrected the CLAUDE.md gotcha to
reflect the verified behavior instead of assuming the same trap applied.
2026-08-11 18:32:30 +02:00
mika kuns c35fabe0a2 Merge claudedo/d8199f1f3df3447f8de2e7aca9ec5064 2026-08-11 17:48:21 +02:00
mika kuns ad6af68895 chore(claude-do): MCP: review_task/merge_task senden Progress während des Veri
## Symptom (real aufgetreten, 2026-08-11)

Ein `review_task(decision="approve")` über MCP lief >5 Min. Claude Codes MCP-Client brach den Call ab mit:

> MCP server "claudedo" tool "review_task" sent no response or progress for 300s; aborting.

Ergebnis: der Merge war **schon gelaufen und comitted** (`0e12be4`, `mergeCommit` am Worktree gesetzt), aber der Task blieb auf `WaitingForReview` hängen —

ClaudeDo-Task: d8199f1f-3df3-447f-8de2-e7aca9ec5064
2026-08-11 17:45:51 +02:00
mika kuns fb9fc6e9b5 Merge claudedo/9ec7d4ea272b46458da441ff3621f2f0 2026-08-11 17:24:35 +02:00
mika kuns 1a0cb97325 Merge claudedo/e39b952576f5469a9a3d821bdc110853 2026-08-11 17:19:23 +02:00
mika kuns 340f2c9e14 Merge claudedo/dc5d5776339b4da6a5fb628a5c2ed648 2026-08-11 17:14:07 +02:00
mika kuns 4ce5ae7189 docs(worker): drop the nameof leak from the handoff tool description 2026-08-11 17:14:01 +02:00
mika kuns f6926f08a6 Merge claudedo/eb5e618d1b844ff7b60d434c4f7ba2a0 2026-08-11 17:08:10 +02:00
mika kuns b656a241a2 fix(worker,data): honor wait_for_task_change's NotFound contract, harden TaskNumberAllocator
wait_for_task_change resolved #<number>/bare-number ids up front via TaskIdResolver, which
throws for an unknown number -- breaking the tool's own documented promise that an unknown
id reports status "NotFound" instead of failing the whole call. Resolve per id and fall back
to the original id on a resolution failure so CheckOnceAsync can still report it.

TaskNumberAllocator.AddWithNumberAsync indexed into the app_settings UPDATE...RETURNING result
without checking for an empty result, throwing on a missing singleton row; it also caught any
DbUpdateException as a number collision, burning up to 5 numbers on an unrelated failure (e.g.
FK violation) before the real error surfaced. Now recreates the missing row and only retries on
the actual unique-index collision (SQLite error 19 on tasks.number), rethrowing everything else
immediately.

Audited the other TaskIdResolver.ResolveAsync/ResolveManyAsync call sites (ExternalMcpService,
HandoffMcpTools, ConfigMcpTools, RunHistoryMcpTools, AttachmentMcpTools, LifecycleMcpTools,
BatchMcpTools): none of their tool descriptions promise a found/NotFound flag for the id itself
(BatchMcpTools.BatchGetTasks already handles this correctly via its own per-id try/catch;
PreviewMergeSet promises a per-task "error" field, not a found/NotFound flag; the rest are
single-id tools that already throw on a missing task downstream) -- left throwing behavior as-is.
2026-08-11 16:57:23 +02:00
mika kuns 1003fdbf46 fix(worker): name quarantined prompt files and their replacements in the recovery Warn
PromptFileRecovery's orphan-quarantine log only reported a bare count, so a customized
merge-helper-execute.md silently stopped applying with no pointer to where it went. Log each
quarantined file's destination path, and for merge-helper-execute.md specifically call out that
it was split into merge-helper-wait.md and merge-helper-merge.md with no content migration.
2026-08-11 16:55:32 +02:00
mika kuns e343baf21a fix(ui): close the outgoing ConPTY pane on merge-helper handoff
Each handoff tile is a live claude process with the full mcp__claudedo__* surface; leaving the
outgoing phase's pane open leaked one process per phase. Close it via CloseConPtySession before
opening the next phase's tile, restoring the one-pane-per-TaskId invariant so
OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can go back to FirstOrDefault.
2026-08-11 16:55:25 +02:00
mika kuns 39b24c2f74 fix(worker): cap the list-handler wait/merge handoff chain server-side
HandoffMcpTools.HandoffListHandler only validated the phase name and
broadcast it -- nothing bounded how many times a ConPTY session could
hand off wait<->merge, so a model that skimmed past the prompt's
"final round" line could loop indefinitely. Add HandoffRoundTracker,
an in-memory per-handler-task counter (a list-handler run always
creates a fresh handler task, so no reset logic is needed): past 4
handoffs (two full wait/merge cycles) a non-final nextPhase is
coerced to its "_final" counterpart, and any handoff after a
merge_final round for that task is rejected outright.
2026-08-11 16:52:49 +02:00
mika kuns 6c4fa7b1a5 chore(claude-do): Task-row visual fixes: chain step badge z-order + hide "#0"
From the 2026-08-11 unpushed-commit review (Low). Both in `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`. **Visual verification by the user is required — do not claim either is fixed without a screenshot; list both as open visual checks in the result.**

## A) Chain step badge is drawn under the task card

Commit `eb66ae7` moved `Border.chain-step-badge` from column 0 into column 1 with `Margin

ClaudeDo-Task: 9ec7d4ea-272b-4645-8da4-41ff3621f2f0
2026-08-11 16:46:21 +02:00
mika kuns 79b35801ae fix(ui): stop the reconcile ticks from destroying user state
The 4s reconcile tick was added to three long-lived surfaces. On two of
them it reloads by rebuilding every row instance, which silently threw
away whatever the user had built up since the overlay opened; on the
third it healed a row's data but left it in the wrong section.

- WorktreesOverview: LoadAsync clears Rows, SelectedCount, ConflictRows
  and BatchProgress, so batch-merge ticks, the highlighted row and a
  finished batch's outcome badges were wiped every four seconds --
  assembling a multi-worktree selection was impossible. Carry that state
  across the reload, keyed by task id, and re-point SelectedRow at the
  fresh instance (or clear it when the worktree is gone).
- MergeHelperSelection: the remember/restore had no re-entrancy guard, so
  a second tick entering between the first one's reload and its restore
  snapshotted rows already back at IsTickedByDefault and wrote that
  default back, re-ticking what the user had unticked. One tick at a time,
  and hold the row instances instead of a value snapshot so a tick landed
  during the await survives.
- TasksIsland: the tick deliberately never called Regroup because Phase 2b
  owned Rows in parallel. 2b has landed, so a healed task that went Done
  stayed in the Open section under a stale count, and a healed depends-on
  link never pulled its dependent under the chain head. Regroup when a
  patch moved a grouping input, gated on a cheap key so an idle tick stays
  free.
2026-08-11 16:39:20 +02:00
mika kuns eb66ae7b8b fix(ui): keep task rows without a chain relation flat
The indent column was hardcoded to 24px, so every row reserved the
gutter even with no child/chain relation. Restore Auto sizing with the
width back on the Border, which collapses to 0 while it is invisible.
Move the step badge into column 1 with a negative margin so its desired
width can never widen the Auto column (same on-screen position).
2026-08-11 15:27:35 +02:00
mika kuns 3c7871afe2 Merge claudedo/e908c51dc7c547f99ed99c491f286eeb 2026-08-11 15:23:12 +02:00
mika kuns b81d6e0b4f feat(ui): render dependency-chain rail, step badge, and after-chip in TaskRowView
Reuses the existing 24px indent track (now gated on ShowAsChild OR
ShowAsChainMember) instead of a second mechanism, adds a round step-number
badge centered on the rail line, and a dimmed "after X" chip for chain
members whose head isn't in view or that render as a planning child.
2026-08-11 15:14:39 +02:00
mika kuns 9977fe4c3e Merge claudedo/573224c1c8a74f62860c32331c234d1e 2026-08-11 14:49:08 +02:00
mika kuns 42279e0806 Merge claudedo/7ecf4a3b2149418390c8c69d8a52e5d1 2026-08-11 14:43:44 +02:00
mika kuns 18babe3dee feat(ui): compute dependency-chain step/grouping in TasksIslandViewModel
Slice 1 of dependency-chain display: TaskRowViewModel gains DependsOnTaskId
plus the extrinsic ShowAsChainMember/ChainStep/ChainAfterLabel contract for
Slice 2's rail/badge rendering. Regroup's ClassifyItems walks each row's
DependsOnTaskId chain per section, pulls dependents directly under their
head regardless of SortOrder, and falls back to a flat row + label when the
head isn't in the same section (mirrors the ParentInView precedent).
Planning children never join a chain group - parent indent wins - and only
ever carry the label.

No AXAML changes; that's Slice 2.
2026-08-11 14:38:16 +02:00
mika kuns e8e2fcec3f docs: update for task-numbers features (Slice 5)
Document task-number allocation in Data layer (TaskEntity.Number,
next_task_number counter, invariants, insert paths).

Update Worker docs to clarify TaskIdResolver wiring (#123 → GUID lookup),
Number in MCP payloads, and correct the 'Two hard conventions' statement
(only the first is test-enforced).

Bump external-mcp.md verified-against commit to 38af549 (Slice 4 merge)
and add new sections on task ID resolution and numbering.

Add Slice 4 visual verification items to open.md (row/detail number display,
worker-log messages).

Verified against:
- src/ClaudeDo.Data/TaskNumberAllocator.cs
- src/ClaudeDo.Data/Repositories/TaskRepository.cs (AddAsync line 20, CreateChildAsync line 276)
- src/ClaudeDo.Worker/External/TaskIdResolver.cs
- src/ClaudeDo.Worker/External/ExternalMcpService.cs (TaskDto/TaskRefDto DTOs)
- tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs (only first convention test-enforced)
2026-08-11 14:26:22 +02:00
mika kuns 0e12be480b Merge claudedo/b8004a65aba245bfbce192d10d90bb36 2026-08-11 13:54:17 +02:00
mika kuns 38af549a80 feat(ui,worker): surface task numbers in row/detail UI and worker log
Slice 4/5 of task-numbers: TaskRowViewModel.Number renders as a dimmed
"#123" before the row title; DetailsIslandViewModel.TaskIdBadge now
shows "#123" instead of the unusable "#T<guid-prefix>" handle; and the
curated WorkerLog business events in TaskRunner, TaskMergeService, and
TaskResetService prefix their quoted title with "#<Number>".
2026-08-11 13:51:20 +02:00
mika kuns a1aeb21481 Merge claudedo/3fe19f438f4446818304a3ae190b8763 2026-08-11 13:14:41 +02:00
mika kuns 2a3133efad feat(worker): accept #123 task numbers as MCP tool input
TaskIdResolver resolves a #123/bare-123 taskId parameter to its GUID
before any lookup, across every External/ MCP tool that takes a task
id, including the batch tools' id arrays (via delegation to the
already-resolving single-entity methods) and update_task's
dependsOnTaskId (empty string still passes through unchanged as the
clear-link sentinel). An unknown number throws a clear error instead
of a silent null. McpToolDocs.TaskNumberHint tells the agent to refer
to tasks as #<number> when reporting to the user, added to the
description of get_task, list_tasks, add_task, update_task_status and
review_task.
2026-08-11 13:10:49 +02:00
mika kuns 9660a29da4 Merge claudedo/42d038e771e249e790f38087cbf2be6f 2026-08-11 12:04:42 +02:00
mika kuns 106c964410 feat(worker): surface task numbers in MCP tool return payloads
Adds Number alongside every task id in External/'s DTOs -- the two
central mappers (ToDto/ToRefDto -> TaskDto/TaskRefDto) plus every
DTO that carries a bare task id and bypasses them (batch results,
queue state, wait-for-change, config, attachments, handoff,
lifecycle, merge-preview-set, worktree list). Input resolution
(#123 as an argument) stays for slice 3.
2026-08-11 11:59:51 +02:00
mika kuns 8da1a12389 fix(test): give Ui.Tests task seeds a unique Number
The task-numbers slice added a UNIQUE index on tasks.number. Ui.Tests seed
TaskEntity rows directly instead of going through TaskRepository, so every row
kept the default Number = 0 and 31 tests failed with
'UNIQUE constraint failed: tasks.number'.
2026-08-11 11:32:01 +02:00
mika kuns 5ab5a6c0a8 Merge claudedo/11da6ebbedbc4737b34878d77722106c 2026-08-11 10:52:33 +02:00
mika kuns 9e46c96b24 feat(data): add task numbers schema, allocator, and backfill migration
TaskEntity.Number is a global, monotonically increasing, never-reused
integer (displayed as #123), allocated from AppSettingsEntity.NextTaskNumber
via a single UPDATE...RETURNING statement rather than MAX(number)+1, which
would reissue a deleted task's number. Both insert paths (TaskRepository.
AddAsync and CreateChildAsync) route through the new TaskNumberAllocator,
with a bounded retry on a unique-index collision. One migration adds the
columns, backfills existing rows in creation order, and creates the unique
index afterwards. Data-layer only; MCP/UI wiring is later slices.
2026-08-11 10:49:06 +02:00
mika kuns 31a9e87b56 docs(specs): add task-numbers and dependency-chain display designs 2026-08-11 09:56:26 +02:00
mika kuns d885b4eb41 Merge claudedo/5e2a441113a34c2a9257b56abb0a605c 2026-08-11 09:41:19 +02:00
mika kuns c8244c67f3 feat(ui): wire list-handler nextPhase through the handoff hub event
HandoffRequested now carries nextPhase end to end (IWorkerClient ->
WorkerClient -> MissionControlViewModel -> GetMergeHelperHandoffLaunchSpecAsync),
and the outgoing tile is left open on handoff instead of being closed --
lookups that need the active pane for a task now use LastOrDefault since a
handler task's ConPtySessions can hold more than one pane.
2026-08-11 09:40:14 +02:00
mika kuns ee7003d964 Merge claudedo/a4834991067a479fabfb3226abcd4b9f 2026-08-11 09:28:22 +02:00
mika kuns f2609d186a feat(worker): wire list-handler phase parameter through handoff chain
Threads a nextPhase parameter (wait/merge/wait_final/merge_final, validated
by the new MergeHelperPhase) from handoff_list_handler through
HubBroadcaster/WorkerHub into InteractiveLaunchSpecService, which now picks
the next session's system prompt (MergeHelperWait/MergeHelperMerge) and
model (HandlerWaitAlias/HandlerMergeAlias) from it instead of hardcoding the
old two-phase Execute prompt -- this also fixes a build break left by the
prior prompt-split task, which removed PromptKind.MergeHelperExecute without
updating its only caller.

Also sets --model/--effort/--permission-mode explicitly for every list-handler
session (Triage included) via PermissionModeResolver instead of inheriting the
CLI's ambient model and hardcoding "auto", and adds Task to the merge-helper
allowlist so the Merge phase can delegate diff reviews to subagents.
2026-08-11 09:27:16 +02:00
mika kuns 6927805600 fix(ui): stop clipping the last log line in scrollable output
Avalonia 12 leaves ScrollViewer.Padding out of the scroll Extent, so at
maximum offset the content still overhangs the viewport by the padding
height — the last lines were cut off and unreachable even after
ScrollToEnd(). Move the inset onto the content (Margin on the inner
ItemsControl/StackPanel) and leave the ScrollViewer padding-free.

Affects the work console Output/Git/Session tabs, the reusable session
terminal (task + prep log) and the log visualizer.
2026-08-11 09:26:06 +02:00
mika kuns 1657a70962 Merge claudedo/46c16cb0e9934697b0865700baf0f244 2026-08-11 09:02:48 +02:00
mika kuns a311152b25 Merge claudedo/204a03ab377147be847c19bb2ce5b834 2026-08-11 08:54:13 +02:00
mika kuns 1203323102 Merge claudedo/033f104f2a0f44b1aac8ca54a73a8866 2026-08-11 08:54:05 +02:00
mika kuns e555bdb8a4 Merge claudedo/cda10d515837448c8a20d608dfe5e599 2026-08-11 08:53:57 +02:00
mika kuns d3b91b5214 feat(data): split list-handler prompt into triage/wait/merge roles
Adds HandlerTriageAlias/HandlerWaitAlias/HandlerMergeAlias to ModelRegistry and
replaces PromptKind.MergeHelperExecute with MergeHelperWait (phase 3 only) and
MergeHelperMerge (phases 4-5), so the list handler can run as three
cost-scoped sessions instead of two. Triage's Phase 2 now checks/sets a wide
verify command via get_list_config/set_list_config once per run. Merge now
merges before reruns, delegates diff review to a sonnet subagent, rejects
0-file diffs, tracks the merged-but-not-Done verify-gate outcome, and caps
reruns at one per task via handoff_list_handler's new nextPhase parameter.

InteractiveLaunchSpecService.cs still references the removed
PromptKind.MergeHelperExecute and fails to build -- wiring the Worker/UI side
onto the new roles is a follow-up task.
2026-08-11 08:52:28 +02:00
mika kuns 095d216bda feat(external-mcp): let set_list_config write the verify command
SetListConfig previously could only pass VerifyCommand through unchanged;
only the UI hub could set it. Add an optional verifyCommand parameter with
matching clear/merge/create-vs-delete handling, and split the list result
into ListConfigDto so the task-only SetTaskConfig/GetTaskConfig DTOs stay
untouched. Also corrects Worker/CLAUDE.md's claim that tasks can override
verify_command — it's list-only.
2026-08-11 08:52:03 +02:00
mika kuns d60ecb7f1b fix(ui): stop review-feedback field clipping placeholder and typed text
Shorten the review-prompt placeholder and localize it (session.reviewFeedbackPlaceholder,
en+de) instead of a hardcoded string that overran the narrow column. Switch the growing
TextBox's VerticalContentAlignment from Center to Top so multi-line input grows downward
instead of being clipped top/bottom, add an auto scrollbar past MaxHeight, and top-align
the prompt glyph and Resume button to match.
2026-08-11 08:50:45 +02:00
mika kuns 6a731beb53 fix(ui): add vertical spacing between task rows
Cards touched with Margin=0. Add margin on the inner task-row border
instead of the ListBoxItem style, so the drag/drop hit-test container
still covers the row and the drop-hint doesn't flicker between items.
2026-08-11 08:47:00 +02:00
mika kuns 11103d4d3e fix(runner): only fail a run on denied write tools with a worktree 2026-08-11 08:32:53 +02:00
mika kuns 1acd4c1734 fix(git): carry the pathspec into GetBranchDiffAsync's fallback 2026-08-11 08:32:53 +02:00
mika kuns d3ba279b54 fix(ui): stop the merge-helper tick from re-ticking unticked candidates 2026-08-11 08:32:52 +02:00
mika kuns 7106bf754f fix(mcp): keep wait_for_task_change waiting through a planning-chain block 2026-08-11 08:32:52 +02:00
mika kuns f31b1b4fb2 fix(mcp): stop set_list_config from clearing settings it doesn't expose 2026-08-11 08:32:52 +02:00
mika kuns d451edd367 fix(data): normalize a list working dir's trailing separator on write
A list working_dir stored as "C:\Dev\Tests\StaplerTracking\" broke every consumer
that puts it on a Windows command line: argv rules read \" as an escaped quote, so
the token never closes. "Open in terminal" passed wt.exe a starting directory of
C:\Dev\Tests\StaplerTracking" and it failed with 0x8007010b; the same data had
already corrupted the ConPTY list handler's arg list in August.

Paths.TrimTrailingSeparator is now the single helper (replacing the copies in
InteractiveLaunchSpecService and ClaudeHelpLauncher) and ListRepository applies it
on Add/Update, which covers every writer: UI create, repo import, hub UpdateList,
and MCP CreateList/UpdateList. OpenInTerminal also switches to ArgumentList so its
quoting is correct regardless of what's stored.
2026-08-10 17:12:46 +02:00
mika kuns 6d8cbacc9b fix(data): stop AddTaskDependency's table rebuild from dropping scope_globs
AddScopeOverlapFields, AddTaskDependency and AddFailureReason were each
generated off the same parent migration, so none of their Designer
snapshots knows the others' columns. AddTaskDependency calls AddForeignKey,
which on SQLite forces a full `tasks` table rebuild generated from that
migration's own frozen snapshot -- silently recreating the table without
`scope_globs`.

Result: the Worker died at startup with "no such column: t.scope_globs"
(Hosting failed to start), so it never bound the SignalR port and the App
could not connect. Fresh installs were affected too, not just existing DBs.

Add the missing ScopeGlobs property to the stale Designer snapshot so the
rebuild preserves the column. Verified by migrating a fresh temp DB and
diffing pragma_table_info('tasks') against the model snapshot: 39/39
columns.

Note: the test suite cannot catch this class of bug -- every fixture uses
Database.EnsureCreated(), which builds the schema from the model and never
applies the migration chain.
2026-08-10 17:00:24 +02:00
mika kuns bbfe40242c fix(ui): re-emit the flat list's section headers on a language change
Phase 2b-1 moved the group headers from {loc:Tr} bindings in TasksIslandView
into HeaderRow.Label, resolved once in Regroup(). RefreshLocalizedText only
updated CompletedHeader and the task rows, so after switching the language the
three section headers kept the old one. Re-run Regroup() there.

Not unit-tested: the only seam is the global static Loc.Current, and mutating
it would bleed into test classes running in parallel.
2026-08-10 16:26:16 +02:00
mika kuns 2fe40f3d65 Merge claudedo/923d3b07183c4ca8bcd15aed12f9df39 2026-08-10 16:21:01 +02:00
mika kuns ec6c230822 refactor(ui): flatten TasksIsland's task list into one virtualized ListBox
Replace the three ItemsControl sections (Overdue/Open/Completed) with a single
flat Rows collection (HeaderRow | TaskRowViewModel) bound to a ListBox +
VirtualizingStackPanel, so the realized container count stays bounded instead
of growing with the list (measured: ~7-8 realized containers at any scroll
position with 1000 source rows). Group headers become regular row entries,
each section is simply omitted from Rows when empty, and the Completed
section's Clear-completed action moves onto the header row itself.

SectionFor/ReorderAsync/FindNextInSameSection now read a row's section off
the nearest preceding HeaderRow in Rows instead of three separate bound
collections, and ScrollSelectedIntoView goes through ListBox.ScrollIntoView so
it still works for rows outside the realized window. Drag/reorder behavior,
grouping/counts, and the Clear-completed button are unchanged.

Auto-scroll-while-dragging and the cross-section reorder bug are explicitly
out of scope (follow-up tasks).
2026-08-10 16:18:55 +02:00
mika kuns 82881acc70 fix(ui): stop the reconcile tick on native close, index rows in the tick
Two follow-ups to the Phase 3 reconcile tick:

- LogVisualizer and WorktreesOverview only stopped their timer in the Close
  command, but ShowLogVisualizerAsync/ShowWorktreesOverviewAsync had no
  dlg.Closed fallback (unlike the MergeHelper picker). An Alt+F4 or owner
  close left a 4s timer polling the worker / re-running LoadAsync on an
  orphaned VM, accumulating one per open. Extracted StopReconcileTick on both
  VMs and wired it from Closed.
- The tick scanned Items linearly per id; at the 500-row cap that is ~250k
  string comparisons every few seconds. Index the rows once instead.
2026-08-10 16:05:37 +02:00
mika kuns 1b7b18fdfc Merge claudedo/45142ae6a226488192290835d6336c5a 2026-08-10 16:03:06 +02:00
mika kuns 1cad264967 feat(ui): add reconcile tick to self-heal lost worker broadcasts
TasksIslandViewModel now runs a periodic tick that diffs the flat Items
collection against SQLite and patches properties in place (capped at 500
rows, sharing the Phase 1 delta sequence guard so it never overtakes a
fresher broadcast). Never rebuilds rows or triggers Regroup/LoadForList, so
it stays decoupled from the parallel Phase 2b virtualization work. The same
cadence now refreshes the long-lived Worktrees Overview, Log Visualizer, and
Merge Helper selection overlays; short-lived modals are untouched.
2026-08-10 15:56:05 +02:00
mika kuns 514d6111fe fix(test): name the cancellationToken arg in the preview_merge_set subset test
Two branches merged into main independently: one added `runVerify` to
PreviewMergeSet ahead of `cancellationToken`, the other added the subset
test that passed the token positionally. Neither conflicted textually, so
the mismatch only surfaced as CS1503 at test-compile time.
2026-08-10 15:26:07 +02:00
mika kuns 66f7a8ae99 Merge branch 'claudedo/9f963a215bb34ca4a28050d4f39178d3' 2026-08-10 15:19:57 +02:00
mika kuns fb12d965fc Merge subtask
# Conflicts:
#	src/ClaudeDo.Data/Git/GitService.cs
#	src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs
2026-08-10 15:18:30 +02:00
mika kuns 8b75a92794 Merge branch 'claudedo/5c80a0aba5d9468a96cf809fc7a6e7a1' 2026-08-10 15:15:26 +02:00
mika kuns 333ce89eef Merge branch 'claudedo/16172942c1424a67a0499e01cefb7d60' 2026-08-10 15:08:51 +02:00
mika kuns a9396c1a99 Merge claudedo/89f989e04fcf43f5a80211d63df7a6d1 2026-08-10 15:07:05 +02:00
mika kuns f41a3e9083 Merge claudedo/1891227787ae4085a52c112bd1dcd75a 2026-08-10 15:05:53 +02:00
mika kuns 88207ee2a2 Merge subtask 2026-08-10 15:05:14 +02:00
mika kuns 85c8c36f2c Merge subtask 2026-08-10 15:05:11 +02:00
mika kuns 6db3f0f537 Merge branch 'claudedo/295d5d409c194ba28dbb95ab13630832' 2026-08-10 15:04:46 +02:00
mika kuns 21c9c3b3b9 Merge claudedo/107e62d083e44436b9074c16d14e2edc 2026-08-10 15:02:52 +02:00
mika kuns f614323fa9 Merge claudedo/1bbc77a12e0844b2a037bf44cfb47eae 2026-08-10 15:01:59 +02:00
mika kuns 2fd7f829bd Merge branch 'claudedo/340b4a2df0f14235a34fe0d02b2dc06f' 2026-08-10 15:01:33 +02:00
mika kuns d95ae55415 Merge claudedo/2a790a4416924ed6beda0880dcf16836 2026-08-10 15:00:04 +02:00
mika kuns 474c7201c9 Merge claudedo/f319b602707f4dfdb6727e9fae5ac757 2026-08-10 14:58:58 +02:00
mika kuns 19b31450b1 Merge claudedo/e5aeaf59fae840adaea3f1e912e203e1 2026-08-10 14:58:35 +02:00
mika kuns df021a6cb8 Merge claudedo/850c2e490711461a87467ca6743052e0 2026-08-10 14:58:14 +02:00
mika kuns 3fc67fb422 Merge claudedo/42d103cfae72447aa048e28121d6e733 2026-08-10 14:57:37 +02:00
mika kuns 52b42c0a35 Merge claudedo/fc88143c0c704feab4ba6806cec49f4f 2026-08-10 14:56:55 +02:00
mika kuns 31a89bd93f Merge claudedo/21d704131b2d486cb1d103134e5ea5e8 2026-08-10 14:56:37 +02:00
mika kuns 9ee6defd69 Merge claudedo/02f6746fd67b4217a581f2f911f6ff0d 2026-08-10 14:56:10 +02:00
mika kuns 5a5c95f1c8 Merge claudedo/3511e9d2d7b44a9aa018739c83143b6f 2026-08-10 14:55:53 +02:00
mika kuns a1c90041c6 Merge claudedo/d86e63f916af4946bbc25098449669ac 2026-08-10 14:55:33 +02:00
Mika Kuns 0bfb1f5657 !WIP: General changes 2026-08-10 14:52:44 +02:00
mika kuns 47918869e9 fix(worker): wire mcp config into resumed runs
TaskRunner.ContinueAsync resolved a ClaudeRunConfig but never attached
a per-run MCP token/config or AllowedTools the way RunAsync does, so a
--resume continuation (e.g. reject-rerun with feedback) lost every
mcp__claudedo_run__* tool, including AskUser. Extracted the setup into
SetupMcpConfigAsync and call it from both paths, with matching cleanup
in ContinueAsync's finally block.
2026-08-10 14:48:04 +02:00
mika kuns 2662e7bd98 feat(worker): auto-rebase overlapping WaitingForReview branches after a merge
After a merge lands on the target branch, TaskMergeService now rebases every
other WaitingForReview task's active worktree onto the new tip -- but only
when that branch actually touches a file the merge just changed, computed via
GitService.GetChangedFileNamesAsync (both the merge's landed files and each
candidate's own diff). A branch merely behind is left alone. On conflict or
any other failure the rebase is aborted and the branch is left exactly as it
was, with a WorkerLog warning naming the task; a successful rebase updates the
worktree's recorded BaseCommit/HeadCommit and broadcasts WorktreeUpdated.
2026-08-10 14:36:55 +02:00
mika kuns db50e710b9 fix(runner): substitute acceptEdits for haiku's silent auto-mode downgrade, fail permission-denial-only runs 2026-08-10 14:35:24 +02:00
mika kuns d3155e6868 feat(queue): warn when the base branch has uncommitted changes
Queuing (update_task_status, batch_update_task_status) and run_task_now
now surface a non-blocking baseDirty warning (separate modified/untracked
counts) when the list's working dir has uncommitted changes at enqueue
time, since a new worktree forks from the commit tip and silently misses
them. BaseDirtyChecker caches per working dir for a few seconds so a
batch queue over many tasks in one list only shells out to git once. The
UI surfaces the same warning via the footer error strip on queue actions.
2026-08-10 14:30:33 +02:00
mika kuns 84715665c2 fix(releases): don't drop the prerelease marker when comparing versions
A MinVer dev build on main computes a prerelease of its own guessed next
version (e.g. 2.9.1-alpha.0.14 after tag v2.9.0), which already sorts
above the last tag by numeric core, so the update banner correctly
stays quiet there. But the reduction to a bare numeric core meant a
genuine new release landing at exactly that guessed version (v2.9.1
real) compared equal to the still-running prerelease and the banner
never fired. A prerelease now only loses to a real release of the same
core, not to another prerelease of the same core.

Verified against the live repo: latest published tag is v2.9.0; a
Release build of this worktree (14 commits ahead) reports
AssemblyInformationalVersion 2.9.1-alpha.0.14+<sha>.
2026-08-10 14:27:23 +02:00
mika kuns 09add29994 fix(mcp): keep wait_for_task_change alive past Claude Code's idle timeout
MCP_TOOL_TIMEOUT (raised by ClaudeDo's launchers) is a wall-clock cap unrelated
to Claude Code's idle-silence abort (default 300s for HTTP-transport MCP
servers), which no launcher raises. A wait near the 900s recommendation was
silently killed at ~300s in any session, launcher or not.

WaitForTaskChange now reports MCP progress every 30s while polling, which
resets that idle timer. Verified against a real claude -p call (no launcher
env overrides) surviving a 341s wait via 60s pings -- the same silence that
previously aborted at 300s. Tool description and the list-handler prompt no
longer claim ClaudeDo's launchers guarantee a long wait survives.
2026-08-10 14:21:18 +02:00
mika kuns a067a7bfff fix(mcp): report a Failed task's failureReason instead of a bare status
get_task/batch_get_tasks now return failureReason (max_turns|timeout|error|
cancelled|unknown) plus failureTurnsUsed/failureMaxTurns on a Failed task, so
max_turns (worktree usually fine, continue_task) is distinguishable from a
real error (reset_failed_task) without pulling get_task_log's raw NDJSON.
Classified and stamped onto TaskEntity by TaskRunner.MarkFailed via
TaskStateService.FailAsync; TaskRunEntity also keeps the CLI's raw
terminal_reason/result_subtype/errors for deeper diagnosis. reset_failed_task's
description now warns explicitly that it discards the worktree and points at
continue_task for max_turns. Surfaced on the task card's status-chip tooltip.
2026-08-10 14:20:00 +02:00
mika kuns ca6eb62b5c feat(mcp): truncate/select batch_get_tasks payload, expose roadblockText
batch_get_tasks with includeDescription=true had no size guard: 8 tasks'
full Description/Result serialized to a single 51k-char line, blowing past
the tool-result token budget and forcing a file/PowerShell workaround to
read it back. Roadblock text was also only reachable via a second get_task
call per task, since roadblockCount (in the lean ref) has no text sibling.

- descriptionMaxChars (default 1500, was unlimited) truncates Description
  and Result independently, flagged via *Truncated/*FullLength so a caller
  never silently works off a cut string.
- fields narrows taskFull to just the named properties, including the new
  roadblockText (the tail of Result after TaskRunner's roadblock marker) —
  reachable without pulling in the rest of Result/Description.
- taskFull is now BatchTaskDetailDto, a batch-only shape decoupled from
  TaskDto so get_task's own contract is untouched.
- The whole response is capped (MaxResponseChars); over that, the call
  throws naming which parameter (descriptionMaxChars/fields/taskIds) to
  adjust instead of shipping an oversized payload.

10 tasks with 5000-char descriptions (default settings) serialize to
~13.8k chars, comfortably under the 25k-char cap.
2026-08-10 14:19:16 +02:00
mika kuns 6c8ec245b3 feat(worker): dependsOn via MCP + honest staleness signal in merge preview
Adds a user/MCP-declared task dependency (DependsOnTaskId) distinct from the
planning chain's internal BlockedByTaskId: add_task/update_task can set it,
the queue picker skips a Queued task until the dependency reaches Done, a
Failed/Cancelled dependency leaves the dependent blocked instead of starving
silently, and setting a link rejects self-reference/unknown-id/cycles.
get_task/list_tasks/batch_get_tasks now report blocked/blockedReason, and
wait_for_task_change reports "Blocked" immediately instead of running out its
timeout on a task the picker will never claim.

preview_merge/preview_merge_set gain staleFiles: files a branch touches that
the target branch also changed since the branch's fork point, a more honest
staleness signal than `behind` alone.
2026-08-10 14:18:55 +02:00
mika kuns 359bb3a439 refactor(ui): clean up task-row drag visuals
Collapse the grabbed row out of layout instead of overlapping neighbors
with scale/opacity/shadow (the ghost window already shows the moving
snapshot). Replace the thin moss drop-hint lines with a dashed
placeholder gap sized like a collapsed row. Suppress the ordinary hover
highlight and transitions on whatever row is currently the drop target
so it doesn't read as "clickable" while dragging.
2026-08-10 13:58:40 +02:00
mika kuns 5eea439129 fix(mcp): make set_task_config/set_list_config patch instead of overwrite
Omitted fields on set_task_config/set_list_config used to be indistinguishable
from an explicit clear, so any single-field update silently wiped every other
override (including SessionSkills, which wasn't even a tool parameter). Now
only the fields you pass are changed; clearing an override requires naming it
in the new clearFields array (a sentinel string doesn't work uniformly since
maxTurns is an int). set_list_config's "all four null deletes the config" case
is preserved but only reachable via clearFields. Return values now reflect the
full resulting config.
2026-08-10 13:55:58 +02:00
mika kuns e98cf46097 perf(ui): build task row context menu lazily on right-click 2026-08-10 13:53:36 +02:00
mika kuns 7f10d45898 perf(ui): push task-list filtering into SQL
LoadForListAsync loaded the entire tasks table (all lists, two Include
joins) then filtered to the selected list in C#, never using
idx_tasks_list_id/idx_tasks_status. ITaskListFilter now exposes
MatchExpression, an Expression<Func<TaskEntity,bool>> that EF Core can
translate to SQL; a new TaskListFilterBase compiles it on demand for the
existing in-memory Matches(). The primary match query now runs as
db.Tasks.Where(filter.MatchExpression), with two small follow-up queries
(scoped by parent-id IN-lists) for contextual planning-parent rows and
pulled-in children, followed by an explicit re-sort to restore the global
SortOrder/CreatedAt ordering Regroup() depends on.
2026-08-10 13:49:09 +02:00
mika kuns 5653daca6e feat(mcp): expose the conflict resolver's hunk API over MCP
get_merge_conflicts and resolve_conflict_hunk let an MCP caller resolve a
leaveConflictsInTree merge hunk-by-hunk (ours/theirs/base/literal text)
instead of grep-parsing diff3 markers itself. Reuses the existing
TaskMergeService/ConflictMarkerParser machinery (adds MergeSegment.StartLine
and a non-staging TaskMergeService.WriteConflictFileAsync) rather than a
second parser. A partially resolved file is written without git add so
continue_merge keeps refusing while any hunk still has markers.
2026-08-10 13:49:07 +02:00
mika kuns 659bf3e743 feat(ui): show child tasks as children in the merge-helper picker
MergeHelperSelectionModalViewModel now loads ParentTaskId and orders
rows hierarchy-aware (parent immediately followed by its children,
mirroring TasksIslandViewModel.Regroup). Children render indented with
the same indent-track visual as the main task list; ticking a parent
cascades selection to its children, while a child can still be
deselected on its own. A child whose parent got filtered out (e.g.
already Done) renders top-level with an explicit hint instead of
disappearing. Confirm keeps returning parents ahead of their children.
2026-08-10 13:47:55 +02:00
mika kuns f74c7dd70b fix(ui): diff viewer opens non-modal and maximizable
The diff viewer used ShowDialog at both call sites (task details, worktrees
overview), blocking navigation of the rest of the app while open. Switch both
to Window.Show(owner) and reuse the one open window (swap DataContext,
Activate()) instead of stacking a second one on re-open.

Add an opt-in maximize/restore button + double-click-titlebar toggle to
ModalShell (ShowMaximizeButton, default false so other modals are untouched),
wired on for DiffViewerView only. Reuses the existing Icon.WinMax/WinRestore
geometry and OffScreenMargin inset from MainWindow's own maximize handling.

The review gate (Approve & Merge locked until the diff is opened) already
fires DiffViewed before the dialog/window is shown, so it is unaffected by
the modal-to-non-modal change.
2026-08-10 13:46:56 +02:00
mika kuns 07ee535b7d feat(claude-do): feat(queue): Option pro Liste — Tasks mit überlappendem Date
## Befund (Batch-Lauf 2026-08-06, Liste "Bandel.Hub")
maxParallelExecutions = 5, 23 Geschwister-Tasks auf demselben Plugin. Die Parallelitaet hat die
Ausfuehrung verkuerzt, aber die Konfliktlast erhoeht: eine CSS-Datei wurde von 10 Tasks
angefasst, drei Razor-Dateien von je 6. Der Engpass des Gesamtdurchlaufs war nicht die
Ausfuehrung, sondern Review und Merge — die sind zwingend seriell. Netto wa

ClaudeDo-Task: 89f989e0-4fcf-43f5-a802-11d63df7a6d1
2026-08-10 13:41:38 +02:00
mika kuns c715c96af9 feat(mcp): paths filter for get_task_diff, subset hint for preview_merge_set
get_task_diff gained an optional paths param (git pathspec, both stat and
full-diff modes) so a large multi-file task can be narrowed to the files
actually in question instead of shipping the whole diff.

preview_merge_set now also reports Subsets: pairs where one task's changed
files are a proper subset of another's in the same set, the strongest
available post-hoc signal that a task may be redundant with another.
2026-08-10 13:39:55 +02:00
mika kuns 2a8991496f docs(explore-notes): report Phase 2a virtualization+drag gate spike
Headless Avalonia ListBox+VirtualizingStackPanel spike answers all five
questions from the Phase 2a gate task: containers stay bounded at 1000
items, the existing InputHitTest ghost-drag model survives recycling,
variable row heights don't drift the Extent (only naive index*height
math would), auto-scroll via Offset mutation is mechanically feasible,
and mixed header/task rows template correctly via implicit DataTemplates.
Recommendation: proceed to Phase 2b. Spike code itself is throwaway and
not checked in.
2026-08-10 13:39:03 +02:00
mika kuns afe1b68f46 fix(merge): refuse a merge that would overwrite an untracked file in the target
Before merging (or staging a conflict resolution's git add -A), compare the
branch's newly-added paths against what's currently untracked in the target
working directory. A collision aborts with a new untracked_collision status
naming the path and size, surfaced through merge_task/review_task,
preview_merge/preview_merge_set (which merge-tree alone can't see), and the
UI merge paths via FlashFooterError/ShowErrorAsync instead of a silent
catch{}. git's own preflight already refuses this while the path stays
untracked at merge time; this closes the gap once a path becomes trackable
in between (e.g. an unrelated conflict resolution's git add -A) or across
the continue_merge TOCTOU window.
2026-08-10 12:15:41 +02:00
mika kuns effece3a41 feat(worker): build/test the merge preview, not just the post-merge merge
preview_merge could only see file-overlap cleanliness via git merge-tree, never
whether the result compiles -- the two costliest findings of the 2026-08-06 batch
run were both merge-tree-clean but build-broken. When a list has a verify command
configured, a clean preview is now additionally materialized (via a commit-tree +
detached scratch worktree, outside the real repo, always cleaned up) and
built/tested there, without ever touching the real working tree.

preview_merge always attempts a verify run when a command is configured;
preview_merge_set only does when its new runVerify parameter is set (default off),
so a set preview never starts N builds unasked. The post-merge verify gate is
unchanged.
2026-08-10 12:10:17 +02:00
mika kuns 5d362b6973 fix(worker): stop sibling worktrees from landing inside the repo tree
Path.GetDirectoryName returned workingDir itself instead of its parent
whenever list.WorkingDir ended in a directory separator, placing
.claudedo-worktrees inside the target repo's own working tree. Add
WorktreeRootResolver to normalize the trailing separator before deriving
the sibling root and to guard that the resulting worktree path never
lands inside workingDir, used by both WorktreeManager and
PlanningSessionManager. Add a startup sweep (LegacyWorktreeFolderRecovery)
that warns when a list's working dir already contains a leftover
.claudedo-worktrees folder from before the fix.
2026-08-10 11:58:34 +02:00
mika kuns 1b7f7f38ae feat(prime): wire prompt_override into the daily prep prompt
The prime_schedules.prompt_override column existed end-to-end but was
never populated or read. DailyPrepPrompt.BuildPrompt now takes an
optional override and appends it as an extra paragraph after the fixed
prompt (additive, never a replacement, so a user can't disable the
required get_daily_prep_candidates/set_my_day flow). PrimeRunner passes
schedule.PromptOverride through. The Prime tab in Settings now has a
multiline field per schedule wired to a new PromptOverride property on
PrimeScheduleRowViewModel (blank persists as null).
2026-08-10 11:57:54 +02:00
mika kuns 29c79e8b8c fix(worker): stage only resolved conflict paths in continue_merge
git add -A ran against the shared target checkout, sweeping in
whatever untracked or unrelated modified files a concurrent session
left there and committing them into an unrelated merge. Stage exactly
the paths that were reported conflicted and resolved instead.
2026-08-10 11:56:37 +02:00
mika kuns 6a2a19cc9e docs(worker): record the findings store and fix the git-test skip convention 2026-08-10 10:47:17 +02:00
mika kuns 0568f003d9 feat(ui): open a list's findings folder 2026-08-10 10:45:51 +02:00
mika kuns 696abd0a7a feat(ui): toggle whether the findings folder is committed 2026-08-10 10:43:12 +02:00
mika kuns f717742379 docs(runner): correct the findings-pointer comment 2026-08-10 10:33:58 +02:00
mika kuns 87397ecfa9 feat(runner): point autonomous runs at the findings index 2026-08-10 10:32:46 +02:00
mika kuns 519f64c02e feat(mcp): add save_finding to the in-task server 2026-08-10 10:27:13 +02:00
mika kuns b312095fb2 refactor(mcp): trim the save_finding description to the style budget 2026-08-10 10:21:30 +02:00
mika kuns 2d4288abca feat(mcp): add save_finding to the external server 2026-08-10 10:19:45 +02:00
mika kuns 9a09dd6186 feat(findings): resolve the target store from task or list 2026-08-10 10:13:29 +02:00
mika kuns 389892d277 feat(data): add findings_tracked to lists 2026-08-10 10:06:50 +02:00
mika kuns 4d997a5f99 refactor(git): share the info/exclude writer with the findings store 2026-08-10 09:59:42 +02:00
mika kuns d3c2e2e7c8 feat(findings): add the .claudedo findings store 2026-08-10 09:55:54 +02:00
mika kuns 37c3b476cf docs(findings): plan the findings store implementation 2026-08-10 09:51:55 +02:00
mika kuns ae1f6fc78a docs(findings): spec the .claudedo findings store 2026-08-10 09:36:10 +02:00
ClaudeDo CI 18a3260683 docs(changelog): update for v2.9.0 2026-08-08 07:28:50 +00:00
mika kuns e10634bdc4 Merge remote-tracking branch 'origin/main'
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 40s
2026-08-08 09:28:28 +02:00
mika kuns 549aee097b feat(ui): default to the Git tab for manual and interactive tasks
A hand-driven task produces no streamed agent output, so the Output tab
opened empty. Bind now selects git for manual/interactive rows and resets
to output for autonomous ones.
2026-08-07 14:01:26 +02:00
mika kuns af0f318bf0 docs(ui): record the shared diff plumbing and the modal chrome rules
DiffEditorSetup is the place for AvaloniaEdit host boilerplate now, so note why
the diff viewer and the merge editor share it but stay separate controls, and
that ModalShell owns the titlebar drag and the OffScreenMargin inset for every
modal.
2026-08-07 13:30:48 +02:00
mika kuns b06bbc213d feat(ui): let resizable modals snap to the screen edges
ModalShell moved its window by assigning Window.Position on every pointer-move,
which bypasses the OS move loop entirely - so Windows never saw a drag gesture
and no snap was ever recognised. Dragging the diff viewer to the top edge did
nothing. The titlebar now calls Window.BeginMoveDrag, which hands the drag to the
window manager and brings back maximise-on-top-edge, half-screen side snap, snap
layouts and the preview overlay. No opt-in flag is needed: the OS only snaps
resizable windows, so the non-resizable modals stay as they are.

The stale comment claiming BeginMoveDrag does not work under Avalonia 12 was
wrong - the method is there in 12.0.4. The VisualRoot cast was the actual problem,
and the window still has to come from TopLevel.GetTopLevel.

Also inset the shell by the window's OffScreenMargin, mirroring what MainWindow
already does for itself. The modals share MainWindow's extended-client-area flags,
so without it a maximised modal overhangs the screen by the invisible resize
border and gets its edges and close button clipped.
2026-08-07 13:30:47 +02:00
mika kuns 9437121f3a fix(diff): label split panes, unify the mode switch, sync split scrolling
Three things were wrong with the fresh side-by-side viewer:

- The panes scrolled independently. The sync hunted the editors' templated
  ScrollViewers at load time, but the viewer starts collapsed (no file selected),
  so nothing was ever measured, no template existed, and the hook silently
  no-opped for the lifetime of the window. Reading now goes through
  TextView.ScrollOffsetChanged, which exists from construction; writing goes
  through a lazily resolved ScrollViewer.Offset. TextEditor.ScrollToVerticalOffset
  is NOT usable here - it is a silent no-op in AvaloniaEdit 12.0.0 even with the
  editor templated (verified headlessly).
- Neither pane said what it showed. Added a BASE | WORKTREE header row inside
  DiffTextView, sharing the pane grid's columns so each label sits over its editor.
- The layout picker was two lookalike ToggleButtons. It is now a segmented switch
  (Border.segmented + Button.segment.active), with wrap demoted to an icon toggle
  since it is orthogonal to the layout mode.

The scroll sync, TextMate setup, grammar switching, brush fallback and segment
struct were duplicated between this control and the 3-pane conflict resolver,
which the design doc had used as a copy-paste template. They now live in
DiffEditorSetup; both surfaces migrated. They stay separate controls on purpose -
a read-only two-way renderer over aligned rows with filler lines is not a variant
of a three-way editor over a writable document.

The resolver thereby also picks up the fixed scroll sync and the TryFindResource
brush lookup (its TryGetResource never resolved anything). All four merge tokens
happen to equal their hardcoded fallbacks, so nothing changes on screen.
2026-08-07 13:30:34 +02:00
mika kuns 6dff72f27c Merge branch 'main' into worktree-diff-side-by-side
# Conflicts:
#	src/ClaudeDo.Ui/CLAUDE.md
2026-08-07 11:16:49 +02:00
mika kuns 15958b992d feat(models): add fable to the cost-ascending model list 2026-08-07 11:14:21 +02:00
mika kuns df9e85a28b Merge branch 'worktree-phase1-reaktivitaet' 2026-08-07 11:01:54 +02:00
mika kuns 7eeb8f5086 feat(usage): split throttle thresholds per bucket, add draggable gauge markers 2026-08-07 11:01:45 +02:00
mika kuns b0c23a2bf6 Merge branch 'main' into worktree-phase1-reaktivitaet 2026-08-07 10:51:48 +02:00
mika kuns 4e44251d54 docs(explore-notes): pin the review-merge verified-against commit 2026-08-07 10:25:55 +02:00
mika kuns 2f3f9387c4 fix(diff): install background renderers on attach so themed brushes resolve 2026-08-07 10:25:30 +02:00
mika kuns 51371cf678 refactor(diff): drop rendering helpers orphaned by DiffLinesView 2026-08-07 10:23:51 +02:00
mika kuns 54b179fb0b fix(diff): resolve themed brushes and stop recomputing alignment on layout toggle 2026-08-07 10:23:45 +02:00
mika kuns c6d1fff8b1 fix(worker-tests): close TOCTOU race in slot-failure broadcast poll
QueueServiceSlotFailureTests's throwing-slot test broke its poll loop the
instant the DB read observed Status==Failed, but TaskStateService.FailAsync
commits the status flip before calling the broadcaster's TaskUpdated, so the
assertion could race ahead of the broadcast landing in hub.Proxy.Calls
(~1-in-5 failures in isolation). Wait for both signals before breaking.
2026-08-07 10:19:54 +02:00
mika kuns 231b063751 docs(handler): implementation plan for handler-run task links 2026-08-07 10:12:33 +02:00
mika kuns 1f8f3efc72 docs: record that the sqlite busy-timeout finding was wrong, drop stale RunCreated mention 2026-08-07 10:12:27 +02:00
mika kuns ac586797ce Revert "fix(data): give both processes a SQLite busy timeout"
This reverts commit f62dbb9239.
2026-08-07 10:11:52 +02:00
mika kuns fb29e8a871 docs(handler): design for linking a handler run to the tasks it processed 2026-08-07 09:59:52 +02:00
mika kuns f7fa8292e0 refactor(diff): render planning mode per file and retire DiffLinesView 2026-08-07 09:56:02 +02:00
mika kuns 66630d5ce2 chore(worker): drop the unsubscribed RunCreated broadcast 2026-08-07 09:55:21 +02:00
mika kuns 1e383e1c1f docs(plans): drop task 7, the handler-task broadcast already exists at the hub 2026-08-07 09:50:54 +02:00
mika kuns 3acb1cba8f fix(worker): broadcast TaskUpdated after online-inbox import 2026-08-07 09:46:43 +02:00
mika kuns cf80fe3cd6 feat(diff): add persisted side-by-side and wrap toggles to the diff viewer 2026-08-07 09:42:43 +02:00
mika kuns a7a3545e2b fix(worker): broadcast WorktreeUpdated when a worktree is created 2026-08-07 09:40:56 +02:00
mika kuns 14e0cffa25 feat(diff): sync vertical scrolling across the split panes 2026-08-07 09:37:38 +02:00
mika kuns 861ba12f7f feat(diff): tint diff rows and changed words via background renderers 2026-08-07 09:36:37 +02:00
mika kuns c1184adc92 fix(worker): fail the task when a queue slot runner throws
RunInSlotAsync only logged an unexpected exception, leaving a task stuck
Running in the DB forever with the UI never notified (the raw-SQL queue
claim that put it there never broadcasts). Cancellation is handled
separately and left alone, since the cancel path already wrote the
terminal status.
2026-08-07 09:35:26 +02:00
mika kuns e299421f76 feat(diff): draw old and new line numbers in a custom margin 2026-08-07 09:35:21 +02:00
mika kuns ffc60c4101 feat(diff): add AvaloniaEdit-based diff control with TextMate highlighting 2026-08-07 09:27:34 +02:00
mika kuns c792765ed3 refactor(mcp): rewrite external MCP tool descriptions for trigger clarity
Every tool description now leads with what the tool does AND when to reach for
it, since MCP clients rank tools by that text. Per-parameter prose moved onto
the parameters as [Description], exhaustive result-shape enumerations and
design/history rationale dropped, and the repeated boilerplate clauses
(lean-task-ref, batch cap, refused-while-Running) pulled into McpToolDocs,
which also documents the style for future tools.

Tool-level description text: 20494 -> 13605 chars (-34%); combined with the new
parameter descriptions 18517 (-10%).

Closes gaps that caused wrong calls rather than just verbose ones:
- list_task_attachments returns metadata only, no file content
- run_task_now shares continue_task's single override slot and throws when busy
- list_runs is ordered oldest-first and feeds get_run
- workingDir on create_list/update_list is an existing local git repo path,
  unvalidated until the first task run
- get_task_worktree's behind=0 also means the main ref was unreachable

Removes get_task_status_values: a whole tool entry for static reference text.
GetTask's description is now the canonical place for status meanings.
2026-08-07 09:25:45 +02:00
mika kuns e2d987e976 feat(diff): add filler, gap and word-diff brushes 2026-08-07 09:25:11 +02:00
mika kuns eeee9591aa test(ui): restore the language settings tests dropped in the previous commit 2026-08-07 09:24:43 +02:00
mika kuns 59827757f0 feat(diff): persist diff view mode and wrap preference in ui config 2026-08-07 09:22:16 +02:00
mika kuns ac099dd1a8 fix(ui): drop stale delta refreshes so the newest task state wins 2026-08-07 09:18:52 +02:00
mika kuns ebbee6005d feat(diff): highlight changed words inside paired diff lines 2026-08-07 09:17:26 +02:00
mika kuns cba7d01c31 fix(ui): retry the task delta refresh instead of swallowing the error 2026-08-07 09:16:21 +02:00
mika kuns 3cd0f0879e feat(diff): align parsed diff lines into side-by-side rows 2026-08-07 09:13:59 +02:00
mika kuns f62dbb9239 fix(data): give both processes a SQLite busy timeout 2026-08-07 09:13:21 +02:00
mika kuns 6e9a7cea87 docs(diff): add implementation plan for the side-by-side diff viewer 2026-08-07 09:07:42 +02:00
mika kuns 8acf9f8d7d docs(plans): step-by-step plan for phase 1 reactivity fixes 2026-08-07 09:02:06 +02:00
mika kuns 292d17173e docs(diff): design for side-by-side diff view with syntax and word highlighting 2026-08-07 08:49:44 +02:00
mika kuns 482d7ec332 docs(specs): design for UI reactivity and task-list performance 2026-08-07 08:49:33 +02:00
mika kuns 315bea7cf9 fix(prompts): correct five prompt claims that contradicted the tool allowlists
Audited all 12 prompt kinds against the code they drive. Every real defect sat on
the boundary between prompt text and the --allowedTools the launcher passes.

- Planning: "Use nothing else" after a six-tool list forbade the brainstorming
  Skill the same prompt demands two paragraphs earlier. WindowsTerminalLauncher
  allowlists mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill -- name them,
  and tell the planner to ground subtasks in the repo with Read/Grep/Glob.
- System: SuggestImprovement is only allowlisted when ParentTaskId is null and
  PlanningPhase is None, and TaskRunMcpService throws for any child, but this
  prompt reaches every run. Planning children were told to use a tool they lack.
- MergeHelperExecute: derived "effective max-turns" from task/list/preset by hand,
  which misses TaskRunner's MaxTurnsCeiling clamp. Call get_effective_run_config
  instead -- built for exactly this and reports the clamp.
- MergeHelperExecute: quoted the override-slot error as the raw lowercase throw
  rather than the string ExternalMcpService actually surfaces.
- Refine: listed Read/Grep/Glob unconditionally though RefinePrompt.BuildArgs only
  appends them when a repo is available.

Two findings deliberately left open, both needing a code decision rather than a
prompt edit: the System prompt's worktree claim is false for a list without a
WorkingDir (task runs in a plain sandbox dir), and 'fable' is missing from both the
prompt's cost ordering and ModelRegistry.ByCostAscending.
2026-08-06 22:47:41 +02:00
mika kuns c4e4e0976a fix(prompts): correct the list-handler wait cap and wait through WaitingForChildren
The execute prompt told the handler to wait with timeoutSeconds up to 170 -- a
leftover from the retired MCP_TOOL_TIMEOUT=200000ms era. The real server-side
clamp is TaskWaitMcpTools.MaxTimeoutSeconds = 900 and every launcher sets
930000ms, so the handler was making ~5x the wait_for_task_change calls it needed
and burning turns on re-waiting.

It also never passed treatWaitingForChildrenAsBusy, and only waited on ids that
were Queued or Running. A task with children reports "changed" the moment it
reaches WaitingForChildren, so such a task both dropped out of the wait set and
signalled completion early -- the handler could reach review/merge while
children were still running.
2026-08-06 21:24:03 +02:00
mika kuns 028ac57398 fix(prompts): make template token substitution order-independent
RenderTemplate replaced tokens one key at a time over a StringBuilder, so a
token appearing inside an already-substituted value got substituted again on a
later pass. The prompt briefs only escaped this because their callers happen to
pass "tasks" last -- reordering the dictionary or adding a fourth token would
have started rewriting user-authored task descriptions, which after the enhance
phase carry file paths and config snippets.

Single-pass regex over the template instead; unknown tokens still pass through.
2026-08-06 21:17:11 +02:00
mika kuns a7ff1b3a2f refactor(prompts): split the list-handler prompt per session phase
Both merge-helper ConPTY sessions loaded PromptKind.MergeHelper, so the
post-handoff session received the phase 0-2 dedupe/enhance instructions and was
told to ignore them by its brief alone.

Split into MergeHelperTriage (phases 0-2 + handoff) and MergeHelperExecute
(phases 3-5), so each session carries only its own phases. Consolidated the
generic ask-the-user rule to one place per prompt, scoped Phase 5's summary to
what the execute session actually knows, and moved the dedupe/enhance bilanz to
the triage handoff. Regression guards assert neither prompt carries the other's
phase headings and that the shared-checkout git rule stays in execute.
2026-08-06 21:00:41 +02:00
mika kuns d5730a8405 docs(explore-notes): document the ConPTY trailing-separator quoting trap 2026-08-06 15:55:16 +02:00
mika kuns 17e6cd6118 fix(app): stop a dispatcher exception from killing the whole app
Ctrl+C in a Mission Control terminal tile is bound by the terminal library to
CopyAsync, which throws IndexOutOfRangeException out of XTerm's selection buffer
on some selections. It runs from an async void key handler, so the exception
reached the dispatcher unhandled and terminated the process -- every open ConPTY
session with it. Handle it instead and surface the message in the footer error
strip. Iciclecreek.Avalonia.Terminal 2.0.3 is the newest release, so there is no
upstream fix to take.
2026-08-06 15:54:17 +02:00
mika kuns 4a7b00ed53 fix(worker): strip trailing separator from the list repo in ConPTY launch args
The ConPTY host flattens LaunchSpec.Args into one Windows command line and quotes
each token, so a list working dir stored as "C:\repo\" produced the token
"C:\repo\" -- whose trailing backslash escapes its own closing quote. Everything
after it collapsed into --add-dir's variadic list, including
--append-system-prompt-file and the positional kickoff, so "Let Claude handle it"
opened a session with no prompt at all and the CLI warned that brief.md is not a
directory. Only user-supplied working dirs can carry a trailing separator; the
session dirs the worker builds never do.
2026-08-06 15:53:46 +02:00
ClaudeDo CI f3e6655fc2 docs(changelog): update for v2.8.0 2026-08-06 13:03:27 +00:00
mika kuns bf19e84e76 Merge remote-tracking branch 'origin/main'
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 42s
2026-08-06 14:55:17 +02:00
mika kuns 32ef88526e test(worker): poll for terminal state in the registration-race tests instead of fixed delays 2026-08-06 14:53:12 +02:00
mika kuns 30efbcbf2f Merge branch 'claudedo/a935c68eed6341bfb96f0bb82f13354e' 2026-08-06 14:43:57 +02:00
mika kuns 96723f4582 Merge claudedo/28d494e791e842fc966eb8691181e6da 2026-08-06 14:41:37 +02:00
mika kuns 967359d6e7 Merge claudedo/ab96ad81d417402088aa669ed52259e9 2026-08-06 14:41:08 +02:00
mika kuns ab888e5291 Merge claudedo/43bb79c93e694f7cbec8ae41a05004bc 2026-08-06 14:40:24 +02:00
mika kuns 7337312eca Merge claudedo/72b309e176cb4a96945159fd8dd6ce92 2026-08-06 14:39:34 +02:00
mika kuns c07225f530 Merge claudedo/58764ab72f3e41e0871b9c81149ba1cc 2026-08-06 14:39:06 +02:00
mika kuns ff52fb9eb1 Merge claudedo/e1933d2e09014050812c1fdab2240e13 2026-08-06 14:38:29 +02:00
mika kuns 109e85da83 fix(worker): honor RunCancellationRegistry.Register's return value at both dispatch sites
Register(taskId, cts) already refuses (and logs) a double-registration, but
both call sites discarded the bool and dispatched anyway under an
unregistered CTS. If the loser then unregistered the winner's CTS during
its own cleanup, TryCancel could silently no-op against a live process.

- OverrideSlotService.StartInSlot now fails RunNow/ContinueTask loudly
  (throws) when it loses the registration race instead of registering
  over — or silently proceeding despite losing to — the queue picker.
- QueueService's picker loop retries registration briefly (the loser's own
  claim-attempt-then-unregister resolves fast) before dispatching; if
  registration never resolves it marks the already-claimed row Failed
  instead of running it unregistered.
- RunCancellationRegistry.Unregister already had compare-and-remove
  semantics (TryRemove(KeyValuePair)), so a loser's cleanup could not have
  removed the winner's CTS once registration correctly failed.

Added regression tests exercising the real registry through both dispatch
paths: RunNow losing the registration race throws without disturbing the
winner, the picker's retry succeeds and TryCancel reaches the live run when
the loser unregisters in time, and the picker fails the task instead of
running unregistered when it never does.
2026-08-06 14:34:48 +02:00
mika kuns 4a28bfe82e fix(ui): treat an immediate ConPTY exit as a start failure
InteractiveSessionId is persisted before the ConPTY process spawns (1a988ff).
If `claude --session-id <guid>` exits immediately after launch (auth/network
hiccup, crash before the TUI starts), OnSessionProcessExited set HasExited
but never StartError, so ConPtyPaneViewModel.CanRetry (which requires
StartError) never offered Retry — every reopen just resumed the same dead
session id, permanently poisoning that task's interactive sessions.

Now a nonzero exit within 5s of the session becoming "running" is treated
as a died-at-startup failure and routed through the same StartError path
as a launch-time exception, so the pane shows the error banner + Retry.

Retry re-fetches the LaunchSpec via BuildForTaskAsync, which reuses the
same persisted InteractiveSessionId — correct for a transient failure
(fresh process, same id), but does not help a genuinely dead session id.
Clearing a dead session id server-side is a separate design question,
left out of scope here.
2026-08-06 14:33:24 +02:00
mika kuns 27d50b81ff fix(ui): surface merge-drain cancel rejection in task-row quick cancel
TasksIslandViewModel.CancelReviewAsync swallowed the HubException the
worker raises when a task's unit merge is draining (79f90a9), unlike
the details-pane version fixed in the same commit. Mirror that fix:
report the rejection via ErrorReported instead of a bare catch.
2026-08-06 14:31:13 +02:00
mika kuns 166021049a fix(ui): close interactive-session gate bypasses in reset-and-retry and plan queueing
Reset & Retry discarded the branch and queued an autonomous run even while the
user had an interactive ConPTY pane open on the task, and finalizing a plan
queued every child unconditionally (the hub has no notion of a UI-hosted
session) — both bypassed the HasInteractiveSession gate added for
CanSendToQueue. CanResetAndRetry now checks it too, with a subscription on the
bound task so the command re-evaluates when the flag flips without Task
itself changing; SendToQueueAsync now blocks queuing the whole plan and
surfaces the affected child titles when any child has an open session.
2026-08-06 14:30:24 +02:00
mika kuns a78cc526a6 fix(ui): surface worker-offline failures when deleting a task
DeleteTaskAsync had no IsConnected guard, unlike every other worker-dependent
command in DetailsIslandViewModel, so an offline delete was a silent no-op.
WorkerClient.DeleteTaskAsync also only caught HubException, letting the
InvalidOperationException thrown by an inactive hub connection escape into
the unobserved command task and vanish.

Gate DeleteTaskCommand behind CanDeleteTask (Task != null && IsConnected),
re-evaluate it on connection-state changes, widen WorkerClient to catch the
connection-inactive case too, and wrap the ViewModel's call in try/catch as
a second line of defense against a race between the guard and the call.
2026-08-06 14:29:15 +02:00
mika kuns 4310f88ebf fix(ui): bound the ConPTY launch-gate wait so a hung launch can't freeze other panes
StartAsync awaited the static launch-gate semaphore with no timeout, so one
hung launch (slow disk, AV scanning claude.exe, a Porta.Pty/ConPTY hiccup)
blocked every subsequent pane open behind it indefinitely, including Retry.
Extracted the wait into WaitForLaunchGateAsync with a 30s timeout that
throws TimeoutException before the try/finally (never releasing a gate it
didn't acquire); the exception flows through StartCoreAsync's existing
catch into StartError, so the pane shows an error + Retry instead of
hanging. StartError renders ex.Message directly (no locale key involved),
so no locale changes were needed.

PtyTerminalSession.StartAsync itself needs a real TerminalControl and stays
impractical to unit-test directly, so tests target the extracted
WaitForLaunchGateAsync helper against a plain SemaphoreSlim: timeout throws,
timeout never acquires the gate, and success still acquires it.
2026-08-06 14:29:05 +02:00
mika kuns b97f55bfb6 fix(ui): reparse-check repo-scan root folder and move scan off UI thread
RepoScanner.Scan() only guarded reparse points on subdirectories found
during recursion, so a configured import root that is itself a junction
walked straight through onto other drives. Apply the same
FileAttributes.ReparsePoint check to the root before scanning.

RepoImportModalViewModel.ScanAndAdd ran the full 5-level recursive scan
synchronously on the UI thread; pointing the folder picker at a broad
directory froze the app. Offload RepoScanner.Scan to Task.Run per folder
and apply results back on the UI thread.

Add a RepoScannerTests case that creates a real junction (mklink /J) at
the scan root and asserts Scan returns empty.
2026-08-06 14:27:39 +02:00
mika kuns bac8387069 Merge claudedo/c52ba287db6e4a7c9bac38c30bcc21ec 2026-08-06 13:50:41 +02:00
mika kuns d1ed29f19f Merge branch 'claudedo/12bae32376504fd28bedb3c7202feba5' 2026-08-06 13:50:14 +02:00
mika kuns 8dbdfb3b89 Merge branch 'claudedo/a92b87e7748840ababcfc87423a8636c' 2026-08-06 13:49:30 +02:00
mika kuns ddf68d66cc Merge claudedo/1f4f59b14e4d481d97e843b6b2014af5 2026-08-06 13:48:37 +02:00
mika kuns bd83cac57d Merge claudedo/973ea49ec0a642ea9b1ddcff9b71b6fe 2026-08-06 13:47:35 +02:00
mika kuns b2940bf1ff Merge claudedo/c4930a56575e45b2ae9be47b1c49eb39 2026-08-06 13:47:11 +02:00
mika kuns 7f03f04268 Merge branch 'claudedo/9c0bff67147148f3beeee180e70b5503' 2026-08-06 13:46:52 +02:00
mika kuns cc90600f72 Merge claudedo/81b054800dc4446598685fd04fefddb5 2026-08-06 13:45:15 +02:00
mika kuns 003e7b2b78 Merge claudedo/15357b9c652545eda0a1226042d7ebc9 2026-08-06 13:44:58 +02:00
mika kuns c90f93e57f Merge claudedo/f5500cade3a34e42876dbbafc8161831 2026-08-06 13:44:33 +02:00
mika kuns 4d9ceefee2 docs(explore-notes): document the ConPTY env race and open-path dedupe fixes 2026-08-06 13:43:12 +02:00
mika kuns 176ba78e11 fix(ui): serialize ConPTY env launch and close open-path dedupe races
Two sessions starting back-to-back could interleave SetEnvironmentVariable
calls before either LaunchProcess() forks, leaking one task's env (e.g.
CLAUDEDO_PLANNING_TOKEN) into another's claude process. Serialize the
set-env + LaunchProcess critical section behind a static SemaphoreSlim in
PtyTerminalSession.

OpenConPtySessionAsync/OpenPlanningConPtySessionAsync ran their TaskId
dedupe check before an awaited DB title lookup, and OpenMergeHelperConPtySessionAsync
awaited task creation before any dedupe was possible - rapid double-invocation
could open two panes or mint two host tasks. Claim the key synchronously at
method entry, before any await, and release it in a finally.
2026-08-06 13:43:00 +02:00
mika kuns 57c61a2043 fix(ui): gate Mission Control submit-for-review, add retry, fix event leak
Submit for Review is now disabled while a ConPTY pane is starting, has
failed to launch, or has already exited, and MissionControlViewModel
guards against a rapid double-click racing two SubmitTaskForReviewAsync
calls. A failed launch no longer permanently occupies its TaskId dedupe
slot -- a Retry button re-fetches the launch spec and restarts the pane
in place. CloseConPtySession/Dispose now also unsubscribe
SubmitForReviewRequested, matching the other pane event handlers. Also
fixes the pre-existing nullable-dereference warning in
IslandsShellViewModel.SyncInteractiveSessionChips.
2026-08-06 13:42:06 +02:00
mika kuns 79f90a9a8e fix(worker,ui): block cancelling a task while its unit merge is draining
TaskStateService.CancelAsync allowed cancelling a WaitingForReview task
even while PlanningMergeOrchestrator was mid-drain on it: ApproveReview
awaits the whole multi-subtask merge synchronously, so a concurrent
CancelReview (UI or MCP) could flip the parent to Cancelled while the
orchestrator kept merging children onto the target branch, then
FinalizeParentDoneAsync would find the parent no longer WaitingForReview
and give up - leaving the merged diffs stranded with no rollback.

CancelAsync now rejects with a clear reason when HasActiveMerge(taskId)
is true. TaskStateService can't take PlanningMergeOrchestrator as a
direct constructor dependency (circular back to ITaskStateService), so
it takes a lazily-resolved Func<IActiveMergeState> instead, mirroring
the existing Func<ITaskStateService> cycle-break already used for
PlanningChainCoordinator.

UI polish: DetailsIslandViewModel.IsMergeDraining gates
CancelReviewCommand's CanExecute (same shape as
WorktreesOverviewModalViewModel.IsMerging), and the command's catch now
raises ErrorReported instead of swallowing the rejection silently.
2026-08-06 13:38:02 +02:00
mika kuns efee14780b fix(worker): filter the transliterated 'fuer' stopword, not the untransliterated 'fur'
NormalizeTitleWords transliterates umlauts before tokenizing ('für' ->
'fuer'), but TitleStopWords listed 'fur' -- a token the pipeline can
never produce -- so 'für' was never filtered as filler and could push
unrelated titles past the near-duplicate threshold.
2026-08-06 13:37:35 +02:00
mika kuns 42a234b46e docs(explore-notes): reflect claim-before-create ordering in worker-task-pipeline
Bumps the verified-against commit to 774f9d3 (the RunNow/picker
double-dispatch race fix) and documents the new claim-before-create
ordering and the RunCancellationRegistry double-registration guard.
2026-08-06 13:33:35 +02:00
mika kuns 774f9d3d13 fix(worker): claim Running before creating run resources
TaskRunner.RunAsync created the worktree (PrepareRunDirectoryAsync)
before claiming Running via StartRunningAsync. RunNow dispatches by
task id with no atomic claim of their own, so a Queued task racing
the queue picker's atomic SQL claim could hit WorktreeManager's
branch-collision self-heal, which force-removes and recreates the
winner's live worktree mid-run.

Move the claim before any resource creation and bail out immediately
when it's rejected. OverrideSlotService.RunNow also fast-rejects a
task already Running in the DB (defense in depth). RunCancellationRegistry
now refuses (and logs) a double registration instead of silently
overwriting the first CTS, so a losing dispatch's cleanup can no
longer unregister the winner's cancellation token.
2026-08-06 13:33:23 +02:00
mika kuns ed7cd7dcf6 docs(explore-notes): document the queueing gate on open ConPTY sessions
Bump conpty-sessions.md's verified-against commit to d84607f and add a
section covering the CanSendToQueue / EnqueueTaskAsync interactive-session
gate and the switch to routing through the worker hub.
2026-08-06 13:30:06 +02:00
mika kuns d84607f796 fix(ui): gate queueing on an open interactive ConPTY session
A task-based ConPTY session leaves the row Idle in the DB (sessions never
touch status), so nothing stopped the queue picker from claiming it too:
CanSendToQueue ignored HasInteractiveSession, and both TasksIslandViewModel.
SendToQueueAsync and MissionControlViewModel.EnqueueTaskAsync (drag-to-queue)
wrote Status=Queued straight via EF, bypassing TaskStateService entirely and
its manual/draft-child guards. That let an autonomous claude process spawn in
the same worktree a user was hand-editing in the ConPTY pane.

Add !HasInteractiveSession to CanSendToQueue, and route both UI enqueue paths
through IWorkerClient.SetTaskStatusAsync (worker hub -> TaskStateService.
EnqueueAsync) instead of raw EF writes. The interactive-session gate itself
stays in the UI: the worker has no notion of a UI-hosted ConPTY pane.
2026-08-06 13:29:44 +02:00
mika kuns 6118698d8c docs(explore-notes): document interactive session id persistence and resume precedence 2026-08-06 13:28:04 +02:00
mika kuns 1a988ff4fc feat(worker,data): persist interactive session id so a closed/aborted ConPTY session can be resumed
Generates the claude session id up front (--session-id <guid>) for a fresh interactive task
session and persists it to TaskEntity.InteractiveSessionId before launch, so an abort at any
point still leaves a resumable id. BuildForTaskAsync now resumes this task's own last
interactive session in preference to the latest autonomous run's session, but never across a
freshly (re)created worktree.
2026-08-06 13:27:52 +02:00
mika kuns 9e2a15e421 fix(worker,ui): route details-pane task delete through worker to advance blocked parents
Deleting a child from the details pane hard-deleted straight from the UI
process via TaskRepository, bypassing TaskStateService.TryAdvanceParentAsync
entirely. Deleting the last child of a WaitingForChildren parent that way
left it wedged forever. WorkerHub.DeleteTask now mirrors the MCP delete_task
tool (running-task guard, FK-friendly error, advance-parent call), and the
UI goes through it.

TryAdvanceParentAsync also short-circuited when zero children remained,
treating "no children left" as "nothing to evaluate" instead of "all done" -
removed the early return so an empty child list (vacuously) counts as all
terminal.
2026-08-06 13:25:49 +02:00
mika kuns 1649291ef0 docs(explore-notes): document stale-Running chain/parent advance fix
Bump worker-task-pipeline verified-against commit to 58741c2.
2026-08-06 13:17:07 +02:00
mika kuns 58741c2bd6 fix(worker): advance planning chains and parents on stale-Running recovery
RecoverStaleRunningAsync bulk-flipped Running rows to Failed via raw
ExecuteUpdate, skipping the chain/parent side effects every other terminal
transition triggers. After a worker crash mid-run of a planning/improvement
child, the chain successor's BlockedByTaskId was never cleared and a
WaitingForChildren parent could wedge forever with no event left to
re-check it. Now each recovered task runs the same OnChildTerminalAsync
side effects (chain advance + parent advance) as FailAsync, best-effort.
2026-08-06 13:16:58 +02:00
mika kuns 7affb4c204 feat(ui): repo scan discovers nested repos in subfolders
RepoScanner.Scan now recurses into subdirectories (max depth 5) instead
of only checking the immediate children of the chosen folder. A found
repo (.git as dir or file) is added without descending further; the
selected folder itself is checked too. Skips node_modules/bin/obj/.git/
.vs/packages and reparse points (junctions/symlinks). Per-directory
IOException/UnauthorizedAccessException no longer aborts the whole scan.
2026-08-06 13:14:56 +02:00
mika kuns 0d1e3b9a6f docs(explore-notes): document the kill-on-detach gotcha and ConPtyPaneHost reparenting 2026-08-06 12:57:42 +02:00
mika kuns aac84e48b7 fix(ui): keep ConPTY sessions alive across Mission Control layout rebuilds
TerminalView kills its child process on any logical-tree detach unless
BeginReparent() suppressed it, and RebuildOverviewGrid() detaches every
existing pane whenever one is added - so opening a second interactive
session killed the first. Focus-mode tab switches had the same defect.

PtyTerminalSession now puts the control in permanent reparent mode after
launch (teardown stays explicit via ConPtyPaneViewModel.Dispose -> Kill),
and the new ConPtyPaneHost reparents each pane's single long-lived view
across grid rebuilds and tab switches instead of letting the DataTemplate
instantiate a dead replacement.
2026-08-06 12:43:55 +02:00
mika kuns 0878bae19e docs(explore-notes): point both drift checks at the merged state 2026-08-06 12:12:12 +02:00
mika kuns 20bce9b424 Merge branch 'claudedo/75514ee68371402b814638290af47cca' 2026-08-06 12:11:38 +02:00
mika kuns 5d1d2d89d0 fix(ui): suppress auto-open conflict resolver during MCP-driven merges
review_task/continue_merge on a planning parent always leaves conflicts in
the tree, and the UI auto-opened the resolver on every PlanningMergeConflict
broadcast regardless of who started the merge -- so a running Claude session
resolving a unit-merge conflict could race a human editing the same shared
checkout in a resolver window neither of them asked for.

PlanningMergeOrchestrator.StartAsync now takes an externallyDriven flag (set
by ExternalMcpService's MCP-driven review_task path, left false for the UI's
ApproveReview) that rides along on the PlanningMergeConflict broadcast. The
UI only auto-opens the resolver when it's false; otherwise it shows a
persistent banner with a manual "Open resolver" button, cleared on
PlanningMergeAborted/PlanningCompleted. A new GetActiveExternalConflictsAsync
query (checked against GitService.IsMidMergeAsync rather than the in-memory
flag alone) lets the UI resync the banner on reconnect instead of trusting a
one-shot broadcast that isn't replayed after a restart.

The childless single-task conflict path was checked and needed no change --
it only broadcasts the generic TaskUpdated, never PlanningMergeConflict.
2026-08-06 12:04:52 +02:00
mika kuns 0358bcf8b2 Merge branch 'claudedo/001ee94a1d8d46b0906f7466ea6beb32' 2026-08-06 12:04:47 +02:00
mika kuns c6213d77c4 fix(installer): release the ShellLink COM object so reading a .lnk doesn't lock it 2026-08-06 12:00:18 +02:00
mika kuns e59f6c2438 fix(worker): surface empty review ranges and blocked children over MCP
preview_merge/preview_merge_set now report isEmpty (ahead==0, or
HandlerBaseCommit==HandlerHeadCommit for a worktree-less list-handler
task) instead of leaving an empty branch indistinguishable from a small
one. preview_merge also stops throwing for worktree-less handler tasks,
falling back to their fixed commit range. review_task's parent approve
returns emptyChildren, naming the Done children whose review range
contributed nothing before the unit merge lands. TaskRefDto/TaskDto now
expose roadblockCount so a CLAUDEDO_BLOCKED child is identifiable over
MCP, since it still reaches Done per the unified parent model.
2026-08-06 11:54:46 +02:00
mika kuns 46e177eb59 Merge claudedo/24acb89f61094773a0dc76d31bdae274 2026-08-06 11:43:19 +02:00
mika kuns abac5e5150 Merge claudedo/c97dcaafbb3542bda9c670e502ffffb6 2026-08-06 11:43:07 +02:00
mika kuns 7240b39f16 Merge claudedo/3a3e87648400492bba6e9643007703db 2026-08-06 11:41:55 +02:00
mika kuns 4a60c2bfa8 Merge branch 'claudedo/0733742e0da8407d8e2be1bc5886bf9b' 2026-08-06 11:40:47 +02:00
mika kuns 03c4ed4607 docs(explore-notes): point external-mcp drift check at the merged state 2026-08-06 11:39:12 +02:00
mika kuns f3e16412f4 Merge branch 'claudedo/c1d2a92d8d8c4705ac93d5f0b2cacaa0' 2026-08-06 11:38:32 +02:00
mika kuns db4177db83 docs(worker): evaluate planning-chain fork-base options, recommend fork-from-predecessor 2026-08-06 11:37:37 +02:00
mika kuns 8bc7bc0c4f feat(ui): warn when the running worker predates the selected repo's merged HEAD
Stamps ClaudeDo.Worker's build with its exact git SHA (SourceRevisionId ->
InformationalVersion) and exposes it via a new GetWorkerBuildInfo hub call.
For the currently selected list, the shell compares that SHA against the
list's git HEAD (GitService.IsAncestorAsync) and shows a persistent footer
banner -- never auto-clearing, never shown on an unrelated repo or when the
ancestry can't be determined -- so "verified against a merge" claims aren't
silently made against a stale process. No auto-restart; the banner just
offers the existing RestartWorkerCommand.
2026-08-06 11:36:26 +02:00
mika kuns af16830060 feat(worker): add treatWaitingForChildrenAsBusy to wait_for_task_change
WaitingForChildren already counted as "changed" since it's outside Queued/Running,
so waiting on a planning parent returned immediately even though its children were
still running. The new opt-in flag (default false, unchanged behavior) keeps polling
through WaitingForChildren and only reports changed once the parent reaches
WaitingForReview or a terminal status.
2026-08-06 11:32:00 +02:00
mika kuns b54a133c16 fix(worker,ui): clean up three review leftovers from the list-handler run
Remove the redundant TaskUpdated broadcast in TaskRunner.ContinueAsync's
queue-claim path, consolidate InteractiveLaunchSpecService's seven
MCP_TOOL_TIMEOUT literals into one constant (fixing the merge-helper handoff
spec's stale 200000ms value), and surface OpenQuickClaudeSession's two
failure cases via ErrorReported/footer instead of a silent no-op, with a
less ambiguous icon.
2026-08-06 11:31:03 +02:00
mika kuns 8247a749a0 Merge claudedo/f9ee427d9f644715bafb55857ad8c3e8 2026-08-06 11:29:49 +02:00
mika kuns f8c48e2ed7 fix(worker): make list_tasks/batch_get_tasks lean by default
list_tasks on a list of ~100 verbosely-described tasks could return
390k+ characters in one call, blowing past the caller's token limit.
Both tools now default to lean TaskRefDto references (no
Description/Result) and take an includeDescription flag to opt back
into the full TaskDto payload — same flag-alongside-nullable-payload
idiom already used by BatchGetTaskResult/TaskConfigResult. get_task is
unchanged.
2026-08-06 11:25:32 +02:00
mika kuns 3e07536ee9 feat(worker): warn on near-duplicate titles in add_task/batch_add_tasks
add_task and batch_add_tasks now report up to 3 open (non-terminal)
tasks in the same list with a strongly overlapping title, so a
parallel agent can notice and mention a likely duplicate instead of
silently creating one. The task is always created regardless. Uses a
cheap normalized-word overlap heuristic (no embeddings/LLM call),
robust to German umlaut/digraph spelling variants. Breaking change:
AddTask now returns AddTaskResult { task, possibleDuplicates } instead
of a bare TaskRefDto; BatchAddTaskResult gained a PossibleDuplicates
field.
2026-08-06 11:24:17 +02:00
mika kuns 6980aae77e Merge claudedo/64fbe15dae7e4d81b8c1045baa7e3c87 2026-08-06 11:22:35 +02:00
mika kuns 75561930d7 chore(claude-do): RepoImportFolders auf den WorkerHub-Pfad umziehen (Ui schrei
## Befund (am Code verifiziert 2026-08-06)

`AppSettingsEntity.RepoImportFolders` (`src/ClaudeDo.Data/Models/AppSettingsEntity.cs:26`, JSON-Array als TEXT) ist **nicht** Teil des `AppSettingsDto` in `src/ClaudeDo.Worker/Hub/WorkerHub.cs:32-49` und wird weder von `GetAppSettings` (~:375-393) noch von `UpdateAppSettings` (~:396-424) transportiert.

Stattdessen liest und schreibt die Ui das Feld dire

ClaudeDo-Task: 64fbe15d-ae7e-4d81-b8c1-045baa7e3c87
2026-08-06 11:18:00 +02:00
mika kuns b66ce580de Merge claudedo/ebd5a205ecfe40c298e3995f9f92f0a1 2026-08-06 11:14:37 +02:00
mika kuns 70322c203c Merge claudedo/7e54be27bc474883bc2389802123f55e 2026-08-06 11:13:30 +02:00
mika kuns db1775fb69 Merge claudedo/94ca2f10e45242ad9a570cb694e76aa3 2026-08-06 11:12:53 +02:00
mika kuns 76dfbc87eb Merge claudedo/4606dadca84644ceb35dc01eaebe22de 2026-08-06 11:12:05 +02:00
mika kuns 7cfe280a23 Merge claudedo/0cb3cb17e7fa4507a7ae0f302f2c4522 2026-08-06 11:10:00 +02:00
mika kuns e38fcec8cc chore(localization): remove unused installer.selfUpdate keys 2026-08-06 11:09:52 +02:00
mika kuns 423ab7f2a9 chore(ui): remove dead AgentStripView fragment
AgentStripView was superseded by WorkConsole during the task-detail
redesign but never wired into any view and never deleted. Drops the
unused Border.agent-strip / diff-meter styles and the 14 agent.* locale
keys that only it referenced, and marks A4 in the feature-unification
spec as resolved by deletion.
2026-08-06 11:09:32 +02:00
mika kuns 2ad9bdd851 Merge claudedo/6678d1ab7da0460282f4e1c2b58dea85 2026-08-06 11:09:30 +02:00
mika kuns 46c664a03f fix(ui): restore ForegroundHelper.AllowAny call before wt.exe launch
OpenInTerminal lost its foreground-grant call, leaving ForegroundHelper
without a caller. Restore it ahead of the Process.Start (covers both the
wt.exe and cmd.exe fallback paths) instead of deleting the helper.
2026-08-06 11:09:25 +02:00
mika kuns 445242cd7d fix(installer): skip rewriting the autostart shortcut when already current
RegisterAutostartStep rewrote the Startup .lnk on every install/update/repair
even when it already pointed at the right worker exe. AutostartShortcut.Install
now reads the existing shortcut's target via ShortcutFactory.TryGetTarget and
skips the rewrite when it matches, reporting the skip in progress output.
Legacy service/scheduled-task cleanup stays unconditional (migration safety net).
2026-08-06 11:08:15 +02:00
mika kuns 86f962ee7d test(installer): prove RegisterMcpStep still resolves from the DI container 2026-08-06 11:07:48 +02:00
mika kuns 68aa2f5f4b fix(ui): remove duplicate list-settings context menu entry
List settings were reachable via three doors (header button, context
menu, double-click). Drop the context menu entry, matching the same
cut already made for the "Let Claude handle it" entry point.
2026-08-06 11:07:31 +02:00
mika kuns 76b748060c chore(ui): remove unused CheckboxBorderConverter, DateOnlyToDateTimeConverter, StatusColorConverter/ConnectionColorConverter 2026-08-06 11:06:57 +02:00
mika kuns 175f160956 Merge claudedo/5f664041b046456d8e752f90d193c906 2026-08-06 11:06:47 +02:00
mika kuns cfd2936c25 Merge claudedo/610f2deab9a94f2ca2c3e9b2b0652f19 2026-08-06 11:04:45 +02:00
mika kuns 3462ca1355 fix(installer): read the persisted MCP port on update instead of the wizard default
RegisterMcpStep built the registration URL from ctx.ExternalMcpPort, which the
Update pipeline never repopulates from the existing installation, so any
update silently re-registered the wizard default (47822) even when
worker.config.json had a different port configured. InstallerWorkerConfig was
also missing external_mcp_port entirely, so the installer had no way to read
it back. Port 0 (external listener disabled) now skips registration instead
of pointing Claude at 127.0.0.1:0/mcp.
2026-08-06 11:04:38 +02:00
mika kuns e717c901b2 fix(installer): show the MCP registration step in the progress list
Update and FreshInstall both run RegisterMcpStep but the UI's Steps
list never included it, so the running step never appeared and
Steps.Count didn't match the executed pipeline length.
2026-08-06 10:58:24 +02:00
mika kuns 0f187d8e82 docs: record the verify-gate reach and the NumericUpDown null trap
review-merge: worktree-less approvals run the gate too, and all three UI merge
entry points handle verify_failed explicitly.
Ui gotchas: NumericUpDown.Value is decimal? and nulls while the box is empty.
2026-08-06 10:22:17 +02:00
mika kuns f106c890b3 fix(ui): refresh the run session id live, close the stale handoff pane
A task selected before its run started kept LatestRunSessionId null, so the
roadblock reply box and Continue stayed dead until it was re-selected.

The handoff left the Phase 1-2 tile open so its last message could be read, but
its process is gone by then and the terminal renders empty -- a dead
placeholder. It is closed on handoff now.

Also drops the WARN flood from worktree cleanup (already-unregistered worktree
and already-deleted branch are normal outcomes, not failures) and replaces the
fixed sleep in UsageGate_TransitionLogging_FiresOncePerChange with the polling
helper that already sits three lines below it in the same file.
2026-08-06 10:20:54 +02:00
mika kuns 56f7d64f07 fix(ui): stop NumericUpDown from writing null into non-nullable settings
Clearing the text box to type a new value sets Value to null, which the TwoWay
binding then wrote into an int/decimal target -- InvalidCastException on the
normal way of editing eight settings fields. KeepLastNumberConverter maps that
null to BindingOperations.DoNothing so the source keeps its last value.
2026-08-06 10:20:53 +02:00
mika kuns 091aca521f fix(merge): apply the verify gate to worktree-less approvals and report it everywhere
ApproveAndMergeAsync short-circuits to Done whenever a task has no active
worktree -- which is exactly how a list-handler task works, since it commits
straight into the list working dir. The verify command was skipped for the run
that lands the most on the target branch at once; the loaded command was even
discarded at the destructuring. It now runs under the same per-repo gate as the
merge path before the task may reach Done.

The two merge entry points that did not know verify_failed reported it as
"Unknown status: verify_failed" (merge modal, dropping the command output) and
as a generic Failed (worktrees batch, claiming the merge never happened).
2026-08-06 10:20:53 +02:00
mika kuns 730ecb1abc docs(open): mark the usage-monitor pass done and drop the light-theme checks
The app is dark-only (App.axaml pins RequestedThemeVariant="Dark", Tokens.axaml
has no light variant), so every "Dark/Light" line was unverifiable by
construction. Adds the roadblock-reply session-id finding from the same pass.
2026-08-06 10:02:14 +02:00
mika kuns b6ecbb13f5 docs(open): record the findings from the 2026-08-06 visual pass
Verified: max-turns hints, usage pill/modal, list-handler run end to end.
New findings: NumericUpDown null binding throws in the settings modal, the
verify gate is skipped for worktree-less tasks and unhandled in two of three
merge entry points, the handoff leaves an empty pane, worktree cleanup logs
already-done as WARN, orphaned git worktrees are invisible to the app, modal
bodies are clipped at the bottom, usage tables need formatting work.
2026-08-06 09:54:15 +02:00
mika kuns b5a8d58e62 docs(open): re-verify the 2026-07-24 findings and drop the fixed ones
Ten of the twelve listed bugs/nits are fixed in the code (outcome JSON,
subtask terminology, live child rows, AskUser in the detail island, Icon.Plus,
gear glyph, rename display, TurnsText, conflict Continue gate, ConPTY resume)
and the blocked-approve silent fail is handled by the WorkerHub throw. Adds the
open verification block for the planning-resume fix.
2026-08-06 09:10:50 +02:00
mika kuns b6791265d1 fix(planning): recover the planning session id from the on-disk transcript
The interactive planning TUI never reports its claude session id back, so
planning_session_id stayed NULL and UpdatePlanningSessionIdAsync had no caller
at all -- ResumeAsync always threw "No Claude session ID captured yet".

Resume now looks the id up in the transcript Claude Code writes to
~/.claude/projects/<encoded cwd>/<sessionId>.jsonl for the planning worktree
(newest file wins; a worktree hosts exactly one session) and persists it, so
the next resume is a plain DB read. No transcript -> a clear "cannot resume"
instead of resuming the wrong session.
2026-08-06 09:10:50 +02:00
mika kuns e113987d2e docs(open): drop the verified 2026-07-27 visual-pass block, keep its design notes 2026-08-06 08:53:02 +02:00
mika kuns 4309c4cb08 docs(open): mark the MaxTurnsCeiling editor as shipped 2026-08-06 08:45:49 +02:00
mika kuns 41c2de5ab0 docs(installer): drop the stale reserved-slot comment now that Claude Help Me shipped 2026-08-06 08:41:46 +02:00
mika kuns a35103418d Merge task branch for: „Claude Help Me"-Button: Claude-Session zur Setup-Fehlersuche starten 2026-08-06 08:37:19 +02:00
mika kuns 1b0ecbe361 Merge branch 'main' into claudedo/4e19605838a3404f98624cb0e90195da
# Conflicts:
#	docs/open.md
#	src/ClaudeDo.Installer/CLAUDE.md
#	src/ClaudeDo.Installer/Pages/SystemCheckPage/SystemCheckPageView.xaml
#	src/ClaudeDo.Installer/Pages/SystemCheckPage/SystemCheckPageViewModel.cs
2026-08-06 08:36:58 +02:00
mika kuns 83ca1fc6dc Merge task branch for: Diagnose-Sektion in der SettingsWindow (Config-Modus) 2026-08-06 08:30:10 +02:00
mika kuns 09e0772673 feat(claude-do): „Claude Help Me"-Button: Claude-Session zur Setup-Fehlersuch
Der Button, der aus „Problem erkannt" ein „Problem gelöst" macht: startet eine interaktive Claude-Session, die dem Nutzer beim Einrichten hilft.

## Warum externes Terminal
Der ConPTY-Stack (`PtyTerminalSession`, `ConPtyPaneView`) liegt in `ClaudeDo.Ui` und ist Avalonia — der Installer ist WPF und referenziert nur Data/Releases/Localization. Beim Fresh Install sind `app\`/`worker\` außerdem noch n

ClaudeDo-Task: 4e196058-38a3-404f-9862-4cb0e90195da
2026-08-06 08:25:36 +02:00
mika kuns 7e1b1177de feat(installer): add Diagnose section to SettingsWindow
Re-runs the environment checks against the installed configuration
(worker.config.json + detected install dir) without blocking navigation
and without auto-running on window open, only on a "Recheck" click.

Extracted the check-row rendering and check-run logic (busy state,
summary, Recheck command) out of SystemCheckPage into a shared
Checks/CheckListViewModel + Checks/CheckListView, composed by both
SystemCheckPage (wizard) and the new DiagnosePage (settings) instead
of duplicating it.
2026-08-06 08:22:45 +02:00
mika kuns b3a8373c70 Merge branch 'claudedo/852c2328ec8b4fc1bf143df203e1dc6c' 2026-08-06 08:01:25 +02:00
mika kuns e7c9ef891f Merge subtask 2026-08-06 07:56:23 +02:00
mika kuns ae6e95d2c0 Merge branch 'claudedo/0c5a46e7b2d84538a54b2e13b44af01e' 2026-08-06 07:56:22 +02:00
mika kuns 8bb1014e54 Merge subtask 2026-08-06 07:54:20 +02:00
mika kuns 66d2dae316 Merge subtask 2026-08-06 07:54:08 +02:00
mika kuns 4988a42620 Merge subtask 2026-08-06 07:54:06 +02:00
mika kuns 7c4bce63d3 Merge subtask 2026-08-06 07:54:04 +02:00
mika kuns ad2d91f658 Merge claudedo/ccd650a8d2b04a7092e81ed07c16dbe0 2026-08-05 22:46:37 +02:00
mika kuns cdfd0614dd Merge claudedo/616befd0f8a64dcb9e3dac9d6499de16 2026-08-05 22:45:32 +02:00
mika kuns 1d258a3e2c Merge claudedo/123b0241b5e94b69bfa592fde89c51aa 2026-08-05 22:44:47 +02:00
mika kuns 23794ed21b Merge claudedo/e3c29bf4538e491e9631ab80076b6fb4 2026-08-05 22:44:05 +02:00
mika kuns 0ce5bad9c1 Merge claudedo/5d8e6246af9444afa500fbb4546873e7 2026-08-05 22:43:41 +02:00
mika kuns f143d5fc18 Merge claudedo/64a2e2653d244714b76d637b3e0f3be8 2026-08-05 22:42:57 +02:00
mika kuns 487e75c031 Merge claudedo/a49fd666577a48b4b7d809bb915dfd48 2026-08-05 22:42:28 +02:00
mika kuns 583c98f2b7 Merge claudedo/f3718cd8a29c48d7910fa39983f1c221 2026-08-05 22:41:58 +02:00
mika kuns adc0eaebb1 Merge claudedo/01556d1f8af54fe2afa4bced3a209121 2026-08-05 22:41:44 +02:00
mika kuns c25300526e Merge claudedo/f67f39609d144607aceb8e3521d57ff7 2026-08-05 22:41:21 +02:00
mika kuns 49f78d2b8d Merge claudedo/da69bd1770ff4bc9930582a717f1b3c9 2026-08-05 22:40:49 +02:00
mika kuns a6af90ff0d docs(installer,worker): document Environment Checks + note unmerged prerequisite gap
Documents the Checks/ + SystemCheckPage feature (gating rule, check list,
FreshInstall-only placement) and the ExecutableResolver/.cmd-shim wiring in
ClaudeDo.Worker's ClaudeCliPreflight/ClaudeProcess. Adds docs/explore-notes/installer-preflight.md
(bumped/corrected against the actual implementation) and links it from the
explore-notes README and root CLAUDE.md.

The underlying code lives only on unmerged task branches (06aca9b3.../40272c0b...),
not on main yet, and two follow-up features ("Claude Help Me" button, Config-mode
Diagnose section) were never implemented because they blocked on that same missing
merge. Both gaps are called out explicitly in the new docs and in docs/open.md's
verification checklist, instead of being asserted as done.
2026-08-05 21:19:21 +02:00
mika kuns d1df54cf5c Merge claudedo/ed464c32c69248d58983b5358a316f21 2026-08-05 21:03:28 +02:00
mika kuns be378ae0ea Merge claudedo/b259a1c9a73846ce833f0b555fb1de79 2026-08-05 21:02:44 +02:00
mika kuns 23b282573c Merge claudedo/9f504bc8ea254eddb3def38a606eb8a5 2026-08-05 21:02:26 +02:00
mika kuns 6887b1d08d fix(ui): make Files-tab system prompts view-only, drop external-editor edit path
Editing a prompt file (even externally via "Open in editor") freezes it as a
user customization and blocks future default updates. The Files tab no longer
opens an external editor; it shows each prompt's content read-only in-app via
a new ViewPromptCommand, dropping the file-seeding side effect. Reset to
default and the customized-prompts deviation list are unchanged.
2026-08-05 21:00:34 +02:00
mika kuns 860201017c fix(worker): broadcast TaskUpdated on queue-claimed task start
QueuePicker's raw-SQL Queued->Running claim bypasses TaskStateService.StartRunningAsync,
the only other place that broadcasts TaskUpdated on this transition, so the task-list
badge stayed on "Queued" until the run finished. Send TaskUpdated for alreadyClaimed
dispatches in TaskRunner.RunAsync/ContinueAsync to close that gap.
2026-08-05 20:53:55 +02:00
mika kuns df16989435 fix(ui): remove duplicate Let-Claude entry point and icon collision
Context-menu "Let Claude handle it" on list rows duplicated the header
button; both routed to the same ListsIslandViewModel command via the
shell, so only the context menu entry (and its now-unused locale key)
is removed. The header button also shared Icon.Broom with Clear Day —
gave it Icon.AgentSuggested instead.
2026-08-05 20:51:19 +02:00
mika kuns d43b5fcefc feat(worker-mcp): raise wait_for_task_change timeout, expose queue slot state
MaxTimeoutSeconds was 170s against runs that take tens of minutes, forcing
a dozen full-context wait rounds per long-running batch. Raise it to 900s
and raise MCP_TOOL_TIMEOUT in lockstep (ClaudeProcess + every
InteractiveLaunchSpecService launch spec) to 930000ms so the client
connection actually stays open that long instead of aborting first.

Add get_queue_state (QueueStateMcpTools): configured vs. effective
parallel-slot count (via QueueService.GetSlotCountsAsync, extracted from
the former GetEffectiveMaxParallelAsync), active slots with taskId +
startedAt including the run_task_now override slot, and queued tasks in
pick order -- so a caller can observe queue occupancy instead of inferring
it from maxParallelExecutions.
2026-08-05 20:47:57 +02:00
mika kuns 29bd1b5069 fix(worker): filter EF Core/ASP.NET Core noise out of log ring buffer
EF Core logs every SQL statement at Information, which BroadcastLogSink
buffers into LogRingBuffer regardless of level, flooding the Log
Visualizer overlay and worker log file with SQL noise that buries
hand-curated business events. Override both to Warning so real
EF Core / ASP.NET Core problems still surface.
2026-08-05 20:46:57 +02:00
mika kuns a7d95a000a fix(prompts): clarify worktree commits aren't auto-commits 2026-08-05 20:42:45 +02:00
mika kuns beef57fba2 docs(claude-md): break growing enumerations into one entry per line
Two remaining single-line lists that accumulate entries over time
(table names in Data/CLAUDE.md, HubBroadcaster event names in
Worker/CLAUDE.md) collide when parallel tasks each append a new
entry, same as the AppSettingsEntity/migration-list lines fixed
earlier today. Content unchanged, one bullet per existing item.
2026-08-05 20:42:10 +02:00
mika kuns a768bc4163 feat(worker): add get_effective_run_config MCP tool
Adds a read-only get_effective_run_config(taskId) tool that reports the
model/max-turns/effort/permission-mode/agent-path/system-prompt/skills a
task will actually run with, each tagged with its source (task/list/
preset/global), plus max-turns' raw requested value and clamp status.

Extracted the model/max-turns/agent-path resolution out of
TaskRunner.ResolveConfigAsync into EffectiveRunConfigResolver so the run
path and the new reporting tool share one codepath instead of risking
drift, per docs/explore-notes/worker-task-pipeline.md's max-turns trap.
2026-08-05 20:39:35 +02:00
mika kuns ecba12997a perf(worker): stop echoing task description from writing MCP tools
update_task, update_task_status, add_task, add_subtask, set_my_day,
abort_merge, review_task, and their batch variants now return a lean
TaskRefDto (id/listId/title/status/sortOrder/isMyDay) instead of the
full TaskDto. Those tools were re-sending the caller's own description
text back on every call, wasting a large share of session context on
list-handler-style runs. get_task/list_tasks/batch_get_tasks are
untouched and still return the full DTO.
2026-08-05 20:37:30 +02:00
mika kuns bee06bc307 docs(claude-do): Prompt-Audit: bekommen interaktive und autonome Sessions den
## Der Zweifel

Ein autonomer Agent und eine interaktive Session sollen sich unterschiedlich verhalten. Unklar war, ob beide denselben System-Prompt mitbekommen.

## Vorbefund (2026-08-05, beim Verfeinern erhoben — **selbst nachpruefen, nicht uebernehmen**)

Sie teilen ihn **nicht**. Pro Startweg:

| Startweg | Einstieg | System-Prompt |
|---|---|---|
| Autonomer Run | `ClaudeArgsBuilder.cs:69-73`

ClaudeDo-Task: b259a1c9a73846ce833f0b555fb1de79
2026-08-05 20:33:04 +02:00
mika kuns 4ef01274f9 feat(list-handler): hand off to a fresh session after Phase 2
The merge-helper ("Let Claude handle it") system prompt now calls a new
handoff_list_handler MCP tool once every surviving task is enhanced,
instead of continuing into Phases 3-5 in the same session -- avoiding
paying for Phases 0-2's dedupe/rewrite context on every polling round of
the run/review/merge phases.

The tool broadcasts HandoffRequested; Mission Control opens a second
ConPTY tile for the SAME handler task id (no new task, HandlerBaseCommit
untouched) running a fresh handoff brief that starts at Phase 3. The
original tile stays open. Adds PromptKind.MergeHelperHandoff,
InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync, and the
GetMergeHelperHandoffLaunchSpec hub method.
2026-08-05 20:27:39 +02:00
mika kuns 8c251c78b1 feat(ui): add copy-last-40-lines button to log visualizer 2026-08-05 20:26:04 +02:00
mika kuns 2975f90f9d feat(settings): make max-turns ceiling editable in General tab 2026-08-05 20:21:05 +02:00
mika kuns 95eba1d5e7 feat(claude-do): Quick-Claude-Button in der Kopfleiste der Tasks-Insel: oeffn
## Ziel

Ein kleines Icon in der Kopfleiste der Tasks-Insel, das fuer die gerade ausgewaehlte Liste **sofort** eine interaktive ConPTY-Claude-Session im Arbeitsverzeichnis dieser Liste oeffnet — ohne Task, ohne Dialog, ohne Ordnerauswahl.

## Ausgangslage (verifiziert 2026-08-05)

Der komplette Unterbau existiert schon; das ist im Kern Verdrahtung:

- `MissionControlViewModel.OpenAdHocConPtySessio

ClaudeDo-Task: 5d8e6246af9444afa500fbb4546873e7
2026-08-05 20:18:22 +02:00
mika kuns 663b09e0a0 test(installer): cover CanGoNext end-to-end through WizardViewModel navigation
Navigates Welcome -> SystemCheck and asserts the Next button's CanGoNext
flips false on a blocking Error+Failed check and back to true once a
recheck comes back clean, exercising the PropertyChanged wiring between
the page and WizardViewModel rather than just the page's own BlocksNavigation.
2026-08-05 20:11:15 +02:00
mika kuns 5cc1ec98c0 feat(installer): add SystemCheckPage with blocking-error gating
Adds a new wizard page (positioned right after Welcome, FreshInstall only)
that auto-runs EnvironmentCheckService on entry and shows one row per check
with status icon, localized title/message, and hint+help-link on
failure/unknown. A "Recheck" button re-runs it, guarded against re-entrancy.

IInstallerPage gets a BlocksNavigation default member; WizardViewModel's
Next button now binds to CanGoNext, which the current page can veto (used
here while a check run is in flight or a blocking Error+Failed result is
present — Warnings and Unknown results never block). The summary line names
the blocking checks so a disabled Next is self-explanatory.

Wires up DI for the check pipeline (IProcessRunner, IPortOwnerResolver,
per-run ClaudeCliLookup) and adds the checks.* / installer.systemCheck.*
locale keys in en.json + de.json.

Visual appearance is NOT verified — needs a manual pass in the running
installer.
2026-08-05 20:09:52 +02:00
mika kuns 05be07b28c feat(installer): pull in preflight check implementations as prerequisite for SystemCheckPage
Git/GitIdentity/Port/WriteAccess and Claude CLI/Version/Auth/PermissionModeAuto
checks plus the ExecutableResolver they depend on were built in two sibling
task branches that hadn't landed on main yet. Vendored the finished files in
from those branches (same content, verified building + tests green) so the
SystemCheckPage task has something to consume.
2026-08-05 19:56:07 +02:00
mika kuns d743a9d0e9 feat(installer): add Claude CLI preflight checks (found, version, login, auto-mode)
Four IEnvironmentCheck implementations in src/ClaudeDo.Installer/Checks/:
- ClaudeCliCheck (Error) — resolves ctx.ClaudeBin via ExecutableResolver, runs
  --version; failure message lists searched PATH entries + fallback dirs, flags
  shim resolution (.cmd/.ps1) in Detail.
- ClaudeVersionCheck (Error) — tolerant version parsing (ignores surrounding
  text), numeric System.Version comparison against a named floor constant
  (2.1.220, see docs/explore-notes/installer-preflight.md §3). Unparseable or
  missing CLI -> Unknown, never Failed.
- ClaudeAuthCheck (Error) — `claude auth status --json`, never sends a prompt.
- PermissionModeAutoCheck (Warning) — per the note's §2 conclusion, real
  auto-mode eligibility (org/model/plan) has no cheap static signal, so this
  only confirms `claude --help` still lists "auto" as a --permission-mode
  choice. Kept as its own check rather than folded into ClaudeVersionCheck,
  since the note explicitly separates "flag recognized" from "mode eligible"
  and only the former is checkable at all.

All four share a new ClaudeCliLookup that resolves the CLI and runs
--version exactly once per install run (memoized, semaphore-guarded), so
none of the three version-dependent checks repeats that call.

Foundation prerequisites this task depends on (docs/explore-notes/
installer-preflight.md, ExecutableResolver, the IEnvironmentCheck/CheckResult/
EnvironmentCheckService abstraction, ProcessCommand, IProcessRunner) existed
only on an unmerged sibling branch, not on main. Rather than merging that
whole unreviewed branch, the specific prerequisite files were copied in
as-is (git history shows their origin). GitCheck/GitIdentityCheck/PortCheck/
WriteAccessCheck from that branch were intentionally NOT brought in — out of
scope for this task.

Deviations/decisions worth flagging:
- Added ExecutableResolver.FallbackDirectories() (public) so ClaudeCliCheck
  can name the checked fallback locations in its failure message; the prior
  branch kept that list private.
- Paths.cs now qualifies System.Environment explicitly. Adding the
  ClaudeDo.Data.Environment namespace makes bare `Environment.X` inside any
  ClaudeDo.Data.* namespace resolve to the sibling namespace instead of
  System.Environment (C# prefers nested/enclosing namespace members over
  usings) — this broke the build until qualified.

Not done (explicitly out of scope): no DI wiring into the wizard UI, no
XAML — matches how the prerequisite Git/Port/WriteAccess checks were also
left unwired.
2026-08-05 19:43:34 +02:00
mika kuns 45bc324402 feat(installer): add Git, GitIdentity, Port, and WriteAccess preflight checks
Implements IEnvironmentCheck for the four checks derivable without a
Claude CLI probe:
- GitCheck (Error) - resolves git via ExecutableResolver (handles .cmd
  shims), parses `git --version`.
- GitIdentityCheck (Warning) - user.name/user.email presence; Unknown
  (not Failed) if git itself is missing, so it doesn't duplicate GitCheck's
  failure.
- PortCheck (Warning) - loopback bind probe for SignalRPort/ExternalMcpPort;
  resolves the owning process via a new NetstatPortOwnerResolver and treats
  a port held by the running ClaudeDo.Worker (update/repair case) as Ok.
  Both ports are configurable, hence a warning.
- WriteAccessCheck (Error) - create+delete a probe file in InstallDirectory
  and ~/.todo-app (walking up to the first existing parent), not an ACL
  read (ACLs lie on virtualized paths).

Process calls go through a new IProcessRunner wrapping the existing static
ProcessRunner, so checks are fakeable in tests instead of spawning real
processes.

DotnetRuntimeCheck was intentionally not added: per
docs/explore-notes/installer-preflight.md, App/Worker publish
self-contained (no preinstalled runtime needed), and the Installer's own
.NET 8 Desktop Runtime requirement is self-proving - a framework-dependent
apphost can't reach managed code at all if that runtime is missing, so a
check running from inside the process can never observe a failure.

Brings in two prerequisite commits this task builds on that hadn't reached
this branch yet: the IEnvironmentCheck/EnvironmentCheckService scaffolding
and the installer-preflight.md research note.
2026-08-05 19:25:02 +02:00
mika kuns 7fa43b5737 Merge branch 'claudedo/40272c0bb3b14562b59c022d09c382b6' into claudedo/c7f96c94227943c8ac7dcf7f3e1cc1a6 2026-08-05 19:15:30 +02:00
mika kuns 30bc12b0ac Merge branches 'claudedo/8e16af76482d423594fb04686caa8097' and 'claudedo/190b7a48d56a4c3799813fb0baed2c4b' into claudedo/c7f96c94227943c8ac7dcf7f3e1cc1a6 2026-08-05 19:14:48 +02:00
mika kuns 8b26e23d73 feat(installer): add check abstraction and EnvironmentCheckService
Scaffolding for environment checks: IEnvironmentCheck, CheckResult/
CheckSeverity/CheckStatus, and EnvironmentCheckService that runs checks
in parallel while preserving input order, never throws (a failing
check becomes Unknown), and reports HasBlockingError only for
Error+Failed.
2026-08-05 19:11:28 +02:00
mika kuns e88f9d01e6 fix(worker): resolve claude CLI shims (.cmd/.bat) not just .exe on PATH
UseShellExecute=false only appends .exe when searching PATH, so an
npm-installed claude.cmd was never found even though it works from a shell.
Adds a shared ExecutableResolver in ClaudeDo.Data (PATH/PATHEXT aware, with
known npm/claude install-dir fallbacks) and wires it into ClaudeCliPreflight
and ClaudeProcess; shims are launched via cmd.exe /c.
2026-08-05 19:08:20 +02:00
mika kuns dce42a9c22 docs(explore-notes): root-cause --permission-mode auto + CLI/.NET preflight research 2026-08-05 18:57:07 +02:00
mika kuns bdee731376 Merge branch 'claudedo/f359858ac98a439593e459df9c5d0a5d' 2026-08-05 16:49:08 +02:00
Mika Kuns d15aa27707 fix:Improve Claude Mds 2026-08-05 16:46:02 +02:00
mika kuns 2700c3d817 fix(usage): stop 429s with an activity-dependent poll cadence + manual refresh
The usage monitor polled the undocumented OAuth usage endpoint every 60s and
earned 429s. It now polls every 5 min while any task is Running and every
15 min while idle (usage_poll_interval_active_seconds / _idle_seconds, both
clamped to >= 60; the old single usage_poll_interval_seconds key is gone).

A 429 comes back as UsageRateLimitedException carrying Retry-After and adds
exponential backoff on top, capped at 30 min and never shorter than the normal
cadence; the strike count resets on the first success. The schedule arithmetic
is the pure static UsagePollSchedule.NextDelay.

Since the idle cadence is slow on purpose, WorkerHub.RefreshUsage drives
UsageMonitorService.RefreshNowAsync behind a Refresh now button in the Usage
Monitor modal: an out-of-band poll that pushes the loop's next-due time out so
no double poll follows, with a 10s cooldown so click-spam can't earn a 429.

Staleness now measures against the slower (idle) interval so an idle worker
isn't flagged stale just for not polling.
2026-08-05 16:40:34 +02:00
mika kuns f6cb8250bb fix(tests): pass FakeTranscriptUsageReader to TaskRunner in FailureDiagnosisTests
The cache-token task added an ITranscriptUsageReader ctor param to TaskRunner
while the failure-diagnosis task added this test file. Both branches were green
in isolation; the merged result did not compile.
2026-08-05 16:15:50 +02:00
mika kuns 79a1403834 Merge branch 'claudedo/87105f5ec4f44af4ae6089cd2e153e3c' 2026-08-05 16:10:37 +02:00
mika kuns 91eb2996c8 Merge branch 'claudedo/2de2f008758640b3a75e95719b1555bf' 2026-08-05 16:07:10 +02:00
mika kuns 19003e68b6 Merge claudedo/38394081d47048fea82317c6c52e01a5 2026-08-05 16:05:19 +02:00
mika kuns 2a217336e8 Merge claudedo/1b599d6711914658b9857b3df996f6e0 2026-08-05 16:03:14 +02:00
mika kuns 18fb483788 Merge claudedo/ca6e55c0992b460e83ff186c076b4315 2026-08-05 16:02:02 +02:00
mika kuns f381ef28fd Merge claudedo/0d0aa8b0fad9484cab532cc19709ec13 2026-08-05 16:01:18 +02:00
mika kuns ca184511ae Merge claudedo/3f39b447914d40d2a90e7505b5aaf432 2026-08-05 16:00:52 +02:00
mika kuns fe9a9596bd Merge claudedo/527b494ae54f4352aec8e381c5afa4f8 2026-08-05 16:00:38 +02:00
mika kuns b153869216 fix(prompts): stop on-disk prompt overrides from freezing forever
EnsureExists blindly seeded ~/.todo-app/prompts/*.md with the bundled
default and never revisited it, so any file created by opening the
Files settings tab shadowed every later default change permanently
(SuggestImprovement/AskUser sections never reached real runs since
2026-06-04). PromptFiles now hashes what a file was seeded/saved with
in prompts/.defaults.json: Classify() tells missing/current-default/
known-past-default/edited apart, ReconcileStaleDefaults() drops files
that only ever matched a superseded default, and real edits are left
alone and surfaced in the Files tab with a diff + reset-to-default
action. QuarantineOrphans() moves stale-named leftovers (agent.md,
planning.md) into prompts/_orphans instead of silently deleting them.
Wired as a Worker startup sweep (PromptFileRecovery) alongside the
existing OrphanRecovery/AttachmentOrphanRecovery services.
2026-08-05 15:59:03 +02:00
mika kuns 2ebdadff08 fix(worker): surface the real reason a Claude run failed instead of a generic exit-code message
StreamAnalyzer now reads subtype/terminal_reason/errors from the CLI's result
event, and TaskRunner builds a specific ErrorMarkdown from them: max_turns names
the turn budget and points at set_task_config + requeue, api_error passes
through the provider's own message (which carries the reset time), and any
other terminal_reason is appended to the previous generic text instead of
staying invisible. Falls through to the old "exited with code N and no result"
text when there's no terminal_reason at all (a real crash).
2026-08-05 15:55:09 +02:00
mika kuns b1e4e543dd docs(localization): rename ConPTY session UI text to interactive session 2026-08-05 15:54:57 +02:00
mika kuns a201d3f43d chore(claude-do): UsageGate: Parallelitaet stufenweise drosseln statt erst bei
## Kontext: Limits sind Fenster, nicht Summen

Die Runs laufen ueber das Claude-Abo. Limits greifen pro 5h-Fenster und pro 7 Tage. Nicht die Wochensumme tut weh, sondern dass ein Agent-Burst ein Fenster leerraeumt, in dem Mika selbst interaktiv arbeiten will.

## Messgrundlage (alle Transcripts unter ~/.claude/projects)

Agent-Runs sind ueber die ganze Historie nur **18,4 %** des Account-Verbrauch

ClaudeDo-Task: 87105f5e-c4f4-4af4-ae60-89cd2e153e3c
2026-08-05 15:53:15 +02:00
mika kuns 7d3d6d7b54 fix(worker): record real raw token usage per run, not the uncached remainder
task_runs.tokens_in only ever held the API's uncached "input_tokens" field
(off by a factor of ~400,000 on a resumed session), and tokens_out summed
only the last result event instead of the whole session. TaskRunner now
reads each run's cache-read/cache-write/input/output totals from the
session transcript via a new ITranscriptUsageReader.ReadSessionTotalsAsync,
storing the delta against prior runs on the same session so a --resume
doesn't double-count. New task_runs.cache_read_tokens/cache_write_tokens
columns; the Session tab now shows the raw total (what actually counts
against the 5h/7d usage limit) with a breakdown tooltip.
2026-08-05 15:44:20 +02:00
mika kuns 08ac8bf7b1 feat(worker): clamp max-turns to a configurable ceiling
Runaway sessions were the single biggest cost driver: model_presets was
never persisted (stayed code-only), default_max_turns shipped at 100, and
ResolveMaxTurns had no upper bound, so a task/list override could run
hundreds of turns unchecked.

- TaskRunner.ResolveMaxTurns now clamps the resolved value to
  AppSettings.MaxTurnsCeiling (new column, default 80) and logs a warning
  with task id / requested / effective value when it clamps.
- default_max_turns default lowered from 100 to 40 (entity, EF config,
  and the seeded row via the new AddMaxTurnsCeiling migration).
- AppSettingsRepository.GetAsync backfills model_presets with the
  shipping defaults on first read instead of leaving the column null.
- Settings > General's per-model preset table and the task/list agent
  editor now show a hint when a set max-turns value exceeds the ceiling.
2026-08-05 15:40:02 +02:00
mika kuns 3ea48ff76d feat(mission-control): make overview panes and queue strip individually resizable
Replace the UniformGrid (all tiles forced to equal size) with a real
Grid + GridSplitters built in code-behind per pane count/column count,
so panes resize individually. The queue strip's fixed 210px column
becomes a drag-resizable Grid column (min 160px) that collapses to 0
when nothing is queued, replacing the old dock-based fixed width.
2026-08-05 15:29:00 +02:00
mika kuns f02b7d6ee5 feat(prompts): add reading-discipline section to default system prompt
Adds a "Reading efficiently" section to SystemDefault covering
locate-before-read (Grep/Glob), targeted reads (offset/limit), avoiding
re-reads, and using an exploration subagent as a context firewall for
orientation questions. Based on measurement across 144 real sessions
showing context resend (Read especially) dominates token cost.
2026-08-05 15:19:15 +02:00
ClaudeDo CI 0e0438d3f2 docs(changelog): update for v2.7.0 2026-08-05 12:35:26 +00:00
mika kuns 83ea429b8a Merge remote-tracking branch 'origin/main'
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
2026-08-05 14:30:56 +02:00
mika kuns 032debc780 docs(handoff): mark the 2026-08-05 list-handler run complete
All three brief tasks and the nine-child Usage Monitor unit are merged and main
verified green. Correct section 5: "exited with code 1 and no result" is a
catch-all, not a CLI crash — max_turns (sonnet presets default to 30 because
app_settings.model_presets is NULL) and the account session limit both surface
through the same message.
2026-08-05 14:28:36 +02:00
mika kuns f822c50102 Merge task: Create a User Centered Readme in the projekt root 2026-08-05 14:17:07 +02:00
mika kuns 7dcbbf484e chore(claude-do): Create a User Centered Readme in the projekt root
this Will outline what this App can do, and how a User Interacts with it

ClaudeDo-Task: a6a6ee7a79f14944ba607ae42ba023da
2026-08-05 14:16:51 +02:00
mika kuns 5872666f31 Merge task branch for: Docs: Usage Monitor in den CLAUDE.md-Dateien und docs/open.md nachziehen 2026-08-05 14:14:05 +02:00
mika kuns cf9dd1cc94 docs(usage): document usage monitor & gate across CLAUDE.md files
Add a dedicated Usage Monitor & Gate section to Worker CLAUDE.md, wire
UsagePillViewModel/UsageMonitorModalViewModel and the new IWorkerClient
usage surface into Ui CLAUDE.md, and record open verification points
for the pill/modal visual pass and the real gate E2E in docs/open.md.
Data CLAUDE.md already covered the new entity columns and migration.
2026-08-05 14:13:06 +02:00
mika kuns 677a4c1853 fix(ui-tests): install a real localizer in UsagePillViewModelTests
The pill tests asserted on real localized strings but never set Loc.Current,
which defaults to a key-echo localizer. They only passed because an unrelated
test class happened to install a real Localizer first; adding the usage-monitor
modal tests changed the ordering and the assertions started seeing raw keys.

Initialize Loc.Current in the constructor, matching every other UI test class.
2026-08-05 14:06:12 +02:00
mika kuns 338fc3903d Merge task branch for: UI: Usage-Monitor-Modal (Gauges + Modell- und Task-Analyse) 2026-08-05 14:00:02 +02:00
mika kuns 57326f1d3b fix(ui): avoid int CommandParameter cast crash in usage monitor presets
RelayCommand<int> casts its object parameter directly rather than
converting it, so a string CommandParameter ("7"/"30") would throw
InvalidCastException at runtime. Split into two parameterless commands.
2026-08-05 13:58:40 +02:00
mika kuns 8103006e26 feat(ui): add usage monitor modal with gauges and model/task usage analysis
Adds a Usage Monitor modal (Worker menu + wired to the footer/Mission-Control
usage pill's Open command): dynamic gauges built from UsageSnapshotDto.Limits
with gate-threshold marks, a stale/blocked-gate band, and Models/Tasks tabs
backed by GetModelUsageAsync/GetTaskUsageAsync over a 7d/30d/custom range.
2026-08-05 13:57:21 +02:00
mika kuns 5115cfc288 Merge task branch for: UI: Gate-Schwellen im Settings-Modal (General) 2026-08-05 13:38:43 +02:00
mika kuns 1e88fefbda feat(claude-do): UI: Gate-Schwellen im Settings-Modal (General)
Macht die zwei Schwellen einstellbar. Setzt den Data-Subtask (Felder in `app_settings`) und den Hub-Subtask voraus.

**Zu bauen**
1. `AppSettingsDto` durchziehen: `WorkerHub` (`GetAppSettings` / `UpdateAppSettings`) und der UI-seitige Record in `WorkerClient.cs` bekommen `UsageGateFiveHourPct` und `UsageGateSevenDayPct`.
2. `GeneralSettingsTabViewModel`: zwei `[ObservableProperty]`-Felder, Validie

ClaudeDo-Task: 06068810-5b5c-4635-80dd-62eeba89fb8c
2026-08-05 13:38:05 +02:00
mika kuns 7661129202 Merge task branch for: UI: Usage-Pill im Footer und im Mission-Control-Header 2026-08-05 13:17:30 +02:00
mika kuns 7cbd4e66ae feat(ui): add usage pill to footer and mission control header
Adds the IWorkerClient/WorkerClient usage surface (GetUsageSnapshot,
GetModelUsage, GetTaskUsage, UsageUpdated event) and a shared
UsagePillViewModel hosted once in IslandsShellViewModel (footer) and
once in MissionControlViewModel (header), showing "5h X% · 7d Y%"
with warn/blocked/stale states via existing design tokens. The
OpenMonitorCommand is wired but currently a no-op, pending the usage
monitor modal.
2026-08-05 13:16:29 +02:00
mika kuns 6e2158d157 Merge task branch for: Worker: Hub-Surface für Usage + Modell pro Run speichern 2026-08-05 12:39:44 +02:00
mika kuns d4cd202460 feat(worker): expose usage/model-usage hub surface and persist run model
Adds GetUsageSnapshot/GetModelUsage/GetTaskUsage to WorkerHub (backed by a
shared UsageSnapshotBuilder), a UsageUpdated broadcast fired after every
UsageMonitorService poll cycle, and records the resolved model on each
task_runs row so per-model/per-task usage can be reported from history.
2026-08-05 12:37:50 +02:00
mika kuns 519ea5a8e0 Merge claudedo/8c1c213004574c4fad6beb75b84b70d7 2026-08-05 12:15:21 +02:00
mika kuns 1aaa40b894 Merge branch 'claudedo/0b2fbb48d44c41558c21d3464c0bd5c2' 2026-08-05 12:11:55 +02:00
mika kuns 3e7126b3f2 Merge branch 'claudedo/9e3071992eca4eb79057d2c675cc57ca' 2026-08-05 12:05:18 +02:00
mika kuns d2ca7fb500 feat(ui): add roadblock reply field to the ROADBLOCK card
A task that reports a roadblock but finishes successfully (Done/WaitingForReview/
Failed/Cancelled) had no way to answer it short of a full reset-and-rerun, losing
the run's context. Adds a reply textbox + Send button to the existing ROADBLOCK
card, modeled on the AskUser question card, that resumes the session via
ContinueTaskAsync with the user's own text. Gated on LatestRunSessionId (disabled
with a hint when there's nothing to resume); failures surface through the footer
error strip instead of a modal.
2026-08-05 11:49:33 +02:00
mika kuns 10e561f336 feat(worker): record merge commit SHA and add revert_merge tool
Persists the merge commit SHA on WorktreeEntity for every successful
single-task and unit merge, and adds a TaskMergeService.RevertMergeAsync
+ revert_merge MCP tool that undoes a merged task's merge via
`git revert -m 1` (never reset/rewrite, since the target checkout is
shared). Rejects cleanly when there's no recorded SHA, the repo is
mid-merge/mid-revert, or the target has foreign uncommitted changes;
a conflicting revert aborts immediately. Also exposes the new
mergeCommit field via get_task_worktree.
2026-08-05 11:46:51 +02:00
mika kuns 32c019bd5d Merge claudedo/20c78c9558cf4c4fa598a00c7cac374f 2026-08-05 11:42:05 +02:00
mika kuns 65db1cdefa feat(planning): let CreateChildTask set maxTurns on child tasks
Planning sessions could already steer a subtask's model but not its turn
budget, so a visibly large subtask would still die at the global default
turn limit. maxTurns is optional (default null = inherit list/global
default, matching model); 0/negative values are rejected as invalid input,
consistent with the existing model-alias validation.
2026-08-05 11:33:16 +02:00
mika kuns 5ba0b63e03 Merge branch 'claudedo/a76d9547ab4a41538b42de79a2c90299' 2026-08-05 11:23:29 +02:00
mika kuns 42c70fb28c Merge branch 'claudedo/99732497092746d193b80b8296804374' 2026-08-05 11:21:12 +02:00
mika kuns c9ba1e2645 feat(worker): add post-merge verification gate for list merges
Per-list optional VerifyCommand (list_config.verify_command) runs via
VerifyCommandRunner in the list's working dir right after a successful
merge/continue-merge, before the task is allowed to reach Done. A
non-zero exit or timeout leaves the merge in place but keeps the task
out of Done and reports StatusVerifyFailed with an output excerpt
through MergeResultDto/review_task; no command configured behaves
exactly as before. Merges against the same repo are now serialized
per working dir so a running verify can't be interrupted by a second
merge landing mid-build. Adds the field to the List Settings modal
(en/de localized) and covers success/failure/timeout in
TaskMergeServiceTests + VerifyCommandRunnerTests.
2026-08-05 11:19:22 +02:00
mika kuns 394febadeb feat(worker): add preview_merge and preview_merge_set MCP tools
Give an autonomous reviewer a non-destructive merge-tree preview
(status/conflicts/changedFileCount/behind) for a task's worktree branch,
plus a file-overlap check across a batch of tasks so same-file collisions
between sibling branches are visible before merging. MergeHelperDefault's
Phase 4 now calls preview_merge_set before merging a batch.
2026-08-05 11:15:43 +02:00
mika kuns 1ee21b560d Merge task branch for: Worker: UsageGate — Queue ab Schwelle pausieren 2026-08-05 11:11:24 +02:00
mika kuns 8f8c2a65b2 feat(claude-do): Worker: UsageGate — Queue ab Schwelle pausieren
> **Stand 2026-08-05 (List-Handler):** Der Roadblock aus dem letzten Lauf ist erledigt. Beide Voraussetzungen sind jetzt auf `main` gemerged: die `app_settings`-Schwellen `UsageGateFiveHourPct`/`UsageGateSevenDayPct` (Merge-Commit `b1efcdc`) und `UsageState`/`IUsageClient`/`UsageMonitorService` unter `src/ClaudeDo.Worker/Usage/` (Merge-Commit `b126a21`). Dein Worktree ist frisch von diesem `main`

ClaudeDo-Task: 06a7cc32-6ab7-4758-98f4-bee77149b2bf
2026-08-05 11:10:30 +02:00
mika kuns 6c5acd09b9 Merge claudedo/0020cd0c4696431996a158f8e1b91cba 2026-08-05 11:05:36 +02:00
mika kuns a7b88098ab Merge claudedo/81e378014c31459c99eb2b140c36ada8 2026-08-05 11:04:53 +02:00
mika kuns ee84a75bd7 Merge claudedo/05827da5ecde413e9a2fe8edd45c24a8 2026-08-05 11:03:56 +02:00
mika kuns 1cc247a590 Merge claudedo/a63c4aaf02a1459f8ffbc43ca542a990 2026-08-05 11:03:09 +02:00
mika kuns c871f35513 chore(worker): external MCP tools return explicit results instead of empty responses
set_task_config/set_list_config now echo which fields were set vs cleared, get_list_config/get_task_config return an explicit found=false instead of null, and delete_list/run_task_now/reset_failed_task/remove_task_attachment return a confirmation record — matching the found/ok convention already used by batch_get_tasks and get_task_log.
2026-08-05 10:58:28 +02:00
mika kuns 3972ce50a6 fix(worker): transport ConPTY task brief via file, not CLI argument
BuildForTaskAsync's fresh-session path flattened the task title+description
into one positional CLI argument, which the ConPTY host joins into a single
command line and claude re-splits on whitespace -- any dash-leading token in
the description (e.g. "->", "--abort") was misread as an unknown option, and
a raw multi-line prompt truncated at its first newline regardless. Now the
brief is written to ~/.todo-app/task-sessions/<taskId>/brief.md and exposed
via --add-dir, with a single-line kickoff pointing claude at it -- the same
pattern BuildForMergeHelperAsync and the planning launcher already use.
2026-08-05 10:54:48 +02:00
mika kuns 8ed6e08710 feat(data): add splitting, turns-preflight, merge-order rules to list-handler prompt
Phase 2 can now propose splitting a bundled/blocked task via add_task/add_subtask
instead of forcing it into one description. Phase 3 reports each task's effective
max-turns and asks before queuing a substantial task with a low value. Phase 4
checks Phase 2's file lists for cross-task collisions and may reorder merges with
a stated reason instead of always following brief order.
2026-08-05 10:51:36 +02:00
mika kuns 194ce58a72 feat(worker): add wait_for_task_change MCP tool
Replaces the list handler's Start-Sleep + blind get_task poll (Phase 3
of the merge-helper prompt) with a blocking MCP tool that returns as
soon as a task leaves Queued/Running, or times out. Implemented as an
async DB poll (short-lived DbContext, 500ms delay, no held connection)
rather than hooking HubBroadcaster, keeping the existing broadcast
callers untouched. timeoutSeconds is clamped server-side to 170s, under
the list handler's 200s MCP_TOOL_TIMEOUT.
2026-08-05 10:49:55 +02:00
mika kuns b38b0857dd feat(worker): render task descriptions into the list-handler brief
Phase 0 forced a batch_get_tasks full-fetch across every task just to see
descriptions, which blew past the client's token limit on larger lists.
brief.md lives on disk and has no such limit, so descriptions now render
there directly (fenced with an extended backtick run, indented under the
list bullet, so embedded headings/lists/code fences can't break the task
list's own structure). Phase 0 now treats the brief as the primary source
and only falls back to batch_get_tasks for fields it doesn't carry.
2026-08-05 10:48:37 +02:00
mika kuns 334cf1e1d2 Merge branch 'claudedo/840fdb981c0e42198062c8769233fc14' 2026-08-05 10:35:30 +02:00
mika kuns b126a21c57 Merge task branch for: Worker: OAuth-Usage-Client + Poller (Usage/) 2026-08-05 10:20:36 +02:00
mika kuns b1efcdce87 Merge task branch for: Data: Usage-Gate-Schwellen + Modell-Spalte auf task_runs 2026-08-05 10:20:23 +02:00
mika kuns e926f4db81 feat(worker): add TranscriptUsageReader for per-model token usage
Aggregates ~/.claude/projects/**/*.jsonl assistant messages by date/model/
scope (ClaudeDo worktree-or-sandbox cwd vs Other), deduped by requestId
(falls back to message.id), with a per-file length+mtime cache so repeat
calls skip unchanged files.
2026-08-05 10:19:27 +02:00
mika kuns 20d17c6887 feat(worker): add OAuth usage client + poller
Adds Usage/ with ClaudeOAuthUsageClient (reads the access token Claude
Code keeps fresh at ~/.claude/.credentials.json, calls the Anthropic
OAuth usage endpoint, defensively parses buckets/limits), UsageState
(threadsafe last-good-snapshot holder that never regresses on
failure), and UsageMonitorService (BackgroundService polling on the
new usage_poll_interval_seconds config, min 15s, one poll at startup,
warns at most once per distinct error).
2026-08-05 10:00:25 +02:00
mika kuns bedd2defbb feat(data): add usage gate thresholds and task run model column
Adds AppSettings.UsageGateFiveHourPct/UsageGateSevenDayPct (defaults
80/90, clamped 0..100 in UpdateAsync) and a nullable TaskRunEntity.Model
column, laying the data foundation for the usage monitor. No worker/UI
changes.
2026-08-05 09:51:42 +02:00
mika kuns 8d7ba1e314 test(ui): poll for the debounced skill auto-save instead of a fixed sleep
Task_toggling_a_skill_auto_saves_selection waited a hard 500 ms for a
debounced save. It passed in isolation and in either half of the suite,
but failed in a full run: this batch added three DB-backed UI test
classes whose real SQLite contexts load the thread pool enough that the
timer callback misses the window. Product code is unchanged.

Same assertion, polled with a 5 s deadline - the pattern the newly added
DetailsIsland/TasksIsland tests already use.
2026-08-05 09:43:13 +02:00
mika kuns 48de816051 Merge claudedo/2e580315aa924bd1b127aa013a127787 2026-08-05 09:30:17 +02:00
mika kuns cc3013a76e Merge claudedo/ff453a6b9db849cd9b076fc63fe5399a 2026-08-05 09:29:51 +02:00
mika kuns eee87f086c fix(ui): drop notification for the removed CanPickUpInTerminal property
Semantic merge collision between two tasks merged in the same run: the
"remove pick up in terminal" refactor deleted DetailsIslandViewModel's
CanPickUpInTerminal, while the status-refresh fix (written against the
pre-deletion base) still raised a change notification for it. Git merged
both cleanly; the build did not.

CanAcceptDrop is still notified - that binding remains.
2026-08-05 09:29:38 +02:00
mika kuns 9804ecefff Merge claudedo/181d4abf368046b99bd57d5e2a7ab97a 2026-08-05 09:27:33 +02:00
mika kuns 7d29ec0725 Merge branch 'claudedo/df0e925e1ff4419d8b7a45f8a184f6d0' 2026-08-05 09:26:46 +02:00
mika kuns 524836ffe8 Merge claudedo/02777289600f499ab274e844eb070ff4 2026-08-05 09:24:21 +02:00
mika kuns 47e2734357 Merge claudedo/b0317ec7aa1a4584ad2cca1e69cf8dd7 2026-08-05 09:23:05 +02:00
mika kuns 37da35b903 Merge claudedo/3be80b8f80654fdfadb26372549bb4c3 2026-08-05 09:22:02 +02:00
mika kuns 6a2a40cd46 Merge claudedo/57abd20b627a48efa23444066d17d8f7 2026-08-05 09:20:39 +02:00
mika kuns 01cd47ffec Merge claudedo/28852ba9219648a19a45fac14d322c36 2026-08-05 09:20:20 +02:00
mika kuns 3a07b319f9 fix(claude-do): Fix: Statuswechsel schlägt in Task-Row und Detail-Pane erst
## Problem (vom Nutzer bestätigt am 2026-08-05)
Wechselt ein Task den Status, bleiben zwei Stellen stehen, bis man die Liste wechselt und zurückwechselt:

1. **Task-Row in der offenen Liste** (mittlere Insel) — Status-Chip und Gruppen-Einordnung aktualisieren sich nicht.
2. **Detail-Pane des ausgewählten Tasks** (rechte Insel) — Status, Review-Buttons und Header-Bar zeigen weiter den alten Zustand

ClaudeDo-Task: 181d4abf368046b99bd57d5e2a7ab97a
2026-08-05 09:19:10 +02:00
mika kuns c07c1f70a8 feat(mission-control): give the list handler its own review task
"Let Claude handle it" now creates one ClaudeDo task per run to host the
ConPTY session (Idle/IsManual, never queued) instead of an untracked
ad-hoc tile, so the run has a real title, diff, and review outcome.
Since the handler merges its own changes straight into the list's
working dir, the task never gets a WorktreeEntity; its review range
lives as new HandlerBaseCommit/HandlerHeadCommit columns on TaskEntity
instead, reusing the existing commit-range diff machinery and keeping
it out of the worktrees overview entirely.
2026-08-05 09:16:34 +02:00
mika kuns 52c2186999 feat(ui): clear focus from textboxes on Escape in the main window
Mirrors the existing click-outside behavior. Scoped to MainWindow only
(not a generic TopLevel handler) so modal Escape-to-close bindings and
Mission Control's ConPTY tiles are unaffected.
2026-08-05 09:13:54 +02:00
mika kuns 47edab5907 feat(ui): show newest log entries first in Log Visualizer
Reverses row order in LogVisualizerViewModel.Apply() so the most recent
entry sits at index 0, matching the "what just happened" use case;
filtering and refresh are unaffected since the reversal happens after
the warn/error filter is applied.
2026-08-05 09:08:30 +02:00
mika kuns 87de53e052 refactor(ui): remove pick-up-in-terminal, keep ConPTY as the single session entry
Two context-menu entries opened a Claude session for the same task via different
mechanisms (embedded ConPTY vs. an external wt terminal). Drop the external-terminal
path entirely, including its worker hub method, launcher plumbing, and localization
keys, since the embedded ConPTY session already covers every case it did.
2026-08-05 09:05:46 +02:00
mika kuns c82ea2eea1 chore(ui): remove dead Mission Control monitor-pane stack
Auto-seeding of monitor tiles was disabled in 724814f; Mission Control now
only shows ConPTY panes. Removes Monitors/EnsureMonitor/SeedActive, the
detach/re-dock machinery, MonitorPaneView and the detached monitor window,
plus their tests and orphaned localization keys.

TaskMonitorViewModel itself stays: DetailsIslandViewModel still uses it as
the backing state for the task Log/AgentState/AskUser-question UI, so only
its Mission-Control-only members (Title/DisplayTitle, detach, cancel command,
IMissionControlPane) were stripped. IMissionControlPane/Panes were kept
(now a 1:1 mirror of ConPtySessions) rather than dissolved, to avoid
churning the still-live ConPTY pane tests and AXAML for a single-implementer
interface.

Visual verification still open: Mission Control with several open ConPTY
tiles, and the Focus/Overview toggle.
2026-08-05 09:03:55 +02:00
mika kuns 7d636c60dd fix(worker): normalize model alias before preset lookup, wire DefaultMaxTurns as fallback
A full model id like claude-sonnet-4-6 in list_config.model never matched any
ModelPresets row (only bare aliases did), so every run under it landed on the
hardcoded 30-turn/DefaultLevel fallback instead of the sonnet preset row -
this is what killed two autonomous tasks at the 30-turn limit.

ModelRegistry.TryNormalizeAlias (non-throwing: exact match, then substring
match against a full model id) lets ModelPresets.For resolve a full model id
to its alias's preset row. For a model that still doesn't resolve, the
hardcoded 30 is replaced by a caller-supplied fallbackMaxTurns, and TaskRunner
now passes AppSettings.DefaultMaxTurns there - so that setting has a real
effect instead of being dead, matching the direction already noted in
docs/open.md.
2026-08-05 08:56:23 +02:00
mika kuns 0b6792620c chore(worker): bump ModelContextProtocol SDK from 1.2.0 to 2.1.0
Both packages restore and build cleanly at 2.1.0 (no NU1605/NU1701,
transitive Hosting/Caching.Abstractions bump to 10.0.10 resolves without
conflict). WithHttpTransport/WithRequestFilters/AddCallToolFilter/MapMcp
APIs are unchanged, so no code in External/ or Planning/ needed touching.
All 699 Worker.Tests pass.
2026-08-05 08:50:28 +02:00
mika kuns fd50a4fb7c fix(prompts): only ask list-handler dedupe questions when a candidate exists
Phase 1 previously asked the user to confirm the absence of duplicates
even when no candidate pair was found. The handler now decides that
itself and moves straight to Phase 2; per-pair questions remain when
at least one candidate exists.
2026-08-05 08:47:41 +02:00
ClaudeDo CI 342a061d94 docs(changelog): update for v2.6.0 2026-08-04 15:09:42 +00:00
Mika Kuns 63d8b5c28d fix(installer): unbreak the update path and cache the download
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 40s
Every update failed at "Could not replace the existing files": the app
relaunches the installer via ShellExecute without a working directory, so
it inherited the app's CWD - which the Start Menu shortcut sets to
<InstallDir>\app. A process's current directory is locked by Windows, so
the installer blocked its own `app` -> `app.bak` rename. Retries and
reboots could not help.

- installer moves its CWD to %TEMP% at startup, and both relaunch sites
  in the UI pass an explicit WorkingDirectory
- cache the release zip in %TEMP%\ClaudeDo-download-cache and reuse it on
  a retry while its SHA-256 still matches, so a failed attempt no longer
  costs another full download; drop it after a successful install, delete
  a mismatching one, prune zips of other versions
- roll back a half-done stash: a leftover app.bak was deleted as a stale
  stash on the next attempt, and that copy was the only one left
- name the blocked path in the error message
2026-08-04 17:09:10 +02:00
mika kuns ab56644ddc fix(ui): clear task selection when switching lists
Switching lists rebuilt Tasks.Items from scratch but left SelectedTask pointing
at a row from the previous list, so the detail pane kept showing that task even
though it is not in the visible list. Drop the selection when the list actually
changes; a reload of the same list (worker refresh, reconnect) keeps it so a
live update never yanks the detail pane away.
2026-08-04 17:09:10 +02:00
ClaudeDo CI 71050e2634 docs(changelog): update for v2.5.0 2026-07-29 15:02:02 +00:00
Mika Kuns ef7645c6b1 Merge claudedo/a6a2df8848a8460aa1fe57c1b69aff47
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 38s
2026-07-29 15:11:35 +02:00
Mika Kuns 92ce7a4a77 feat(ui): move a task to another list via drag & drop
Dragging a task row onto a user list in the Lists island now reassigns it
to that list. The task drag holds the pointer capture, so the release is
resolved geometrically (new case between the Mission Control and reorder
cases) instead of going through the Lists island's own DragDrop path,
which never sees a DragEventArgs during a task drag.

TaskRepository.MoveToListAsync reassigns the task plus every descendant
(a child must never sit in a different list than its parent) and appends
the task at the end of the target list. Guards: running tasks and tasks
holding an Active/Kept worktree are rejected to the footer error strip;
a move that changes repo asks for confirmation naming both repos.

The source repo is read from the task's own list rather than the island's
current list, which is a smart/virtual list with no working dir of its own
whenever one of those is shown.
2026-07-29 15:10:59 +02:00
mika kuns 4dc4fe27e3 Merge claudedo/f4d2d2f8eef340ca82540a189f9e299a 2026-07-29 13:29:18 +02:00
mika kuns 0ee30bee03 Merge claudedo/3832008a0b5147bf8308407ed4bd1ded 2026-07-29 13:26:15 +02:00
mika kuns b9e0721875 Merge claudedo/113934cda24a4ae99e15d77fca29507e 2026-07-29 13:25:08 +02:00
mika kuns 92eb654f9b Merge claudedo/50a6027eba294dde8b18e4082dfc1e9b 2026-07-29 13:24:50 +02:00
mika kuns 724814f770 Merge claudedo/9c97a8ca2e134729bf96ca282cfbf346 2026-07-29 13:24:26 +02:00
mika kuns b5464fc533 fix(worker-client): stop swallowing mutating hub call failures
TryInvokeAsync catches every exception and returns null, which is fine
for read-only calls but hid real HubException reasons behind a generic
"offline" message for the 7 mutating call sites (RestoreDefaultAgents,
UpsertPrimeSchedule, AddDailyNote, CleanupFinishedWorktrees,
ResetAllWorktrees, ForceRemoveWorktree, BuildPlanningIntegrationBranch)
— the same bug class fixed for ApproveReview in e1807fd. Each of the 22
TryInvokeAsync call sites was audited; the 15 read-only ones are left
unchanged (empty/offline is the right display). For the 7 switched to
a direct hub invoke, every caller was checked and, where it had no
catch, one was added so the exception surfaces (StatusMessage,
ShowErrorAsync/CombinedWarning) instead of crashing.
2026-07-29 13:19:38 +02:00
mika kuns 44cdad386c feat(mission-control): sidebar shows queued+running, no auto-monitor seeding
- TaskStarted no longer creates a monitor pane; Panes stays empty unless a
  ConPTY session is explicitly opened
- SeedActive no longer called on construction or ConnectionRestored
- EnsureMonitor / SeedActive kept compiling and functional (internal), just unused
- RefreshQueueAsync now loads Queued + Running tasks, Running sorted first
- QueuedTaskViewModel gains IsRunning + OpenInAppCommand (IRelayCommand)
- Sidebar row is a Button; running rows get RunningTint overlay + "Running" label
- HasQueued is true whenever any queued or running task exists
- New localization keys: missionControl.running (en + de)
- Tests updated: 12 monitor-machinery tests use EnsureMonitor directly;
  3 new acceptance-criteria tests (no-auto-pane, running-first sort, row click)
2026-07-29 12:12:34 +02:00
mika kuns 58f8b11dbb fix(worker-tests): pin LogRingBuffer clock in Does_not_throw_when_detached
The test built the buffer with the real DateTime.UtcNow clock but emitted
an event with the file's fixed EvtTime timestamp (2026-06-23). As real
time drifted more than the 1h window past that fixed timestamp, the
just-appended record was evicted inside the same Append call, before
Snapshot() ran. BroadcastLogSink.Emit itself buffers unconditionally
regardless of attach state, so this was a test bug, not a sink bug. Pin
the buffer's clock to EvtTime, matching every sibling test's NewSink setup.
2026-07-29 12:10:58 +02:00
mika kuns e653677487 feat(worker): expose maxParallelExecutions in get_app_settings
The list-handler prompt (MergeHelperDefault) tells the handler to read
maxParallelExecutions via get_app_settings, but the DTO never carried
the field. Handler had to fall back to reading app_settings directly
from the DB on the 2026-07-29 E2E run.
2026-07-29 12:06:52 +02:00
mika kuns 994e94c2af fix(claude-do): List-Handler-Session mit --permission-mode auto starten
## Problem
Die "Let Claude handle it"-Session (List-Handler) startet mit `--permission-mode default` und fragt
dadurch bei jedem Tool-Aufruf nach Bestätigung. Sie soll autonom durchlaufen können — der User
überwacht die ConPTY-Kachel, statt jede Aktion einzeln freizugeben.

## Ist-Zustand
`src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, `BuildForMergeHelperAsync`
(Zeile ~183-249). Die

ClaudeDo-Task: 50a6027eba294dde8b18e4082dfc1e9b
2026-07-29 12:03:30 +02:00
mika kuns db447f36da Merge remote-tracking branch 'origin/main' 2026-07-29 09:29:32 +02:00
mika kuns df2fcd8def Merge claudedo/9a0fd00eaf164f3883e4a9e7e28ac99f 2026-07-29 09:15:06 +02:00
mika kuns 17ef99bc9b Merge claudedo/c90fe66442cb4b72b0476777c2225c65 2026-07-29 09:13:53 +02:00
mika kuns c4425d6499 Merge claudedo/5af6ac9acb4447929e3e550c3c233a25 2026-07-29 09:13:06 +02:00
mika kuns be6ccb2c17 Merge claudedo/f6226a2ec97d41fe8906f1f86ed33e41 2026-07-29 09:12:51 +02:00
mika kuns 57d433276e Merge claudedo/c0630785eb8e47c8bf1da4108028a3c4 2026-07-29 09:12:36 +02:00
mika kuns 785ebe55e4 Merge claudedo/85e48360c061409a9ffd9c772450cf73 2026-07-29 09:12:24 +02:00
mika kuns e1807fd53b fix(review): propagate HubException from ApproveReviewAsync so blocked merges surface errors
TryInvokeAsync swallowed all exceptions including HubException, so a
blocked merge (uncommitted changes in target, mid-merge state, inactive
worktree) returned null silently — the task stayed WaitingForReview with
no feedback shown.  Switch to a direct _hub.InvokeAsync so both VM
catch blocks (TasksIsland ErrorReported, DetailsIsland ShowErrorAsync)
actually fire.

Add regression tests for both call sites verifying that a throwing
worker causes the error to be reported.
2026-07-29 09:08:19 +02:00
mika kuns 149e2adadb feat(ui): accent color presets in Settings → General
Adds Moss / Peat / Sea preset swatches to the General settings tab.
Selecting a preset mutates the live SolidColorBrush objects in the
Application resource dictionary so all StaticResource consumers update
instantly; the choice is persisted to ui.config.json and re-applied
at startup. Missing or unknown saved value falls back to Moss.
2026-07-29 09:08:13 +02:00
mika kuns d569313598 feat(mcp): allow Done via update_task_status with worktree guard
External tasks finished outside a ClaudeDo run had no way to close out
their tracking task; update_task_status now permits Done alongside
Idle/Queued/Cancelled, refusing it when the task has an active
worktree so review_task stays the only path that merges.
2026-07-29 08:58:34 +02:00
mika kuns 0a3c25840f feat(claude-do): Neu angelegter Task soll direkt ausgewählt werden (Detailpan
## Problem
Nach "Neuen Task hinzufügen" (Eingabefeld im Tasks-Header, `Ctrl+N`) wird der Task angelegt und in die
Liste eingefügt, aber **nicht ausgewählt**. Um Beschreibung/Config zu ergänzen, muss man ihn erst
manuell anklicken.

## Ist-Zustand
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, `AddAsync()` (ab Zeile ~424):
legt die `TaskEntity` an, speichert über `TaskRepository.AddA

ClaudeDo-Task: 5af6ac9acb4447929e3e550c3c233a25
2026-07-29 08:57:55 +02:00
mika kuns 7d6cb2bd3e feat(ui): add Let Claude handle it broom button to tasks header 2026-07-29 08:55:26 +02:00
mika kuns 7c8a9dd61b refactor(claude-do): Task-Kontextmenü: "Als manuell markieren" in das "Markieren
## Problem
Im Task-Kontextmenü gibt es bereits ein Untermenü "Markieren als …" (`tasks.ctxMarkAs`) mit
"Erledigt" und "Abgebrochen". Die beiden Manuell-Einträge hängen dagegen ganz unten flach im
Hauptmenü — inkonsistent, obwohl sie semantisch dasselbe "markieren als"-Verb sind.

## Ist-Zustand
`src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`:
- Zeile ~47-50: `<MenuItem Header="{loc:Tr tasks.ctxM

ClaudeDo-Task: c0630785eb8e47c8bf1da4108028a3c4
2026-07-29 08:53:05 +02:00
ClaudeDo CI 3fbbd7ab93 docs(changelog): update for v2.4.0 2026-07-27 13:07:19 +00:00
Mika Kuns 24f999facd docs: record the manual-task, effort-preset and chip/spinner changes
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 38s
2026-07-27 15:02:51 +02:00
Mika Kuns 3a648b7d77 feat(tasks): mark tasks and lists as manual
Reminders written down as todos had no home: every task looked like Claude work.
A manual task now shows a MANUAL badge and hides send-to-queue, refine and the
planning session; the queue picker, daily prep and the list handler all skip it,
with a TaskStateService guard so the MCP surface and hub cannot start one either.
Opening a hand-driven ConPTY session stays available on purpose.

A list can be marked manual in its settings, which makes tasks created there
(UI and MCP add_task) start out manual. Toggle per task from its context menu.
2026-07-27 15:02:51 +02:00
Mika Kuns fde9615b34 feat(settings): per-model effort and turn presets
ClaudeDo never passed --effort, so every session inherited whatever effortLevel
the user's Claude Code config happened to carry. Settings -> General now holds one
row per model alias (haiku medium/20, sonnet high/30, opus high/40, fable high/25)
supplying the global effort and turn defaults; list- and task-level max-turns
overrides still win, and the agent editor's inherited badge follows the model.

--effort is applied to autonomous runs and to every ConPTY spec (task session,
planning start/resume, ad-hoc, list handler). The model itself is deliberately not
forced on interactive sessions. The single global 'Max turns' field is replaced by
the table, and 'fable' joins ModelRegistry.Aliases.

The migration also adds the is_manual columns used by the next commit.
2026-07-27 15:02:51 +02:00
Mika Kuns c93a20f20c feat(ui): Interactive chip for tasks with an open ConPTY session
A task driven by hand in Mission Control is Idle with an Active worktree, so its
lifecycle chip read "Parked" — indistinguishable from a task genuinely set aside.
The shell now mirrors Mission Control's open panes onto the rows, which show an
accent "Interactive" chip instead; tapping it surfaces Mission Control and
focuses that pane (the open command already dedupes by task id).
2026-07-27 15:02:51 +02:00
Mika Kuns 1466d0fbab feat(ui): spinners for ConPTY session start and task refine
Both actions previously gave no feedback: opening a ConPTY session only created
the tile after the launch-spec roundtrip (which may build a worktree), and the
refine button just disappeared while the run was in flight.

Add a shared Ellipse.spinner style, and let ConPtyPaneViewModel resolve its own
launch spec so the tile shows up immediately with a starting overlay. A failed
launch now keeps the tile with its inline error banner instead of never
appearing — Start() is separated from the ctor so the host can subscribe to
ErrorReported before the launch begins.
2026-07-27 15:02:51 +02:00
Mika Kuns 6c8de4aef3 style(ui): pad the diff-viewer file-tree stats off the right edge 2026-07-27 15:02:50 +02:00
Mika Kuns 7f9f0ca128 fix(ui): bind search focus to Ctrl+K instead of OemQuestion
OemQuestion is the '#' key on a German layout, so the window-level binding both
swallowed '#' app-wide and left Ctrl+K dead — even though the search box already
advertises 'Ctrl K' as its shortcut.
2026-07-27 15:02:50 +02:00
Mika Kuns edd2774d86 fix(ui): persist title edits from the detail pane
EditableTitle had no save handler at all (only EditableDescription did), so
retitling a task in the details island was silently discarded on rebind. Mirror
the debounced description save, capturing the row so a task switch mid-debounce
cannot retitle the wrong task.
2026-07-27 15:02:50 +02:00
Mika Kuns 62fc5aaa5a docs(ui): document the list-handler selection modal 2026-07-27 15:02:50 +02:00
Mika Kuns 023e136c53 docs: describe the list-scoped five-phase handler 2026-07-27 15:02:50 +02:00
Mika Kuns 4877802bcd refactor(worker): make the list-handler launch spec single-list and single-repo 2026-07-27 15:02:50 +02:00
Mika Kuns 40eb979924 refactor(ui): scope "Let Claude handle it" to a single list 2026-07-27 15:02:50 +02:00
Mika Kuns e3bacc3143 feat(data): five-phase list-handler prompt with dedupe and enhance 2026-07-27 15:02:50 +02:00
Mika Kuns cc823ec4f6 feat(worker): allow update_task_status to set Cancelled 2026-07-27 15:02:50 +02:00
Mika Kuns ec10b06848 docs(plans): implementation plan for the per-list task handler 2026-07-27 15:02:50 +02:00
Mika Kuns 81cf94187f docs(specs): per-list task handler with read/dedupe/enhance/run/merge 2026-07-27 15:02:50 +02:00
ClaudeDo CI 538b4ede09 docs(changelog): update for v2.3.1 2026-07-24 12:49:31 +00:00
mika kuns 95918414a0 fix(installer): retry stashing app/worker through transient file locks on update
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
The update pipeline moved app/ and worker/ to .bak before extraction with no
retry, so a just-killed worker whose file handles had not been released yet
(WaitForExit returns before the OS flushes them) caused Directory.Move to throw
ERROR_SHARING_VIOLATION, stopping the pipeline on a cryptic error screen.

- DownloadAndExtractStep: stash/rollback moves+deletes now retry through
  transient IO/access errors (~5s); a persistent lock returns an actionable
  message instead of the raw error.
- StopWorkerStep: reading MainModule no longer skips Kill on failure, and a
  short settle follows the kill so handles are released before extraction.
2026-07-24 14:49:15 +02:00
ClaudeDo CI 9349386675 docs(changelog): update for v2.3.0 2026-07-24 12:21:49 +00:00
mika kuns 083e1f3948 feat(ui): open merge-helper ConPTY tile from selection
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
2026-07-24 14:21:26 +02:00
mika kuns ecbc73495c feat(ui): add "Let Claude handle it" entry points 2026-07-24 14:21:26 +02:00
mika kuns 327ae2b69c feat(ui): add merge-helper task selection dialog 2026-07-24 14:21:26 +02:00
mika kuns 5ba0c09d8f feat(worker): expose merge-helper launch spec over hub + client 2026-07-24 14:21:26 +02:00
mika kuns 78d4e1a46b feat(worker): build merge-helper interactive launch spec 2026-07-24 14:21:26 +02:00
mika kuns c7d64e9c9b feat(data): add merge-helper prompt templates 2026-07-24 14:21:26 +02:00
mika kuns 7517f2a9b3 feat(worker): add continue_merge and abort_merge MCP tools 2026-07-24 14:21:26 +02:00
mika kuns f4f7c81059 feat(worker): let review_task/merge_task leave conflicts in tree via MCP 2026-07-24 14:21:26 +02:00
mika kuns 2a3ab5504a docs(merge-helper): helper handles all merges; manual conflict fallback 2026-07-24 14:21:25 +02:00
mika kuns 962f68c92b docs(merge-helper): spec + implementation plan 2026-07-24 14:21:25 +02:00
ClaudeDo CI d12a888683 docs(changelog): update for v2.2.0 2026-07-24 11:25:59 +00:00
mika kuns 5c3ec4810e Merge origin/main (v2.1.0 changelog)
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
2026-07-24 13:25:42 +02:00
mika kuns 49222a92a4 Merge claudedo/5d627df8 — #12 planning permission-mode default 2026-07-24 13:20:19 +02:00
mika kuns 109a35c505 feat(review): submit interactive (ConPTY) work for review
An embedded ConPTY session leaves its worktree changed but never touches
task status, so hand-driven work had no path into the review/merge flow.

Add SubmitTaskForReview: commit the worktree (same auto-commit as a headless
run), then transition Idle/Failed -> WaitingForReview via the new
TaskStateService.SubmitInteractiveForReviewAsync. Approve then merges it.

Surfaces: a 'Submit for review' button in the detail work console (shown for
an Idle/Failed task with a worktree) and on the ConPTY Command Center pane
header (task-based panes; closes the pane on success). Tests cover the new
transition (Idle/Failed accepted, Running/Queued/Done/Review rejected).
2026-07-24 13:05:22 +02:00
mika kuns 34b17537fc Merge claudedo/f9809a93 — Approve & Merge diff gate 2026-07-24 12:53:07 +02:00
mika kuns 2aaaa23912 feat(review): gate Approve & Merge behind opening the diff
Approve & Merge is disabled until the pending changes have been inspected:
DetailsIslandViewModel tracks ReviewDiffViewed (reset on task switch and on
every state change), MergeSectionViewModel raises DiffViewed when a diff or
combined diff is opened and exposes HasReviewableDiff, and a hint sits next
to the button. A review with nothing to inspect (sandbox run, no worktree)
approves straight through.

ClaudeDo-Task: f9809a93
2026-07-24 12:52:57 +02:00
mika kuns 624ec7a668 fix(planning): use default permission mode so MCP planning tools don't prompt
Interactive planning sessions launched with --permission-mode plan, which
gates EVERY MCP tool call regardless of --allowedTools (verified: even a
read-only mcp__claudedo__list_task_lists is denied under plan mode). So the
session prompted the user on the first CreateChildTask -- the whole point of
a planning session.

Switch BuildPlanningStartArgs/BuildPlanningResumeArgs to --permission-mode
default, which honours the allowlist. File edits stay blocked via the planning
system prompt + AllowedTools omitting Write/Edit/Bash. Resume also re-passes
--allowedTools, since the CLI does not restore it across --resume.

The earlier 'glob does not match' hypothesis was empirically falsified:
mcp__claudedo__*, the bare server name, and the explicit tool name all allow
the tool with zero permission_denials in default mode.
2026-07-24 12:38:45 +02:00
mika kuns 3e9ea3ad58 Merge claudedo/c14606f009e44e4ba04bfe5bcbc884ad 2026-07-24 12:13:18 +02:00
mika kuns ef285b21fd feat(planning): run interactive planning sessions via embedded ConPTY
Planning start/resume opened an external Windows Terminal (wt) window.
Route them through the embedded ConPTY Command Center pane instead, matching
the existing interactive-session UX (no external window).

- Extract bare planning arg builders (BuildPlanningStartArgs/ResumeArgs) from
  WindowsTerminalLauncher; the wt path still uses them (kept, not removed).
- InteractiveLaunchSpecService.BuildPlanningStart/Resume map a planning
  context into a LaunchSpec (planning args + env: MAX_THINKING_TOKENS,
  CLAUDEDO_PLANNING_TOKEN). Hub GetPlanningStart/ResumeLaunchSpec run
  StartAsync/ResumeAsync then return the spec.
- UI: OpenPlanningSession + the resume branch raise OpenPlanningConPtyRequested;
  the shell opens Mission Control and hosts a planning ConPTY pane.

Env is process-global by design (sequential human-paced sessions). wt planning
code retained. Tests added for the arg/env mapping.
2026-07-24 12:12:23 +02:00
mika kuns 2612831a5e docs(planning): spec for ConPTY planning sessions 2026-07-24 12:12:22 +02:00
mika kuns 5e27486b18 chore(claude-do): Update notif popup is still not in the bottom Right hand cor
THe update notification should be in the Bottom Rught hand corner as a Small Popup card. IT should have the Update and Dismiss Butotns. It should only show when there is a new Update available, not if everything is up to date

ClaudeDo-Task: c14606f009e44e4ba04bfe5bcbc884ad
2026-07-24 11:55:29 +02:00
mika kuns 04044bd115 docs(fix-plan): record session progress (A/B done, C#9+#11, D#13+#14; C#10/#12 + group E deferred) 2026-07-24 11:44:21 +02:00
mika kuns 798d100636 feat(ui): AskUser question banner in the detail island
The inline answer banner for a running task's AskUser question only existed
in Mission Control. Surface the same banner in the detail pane, bound to the
detail island's existing TaskMonitorViewModel (shared state — no new state,
no duplication) so a question can be answered without opening Mission
Control. Mirrors MC: live TaskQuestionAsked events for the selected task.
2026-07-24 11:41:12 +02:00
mika kuns e8f7e3a47a fix(ui): hide misleading Idle chip on planning parents
A planning parent stays Status=Idle while its session is Active (or
Finalized-but-not-queued), so the lifecycle chip read "Idle"/"Parked"
next to the PLANNING/PLANNED badge. Hide the chip in that case; the badge
already conveys the state.
2026-07-24 11:38:49 +02:00
mika kuns 8a7275a75f fix: show structured-output summary instead of raw JSON in OUTCOME
A --json-schema run can echo the structured {"summary":...} JSON as the
result text, which then landed verbatim in task.Result and rendered raw in
the OUTCOME card. Unwrap the summary in StreamAnalyzer when the result text
is itself such a JSON object (a plain markdown result is kept as-is), and
add a UI safety net in ApplyOutcome for rows already persisted as raw JSON.
2026-07-24 11:36:25 +02:00
mika kuns f4dd67d595 fix(ui): live-refresh child rows on parent planning transitions
Finalize/Discard broadcast only the parent's TaskUpdated; the delta path
updated the parent row but never recomputed child-derived flags or dropped
discarded children, so subtasks stayed "Draft" after finalize and deleted
rows lingered after discard until a manual reload. Reconcile the whole
list when the updated task is (or owns) a planning subtree.
2026-07-24 11:26:19 +02:00
mika kuns 0226c98076 fix(ui): restore turn/token counts on task reload
Turns/Tokens were never populated from history, so a reloaded terminal
task showed "0/max". Restore them from the latest task_run.
2026-07-24 11:21:21 +02:00
mika kuns b9b3053051 fix(ui): clearer rename display in diff viewer
Show renames as old → new in the file header and suppress the misleading
"+0 −0" stats for a pure rename (the R badge already conveys it).
2026-07-24 11:21:20 +02:00
mika kuns 671c886c75 fix(ui): diagnostic error surfacing on attachment drop
Opening the dropped IStorageFile stream ran outside the try, so a
first-drop failure escaped the async-void handler as an unobserved fault
(generic "An error occurred"). Wrap the stream-open loop and surface the
exception type via DropStatus; include the exception type in the generic
add-file failure too, so the intermittent case is analyzable.
2026-07-24 11:18:06 +02:00
mika kuns ffff1ee183 fix(ui): surface resume-planning-session failures
ResumePlanningSessionAsync wrapped everything in an empty catch, so a
failed resume vanished silently. Report it via the footer error channel
with a dedicated planningResumeFailed message.
2026-07-24 11:17:57 +02:00
mika kuns da6a70aec1 feat(ui): conflict resolver shows why Continue is disabled
Continue was already gated on CanContinue (all files resolved); surface a
footer hint counting the still-unresolved conflicts/files so the disabled
state is explained, not just for binary files.
2026-07-24 11:14:06 +02:00
mika kuns 85d0f9dcec feat(ui): session-skills empty-state + neutral subtask terminology
Show an explanatory empty-state under the install row when no skills are
installed. Rename the unified-parent status/labels from "Improvements" to
neutral "Subtasks" (waitingForChildren, agentStatus.children,
childOutcomesLabel) since WaitingForChildren now covers planning too.
2026-07-24 11:14:01 +02:00
mika kuns ad2acddc9a fix(ui): render Plus and agent-settings gear icons
Icon.Plus was stroke-only geometry, invisible when used in a PathIcon
(fills). Author it as a filled cross, fixing every PathIcon use at once
(New-session, add-list, add-task). Replace the header bar's raw gear
TextBlock with the shared Icon.Settings PathIcon for consistency.
2026-07-24 11:13:52 +02:00
mika kuns efd7cc9b0a docs(verification): add fix-plan for fresh session (findings grouped by fixability); defer §10, mark §11 OK per Mika 2026-07-24 11:06:57 +02:00
mika kuns 75a6e0efc4 docs(verification): §3 UnfinishedPlanning modal (Finalize/Discard PASS, Resume BUG); generalize child-row live-refresh finding; edges done 2026-07-24 11:00:56 +02:00
mika kuns 9efc5c90f4 docs(verification): finding — Resume planning session is broken (session_id never captured) + error swallowed by empty catch 2026-07-24 10:58:37 +02:00
mika kuns 6ca8cac79f docs(verification): §1 DiffModal error-state resolved via code analysis (defensive/unreachable, gates prevent it) 2026-07-24 10:34:05 +02:00
mika kuns 9a1fa3dbaf docs(verification): §4 merge-editor Abort PASS (tree clean, task stays WaitingForReview) 2026-07-24 10:32:12 +02:00
mika kuns 9b4d3431b0 docs(verification): §9 attachments drag&drop UI PASS (overlay/drop/picker/remove); finding — intermittent first-drop error 2026-07-24 10:26:42 +02:00
mika kuns a1ba3b6ebb docs(verification): §8 Session Skills complete (Remove PASS); refresh handoff summary + fixture state 2026-07-24 10:15:45 +02:00
mika kuns 9b041ba791 docs(verification): §8 per-task activation + no-leak counterprobe PASS; UI partial (cards/general-tab open) 2026-07-24 10:12:39 +02:00
mika kuns 39fc594b98 docs(verification): finding — agent-settings gear uses Unicode glyph, not Icon.Settings PathIcon (inconsistent) 2026-07-24 10:10:15 +02:00
mika kuns b353ed6cfd docs(verification): §8 skill install PASS (6 skills, commit-pinned); finding — Skills tab has no empty-state 2026-07-24 10:06:31 +02:00
mika kuns 416e47ecef docs(verification): note Mika explicitly wants AskUser interaction in detail island 2026-07-24 10:04:06 +02:00
mika kuns e8b5e97a9b docs(verification): §7 AskUser complete — timeout UI-cleanup visually verified (banner clears) 2026-07-24 10:01:56 +02:00
mika kuns d19ef5403f docs(verification): §7 AskUser PASS (happy-path + backend timeout); finding — banner only in Mission Control, absent in detail island 2026-07-24 09:54:07 +02:00
mika kuns ded068c564 docs(verification): note fixture cleanup (verif tasks/worktrees removed, ClaudeDoTests reset) 2026-07-24 09:37:00 +02:00
mika kuns 9556e0e848 docs(verification): refresh handoff for next session — progress, remaining (§7-§11+edges), gotchas, fixture state 2026-07-24 09:33:53 +02:00
mika kuns 9d39a8fc69 docs(open): trim to actively-verified 2026-07-24 findings; drop stale manual-verif/historical blocks 2026-07-24 09:30:56 +02:00
mika kuns b536b6fb66 docs(verification): §5 ConPTY/Mission Control PASS (prompt-send, close kills proc); ad-hoc icon invisible + re-open re-sends noted 2026-07-24 09:27:22 +02:00
mika kuns 1b80bb0a2d docs(open): §5 findings — invisible New-session icon (Icon.Plus stroke-only), re-open re-sends prompt 2026-07-24 09:24:15 +02:00
mika kuns 4394623bb0 docs(verification): §3 PASS end-to-end (+§1 children-band, +§4 planning-conflict); dequeue-X UX nit 2026-07-24 09:18:10 +02:00
mika kuns f0b0582517 docs(open): §3 finalize findings — improvements-mislabel, child-badge live-refresh, chain not visualized 2026-07-24 09:10:44 +02:00
mika kuns 07de897147 docs(open): planning session permission-prompt bug + planning-active parent shows Idle (UX) 2026-07-24 09:06:03 +02:00
mika kuns 21012283a9 docs(verification): clean additive approve PASS 2026-07-24 08:56:57 +02:00
mika kuns 8241bf8d41 docs(verification): §1 commit-range-after-merge PASS; blocked-merge silent-fail confirmed on clean path too 2026-07-24 08:52:12 +02:00
mika kuns 255705d8bf docs(verification): §4 merge editor PASS end-to-end + UX findings (continue-btn, multi-file, blocked-merge) 2026-07-24 08:48:03 +02:00
mika kuns 3dfd75fba8 docs(open): Approve & Merge silently swallows a blocked merge (no footer error) 2026-07-24 08:43:37 +02:00
mika kuns 26c03a5a5f docs(verification): §1 findings — raw-JSON outcome bug, rename/turns nits, session-tab expected 2026-07-24 08:26:33 +02:00
mika kuns 3211bfc0f9 docs: correct permission finding — auto+haiku denies writes (not a CLI regression), §2 happy-path PASS 2026-07-24 08:08:15 +02:00
mika kuns 79ce7afe46 docs(verification): log autonomous-batch results (§2/§6/§9/§12) 2026-07-24 07:54:20 +02:00
mika kuns 0ad93f48b5 docs(open): track CLI 2.1.207 --permission-mode auto write-denial regression 2026-07-24 07:50:44 +02:00
mika kuns fee69998f8 fix(worker): kill cancelled runs' processes and make MCP approve actually merge
- CancelAsync now signals the running Claude process of the cancelled task and
  its cascaded children via the new RunCancellationRegistry (queue + override
  slots register their CTS there) instead of only flipping DB state.
- external MCP review_task 'approve' now mirrors the hub's ApproveReview:
  unit merge for parents, ApproveAndMergeAsync for childless tasks, optional
  targetBranch; ReviewTaskResult carries mergeStatus/conflicts.
2026-07-23 20:24:36 +02:00
mika kuns 451afc80f8 Merge task branch for: fix(worker): external MCP optional params are generated as required; errors are opaque 2026-07-23 20:08:26 +02:00
mika kuns 4b2667062f Merge task branch for: chore: remove dead GitService.MergeFfOnlyAsync, move AgentMcpTools to its own file, fix stale Zitadel doc note 2026-07-23 20:08:15 +02:00
mika kuns 305eb6ee8b fix(ui): UnifiedDiffParser handles paths with spaces and git-quoted paths
# Conflicts:
#	docs/open.md
2026-07-23 20:07:53 +02:00
mika kuns 15bef2a2e3 fix(git): merge preflights ignore untracked files (HasChangesAsync includeUntracked)
# Conflicts:
#	docs/open.md
2026-07-23 20:07:33 +02:00
mika kuns d7ebafd556 fix(worker): make external MCP filter params optional, surface tool errors
Nullable filter/patch params across the External/ MCP tool classes (ListTasks,
UpdateTask, AddSubtask, ReviewTask, SetMyDay, SetListConfig/SetTaskConfig,
CreateList/UpdateList) lacked C# default values, so the generated tool schema
marked them required — MCP clients omitting them (the common case) failed.
Gave every such parameter a default value.

Also registered a call-tool filter (ExternalMcpExceptionFilter) on the external
MCP host that translates InvalidOperationException/ArgumentException into
McpException, since the SDK's own catch-all discards ex.Message for any other
exception type and returns a generic "An error occurred invoking 'X'." string.

Added a reflection-based schema test sweeping every [McpServerToolType] class
to guard against reintroducing a required-but-nullable parameter.
2026-07-23 18:21:46 +02:00
mika kuns 14e4c086e2 fix(data): merge preflights ignore untracked files in target working tree
HasChangesAsync counted untracked files, so a stray file in the shared
target working dir (e.g. left by a concurrent session) blocked merge
preflights even though nothing tracked changed. Add an includeUntracked
overload defaulting to true, and pass includeUntracked: false only from
the two target-working-tree merge preflights (TaskMergeService.MergeAsync,
PlanningMergeOrchestrator.StartAsync). Auto-commit and the worktree
cleanup data-loss guard keep counting untracked files, since those
callers need to know about them.
2026-07-23 18:14:46 +02:00
mika kuns 0f2d202b01 fix(ui): UnifiedDiffParser mishandles paths with spaces and git-quoted paths
diff --git headers pack two paths on one space-delimited line, which broke
for unquoted paths containing spaces and for git's C-style octal-quoted
paths (non-ASCII filenames). Add quote-aware header splitting plus a git
unquote helper, and prefer the unambiguous "--- a/"/"+++ b/" lines to
correct the file's identity when present.
2026-07-23 18:11:52 +02:00
mika kuns 2a4633fdd8 chore: remove dead MergeFfOnlyAsync, split AgentMcpTools into its own file, fix stale Zitadel doc
- GitService.MergeFfOnlyAsync had no callers anywhere in code; removed.
- AgentMcpTools moved out of LifecycleMcpTools.cs into External/AgentMcpTools.cs (no behavior change).
- Worker CLAUDE.md described ZitadelAuthProvider as a Phase 2 stub; it's fully implemented (OIDC discovery + refresh-token flow), updated both mentions.
2026-07-23 18:07:48 +02:00
mika kuns 3d668da65c docs(open): remove chain-cascade bug bullet (fixed in 110364a) 2026-07-23 18:03:15 +02:00
mika kuns f18e03354a fix(merge): validate conflict resolution before staging in ContinueMergeAsync
# Conflicts:
#	docs/open.md
2026-07-23 18:01:49 +02:00
mika kuns a1986dd485 Merge task branch for: fix(worker): deleting the last non-terminal child leaves the parent stuck in WaitingForChildren 2026-07-23 18:00:48 +02:00
mika kuns 110364aa6c Merge task branch for: fix(worker): planning-chain cascade stalls at an Idle middle link 2026-07-23 18:00:40 +02:00
mika kuns 0b86ccd74b Merge task branch for: fix(worker): cascade cancel of a WaitingForChildren parent to its non-terminal children 2026-07-23 18:00:34 +02:00
mika kuns d6891b8bd4 docs: explore-notes convention + verification handoff for manual checks 2026-07-23 17:58:48 +02:00
mika kuns 377409e633 fix(worker): validate conflict markers before staging in ContinueMergeAsync
git add -A resolves the index U-stage regardless of file content, so a
conflicted file whose markers were never removed still got staged and
committed as-is. Check previously-conflicted files for leftover
<<<<<<< markers before staging; refuse with a conflict result if any
remain, leaving the repo mid-merge so Abort still cleans up.

Adds a regression test that leaves one conflict unresolved and asserts
ContinueMergeAsync refuses (no commit) and AbortMergeAsync still
restores a clean tree.
2026-07-23 17:16:46 +02:00
mika kuns 941c8b98cc fix(worker): cascade cancel of a WaitingForChildren parent to its non-terminal children
Confirmed via docs/open.md (Korrektheits-Review 2026-06-09): TaskStateService.CancelAsync
only flipped the target row to Cancelled - a Queued/Running child kept going and could
still commit into its worktree after the parent was gone.

CancelAsync now also cancels the task's own non-terminal children (Idle/Queued/Running/
WaitingForReview/WaitingForChildren) in the same pass, clearing BlockedByTaskId so no
successor is left wedged, and broadcasts TaskUpdated for each.
2026-07-23 17:14:33 +02:00
mika kuns 2452e39345 fix(worker): advance parent when the last non-terminal child is deleted
DeleteTask never re-evaluated a WaitingForChildren parent, so deleting
the last non-terminal child left it stuck (the BlockedByTaskId SET NULL
FK only repairs the blocked chain, not parent status). Expose
TaskStateService.TryAdvanceParentAsync on the interface and call it
from ExternalMcpService.DeleteTask after a child delete.
2026-07-23 17:13:11 +02:00
mika kuns 816f247d90 fix(worker): keep planning-chain cascade moving past an Idle middle link
OnChildFinishedAsync ignored CancelAsync's result. If a chain successor
sat in a non-cancellable state (e.g. parked to Idle out of band) when
its predecessor failed/was cancelled, CancelAsync was a silent no-op and
the cascade stopped there, leaving the rest of the chain Queued+blocked
forever. Now it walks past any link CancelAsync can't touch and keeps
cancelling downstream.
2026-07-23 17:09:55 +02:00
ClaudeDo CI ad58129194 docs(changelog): update for v2.1.0 2026-07-23 14:47:45 +00:00
mika kuns 85c7e650c9 docs(interactive): update ConPTY spec + open items to final state
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 41s
Correct the spec's binding approach (library LaunchProcess, not the abandoned
custom-pty bypass), add the monospace-font sizing gotcha, on-demand worktree +
prompt seeding, and the deferred Avalonia 12.1 upgrade. Add a ConPTY manual-
verification entry to open.md.
2026-07-23 16:47:16 +02:00
mika kuns c412a84fdf refactor(interactive): remove streaming interactive stack (superseded by ConPTY)
The embedded ConPTY terminal replaced the in-app streaming interactive session, so
delete the dead stack: StreamingClaudeSession, InteractiveSessionService,
ProcessClaudeStreamTransport, IClaudeStreamTransport, ILiveSession, LiveSessionRegistry,
IdleSessionReaper (+ WorkerConfig.InteractiveIdleTimeoutMinutes), the WorkerHub
interactive methods + HubBroadcaster events, IWorkerClient interactive members, the
TaskMonitorViewModel composer + SessionTerminalView composer markup, and the old
'Run interactively' entry. AskUser/PendingQuestionRegistry, the autonomous path,
planning, ResumeTaskInTerminal, and all ConPTY code are kept. Localization pruned.
2026-07-23 16:47:16 +02:00
mika kuns d8194ad57e feat(interactive): seed fresh task session with the task prompt
A fresh (non-resume) task-based ConPTY session now opens claude on the task's
prompt (title + description) as the positional argument, so the session starts on
the task instead of an empty prompt. Resume sessions and ad-hoc sessions are
unchanged.
2026-07-23 16:47:16 +02:00
mika kuns d9a4627a1f fix(interactive): set monospace font + stretch on ConPTY terminal
The control derives Cols/Rows from arranged-size / character-cell-size; without an
explicit monospace font (as the working spike set) the cell metrics are off and the
terminal miscomputes its size, so the child TUI renders into the wrong area. Match
the spike's font/BufferSize and stretch to fill the pane.
2026-07-23 16:47:15 +02:00
mika kuns 2b06ab0ab4 fix(interactive): use library LaunchProcess instead of custom pty bypass
The custom Porta.Pty bypass (own read loop, key tunneling, resize sync) rendered
wrong, lagged, and dropped input. The spike proved TerminalControl.LaunchProcess()
renders correctly and stays responsive, so hand pty/input/render/resize/focus back
to the library. PtyTerminalSession shrinks to a thin wrapper: apply descriptor.Env
process-wide (Porta.Pty inherits the process env; no per-launch env seam), set
Process/Args/StartingDirectory, LaunchProcess(). Process="" still suppresses the
control's auto-launch so exactly one process starts.
2026-07-23 16:47:15 +02:00
mika kuns bb62740ac8 fix(interactive): correct ConPTY terminal size + reduce lag
Sizing: the pty was spawned at the stale 80x24 default because terminal.Cols/Rows
were read before any layout pass; and a resize during the spawn await was missed.
Force UpdateLayout() before reading the size, subscribe Resized before spawn,
resync once after, and add a LayoutUpdated-driven resync (deduped) as a safety net.
Lag: the read loop awaited a UI-thread dispatch per chunk, serializing pipe reads
behind rendering; switch to Dispatcher.Post (FIFO preserved, no backpressure).
2026-07-23 16:47:15 +02:00
mika kuns 25922a2768 fix(interactive): forward keyboard input to ConPTY terminal
TerminalView's OnKeyDown/OnTextInput early-return when its private pty connection
is null -- which it always is, since we bypass LaunchProcess() to inject a custom
env -- so keystrokes were silently dropped, and Terminal.DataReceived only carries
terminal auto-replies, never user input. Tunnel KeyDown/TextInput on the control,
translate via the terminal's public GenerateKeyInput/GenerateCharInput, and write
to our own IPtyConnection. Focus the control on start (LaunchProcess would have).
2026-07-23 16:47:15 +02:00
mika kuns 3feb08d9d9 feat(interactive): New session button for ad-hoc ConPTY sessions
Adds a 'New session' header button in Mission Control that opens a folder picker
and starts a task-less embedded ConPTY session in the chosen directory. ConPtyPaneViewModel
TaskId is now nullable (ad-hoc panes have no task and are never deduped) with a
CreateAdHoc factory; the view does the picking, the VM stays picker-agnostic.
2026-07-23 16:47:15 +02:00
mika kuns 9ab48d7094 feat(interactive): fresh-worktree-on-demand + ad-hoc launch specs
BuildForTaskAsync now creates a worktree on demand (via WorktreeManager.CreateAsync,
the same path TaskRunner uses) when a task has a configured working dir but no
Active/Kept worktree, returning a fresh-start spec -- so never-run tasks can be
opened interactively. Adds BuildForDirectoryAsync + GetAdHocLaunchSpec hub/client
for ad-hoc sessions in an arbitrary directory (no task, no worktree, no skill seeding).
2026-07-23 16:47:15 +02:00
mika kuns 0513265c49 feat(interactive): host task-based ConPTY sessions in Command Center
Adds an 'Open ConPTY session' entry that fetches a task's launch spec and hosts
an embedded ConPTY terminal as a Mission Control pane, coexisting with the
streamed-log monitor panes (streaming stack untouched). Introduces IMissionControlPane
+ ConPtyPaneViewModel, a non-destructive Panes mirror (Monitors prefix + ConPtySessions
suffix) so unrelated monitor churn never tears down a live terminal, and a grid<->tabs
layout toggle. Launch failures surface via the footer error strip.
2026-07-23 16:47:15 +02:00
mika kuns d28c63d2df docs(interactive): record ConPTY library + binding decision from spike 2026-07-23 16:47:15 +02:00
mika kuns 5f740c05d8 feat(interactive): embedded ConPTY terminal host in UI
Adds a self-contained terminal host: PtyTerminalSession drives a Porta.Pty child
directly (custom env) and pumps it through Iciclecreek's XTerm.NET renderer via
TerminalControl.Terminal, bypassing LaunchProcess() so a fully-populated
environment can be passed. InteractiveTerminalView/ViewModel host it with an
order-independent attach/start. Adds Iciclecreek.Avalonia.Terminal 2.0.3.
Sets Process="" to suppress the control's auto-launch of a stray shell.
2026-07-23 16:47:15 +02:00
mika kuns 1245e75902 feat(interactive): worker launch-spec for embedded ConPTY sessions
Adds InteractiveLaunchSpecService + GetInteractiveLaunchSpec hub method that
prepares a task worktree (session-skills seeding, run env) and returns a
LaunchSpec {cwd,exe,args,env} for a UI-hosted ConPTY terminal. Reuses
ISessionSkillSeeder, TaskRunner.UnionSkillNames, and WindowsTerminalLauncher
resume-arg/resolve logic. Guards mirror ResumeTaskInTerminal; a never-run task
yields a fresh-start spec instead of an error.
2026-07-23 16:47:15 +02:00
mika kuns d91ad2d635 docs(interactive): ConPTY interactive sessions spec + plan 2026-07-23 16:47:15 +02:00
mika kuns 17235a6cde Fix empty error message 2026-07-23 16:47:15 +02:00
Mika Kuns f33838d028 docs(open): session skills verification items 2026-07-23 16:47:14 +02:00
Mika Kuns 1cfd96c15f test(ui): load Loc.Current in session-skills tab test to fix ordering flake 2026-07-23 16:47:14 +02:00
Mika Kuns 7c3c061428 feat(ui): session skills registry tab + per-level selectors 2026-07-23 16:47:14 +02:00
Mika Kuns b4c58087d2 feat(worker): session skills SignalR surface + per-level persistence 2026-07-23 16:47:14 +02:00
Mika Kuns 4626481359 feat(worker): resolve and seed session skills before each run 2026-07-23 16:47:14 +02:00
Mika Kuns dea2b7db8b feat(worker): session skill registry (install/update/remove, pinned clone) 2026-07-23 16:47:14 +02:00
Mika Kuns 54cdaf89d5 feat(data): session skills entity, repository, and migration 2026-07-23 16:47:14 +02:00
Mika Kuns dbaefe92c6 docs(skills): mark cwd-skill discovery verified in headless mode 2026-07-23 16:47:14 +02:00
Mika Kuns 62b245aaea docs(skills): revise for multi-skill plugin repos (ponytail) 2026-07-23 16:47:14 +02:00
Mika Kuns 4e5057d3f6 docs(skills): spec + plan for per-level session skills 2026-07-23 16:47:14 +02:00
Mika Kuns 1bf08eca27 docs(open): pick up a task's session in a terminal — verification 2026-07-23 16:47:14 +02:00
Mika Kuns eb88dc130c feat(ui): pick up a task's session in a terminal 2026-07-23 16:47:13 +02:00
Mika Kuns 140ae2fda1 feat(worker): resume a task's claude session in a terminal 2026-07-23 16:47:13 +02:00
Mika Kuns 865e12c0de fix(worker): seed planning brief via file to avoid newline truncation 2026-07-23 16:47:13 +02:00
ClaudeDo CI 914fa5aa9f docs(changelog): update for v2.0.0 2026-06-26 14:12:23 +00:00
Mika Kuns 711374e858 fix(worker): reap idle interactive sessions so they don't pile up
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 51s
Interactive/streaming sessions are persistent claude.exe processes that
wait on stdin and never exit on their own. The only teardown was an
explicit StopInteractiveSession from the UI — there is no client-disconnect
or shutdown sweep — so an abandoned chat (UI closed, navigated away,
crashed) kept its claude.exe (+ conhost) alive for the worker's whole
lifetime. Under a long-running autostart worker these accumulate to dozens
of orphaned child processes.

LiveSessionRegistry now tracks per-session activity (Touch on every output
line and user action) and exposes ReapIdleAsync, which stops sessions idle
past a timeout while skipping any with a turn in flight. IdleSessionReaper
(BackgroundService) sweeps every 5 min; idle timeout defaults to 30 min,
configurable via interactive_idle_timeout_minutes (0 disables).
2026-06-26 16:11:53 +02:00
Mika Kuns faf6104645 fix(worker): kill spawned claude trees when the worker dies
Spawned claude processes were only torn down on graceful cancellation
(Process.Kill(entireProcessTree)). A hard worker death — Task Manager
End Task, crash, OS restart, installer update — ran no cleanup, orphaning
the claude->node->conhost tree, which lingered and piled up across
restarts.

Assign every spawned claude process to a Windows Job Object with
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. The worker holds the only handle, so
when it terminates for any reason the OS tears down the whole job. No-op
on non-Windows; best-effort with a one-time warning if the Win32 calls
fail.
2026-06-26 16:11:53 +02:00
Mika Kuns 3eea2b7c96 docs(open): queued messages can be removed via ✕ 2026-06-26 16:11:53 +02:00
Mika Kuns afe7218b7c feat(ui): remove a queued interactive message with a ✕
Queued rows are now QueuedMessageViewModel (Text + RemoveCommand); each shows a
✕ (Icon.WinClose) that calls RemoveQueuedInteractiveMessageAsync(taskId, text).
The worker re-broadcasts the queue, rebuilding the strip without the removed
message. Adds session.composer.unqueue (en/de).
2026-06-26 16:11:53 +02:00
Mika Kuns fd1e38fb7f feat(worker): remove a queued interactive message
StreamingClaudeSession.RemoveQueuedAsync drops the first occurrence of a queued
message from _pending and re-broadcasts the updated queue. Wired through
InteractiveSessionService + WorkerHub.RemoveQueuedInteractiveMessage +
IWorkerClient.RemoveQueuedInteractiveMessageAsync. Removal by text (first match)
is robust to a turn flushing mid-click. Fakes + ILiveSession impls updated.
2026-06-26 16:11:53 +02:00
Mika Kuns e7fa373a74 docs(open): queued messages show in a pending strip above the composer 2026-06-26 16:11:53 +02:00
Mika Kuns 7c9ff18ced feat(ui): show queued interactive messages above the composer
A queued message now appears in a pending strip above the input box (driven by
InteractiveQueueChanged), not optimistically in the transcript. The transcript
user line is added on delivery via InteractiveMessageSent. SessionTerminalView
gains QueuedMessages/HasQueuedMessages styled props (Mission Control); WorkConsole
binds Monitor.* (task detail). Adds session.composer.queued (en/de).
2026-06-26 16:11:53 +02:00
Mika Kuns 84034e8395 feat(worker): broadcast interactive message queue + delivery
StreamingClaudeSession raises onQueueChanged (pending snapshot) and onUserMessageSent
(on delivery, incl. the seeded first prompt); InteractiveSessionService forwards these
as InteractiveQueueChanged/InteractiveMessageSent broadcasts. Lets the UI show queued
messages above the input and move a message into the transcript only when actually
delivered to Claude. Client events + fakes updated.
2026-06-26 16:11:52 +02:00
Mika Kuns 8e1732a3a0 docs(open): interactive send=queue, interrupt opt-in via stop button 2026-06-26 16:11:52 +02:00
Mika Kuns 786eb2877f feat(ui): highlight user chat messages + opt-in interrupt (stop) button
LogKindForegroundConverter drives the log message foreground via a local
binding (beats the dim local value), so user messages render in the accent
color instead of vanishing into the transcript. Adds a small stop (Icon.Stop)
button next to Send in both composers (SessionTerminalView + WorkConsole) wired
to InterruptInteractiveCommand → InterruptInteractiveSessionAsync. Adds
session.composer.interrupt (en/de).
2026-06-26 16:11:52 +02:00
Mika Kuns bdda98eccd feat(worker): queue interactive messages by default, interrupt opt-in
StreamingClaudeSession now buffers a mid-turn user message in a FIFO queue and
flushes one when the turn's result arrives (no implicit interrupt). InterruptAsync
only writes the control_request (no-op when idle); the resulting turn-end then
flushes any queued message. New InteractiveSessionService.InterruptAsync +
WorkerHub.InterruptInteractiveSession + IWorkerClient.InterruptInteractiveSessionAsync.
2026-06-26 16:11:52 +02:00
Mika Kuns 9c292e5080 docs(open): manual-verification items for in-app interactive sessions 2026-06-26 16:11:52 +02:00
Mika Kuns 1fe72a1fe2 feat(ui): interactive chat composer in the session terminal + work console
SessionTerminalView gains an opt-in composer (IsComposerVisible / ComposerText /
SubmitCommand / ComposerPlaceholder styled props); Mission Control binds it to the
monitor VM. Task detail's WorkConsole output tab gets a matching shell-prompt
composer bound through Monitor.*, shown only while an interactive session is live.
log-user lines render in the accent color. Adds session.composer.* (en/de).
2026-06-26 16:11:52 +02:00
Mika Kuns 140b8e1551 feat(ui): interactive chat composer state on the session monitor VM
TaskMonitorViewModel gains IsInteractiveLive + ComposerDraft + SubmitComposer
(optimistic LogKind.User echo, then SendInteractiveMessageAsync) + StopInteractive,
driven by the InteractiveSessionStarted/Ended events. Since DetailsIslandViewModel
embeds this monitor, both task detail and Mission Control get the composer. Mission
Control auto-creates a monitor on InteractiveSessionStarted. Adds LogKind.User.
2026-06-26 16:11:52 +02:00
Mika Kuns 9effddeb2c feat(ui): worker client surface for in-app interactive sessions
Adds SendInteractiveMessageAsync/StopInteractiveSessionAsync and the
InteractiveSessionStarted/Ended events to IWorkerClient + WorkerClient
(UI-thread dispatch mirroring TaskQuestionAsked). Updates the IWorkerClient
fakes in both test projects.
2026-06-26 16:11:52 +02:00
Mika Kuns 30e87e698e feat(worker): in-app interactive session service, replacing the wt terminal launch
InteractiveSessionService resolves a task's list working dir + seeded prompt,
spawns a StreamingClaudeSession (claude stream-json in the list dir, model+auto
as before), registers it in LiveSessionRegistry, streams output over TaskMessage,
and broadcasts InteractiveSessionStarted/Ended (an exit watcher fires Ended). The
hub's OpenInteractiveTerminalAsync now starts this in-app session; SendInteractiveMessage
and StopInteractiveSession route to it. The external Windows-Terminal interactive
launch (LaunchInteractiveAsync / InteractiveLaunchContext / OpenInteractiveAsync) is
removed; planning sessions keep their terminal launch.
2026-06-26 16:11:52 +02:00
Mika Kuns d8a043fae7 feat(worker): persistent streaming Claude session + live session registry
StreamingClaudeSession drives claude --input-format stream-json over a kept-
open stdin: sends user messages, interrupts the in-flight turn via the verified
control_request protocol, and tracks turn state from result events (treating an
interrupt-aborted error_during_execution result as turn-ended). IClaudeStreamTransport
abstracts the process I/O so it is unit-tested with a fake (no real claude).
LiveSessionRegistry maps taskId -> live session for the hub to route into.

Backs the upcoming in-app interactive sessions; autonomous task execution untouched.
2026-06-26 16:11:52 +02:00
Mika Kuns 10342bc562 docs(interactive): spec + plan for in-app interactive sessions
Replace the external wt.exe 'Run interactively' launch with an in-app
streaming chat (persistent claude --input-format stream-json), rendered in
the shared SessionTerminalView in task detail and Mission Control. Autonomous
task execution is untouched. Mid-turn interrupt+redirect verified against CLI
2.1.191 via spike.
2026-06-26 16:11:52 +02:00
Mika Kuns 917301d61c feat(ui): answer a running task's question inline in Mission Control
TaskMonitorViewModel surfaces a pending AskUser question (TaskQuestionAsked /
TaskQuestionResolved events) with an AnswerDraft + SubmitAnswerCommand that calls
the new IWorkerClient.AnswerTaskQuestionAsync; MonitorPaneView shows an accent
question banner with an input box above the terminal. Pending question is cleared
on answer/resolve/finish and re-hydrated on attach via GetPendingQuestionAsync.
en/de localization for missionControl.question.*; test fakes updated.
2026-06-26 16:11:52 +02:00
Mika Kuns c7f8280106 feat(worker): AskUser MCP tool so a running task can ask the user mid-run
A running task can call mcp__claudedo_run__AskUser(question) to block (up to 3
min) on a human answer. PendingQuestionRegistry holds the pending question +
TaskCompletionSource; the tool broadcasts TaskQuestionAsked, awaits the answer
(WorkerHub.AnswerTaskQuestion resolves it), and returns it as the tool result —
or a 'proceed on your judgment' fallback on timeout. The run stays Running
throughout (no status/schema change). ClaudeProcess raises MCP_TOOL_TIMEOUT so
the 60s HTTP-MCP cap doesn't kill the wait; the run MCP is now wired for every
task, not just standalone ones. System prompt updated to reconcile 'unattended'.
2026-06-26 16:11:51 +02:00
Mika Kuns bec26b2232 feat(ui): replace OLE task-row drag with custom ghost drag
Task rows now drive a hand-built pointer-capture drag instead of
DragDrop.DoDragDropAsync: armed on press, begins past a 4px threshold so a
plain click still selects. The ghost follows the screen cursor across windows;
on release the action is decided by what is under the cursor -- over the
Mission Control window queues the task (geometric DragHitTest, no OLE drop),
over another row in the same user list reorders, anywhere else cancels and
restores the row. Drag starts from any list kind (drag-to-queue everywhere)
but reorder-on-drop stays gated on CanReorder. Removes the obsolete OLE
TaskRowFormat path from both the source and MissionControlView (pane
PaneFormat reorder is untouched).
2026-06-26 16:11:51 +02:00
Mika Kuns 05aec8ebfa feat(ui): ghost-window drag infrastructure for task rows
Add the borderless, transparent, topmost, click-through DragGhostWindow that
hosts a tilted (~-6deg) translucent snapshot of the dragged row, a
TaskDragController that owns its lifecycle (snapshot -> show -> follow -> close),
and a pure DPI-aware DragHitTest helper (unit-tested) for the cross-window
screen hit test. Adds the TaskRowViewModel.IsDragging flag and the
'grabbed' Border.task-row.dragging style (lift + scale + lower opacity +
shadow). Not yet wired into the drag source.
2026-06-26 16:11:51 +02:00
Mika Kuns 946d26cc4b docs(ask-user): spec + plan for answering Claude's mid-run questions in Mission Control 2026-06-26 16:11:51 +02:00
Mika Kuns 3b629c218f feat(ui): drag a task into Mission Control to queue it 2026-06-26 16:11:51 +02:00
Mika Kuns 9eb54a0d2f feat(ui): read-only queue side strip in Mission Control 2026-06-26 16:11:51 +02:00
Mika Kuns 1c94fbdb14 feat(worker): batch MCP tools for the external endpoint
Add seven best-effort batch variants of the single-entity external MCP
tools: batch_get_tasks, batch_add_tasks, batch_update_task_status,
batch_cancel_tasks, batch_delete_tasks, batch_set_my_day, and
batch_cleanup_task_worktrees. Each loops the existing ExternalMcpService
methods sequentially (scoped DbContext is not thread-safe), returns a
per-item result array so a failing item never aborts the rest, and
rejects empty or over-100-item batches. Merge/review stay single-task.
2026-06-26 16:11:51 +02:00
Mika Kuns 7f4dc8b973 feat(ui): open Settings from the Mission Control header 2026-06-26 16:11:51 +02:00
Mika Kuns f6ecfc995f feat(ui): drag-reorder Mission Control panes by their header 2026-06-26 16:11:51 +02:00
Mika Kuns f63be285a2 fix(ui): scroll revealed task into view + stronger selection highlight 2026-06-26 16:11:51 +02:00
Mika Kuns e2fad88f37 feat(ui): mission control pane header actions + status tinting 2026-06-26 16:11:51 +02:00
Mika Kuns fbcffce79c feat(ui): mission control detach/redock toggle, clear review panes, reorder helper 2026-06-26 16:11:51 +02:00
Mika Kuns 5f6e7480f2 feat(ui): detach a monitor into its own window 2026-06-26 16:11:51 +02:00
Mika Kuns 4e2798b400 test(ui): cover TaskMonitorViewModel streaming core 2026-06-26 16:11:50 +02:00
Mika Kuns b1bd91292f feat(ui): open Mission Control from the title bar 2026-06-26 16:11:50 +02:00
Mika Kuns 283310a3fd feat(ui): add MissionControl window + grid 2026-06-26 16:11:50 +02:00
Mika Kuns 15a3e65508 feat(ui): add MonitorPaneView 2026-06-26 16:11:50 +02:00
Mika Kuns 5a21d673c1 feat(ui): reveal a task by id from anywhere 2026-06-26 16:11:50 +02:00
Mika Kuns 42da840066 feat(ui): add MissionControlViewModel 2026-06-26 16:11:50 +02:00
Mika Kuns aa7a49f634 feat(ui): extract TaskMonitorViewModel streaming core; DetailsIsland delegates 2026-06-26 16:11:50 +02:00
Mika Kuns 7b6a8f0852 refactor(ui): split LogLineViewModel into its own file 2026-06-26 16:11:50 +02:00
Mika Kuns d00899b655 style(ui): use gear icon for the lists settings button 2026-06-26 16:11:50 +02:00
Mika Kuns 66907d24c9 fix(settings): persist Online Inbox tab on settings save 2026-06-26 16:11:50 +02:00
Mika Kuns 38defee3d8 feat(ui): collapse parent task rows by default with granular row sync 2026-06-26 16:11:50 +02:00
Mika Kuns d80a57836c docs(ui): add Mission Control multi-task monitoring spec + plan 2026-06-26 16:11:50 +02:00
Mika Kuns 178fd25b55 fix(ui): paint accent buttons with moss tokens instead of Fluent blue
Button.accent set Background on the control, but Fluent's built-in accent button
paints the ContentPresenter with SystemAccentColor (blue) at higher specificity,
so the moss intent never showed (e.g. the Approve & Merge button rendered blue).
Override at the /template/ ContentPresenter level for rest/hover/pressed with the
moss accent tokens, matching the ListBoxItem overrides already in App.axaml.
2026-06-26 16:11:50 +02:00
Mika Kuns df84fc3f2c fix(ui): make worktree state chips readable with on-theme tints
The state badge in the worktrees overview used bright off-palette Material colors
with hardcoded near-black text (via WorktreeStateColorConverter), which was hard
to read. Switch to the existing chip pattern (subtle tint background + matching
border + colored text): active=blue, merged=green, kept=amber, discarded=gray.
Drop the now-unused WorktreeStateColorConverter.
2026-06-26 16:11:50 +02:00
Mika Kuns ea16da2756 fix(worker): keep interactive & planning prompts intact past Windows Terminal
wt.exe treats ';' as a command/tab delimiter in every argument, with no escape
that survives quoting (microsoft/terminal#13264), so a task description
containing ';' spawned extra terminals on "Run interactively" and planning start.
Route the launch as wt -> powershell -> claude and pass the free-text prompt via
$env:CLAUDEDO_LAUNCH_PROMPT so it never reaches the wt command line; PowerShell
binds the variable as a single argument (embedded quotes escaped for PS 5.1).

Also clarify the launcher, which serves interactive runs too (not just planning):
IPlanningTerminalLauncher -> ITerminalLauncher, WindowsTerminalPlanningLauncher ->
WindowsTerminalLauncher, LaunchStart/Resume -> LaunchPlanning{Start,Resume}Async.
2026-06-26 16:11:49 +02:00
Mika Kuns f86b78593e fix(online): honor runtime disable in sync loop to stop OIDC discovery
OnlineSyncService is registered once at startup; toggling the feature off
in Settings persisted the flag but never stopped the running loop, so it
kept polling and failing OIDC discovery every cycle. Guard TickAsync on
the shared config's Enabled flag so disabling takes effect live.
2026-06-26 16:11:49 +02:00
Mika Kuns 19340fd9de chore(git): ignore .claude/worktrees 2026-06-26 16:11:49 +02:00
Mika Kuns 0a119f1450 feat(ui): shell-style review prompt line in WorkConsole 2026-06-26 16:11:49 +02:00
Mika Kuns 167d2fec6a refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff 2026-06-26 16:11:49 +02:00
Mika Kuns 4022bd7197 docs(logging): document footer log routing + Log Visualizer overlay 2026-06-26 16:11:49 +02:00
Mika Kuns c4f74a7aea feat(ui): Log Visualizer overlay reachable from a clickable footer log line 2026-06-26 16:11:49 +02:00
Mika Kuns 08a4f97a78 feat(worker): route Serilog Warn/Error to footer + buffer recent logs for overlay 2026-06-26 16:11:49 +02:00
Mika Kuns eb0ddb56d3 refactor(agent-config): single AgentConfigEditor for list + task scopes 2026-06-26 16:11:49 +02:00
Mika Kuns 60eb671e8f docs(logging): spec + plan for worker-log footer routing and log visualizer overlay 2026-06-26 16:11:49 +02:00
Mika Kuns 134b9fb598 fix(ui): surface interactive/planning launch errors in footer 2026-06-26 16:11:49 +02:00
Mika Kuns 9301bbc81a feat(details): segmented Description/Steps/Files header
Replace the static DETAILS label and its dead space with a segment switcher; the card body now shows one section at a time. Step/file counts sit in the tab labels, the edit/preview toggle is scoped to Description, and drag-and-drop or add jumps to the Files tab. Tab labels localized (en/de).
2026-06-26 16:11:48 +02:00
Mika Kuns 637886f33a fix(attachments): render X remove icon as filled geometry
Icon.X was a stroke-only geometry; PathIcon fills its path, so the glyph rendered invisible and the attachment remove button had no visible affordance. Author it as a filled X outline. Also restores the X glyph on the task-row dequeue, agent-strip cancel, and details-header close buttons.
2026-06-26 16:11:48 +02:00
Mika Kuns 3cb4802f38 refactor(installer): drop self-update, publish stable-named ClaudeDo.Installer.exe
Release workflow now names the installer asset ClaudeDo.Installer.exe (no version) for a permanent download URL; it is still uploaded and checksummed on every release. App + worker keep the git tag version.

Removes the self-update preflight from App.OnStartup and deletes the now-dead SelfUpdater / SelfUpdatePromptWindow / SelfUpdateResult plus their tests. App-update detection is unaffected: the manifest records the release tag via DownloadAndExtractStep.

Updates the installer CLAUDE.md.
2026-06-26 16:11:48 +02:00
Mika Kuns 8716dd8e3a docs(attachments): document task file attachments across project docs
Data/Worker/Ui CLAUDE.md + docs/open.md updated for TaskAttachmentEntity,
AttachmentStore, AttachmentMcpTools, AttachmentOrphanRecovery, the run-prompt
injection, and the detail-pane drag-and-drop UI (incl. a manual verification
item).
2026-06-26 16:11:48 +02:00
Mika Kuns d8ff8cc110 feat(attachments): drag-and-drop file attachments on the detail pane
Drop a file anywhere on the detail pane to attach it: pane-wide drop target
with a 'Drop to attach' hover overlay (Copy cursor, gated on an idle selected
task), an explicit lingering confirmation/error line, plus an Attachments list
with size, remove, and an Add file… picker in the DETAILS card. ComposedPreview
now shows the reference files too. en/de keys added.
2026-06-26 16:11:48 +02:00
Mika Kuns f7e946e472 feat(attachments): MCP tools to attach/list/remove task files
AttachmentMcpTools exposes add_task_attachment (text or base64),
list_task_attachments, and remove_task_attachment on the external MCP
endpoint, so an agent can prepare reference files (plans, scripts) on a task
that will run later. Re-attaching the same name overwrites; add/remove refuse
on a running task.
2026-06-26 16:11:48 +02:00
Mika Kuns 6a0c0f59a5 feat(attachments): inject reference files into the run + clean up files on delete
TaskRunner appends attached files (absolute paths) to the run prompt as the
read-only Reference files section. Task and list deletes now remove the
on-disk attachment dir eagerly, and a startup AttachmentOrphanRecovery sweep
drops any attachments/<taskId>/ whose task no longer exists (covers list
cascade and planning-discard paths).
2026-06-26 16:11:48 +02:00
Mika Kuns 5be4b5c5fb refactor(merge): single IMergeCoordinator replaces the 5 conflict seams
The RequestConflictResolution Func was declared on 5 VMs and hand-threaded shell->details->merge-section->diff->merge-modal. Replaced with a DI-singleton IMergeCoordinator (MergeCoordinator holder; shell wires its Handler at composition, breaking the shell<->island cycle). Invokers (MergeModal, DetailsIsland, WorktreesOverview) depend on the interface; the two pass-through VMs (DiffModal, MergeSection) drop the seam entirely. No behavior change; conflict-seam + batch tests rewired to assert via the coordinator.
2026-06-26 16:11:48 +02:00
Mika Kuns 3f9f047955 feat(attachments): data layer for task file attachments
TaskAttachmentEntity (+config, cascade FK), TaskAttachmentRepository, and an
AttachmentStore that writes files under ~/.todo-app/attachments/<taskId>/ with
a path-traversal guard and a 5 MB cap. TaskPromptComposer gains an optional
read-only 'Reference files' section. Migration AddTaskAttachments.
2026-06-26 16:11:48 +02:00
Mika Kuns 5231ad6b86 fix(worktrees): hide batch Merge All in the global overview
The select-all + target picker + Merge All cluster only makes sense per-list: a single target branch is meaningless across repos. Now gated on !IsGlobal; Refresh/Cleanup/Status stay available globally.
2026-06-26 16:11:47 +02:00
Mika Kuns d598a539bc refactor(ui): single IDialogService replaces scattered Show* dialog seams
Collapses the ~10 per-modal Show*Modal Func callbacks (wired separately on the shell and the lists island) into one IDialogService + WindowDialogService impl. Removes the RepoImport/WorktreesOverview dialog construction duplicated across MainWindow and ListsIslandView, plus the Confirm/Error dialogs duplicated in both code-behinds. Shell/lists Open* commands now route through an injected Dialogs handle (propagated shell->lists); the per-list worktrees overview also wires conflict resolution now, matching the global one. No VM ctor changes (Dialogs is a settable seam), so no test-fake impact.
2026-06-26 16:11:47 +02:00
Mika Kuns 1fb2e34f85 refactor(tasks): route UI quick-add through TaskRepository.AddAsync
Drops the append-SortOrder query duplicated inline in TasksIslandViewModel.AddAsync; the repository (already used by MCP AddTask) is now the single home for the create+SortOrder invariant. Sets Status=Idle explicitly for parity.
2026-06-26 16:11:47 +02:00
Mika Kuns b3e099ca01 refactor(merge): drop dead hunks conflict API
GetConflictsAsync/GetMergeConflicts (+ MergeConflicts/ConflictFileContent/ConflictFileDto/ConflictHunkDto DTOs and the now-orphaned GitService.ShowStageAsync) were superseded by the segment-based GetMergeConflictDocuments path and had no production callers. Removes the IWorkerClient member, both test fakes, the lingering test, and updates the Worker/Ui/Data CLAUDE.md surface notes.
2026-06-26 16:11:47 +02:00
Mika Kuns 0993eb0e75 docs(unification): spec + phased plan for one-component-per-feature
Maps duplication into three buckets (parallel impls, entry-point sprawl, dead/leftover) and defines six phased unification slices: groundwork, DialogService, MergeCoordinator, WorktreeActions, AgentConfigeditor, unified DiffViewer.
2026-06-26 16:11:47 +02:00
ClaudeDo CI bae8921201 docs(changelog): update for v1.9.0 2026-06-19 11:23:34 +00:00
Mika Kuns 23a93ce0bb fix(merge): unresolved conflicts compose to empty, not Ours (+ review nits)
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
Code-review follow-ups before push:
- MergeFile.ResultText/Compose() fell back to Ours for unresolved conflicts while
  the editor seeds them empty — align both on empty so the public model matches the
  pane and Continue can't silently auto-accept Ours.
- Bound the gutter re-layout retry (was an unbounded Background re-post when the
  editor isn't laid out, e.g. minimized).
- Pluralize the readout ('1 conflict' not '1 conflicts'). Tests updated. Ui 128 green.
2026-06-19 13:14:51 +02:00
Mika Kuns 29a294b7f3 feat(merge): diff Merge opens the 3-pane editor + conflict overview ruler
- The Merge button in the Diff window now hands a conflicting merge to the in-app
  3-pane editor (MergeModal routes 'conflict' through RequestConflictResolution,
  the same seam Approve uses) instead of dead-ending on a conflict message.
- Add a conflict overview ruler right of the Result pane: a proportional map of
  every conflict in the file, recolored by resolved state, click a tick to jump —
  so conflicts are findable in long files without scrolling.
- New MergeResolvedEdgeBrush token + conflictMap en/de key. Ui 128 + Loc 16 green.
2026-06-19 11:31:34 +02:00
Mika Kuns ca4377e641 feat(merge): toggle add/remove per side, MAIN/INCOMING labels, files readout
- Conflict accept is now a per-side toggle: > adds MAIN (ours), < adds INCOMING
  (theirs) in click order (first on top); clicking again removes that side, so each
  side is included at most once. Region content is rebuilt from the included set.
- Drop the separate reset (x) control — toggling both off clears the region.
- Relabel the panes/tooltips Ours/Theirs -> MAIN/INCOMING (merge target vs task).
- Add a cross-file 'N of M files unresolved' readout (FilesSummary) so you can see
  how many more files still have conflicts. en/de updated; Ui 128 + Loc 16 green.
2026-06-19 11:12:02 +02:00
Mika Kuns d5eec75bea feat(merge): additive conflict accept — stack ours/theirs in click order
Replace the single-side replace (and the short-lived accept-both button) with
additive accepts: each result conflict region starts EMPTY (thin marker bar), and
the gutter controls append a side in click order — > adds ours, < adds theirs
(first pick on top, next below), x clears. Controls stay visible after the first
pick so both sides can be stacked; empty/unresolved regions render a marker so they
stay visible. en/de keys updated; Ui 128 + Localization 16 green.
2026-06-19 10:50:57 +02:00
Mika Kuns 18479c023e feat(merge): add accept-both control to the 3-pane conflict gutter
The between-pane gutter only offered single-side replace (accept ours / accept
theirs). Add an 'accept both' (⊕) control under the ours chevron that drops
ours-then-theirs into the result region, so a conflict can be combined in one
click instead of picking one side and hand-adding the other. en/de keys added.
2026-06-19 10:43:35 +02:00
Mika Kuns 869dd25a23 fix(merge): harden 3-pane editor + document the new conflict resolver
Review follow-ups: coalesce gutter re-layout posts (avoid dispatcher flooding when
visual lines aren't ready), drop the zero-length deletable segment (undo hygiene),
and clear stale scroll-sync hooks on DataContext swap. Update Ui/CLAUDE.md to the
3-pane editor and log visual-verification items (incl. empty-side + alignment edges)
in docs/open.md.
2026-06-19 10:21:32 +02:00
Mika Kuns c4d1acc75b feat(merge): Rider-style 3-pane conflict editor view
Replace the Base|Ours|Theirs read-only columns + single-conflict result with a
whole-file 3-pane editor: Ours (read-only) | editable Result | Theirs (read-only),
reconstructed from the active file's segments so the panes line up on stable text.

- IBackgroundRenderer paints each conflict block (unresolved=blood, resolved=green)
  across all three panes.
- Result document edits are gated by an IReadOnlySectionProvider (stable text is
  read-only; only conflict regions, tracked via TextAnchors, are editable); edits
  flow back to the owning block.
- Between-pane gutters host inline accept controls (>/< ) positioned per conflict;
  click accepts ours/theirs into the result.
- Proportional synced vertical scroll across the panes; file switcher + change-nav
  arrows (F8 / Shift+F8); active-file 'M conflicts - K resolved' readout.
- Merge block tints + AmberBrush tokens; en/de keys for the new labels.

Seam unchanged. App builds; Ui.Tests 128, Localization.Tests 16.
2026-06-19 10:15:12 +02:00
Mika Kuns 378a92c156 feat(merge): unify planning conflicts onto the resolver + 3-pane VM foundation
Route planning unit-merge conflicts through ConflictResolverViewModel
(OpenForPlanningAsync) and delete the old ConflictResolutionViewModel dialog.
Add active-file 3-pane reconstruction (MergeFile OursText/TheirsText/ResultText,
ActiveFile, SelectFileCommand, active-file readout) as the VM foundation for the
Rider-style editor. Seam preserved; Ui.Tests 128/128.
2026-06-19 09:58:32 +02:00
Mika Kuns 983c177c9a docs(merge): spec + plan for Rider-style 3-pane merge editor 2026-06-19 09:56:15 +02:00
Mika Kuns 3e4e4a03f7 feat(ui): move review feedback to the Output tab + review/worktree polish
- Feedback box + a new "Resume session" button move from the Git tab to the
  Output tab; the Git review block keeps Approve & Merge / Park / Cancel / Reset.
- Add a "Parked" chip for Idle tasks that still hold an Active worktree.
- Stop showing the "Session was Cancelled" band on cancel (failed-only now).
- Fix the Worktrees-overview state-chip contrast (dark text on the colour).
2026-06-19 09:31:53 +02:00
Mika Kuns 92767c646e feat(merge): in-app 3-way merge editor (chunk 2b)
Replace the whole-file conflict resolver with a real 3-way merge editor
built on the line-level hunk pipeline.

- ConflictModels: MergeFile/MergeFileSegment/MergeConflictBlock with
  Compose() that reassembles stable text + chosen resolutions
- ConflictResolverViewModel (same seam contract): loads conflict
  documents, flattens conflicts for one-at-a-time navigation, per-block
  Accept Ours/Base/Theirs/Both + editable result, binary files block continue
- ConflictResolverView: 3-column Base|Ours|Theirs + editable result via
  AvaloniaEdit with TextMate syntax highlighting by file extension;
  editors synced in code-behind
- add Avalonia.AvaloniaEdit + AvaloniaEdit.TextMate + TextMateSharp.Grammars;
  AvaloniaEdit theme StyleInclude in App.axaml
- rewrite ConflictResolverViewModel tests (load/gating/compose/nav/binary/abort)
2026-06-18 16:46:43 +02:00
Mika Kuns e779e13654 feat(merge): real conflict-hunk parsing pipeline (chunk 2 backend)
Replace the whole-file conflict model with line-level hunks, the
foundation for the full in-app merge editor.

- ConflictMarkerParser: parses git conflict markers (incl. diff3 base)
  into ordered stable/conflict MergeSegments; exact round-trip + Compose
- GitService.MergeNoFfAsync passes -c merge.conflictStyle=diff3 so the
  working tree carries the merge base in conflict markers
- TaskMergeService.GetConflictDocumentsAsync: reads each conflicted file,
  parses into segments, flags binary files
- hub GetMergeConflictDocuments + DTOs (MergeConflictDocumentsDto/
  ConflictDocumentDto/MergeSegmentDto), IWorkerClient + both fakes
- tests: 8 parser unit tests + a real-git integration test asserting
  line-level hunks with a diff3 base
2026-06-18 16:22:56 +02:00
Mika Kuns 4847c5c0a4 feat(ui): My Day actions, orphan-aware grouping, menu restructure
Pending UI work:
- My Day add/remove context actions on task rows (parent removal cascades to children)
- orphan-aware grouping: a child whose parent isn't in view renders as a top-level row, not an indented draft
- shell menu restructure (Worker / Repositories submenus); 'Finalize plan' action, drop 'Queue subtasks sequentially'
- notes editor refinements
- subtask-row hover tweak (Surface3, no transition)
- bump Avalonia 12.0.0 -> 12.0.4
2026-06-18 16:22:29 +02:00
Mika Kuns 43fb506e87 feat(review): unify review actions into the Git-tab cockpit
Grow the detail-pane Git tab into the review+merge cockpit: target,
pre-flight mergeability, inspect actions, then the four review verbs
(Approve & Merge / Send back / Park / Cancel) plus a demoted
Reset (discard branch).

The decision block is gated independently of the merge controls so
sandbox (no-worktree) review tasks still get the buttons.

- Add ParkReviewCommand (-> RejectReviewToIdleAsync)
- Send back (reject-to-queue) disabled until feedback is entered
- Remove the mislabeled [Continue]/[Reset] line from the Output tab
- Accent dot on the Git tab while awaiting review
2026-06-18 15:52:41 +02:00
mika kuns b75a7b1b5a Merge remote-tracking branch 'origin/main' 2026-06-15 15:40:15 +02:00
mika kuns 824f785fd0 fix(): Maximize button hides the window instead of maximizing 2026-06-15 15:11:49 +02:00
mika kuns 0d1475cb7a fix(claude-do): Maximize button hides the window instead of maximizing
## Bug
Clicking the maximize control in the custom title bar makes the main window disappear/hide instead of filling the screen. Restore is then hard or impossible.

## Where
`MainWindow` uses custom client-area chrome, so the OS does not manage maximize:
- `src/ClaudeDo.Ui/Views/MainWindow.axaml:14-16` — `WindowDecorations="BorderOnly"`, `ExtendClientAreaToDecorationsHint="True"`, `ExtendClientAr

ClaudeDo-Task: 7d3d9501a8eb4111b9d433fd917f5a22
2026-06-15 15:08:02 +02:00
mika kunsandClaude Opus 4.8 cfe23cdd23 fix(online-inbox): invalidate cached access token when the signed-in user changes
ZitadelAuthProvider cached the access token in memory and only re-read the
refresh token when the cache expired. Re-signing as a different user saved a
new refresh token but the worker kept serving the previous user's cached
access token until it expired — so sync (and ownerId stamping) continued under
the old identity.

Track the refresh token that minted the cached token and invalidate the cache
when the stored refresh token changes (user switch or sign-out). Switching
users now takes effect on the next sync without a worker restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:38:31 +02:00
mika kunsandClaude Opus 4.8 cee051bb6d feat(online-inbox): carry ownerId on sync to prepare for multi-user
Plumb a per-resource owner (Zitadel sub) through the sync contract without
enforcing isolation client-side — the server stays the authority.

- Dtos: add optional ownerId to RemoteList/RemoteTask/MirrorTask
- JwtClaims: decode the sub claim from the access token (never throws)
- OnlineSyncService: stamp ownerId on pushed lists + mirror; defensively skip
  pulled tasks owned by a different user (unowned tasks still sync, so
  single-user behavior is unchanged)
- docs: contract documents ownerId + multi-user readiness

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:57:39 +02:00
mika kunsandClaude Opus 4.8 23c3065f20 feat(online-inbox): gate access on Zitadel "user" project role
The Online API now requires the "user" project role (claim
urn:zitadel:iam:org:project:roles) instead of an ALLOWED_USER_IDS allowlist.

- IOnlineAuthProvider: add GetAccessTokenAsync(forceRefresh) overload
- ZitadelAuthProvider: forceRefresh drops the cached token and re-runs the
  refresh-token grant to mint a fresh, role-bearing token
- OnlineInboxApiClient: on 401, force-refresh and retry once; if still 401,
  throw a clear "missing 'user' role" error
- OnlineSyncService: surface the 401 at Error level (no longer silent)
- UI: ZitadelTokenInspector decodes the access token after login and warns
  early when the "user" role is absent (fail-open); shown in settings
- docs: online-inbox-api-contract reflects role-based access (no allowlist)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:46:17 +02:00
mika kunsandClaude Opus 4.8 80a2de6c74 feat(ui): Online Inbox settings tab + auth-code/PKCE login
New Settings tab: enable toggle, config fields, sign-in/out + status.
OnlineLoginService runs the PKCE loopback flow (Duende.IdentityModel.OidcClient
7.1.0), opens the system browser, captures the callback, hands the refresh
token to the Worker. en/de localized. Fixes: loopback callback URL built from
host:port base (avoids doubled redirect path); PollIntervalSeconds threaded
through the state DTO so it loads instead of resetting to 60.

Visual layout + the live sign-in round-trip need manual verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:02:14 +02:00
mika kunsandClaude Opus 4.8 17c7ff517a feat(worker,ui): Online Inbox config + auth hub plumbing (Phase 2)
Hub: GetOnlineInboxState / SetOnlineInboxConfig / SetOnlineInboxAuth /
ClearOnlineInboxAuth. WorkerConfig.SaveOnlineInbox persists only the
online_inbox section. OnlineTokenStore + config registered always so hub
methods work when sync is disabled. IWorkerClient surface + all test fakes
synced. RedirectUri config (default http://localhost:8765/callback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:49:49 +02:00
mika kunsandClaude Opus 4.8 8b347de131 fix(worker): preserve API base path in Online Inbox client
The API base URL is https://claudedo.kuns.dev/api — leading-slash request
paths discarded the /api segment. Use relative paths so they nest under the
base. Tests now use a /api/ base to guard the regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:35:30 +02:00
mika kunsandClaude Opus 4.8 619bc0c38d feat(worker): real ZitadelAuthProvider (refresh-token grant, auth-code+PKCE)
Headless refresh-token -> access-token exchange via OIDC discovery + token
endpoint. Cached to expiry (60s margin), thread-safe, persists rotated refresh
tokens, graceful null on invalid_grant/network errors. Wired into DI when
online_inbox is enabled. Interactive PKCE login (UI) still pending the
registered redirect URI. 7 tests, stubbed HttpMessageHandler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:08:33 +02:00
mika kunsandClaude Opus 4.8 96da9fbae5 docs(online-inbox): KunsZitadel is server-side only; desktop uses an OIDC client flow
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:02:12 +02:00
mika kunsandClaude Opus 4.8 1ac9ced0bd feat(worker): Online Inbox sync engine (Phase 1)
Optional, opt-in (online_inbox.enabled, default false → zero network).
Worker-side reconcile loop: pull web-created tasks down as Idle, push the
list catalog and the Idle backlog mirror up. Auth behind IOnlineAuthProvider
(StaticTokenAuthProvider default; ZitadelAuthProvider stubbed for Phase 2).
DPAPI refresh-token store. 35 tests, no real network/Zitadel/Claude.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:55:20 +02:00
mika kunsandClaude Opus 4.8 8cbe1adb32 docs(online-inbox): API contract, desktop design spec, and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:35:20 +02:00
mika kunsandClaude Fable 5 23ff3916cc docs: close out the review round in open.md, sync CLAUDE.md with merges
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:40:55 +02:00
mika kuns 360ff77e18 Merge task branch for: refactor(ui): DetailsIslandViewModel (1431 Zeilen) in Sektions-VMs aufteilen 2026-06-10 00:34:31 +02:00
mika kuns e272053e72 chore(claude-do): refactor(ui): DetailsIslandViewModel (1431 Zeilen) in Sektio
Kontext: src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs ist mit 1431 Zeilen ein God-VM mit ~12 Concerns (Log-Streaming, Titel/Description-Editing, Subtasks, Child-Outcomes, Merge-Preview/-Targets, Diff, Agent-Settings-Overrides, Notes-Mode, Prep-Mode, Tabs, Session-Outcome/Roadblocks, Worktree-Info). Jedes neue Feature landet dort.

Änderungen — drei klar abgrenzbare Sektionen als ei

ClaudeDo-Task: 483e419f-1ec8-46ba-986b-8b90d6596b49
2026-06-10 00:31:09 +02:00
mika kunsandClaude Fable 5 74ca2e0dcd fix(worker): queue dispatches skip the StartRunning re-claim
The picker claims Queued->Running atomically before dispatch; the new
StartRunningAsync guard then rejected every queue-dispatched run. Add
alreadyClaimed to RunAsync/ContinueAsync (queue passes true, override
slot keeps the guard) and align the routing tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:59:56 +02:00
mika kuns 0cba9f9640 Merge task branch for: fix(worker): Abort-Pfad für unterbrochenen Unit-Merge nach Worker-Restart 2026-06-09 23:46:37 +02:00
mika kuns c6534165b2 Merge task branch for: fix(worker): FailAsync-Guard untersuchen — ist Queued→Failed erreichbar/gewollt? 2026-06-09 23:46:18 +02:00
mika kuns 290b4a602a Merge task branch for: refactor(hub): Konflikt-Merge-Methoden eindeutig benennen (ContinueMerge → ContinueConflictMerge) 2026-06-09 23:45:49 +02:00
mika kunsandClaude Sonnet 4.6 fe73f45b74 fix(worker): document and test Queued→Failed guard in FailAsync
OverrideSlotService dispatches RunAsync before calling StartRunningAsync,
so a preflight failure (list not found, worktree setup) can reach MarkFailed
while the task is still Queued. The guard is intentional, not dead code.

- Add comment in FailAsync explaining the OverrideSlotService preflight gap
- Add FailAsync_FromQueued_TransitionsToFailed test
- Update CLAUDE.md transition table with the precise rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:41:12 +02:00
mika kuns d2a08d2cda chore(claude-do): refactor(hub): Konflikt-Merge-Methoden eindeutig benennen (C
Kontext: Auf der Hub/Client-Ebene existieren zwei fast gleichnamige Methodenpaare mit unterschiedlicher Semantik: ContinueMerge/AbortMerge (Single-Task-Konflikt-Resolver, Layer C) vs. ContinuePlanningMerge/AbortPlanningMerge (Unit-Merge eines Parents mit Kindern). Verwechslungsgefahr.

Änderungen (NUR die Hub/Client/UI-Ebene umbenennen):
1. src/ClaudeDo.Worker/Hub/WorkerHub.cs: ContinueMerge → Con

ClaudeDo-Task: 5f2e0f88-d4c9-490b-95a7-46244465dbb6
2026-06-09 23:36:18 +02:00
mika kuns 8194dadb6a Merge task branch for: fix(worker): TaskRunner bricht ab, wenn StartRunningAsync fehlschlägt (Doppellauf-Race) 2026-06-09 23:36:07 +02:00
mika kunsandClaude Sonnet 4.6 fb1d799b82 fix(worker): stateless AbortPlanningMerge after worker restart mid-merge
PlanningMergeOrchestrator._states is in-memory. A worker restart during a
conflict pause left the list repo mid-merge with no recovery path: both
ContinuePlanningMerge and AbortPlanningMerge threw "no in-progress merge",
and re-Approving failed on the IsMidMergeAsync guard.

AbortAsync now falls through to a stateless path when no _states entry exists:
it looks up the parent's list WorkingDir and, if the repo is mid-merge, runs
git merge --abort there directly, then broadcasts PlanningMergeAborted.
Parent remains WaitingForReview — the next Approve restarts the unit merge
(already-Merged child worktrees are skipped as before).

ContinueAsync error message now points to AbortPlanningMerge as the recovery
action. StartAsync mid-merge guard also carries an actionable hint.

Tests: AbortAsync stateless + mid-merge (restart recovery), AbortAsync
stateless + clean repo (clear error).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:35:08 +02:00
mika kuns 12fdb55a8e chore(claude-do): fix(worker): TaskRunner bricht ab, wenn StartRunningAsync fe
Befund (bestätigt): src/ClaudeDo.Worker/Runner/TaskRunner.cs:101 (RunAsync) und :211 (ContinueAsync) ignorieren das TransitionResult von _state.StartRunningAsync. Race-Szenario: Der QueuePicker claimt Queued→Running atomar; ruft der Override-Pfad (RunNow) kurz danach RunAsync für denselben Task auf, schlägt StartRunningAsync fehl (0 rows affected), der Runner startet Claude aber trotzdem → derselb

ClaudeDo-Task: 44f86be2-7f3d-462e-98b3-eb94c0174eea
2026-06-09 23:32:57 +02:00
mika kuns eee5c99e2f Merge task branch for: fix(ui): DiffModal — Commit-Range ohne HeadCommit zeigt stillen Falsch-Diff 2026-06-09 23:21:56 +02:00
mika kuns 37df51475e Merge task branch for: fix(worker): FinalizeParentDoneAsync über TaskStateService statt Status-Direkt-Write 2026-06-09 23:21:35 +02:00
mika kuns 53b666dfbd Merge task branch for: refactor(ui): IWorkerClient auf Parität mit WorkerClient bringen 2026-06-09 23:21:23 +02:00
mika kuns cd5501e6a6 Merge task branch for: test(worker): Fakes nach Infrastructure/ konsolidieren + Tag-Ära-Namen aufräumen 2026-06-09 23:21:11 +02:00
mika kunsandClaude Sonnet 4.6 b5417f6b09 refactor(ui): bring IWorkerClient to parity with WorkerClient
Add 16 missing members to IWorkerClient (IsReconnecting, WorkerLogReceivedEvent,
PrimeFired, LastApproveTarget, Refresh/RestoreDefaultAgents, UpdateAppSettings,
prime schedule CRUD, UpdateList/UpdateListConfig, all worktree ops).
Switch all production consumers off the concrete WorkerClient type; only
Program.cs/App host still resolves the concrete registration.
Update StubWorkerClient and FakeWorkerClient to satisfy the expanded interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:15:05 +02:00
mika kuns 7e739afafb chore(claude-do): fix(ui): DiffModal — Commit-Range ohne HeadCommit zeigt stil
Befund (bestätigt): src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs, LoadAsync (~Zeile 116): bei FromCommitRange=true aber HeadCommit==null fällt der Ternary still auf GetBranchDiffAsync(WorktreePath, BaseRef) zurück. In diesem Modus ist WorktreePath aber das Listen-Working-Dir (Repo-Root, kein Worktree) — es wird ein falscher Diff angezeigt, ohne jeden Hinweis.

Änderungen:
1. Guard: From

ClaudeDo-Task: d667c80c-3f32-478c-8584-46aec78357b6
2026-06-09 23:14:37 +02:00
mika kunsandClaude Sonnet 4.6 e9e4ad8fbc fix(worker): route FinalizeParentDoneAsync through TaskStateService
Replaces the direct EF Status write in PlanningMergeOrchestrator with
_state.ApproveReviewAsync, enforcing the TaskStateService invariant as
sole owner of Status writes. Handles the improvement-parent path where
TaskMergeService already approved the parent's own worktree during the
drain (status == Done on entry → still success). If the parent was
concurrently cancelled, the transition guard rejects the approve,
PlanningCompleted is not broadcast, and the cancelled status is
preserved. ApproveReviewAsync now also sets FinishedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:13:30 +02:00
mika kunsandClaude Sonnet 4.6 d4af345ac3 test(worker): consolidate fakes into Infrastructure/, drop tag-era names
- Extract FakeClaudeProcess to Infrastructure/FakeClaudeProcess.cs (was
  defined inline in QueueServiceTests #region); all consumers updated
- Replace duplicate FakeHubContext/FakeHubClients/FakeClientProxy
  (QueueServiceTests) with existing CapturingHubContext from Infrastructure
  across all 7 affected files; Planning's file-local FakeHubContext kept
- Rename SeedListWithAgentTag → SeedListAsync (return Task<string>, drop
  unused agentTagId tuple element) and SeedListWithAgentTagAsync → SeedListAsync
- PrimeRunnerTests keeps its private nested FakeClaudeProcess: constructor
  API (delay/exitCode/lines/result params) differs from the shared one and
  replacement would require rewriting every test in that file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:04:59 +02:00
mika kunsandClaude Fable 5 ddeded988a docs(open): record correctness-review findings (4 confirmed as tasks)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:48:33 +02:00
mika kunsandClaude Opus 4.8 c27a179d2b feat(worker): let Claude set the cheapest model per generated task via MCP
AddTask, planning CreateChildTask, and SuggestImprovement now accept an
optional alias-validated model (haiku/sonnet/opus; blank = inherit) so the
model is chosen at creation time instead of a follow-up set_task_config call.
The planning, system, and improvement prompts instruct Claude to pick the
cheapest capable model (haiku < sonnet < opus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:22:17 +02:00
mika kunsandClaude Fable 5 1448794748 docs(open): record review findings as refactoring backlog
Five findings filed as ClaudeDo tasks (IWorkerClient parity, merge-API
naming, DetailsIslandViewModel split, test-fake hygiene, FailAsync guard)
plus the deferred WorkerHub split and the AgentMcpTools file move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:18:48 +02:00
mika kuns 51ef488d2f docs: spec + plan for per-task model override via MCP 2026-06-09 22:05:01 +02:00
mika kunsandClaude Fable 5 49046310ef docs: refresh CLAUDE.md files and open.md to current code state
- Ui CLAUDE.md rewritten around the islands architecture (old
  MainWindow/TaskList/StatusBar VMs no longer exist)
- Worker: folder layout (Refine/, Lifecycle/Planning extras), full hub
  method/event surface, external MCP tool inventory
- Data: complete GitService operation list incl. commit-range diffs
- App: missing DI registrations; Tests: current test-area overview
- root: project list (Localization, Installer, six test projects) and
  honest docs index; plan.md/improvement-plan.md marked historical
- open.md: date bump + visual check for new diff viewer / attention band

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:00:55 +02:00
ClaudeDo CI f8f20bf6ed docs(changelog): update for v1.8.0 2026-06-09 14:41:28 +00:00
mika kunsandClaude Opus 4.8 f21c65be18 feat(ui): richer diff viewer + surface child roadblocks on parents
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
- UnifiedDiffParser detects added/deleted/renamed/binary files; diff
  modal shows a file list, binary/empty placeholders, and can diff a
  merged task by commit range after its worktree is gone
- DetailsIslandViewModel flags children needing attention (failed,
  cancelled, awaiting review, or with roadblocks) on the parent
- GitService gains worktree head-commit/range support; planning chain,
  merge orchestration, and session manager tweaks with updated tests
- refresh app/installer/worker icons

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:40:59 +02:00
mika kunsandClaude Opus 4.8 c300f8c313 docs: document the unified parent-task model
Add WaitingForChildren to the status tables, document the single parent
lifecycle (planning + improvement) and approve-merges-the-whole-unit across
the root, Worker, and Data CLAUDE.md files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:46:02 +02:00
mika kunsandClaude Opus 4.8 d6e0953293 feat(worker): allow cancelling a WaitingForChildren parent
Add WaitingForChildren to the CancelAsync guard so a parent waiting on its
children can be cancelled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:44:18 +02:00
mika kunsandClaude Opus 4.8 a8b86e25e6 feat(ui): single approve action merges the whole unit
Approve & Merge is now the only review+merge entry. For a parent with
children it drives the unit merge via the worker (conflicts still surface
through the existing PlanningMergeConflict dialog); the separate Merge All
Subtasks button, MergeAllCommand, CanMergeAll plumbing, and the dead
MergeAllPlanningAsync client method are removed. Combined-diff preview and
conflict continue/abort are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:43:04 +02:00
mika kunsandClaude Opus 4.8 1abb429f12 feat(worker): approve drives the unit merge for parents with children
ApproveReview routes a parent that has children through
PlanningMergeOrchestrator (merge parent + each Done child, set parent Done,
conflict continue/abort) instead of the parent-only ApproveAndMergeAsync.
Childless tasks are unchanged. Removes the now-redundant MergeAllPlanning hub
method (UI rewiring follows separately).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:32:33 +02:00
mika kunsandClaude Opus 4.8 803c04d9e0 docs(worker): Task 4 = full approve/merge UX consolidation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:27:35 +02:00
mika kunsandClaude Opus 4.8 12732d6dc9 feat(worker): planning finalize enters WaitingForChildren
A finalized planning parent now joins the unified parent lifecycle:
WaitingForChildren while its child chain runs (or WaitingForReview directly
if it has no children), advancing to review like an improvement parent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:19:29 +02:00
mika kunsandClaude Opus 4.8 b3a2daf40d refactor(worker): single parent-advance path for planning + improvement
Collapse TryCompleteParentAsync (planning -> Done) and
TryAdvanceImprovementParentAsync (improvement -> WaitingForReview) into one
TryAdvanceParentAsync that surfaces any WaitingForChildren parent for review
once all children are terminal. Planning parents no longer auto-complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:14:43 +02:00
mika kunsandClaude Opus 4.8 8f49ebb248 docs(worker): spec + plan for unifying the parent-task model
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:58:45 +02:00
mika kunsandClaude Opus 4.8 f56cc617c3 fix(worker): mark task Done on every successful merge path, not just approve
Generalizes the previous merge_task fix: the WaitingForReview->Done transition
now lives in TaskMergeService.MergeAsync/ContinueMergeAsync, so the UI Merge
button (WorkerHub.MergeTask), conflict-merge, continue-merge and the external
MCP all land a merged task in Done. ApproveAndMergeAsync no longer double-approves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:41:08 +02:00
mika kunsandClaude Opus 4.8 ca8326c4c5 fix(mcp): merge_task marks the task Done after a successful merge
merge_task only flipped the worktree to Merged; it never transitioned the task
status. With allowWaitingForReview this left a merged task stuck in
WaitingForReview. Approve it to Done on a successful merge (a Done task is
already terminal). Mirrors the ApproveAndMergeAsync review flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:36:26 +02:00
mika kunsandClaude Opus 4.8 f5d165baae fix(data): drop unique index on lists.name (allow duplicate list names)
The startup-race hardening added a global unique index on lists.name, but
duplicate list names are legitimate and the index broke 8 Worker tests that
seed same-named lists. The seeder race is already handled by the atomic
INSERT...WHERE NOT EXISTS, so the index is redundant. Keep the de-dup migration
step, remove the unique index from config, migration and model snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:15:42 +02:00
mika kuns 61a40d549b Merge task branch for: Data hardening: per-connection FK pragma + startup seed/appsettings race 2026-06-09 10:07:05 +02:00
mika kuns 5723b81992 Merge task branch for: Worker hardening: CLI arg injection, stuck-Running, planning-chain wedge, Fail guard 2026-06-09 10:06:59 +02:00
mika kunsandClaude Sonnet 4.6 7f1a14ab80 fix(data): harden FK pragma per-connection and seed concurrency
- Add SqliteForeignKeyInterceptor (DbConnectionInterceptor) registered via
  OnConfiguring so every IDbContextFactory-created context runs
  PRAGMA foreign_keys=ON, not only the MigrateAndConfigure context.
- DefaultListsSeeder: replace TOCTOU read-then-insert with atomic
  INSERT … SELECT … WHERE NOT EXISTS — one SQLite writer lock, no race.
- AppSettingsRepository.GetAsync: catch DbUpdateException on the
  get-or-create path and re-read so concurrent startup cannot throw.
- Migration 20260609000000_UniqueListName: de-duplicates empty list rows
  (startup-race leftovers) then adds a UNIQUE index on lists.name.
- ForeignKeyTests: verifies ON DELETE SET NULL (blocked_by_task_id) is
  enforced on a fresh DbContext with no manual PRAGMA call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:05:41 +02:00
mika kunsandClaude Sonnet 4.6 33bdff8a6e fix(worker): harden CLI injection, stuck-Running, chain wedge, and Fail guard
1. ArgumentList (fix injection): ClaudeArgsBuilder.Build() now returns
   IReadOnlyList<string>; ClaudeProcess populates ProcessStartInfo.ArgumentList
   instead of Arguments, so values like system prompts are never shell-split.
   DailyPrepPrompt, RefinePrompt, and WeekReportService migrated similarly.
   All IClaudeProcess fakes updated.

2. ContinueAsync exception guard: wrap RunOnceAsync in try/catch matching
   the RunAsync pattern so an unexpected exception never leaves the task
   stuck in Running status.

3. Planning chain cascade: OnChildFinishedAsync now calls CancelAsync on
   the immediate blocked successor when a child fails or is cancelled,
   triggering a recursive cascade that clears the entire remaining chain
   instead of leaving it wedged.

4. FailAsync guard: restrict valid source states to Running and Queued;
   WaitingForReview -> Failed is now rejected, preventing an invalid
   transition that could corrupt the review workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:05:40 +02:00
mika kuns b5cf19b19a Merge task branch for: MCP: add missing external tools + fix status enum, branchDeleted, merge-from-review 2026-06-09 10:00:11 +02:00
mika kunsandClaude Sonnet 4.6 9f19a714f7 feat(mcp): add get_task_config, continue_task; fix status enum, branchDeleted, merge-from-review
- ConfigMcpTools: add get_task_config read-back (was write-only)
- ExternalMcpService: add WaitingForChildren to ListTasks filter and GetTaskStatusValues
- ExternalMcpService: add continue_task tool wrapping QueueService.ContinueTask
- ExternalMcpService: add allowWaitingForReview param to merge_task (default false)
- ExternalMcpService: fix CleanupTaskWorktree branchDeleted — now uses real branch-delete outcome
- WorktreeMaintenanceService: TryRemoveAsync returns (Removed, BranchDeleted) tuple; ForceRemoveResult gains BranchDeleted field
- Tests: 9 new cases covering all five changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:57:47 +02:00
mika kunsandClaude Opus 4.8 b672c9aaf3 fix(git): serialize concurrent worktree add to prevent commondir race
Parallel task starts called 'git worktree add' simultaneously; git's shared
.git/worktrees metadata mutation isn't concurrency-safe and one add failed with
'failed to read .git/worktrees/<other>/commondir'. Serialize adds behind a
process-wide gate plus a bounded retry on the transient error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:55:39 +02:00
mika kunsandClaude Opus 4.8 384e058812 docs: add CHANGELOG (Keep a Changelog format)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:54:32 +02:00
mika kunsandClaude Opus 4.8 01e0c1d794 fix(ui): dispose VM subscriptions/timers, guard offline Stop, align review delta-path
- DetailsIslandViewModel/TasksIslandViewModel/ListsIslandViewModel: implement
  IDisposable, unsubscribe Loc.LanguageChanged and worker events (memory leaks).
- IslandsShellViewModel: dispose the three System.Timers.Timer instances.
- StopAsync: guard on Task/IsRunning/IsConnected and wrap CancelTask in try/catch.
- TaskMatchesList virtual:review now matches WaitingForReview (aligns with ReviewFilter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:53:58 +02:00
mika kunsandClaude Opus 4.8 00a065bf7f fix(review): populate review queue from WaitingForReview tasks
ReviewFilter matched Status==Done && active worktree, but a successful run
lands a task in WaitingForReview, so the Review virtual list was always empty.
Match WaitingForReview instead; update VirtualFilterTests accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:53:57 +02:00
mika kunsandClaude Opus 4.8 763732a9b3 feat(ui): surface agent roadblocks and run outcome in the detail pane
- Parse CLAUDEDO_BLOCKED roadblocks out of the run result and show them in a
  colored card between Details and Output (ApplyOutcome / ShowRoadblockCard).
- Show the run outcome summary as an OUTCOME card in the Output tab, loaded from
  the task result (falls back to the run's ErrorMarkdown) and refreshed on finish.
- Guard the Session tab so it only appears when there are child outcomes.
- Make console resize per-task and proportional (description capped at 2/3,
  console floored at ~1/3) so a long description no longer spills over the footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:34:37 +02:00
mika kunsandClaude Opus 4.8 a41b8de47a feat(i18n): localize task-header, task-row and prime-schedule tooltips
Replace hardcoded tooltips with loc keys (kill-session, delete-task, toggle-subtasks, agent-suggested, star, remove-schedule) and drop the unused console.maximizeTip key; en/de kept in parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:34:26 +02:00
mika kunsandClaude Opus 4.8 18b777a712 ci: add dependency-audit and changelog Gitea workflows
- audit.yml: weekly `dotnet list package --vulnerable` scan that files an issue on findings
- changelog.yml: generate a changelog on `v*` tag pushes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:34:18 +02:00
mika kuns 7f173daecb feat(ui): wire layer A/B conflict seams to the inline resolver 2026-06-05 11:12:42 +02:00
mika kuns e71c0ed24f merge(layer-b): multi-worktree batch-merge cockpit 2026-06-05 11:09:09 +02:00
mika kuns d450153183 merge(layer-c): inline conflict resolver + worker conflict plumbing 2026-06-05 11:09:02 +02:00
mika kuns 72687e9b30 feat(ui): expose conflict-resolver factory and dialog seam for integrator 2026-06-05 11:00:37 +02:00
mika kunsandClaude Sonnet 4.6 d52243ccd1 refactor(ui): render worktree modal diff via canonical DiffLinesView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:00:19 +02:00
mika kuns 8cafad370e feat(ui): add inline conflict resolver view and localization 2026-06-05 10:58:19 +02:00
mika kuns d8a973d0e1 feat(ui): add inline conflict resolver view-model 2026-06-05 10:56:47 +02:00
mika kuns 0b623b8e4a feat(ui): add inline conflict model (file/hunk with resolution) 2026-06-05 10:55:20 +02:00
mika kuns 5edb433755 feat(ui): batch-merge cockpit view with checkboxes and conflicts panel 2026-06-05 10:54:34 +02:00
mika kuns c8f82ed3c2 feat(i18n): add batch-merge cockpit strings (en/de) 2026-06-05 10:52:28 +02:00
mika kunsandClaude Sonnet 4.6 1aa06077a8 feat(ui): wire batch selection, target loading and resolve seam
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 10:50:38 +02:00
mika kuns cb20877620 feat(hub): expose conflict-resolution merge methods 2026-06-05 10:50:04 +02:00
mika kuns dcbf67c63b feat(merge): read conflict stages and write user resolutions 2026-06-05 10:49:07 +02:00
mika kuns 02b11c727c feat(ui): add skip-and-continue batch merge orchestration 2026-06-05 10:47:17 +02:00
mika kuns 74afc46909 feat(git): add conflict-stage blob reads and single-path staging 2026-06-05 10:47:14 +02:00
mika kuns ef3fba1690 feat(ui): add batch-merge row state to worktrees cockpit VM 2026-06-05 10:44:18 +02:00
mika kuns ef2f5c51e4 docs(plan): Layer C inline conflict resolver 2026-06-05 10:44:18 +02:00
mika kuns 3060cb0242 docs(plan): Layer B multi-worktree merge cockpit plan 2026-06-05 10:42:02 +02:00
mika kuns 3596053512 feat(ui): fuse git tab into one approve+merge cockpit 2026-06-05 10:32:02 +02:00
mika kuns 4bf4a27036 feat(ui): route single-task merge conflicts into a resolution seam 2026-06-05 10:30:43 +02:00
mika kuns de4ad5dcf3 feat(ui): maximize work console via green traffic-light dot 2026-06-05 10:27:47 +02:00
mika kuns 2dfc4559b1 feat(ui): add conflict-resolution worker contract (foundation for merge rework) 2026-06-05 10:20:42 +02:00
mika kunsandClaude Opus 4.7 dd3b03b9e4 docs(plan): foundation + Layer A plan and Layer B/C parallel kickoff prompts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 10:15:52 +02:00
mika kunsandClaude Opus 4.7 f4416ee1c3 docs(design): git tab merge & review rework — shared foundation + 3 layers
Design for simplifying single-task review/merge (Layer A), multi-worktree
batch merge cockpit (Layer B), and inline conflict resolver (Layer C),
with frozen shared contracts so B/C build in parallel worktrees.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 10:09:10 +02:00
mika kunsandClaude Opus 4.7 42bb79e2b7 feat(ui): rename review Retry to Continue and make Reset discard the worktree
[Continue] keeps the reject-to-queue + resume behaviour. [Reset] now calls
ResetTaskAsync (discards the task worktree and returns it to Idle) behind a
confirmation, replacing the old park-to-idle action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 09:03:47 +02:00
mika kunsandClaude Opus 4.7 561028e67b fix(ui): set prompt-action resting color on ContentPresenter
The Fluent theme sets text color on the inner ContentPresenter, so setting
Foreground on the Button only took effect on hover. Move the normal-state
color onto the ContentPresenter so [Retry] shows green at rest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:58:26 +02:00
mika kunsandClaude Opus 4.7 07a9d07cf6 style(ui): align refine button with star and update refine icon
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:57:50 +02:00
mika kunsandClaude Opus 4.7 19435b2d48 style(ui): render review actions as bracketed terminal text
Replace the chromed btn/accent buttons in the review prompt with borderless
bracketed text actions ([Retry] [Reset]) so they read as terminal commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:42:17 +02:00
mika kunsandClaude Opus 4.7 e22a3267fe refactor(ui): blend review prompt into the terminal instead of a boxed footer
Drop the bordered Surface2 footer and lay the feedback prompt directly on the
terminal background, aligned with the log lines, so it reads as a shell input
line rather than a separate panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:39:00 +02:00
mika kunsandClaude Opus 4.7 9c5872eb27 feat(ui): send Retry on Enter in the review prompt
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:34:30 +02:00
mika kunsandClaude Opus 4.7 8819a56496 feat(ui): rework review into terminal footer and add Git tab
Move review feedback into a prompt-style footer on the Output tab with
Retry/Reset actions, relocate Approve and all merge/worktree controls to a
new Git tab, and reduce the Session tab to subtask outcomes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:34:29 +02:00
mika kuns 6c65158be8 feat(ui): add IsGitTab flag to work console view model 2026-06-05 08:28:12 +02:00
mika kunsandClaude Opus 4.7 096519b978 docs(review): add implementation plan for terminal-style review controls
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:21:33 +02:00
mika kunsandClaude Opus 4.7 266e6d191b docs(review): spec terminal-style review with Git tab and footer actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:17:17 +02:00
mika kunsandClaude Sonnet 4.6 cb4c396a53 docs(merge): document real git merge on approve, PreviewMerge hub method, and new GitService/WorkerClient members
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:43:44 +02:00
mika kunsandClaude Opus 4.7 6e3f90d289 fix(ui): discard stale mergeability probe after task or target switch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:41:59 +02:00
mika kunsandClaude Opus 4.7 de01579e84 feat(ui): add mergeability indicator and Merge button to work console
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:36:56 +02:00
mika kunsandClaude Sonnet 4.6 0d8999dc20 feat(ui): show mergeability and surface approve conflicts in the work console
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:35:53 +02:00
mika kunsandClaude Sonnet 4.6 3202c76674 feat(ui): wire merge-aware approve and preview into the worker client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:32:12 +02:00
mika kuns 43f8f7f7d8 feat(worker): expose PreviewMerge hub method and merge-on-approve 2026-06-04 23:29:05 +02:00
mika kunsandClaude Opus 4.7 f1cf29b58d fix(worker): guard blank working dir in approve-merge before resolving target
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:27:59 +02:00
mika kuns 98b0d58e03 fix(worker-tests): update TaskMergeService ctor calls after ITaskStateService injection 2026-06-04 23:25:03 +02:00
mika kuns b817c87656 feat(worker): approve merges worktree before marking task done 2026-06-04 23:24:50 +02:00
mika kuns 2a6781f80f feat(ui): add Refine button, icon, and command to task card 2026-06-04 23:21:30 +02:00
mika kuns 4098f7f341 feat(git): add non-destructive merge-tree conflict probe 2026-06-04 23:18:54 +02:00
mika kunsandClaude Sonnet 4.6 82390047d2 feat(ui): add RefineTask client call and refine events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:16:58 +02:00
mika kunsandClaude Opus 4.7 75ad7b1735 docs(merge): add approve-merge + conflict-preview implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:16:11 +02:00
mika kuns e523ed85eb feat(refine): wire RefineTask hub method, broadcaster events, and DI 2026-06-04 23:14:00 +02:00
mika kunsandClaude Sonnet 4.6 0460d7bea5 feat(refine): add RefineRunner, prompt/args helper, and interfaces
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:09:30 +02:00
mika kunsandClaude Opus 4.7 66a7b2377f docs(merge): add approve-merge + conflict-preview design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:07:25 +02:00
mika kuns eca6813cdb feat(prompts): add Refine prompt kind and default 2026-06-04 23:04:24 +02:00
mika kuns 22830d3ea8 feat(mcp): add add_subtask tool to claudedo MCP 2026-06-04 23:03:07 +02:00
mika kunsandClaude Opus 4.7 3573548348 docs(refine): add Refine Task implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:00:01 +02:00
mika kunsandClaude Opus 4.7 0867bc8296 docs(refine): add Refine Task design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:53:17 +02:00
mika kunsandClaude Opus 4.7 1603be0c78 fix(ui): stop the console clipping the last log line
The tab body ran flush into the console's rounded bottom corner, so the final
log line was shaved off. Inset the tab body from the bottom so the scroll
viewport ends above the corner and ScrollToEnd reveals the whole last line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:51:49 +02:00
mika kunsandClaude Opus 4.7 71a3765c07 fix(ui): render Output log directly on the console, not as a nested card
The Output tab embedded SessionTerminalView, which is itself a bordered terminal
card with its own header — a card inside the console card. Render the log lines
directly on the console body instead (the console already provides the terminal
chrome, traffic lights, and status chip), with auto-scroll moved to code-behind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:27:03 +02:00
mika kunsandClaude Opus 4.7 b840655163 feat(ui): resize detail split by dragging the console's top edge
Replace the standalone GridSplitter bar between the details card and the work
console with a transparent splitter over the gap above the console, so the user
drags the console's top edge to resize. Restore the prep-log terminal's inset
now that SessionTerminalView no longer hard-codes its own margin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:18:44 +02:00
mika kunsandClaude Opus 4.7 ac9bae9546 feat(ui): rework work console — single Session tab, right-aligned header, turns x/y
- Merge "Actions" + "Session" into one state-aware Session tab: review controls
  on top, then merge/worktree management, then child outcomes — each gated on the
  current state, with an empty-state hint when there's nothing to manage. This is
  the home the real merge/diff work (task 09eb5d52) will slot into.
- Move the model · turns · diff info block to the right of the title bar.
- Show turns as current/max using the resolved turn budget (task → list → global).
- Output terminal now fills the console body cleanly: clip the console to its
  rounded corners and inset the embedded terminal instead of clipping its bottom.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:18:36 +02:00
mika kunsandClaude Opus 4.7 99c6bf4478 feat(ui): make steps visible at a glance; lift details card off background
The single flip-icon hid that steps existed until toggled. Replace it with an
always-visible "STEPS" summary strip below the description (open/total count,
click to expand and manage). Description is now always the card body. Give the
card a Surface2 background + LineBrush border so it separates from the window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:18:24 +02:00
mika kunsandClaude Opus 4.7 3e848710b8 refactor(ui): remove dead inline-layout handlers from DetailsIslandView
The redesigned detail island moved the title, subtask rows, and copy/edit
controls into TaskHeaderBar and DescriptionStepsCard, leaving four unused
code-behind handlers (OnSubtaskTitleTapped, OnSubtaskEditLostFocus,
OnTaskIdTapped, OnCopyDescriptionClick) and their imports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:17:45 +02:00
mika kunsandClaude Opus 4.7 a2c339cd87 docs(web): add ClaudeDo distribution website design spec
Approved design for a Nuxt 3 site at claudedo.kuns.dev: "the page is the
app" concept (3-island layout), build-time release fetch, and a Nitro
release proxy that fronts the self-updater to hide the Gitea URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:07:58 +02:00
mika kunsandClaude Opus 4.7 c71026d125 feat(ui): wire redesigned detail island (header + description/steps card + work console)
Replace the long scrolling DetailsIslandView with the new pinned layout: a
separated TaskHeaderBar (trash↔skull, gear), a DescriptionStepsCard (text⇄steps
toggle, Preview = composed prompt), and a pinned WorkConsole (Output/Actions/
Session tabs). The three components now bind to DetailsIslandViewModel; their
scaffolding sample VMs are removed. Drops the old inline sections + AgentStripView.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 19:49:41 +02:00
mika kunsandClaude Sonnet 4.6 ce50f9fcce feat(ui): add WorkConsole detail component
Standalone terminal-styled card with traffic-light title bar, roadblock
band, and three tabs (Output / Actions / Session). Renders fully via
design-time sample data; does not touch DetailsIslandView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:35:35 +02:00
mika kunsandClaude Sonnet 4.6 c323953f8c feat(ui): add DescriptionStepsCard detail component
Standalone UserControl combining Description + Steps into one card with
a top-right toggle. Description view shows raw editor or composed
MarkdownView (title + description + open steps). Steps view has an
add-step input and subtask rows with inline editing and check circles.
Adds Icon.Text geometry to IslandStyles for the steps→description toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:35:28 +02:00
mika kunsandClaude Sonnet 4.6 9f95942dd1 feat(ui): add TaskHeaderBar detail component
Standalone UserControl for the task detail island redesign.
Grid layout: id badge + editable title | trash/skull toggle | gear flyout.
Skull geometry added to IslandStyles.axaml (Icon.Skull, EvenOdd fill).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:35:21 +02:00
mika kunsandClaude Opus 4.7 299867d8df feat(worker): compose task prompt from title + description + open steps only
Resolved sub-tasks are no longer appended to the prompt. Extracted into a
shared TaskPromptComposer so the UI's description preview can render the same
'what Claude gets' text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 19:16:37 +02:00
mika kuns 8f7e2898fe docs(ui): task-detail island redesign spec + component build prompts 2026-06-04 19:12:04 +02:00
mika kuns 9f37b1e21e feat:(workflows) Add Changelogs to Relase 2026-06-04 19:07:05 +02:00
mika kunsandClaude Opus 4.7 c5a4e350e9 docs(logging): implementation plan for build-config logging + traceability
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 18:27:49 +02:00
mika kunsandClaude Opus 4.7 e547921fdd docs(logging): runtime build-config detection, Warning in Release, retain 2
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 18:20:37 +02:00
mika kunsandClaude Opus 4.7 f1316dfd0e docs(logging): design for build-config debug logging + task traceability
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 18:12:46 +02:00
mika kunsandClaude Opus 4.7 cc7355eaa4 fix(ui): stop app crash when approving review after Merge all
The Details island review commands (Approve/Reject/Park/Cancel) invoked the
hub without catching exceptions. After "Merge all" folds the parent out of
WaitingForReview, pressing Approve made the hub throw a HubException, which
escaped the generated AsyncRelayCommand as an unobserved async-void exception
and crashed the app. Wrap the calls in try/catch like the Tasks island does;
the TaskUpdated broadcast reconciles the UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 18:04:37 +02:00
mika kunsandClaude Opus 4.7 22a1ba7f30 refactor(ui): share color-coded diff rendering between per-task and combined diff viewers
Extract the unified-diff parser into UnifiedDiffParser and the styled line
renderer into a reusable DiffLinesView control. The combined (planning) diff
now parses its unified-diff string and renders color-coded rows (green
additions / red deletions, file headers) identical to the per-task viewer
instead of dumping plain text into a TextBox.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 17:56:06 +02:00
mika kuns a3f407b0e5 fix(ui): live-update child outcomes + enable Review combined diff for improvement parents 2026-06-04 16:53:43 +02:00
mika kuns 469e68bbc8 feat(merge): fold parent branch into combined-diff for improvement parents 2026-06-04 16:53:42 +02:00
mika kuns 176b9855bf feat(prompt): focused custom prompt for improvement children so they stay narrow 2026-06-04 16:53:41 +02:00
mika kuns 5d34f95fe0 feat(ui): show improvement-child outcomes on the parent review card + enable tree-merge 2026-06-04 16:32:37 +02:00
mika kunsandClaude Sonnet 4.6 0e130177fc feat(ui): mark agent-suggested improvement children in the task tree
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:22:38 +02:00
mika kunsandClaude Sonnet 4.6 5363570fb4 feat(ui): surface WaitingForChildren status (chip, color, agent-strip, labels)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:19:31 +02:00
mika kuns f60becaf06 feat(prompt): instruct agents to offload out-of-scope work via SuggestImprovement 2026-06-04 16:10:39 +02:00
mika kunsandClaude Sonnet 4.6 519bfbe6b3 feat(merge): fold parent branch into tree-merge for improvement parents
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:09:44 +02:00
mika kunsandClaude Sonnet 4.6 06e3acd5ac feat(runner): mint per-run MCP token + emit run-scoped --mcp-config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 16:03:51 +02:00
mika kunsandClaude Sonnet 4.6 f3052dc5fc feat(mcp): resolve per-run tokens in MCP auth + register TaskRunMcpService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:57:12 +02:00
mika kuns 9d133e227b feat(mcp): add SuggestImprovement tool (server-stamped, one layer deep) 2026-06-04 15:51:57 +02:00
mika kuns 7542bc2058 feat(mcp): add TaskRunMcpContext + accessor 2026-06-04 15:50:30 +02:00
mika kuns ef86a8c29b feat(mcp): add per-run TaskRunTokenRegistry 2026-06-04 15:50:06 +02:00
mika kuns da23b6cd3a feat(worktree): base improvement-child worktree on parent HEAD 2026-06-04 15:46:44 +02:00
mika kuns c10f564265 feat(runner): route standalone success with children to WaitingForChildren + enqueue them 2026-06-04 15:46:38 +02:00
mika kunsandClaude Sonnet 4.6 8036de1019 fix(state): only planning-active children are drafts; allow improvement children to queue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:40:26 +02:00
mika kunsandClaude Sonnet 4.6 7873e60095 feat(state): advance WaitingForChildren parent to review when children terminal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:39:24 +02:00
mika kunsandClaude Sonnet 4.6 6f4b5d5544 feat(state): add SubmitForChildrenAsync (Running -> WaitingForChildren)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:38:15 +02:00
mika kuns f25c7599bd fix(children): exempt improvement children from orphan-dequeue sweep 2026-06-04 15:35:06 +02:00
mika kuns 6fdf04d6a0 feat(children): generalize CreateChildAsync for any parent + CreatedBy stamp 2026-06-04 15:32:18 +02:00
mika kuns ee0d1257dd feat(status): add WaitingForChildren task status value 2026-06-04 15:32:11 +02:00
mika kuns 204b089000 docs(plan): align Task 6 with rebased HandleSuccess (preserve SetRoadblockCount) 2026-06-04 15:27:17 +02:00
mika kuns da4ab0ca5e docs(plan): child tasks + agent improvement loop implementation plan 2026-06-04 15:26:25 +02:00
mika kuns c035720b37 fix(ui): populate diff meter when selecting a finished task 2026-06-04 15:24:06 +02:00
mika kuns 4522ac906b fix(ui): warning icon fill-rule and dedicated review section header 2026-06-04 15:10:45 +02:00
mika kunsandClaude Sonnet 4.6 2455eacb1f feat(ui): roadblock badge on the task card; relocate review actions off the row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:06:53 +02:00
mika kunsandClaude Sonnet 4.6 d8b86e33a3 feat(ui): host review actions in the details panel; show review state and diff meter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 15:03:19 +02:00
mika kuns 49b9f1ffde feat(roadblock): persist roadblock count on the task 2026-06-04 14:58:59 +02:00
mika kuns 4d52845130 docs: plan for review & roadblock UX follow-up 2026-06-04 14:54:27 +02:00
mika kunsandClaude Sonnet 4.6 9a117a5429 fix(prompts): apply system default on every run; dedupe roadblocks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:25:55 +02:00
mika kuns 202e8dea49 docs: refresh prompt inventory for externalized prompts + roadblock marker 2026-06-04 14:20:48 +02:00
mika kunsandClaude Sonnet 4.6 1e547dea18 feat(roadblock): surface reported roadblocks in the review result
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:18:51 +02:00
mika kuns 56ebc2803f feat(roadblock): carry blocks through RunResult 2026-06-04 14:16:56 +02:00
mika kuns cf7f0da400 feat(roadblock): collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer 2026-06-04 14:15:45 +02:00
mika kunsandClaude Sonnet 4.6 ac1e9b06de feat(prompts): weekly-report instructions from file, point at data sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:13:15 +02:00
mika kuns 79bfc79d33 feat(prompts): daily-prep prompt from file, English default 2026-06-04 14:11:30 +02:00
mika kuns 1b3c6bdbb4 refactor(prompts): planning prompts read from editable files 2026-06-04 14:09:45 +02:00
mika kuns bd1e3db1d9 feat(ui): expose all editable prompt files, drop agent prompt 2026-06-04 14:07:43 +02:00
mika kunsandClaude Sonnet 4.6 edc9f77357 feat(prompts): retry prompt from file, append only real captured errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 14:03:32 +02:00
mika kuns 883dbc6af7 refactor(prompts): collapse agent prompt into system prompt 2026-06-04 13:59:44 +02:00
mika kunsandClaude Sonnet 4.6 9bdf99d95f feat(prompts): externalize prompt kinds with defaults and token renderer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 13:55:47 +02:00
mika kunsandClaude Opus 4.7 c8f468f270 docs: implementation plan for bundled-prompts overhaul
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:51:37 +02:00
mika kunsandClaude Opus 4.7 84fd2c11a0 docs: child base off parent HEAD, shared planning-style tree merge
Children fan out from the parent's worktree HEAD and merge via a
generalized planning orchestrator (parent branch + children); child
roadblocks roll up to the parent review card.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:45:54 +02:00
mika kunsandClaude Opus 4.7 30b49d1071 docs: design for reusable child tasks + agent improvement loop
Agent offloads out-of-scope work via SuggestImprovement; children run
automatically; new WaitingForChildren state; generalize planning's
parent/child machinery.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:36:53 +02:00
mika kunsandClaude Opus 4.7 ad7d74820a docs: design for bundled-prompts overhaul
Externalize all prose prompts to editable files, collapse system+agent,
add an inline roadblock protocol detected by StreamAnalyzer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 13:20:24 +02:00
mika kunsandClaude Opus 4.7 75aa42b877 docs: note max-turns override and inherited markers in module docs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:42:44 +02:00
mika kunsandClaude Opus 4.7 925b72ae83 test(worker): cover max-turns in ConfigMcpTools round-trip
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:41:54 +02:00
mika kunsandClaude Sonnet 4.6 cd683ba227 feat(ui): show inherited markers and max-turns override in task flyout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:37:45 +02:00
mika kuns d0ab382973 feat(ui): show inherited markers and max-turns override in list settings 2026-06-04 12:32:28 +02:00
mika kunsandClaude Opus 4.7 3e3041c1c7 feat(ui): add reusable inherited-source badge control
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:29:15 +02:00
mika kuns 92cee125cc feat(ui): add inheritance resolver returning value and source 2026-06-04 12:28:12 +02:00
mika kunsandClaude Opus 4.7 bba3c55e1c feat(i18n): add inherited-marker, turns, and prepended-prompt strings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:26:31 +02:00
mika kunsandClaude Opus 4.7 26f5936d14 feat(ui): mirror max-turns field on signalr config dtos
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:23:39 +02:00
mika kuns b72a7888e4 feat(worker): expose max-turns override over signalr and mcp config tools 2026-06-04 12:22:34 +02:00
mika kunsandClaude Sonnet 4.6 beae2d639d feat(worker): resolve max-turns from task then list then global default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:20:35 +02:00
mika kunsandClaude Sonnet 4.6 ac137f7c1c feat(data): persist max_turns in list and task repositories
Add MaxTurns to ListRepository.SetConfigAsync upsert branch and
TaskRepository.UpdateAgentSettingsAsync; fix positional CancellationToken
call in ConfigMcpTools. Covered by MaxTurnsRoundTripTests (2 tests).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:18:32 +02:00
mika kuns 97e38fb480 feat(data): add nullable max_turns override to list_config and tasks 2026-06-04 12:15:15 +02:00
mika kunsandClaude Opus 4.7 b63c78c234 docs: implementation plan for inherited markers, overrides, and Turns
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:12:37 +02:00
mika kunsandClaude Opus 4.7 37ce673a57 docs: spec for inherited-settings display, overrides, and Turns
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 12:04:06 +02:00
mika kunsandClaude Opus 4.7 b9741ef38b docs: slim open.md down to open items only
Drop the changelog of completed/verified work — that lives in commits and
code. Keep only pending manual verification, the one open code item, and a
short "decided against" list to prevent re-proposing dropped ideas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:46:59 +02:00
mika kunsandClaude Opus 4.7 0a0d7e8551 docs: park mailbox proposal; skip architecture.md and ADRs
The generic Claude-Mailbox plugin already covers cross-session messaging,
so the ClaudeDo-internal integration is parked. architecture.md and ADRs
are deliberately skipped — per-project CLAUDE.md files are the living
architecture doc, and ADRs add little for a solo project.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:45:52 +02:00
mika kunsandClaude Opus 4.7 2dfa9956c5 revert: drop real-claude smoke test; track as manual verification
A test that spawns the actual claude binary shouldn't live in the suite —
dotnet test must never invoke Claude. §1.0 step 3 stays a manual check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:39:20 +02:00
mika kunsandClaude Opus 4.7 773811d060 test(worker): add opt-in real-claude smoke test
Spawns the actual claude binary and asserts exit code 0, a session id,
non-empty result, and output tokens > 0 (plan-verification §1.0 step 3).
Inert unless CLAUDE_AUTHENTICATED=1, since it needs an authenticated CLI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:36:51 +02:00
mika kunsandClaude Opus 4.7 3756b81817 refactor: address code smells (run-dir helper, App DI injection)
- TaskRunner: extract worktree-vs-sandbox selection into
  PrepareRunDirectoryAsync so RunAsync reads linearly (a small helper, not
  a Strategy pattern — overkill for a two-way branch).
- App: drop the public static ServiceProvider locator; inject the provider
  via constructor through AppBuilder.Configure(() => new App(services)).
  Parameterless ctor + BuildAvaloniaApp() retained for the XAML designer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:33:10 +02:00
mika kunsandClaude Opus 4.7 72a86fc173 docs: drop CI-pipeline item (push-to-main + release workflow makes it redundant)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:26:46 +02:00
mika kunsandClaude Opus 4.7 cc46019622 test(worker): cover External MCP worktree/git tools
Add error-path + git-backed happy-path tests for the five previously
untested ExternalMcpService tools: GetTaskWorktree, GetTaskDiff,
MergeTask (dry-run + not-Done guard), ListWorktrees, CleanupTaskWorktree.
Git-backed cases skip when git is unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:24:45 +02:00
mika kunsandClaude Opus 4.7 71ac48162a fix(worker): clean up orphaned worktree when the DB row insert fails
If WorktreeAddAsync succeeds but the worktrees-row insert throws, the
worktree was left on disk and branch undeleted with nothing tracking it.
Wrap the insert in try/catch and best-effort remove the worktree+branch
(non-cancellable) before rethrowing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:21:40 +02:00
mika kunsandClaude Opus 4.7 bcf5e2f51f docs: regenerate open.md against verified current state
Audit found the backlog stale: many open items shipped, several large
features (localization, weekly report, daily notes, daily-prep) were
missing, and the removed tag system was still treated as live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 11:16:18 +02:00
mika kunsandClaude Opus 4.7 fb055ce740 docs: document daily-prep across area CLAUDE.md files; add Installer CLAUDE.md
Worker/Ui/Data CLAUDE.md updated for the daily-prep feature (Prime/ area,
new MCP tools, hub methods, broadcaster events, prep mode, DailyPrepMaxTasks);
new ClaudeDo.Installer/CLAUDE.md maps the WPF installer (modes, pipelines,
steps, MCP registration, Startup-shortcut autostart).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:54:13 +02:00
mika kunsandClaude Opus 4.7 9e7f37b5cc docs: add autonomous working-style loop and agent gotchas to CLAUDE.md
Codifies the brainstorm -> spec/plan -> subagent-driven implementation
-> verify loop, plus project gotchas (release builds, subagent staging,
PathIcon fill, locale parity, test-fake sync).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:36:37 +02:00
mika kunsandClaude Opus 4.7 39fa83a0a0 fix(daily-prep): hide task header, footer and agent strip in prep/notes mode
The delete/close footer, task header, and the DIFF/worktree agent strip
sit outside the mode-switched body, so they leaked into the prep-log and
notes views. Gate all three on IsTaskDetailVisible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:28:27 +02:00
mika kunsandClaude Opus 4.7 15ed624d4a style(daily-prep): brighten and enlarge the Plan-My-Day icon
Rest stroke -> TextBrush (was too dim vs the filled neighbours),
hover -> AccentBrush, icon Viewbox 15 -> 18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:23:19 +02:00
mika kunsandClaude Opus 4.7 52e3980cd1 feat(daily-prep): replace Plan-My-Day header icon with a stroked sun icon
Renders the new SVG faithfully via a stroked Path (PathIcon fills, so a
line-art icon would vanish). Renamed the button to "Plan My Day".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:18:43 +02:00
mika kunsandClaude Opus 4.7 53d897aff4 docs(daily-prep): add plan-day-in-log-window plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 10:02:29 +02:00
mika kunsandClaude Sonnet 4.6 7d743f17c6 feat(daily-prep): trigger planning from inside the prep-log window with an empty-state hint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:01:27 +02:00
mika kunsandClaude Opus 4.7 26758b6e8a docs(daily-prep): add prep-log persistence plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:46:42 +02:00
mika kunsandClaude Sonnet 4.6 914095dc99 feat(daily-prep): load persisted prep log into the terminal on open
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:44:38 +02:00
mika kunsandClaude Sonnet 4.6 4d82079cac feat(daily-prep): persist last prep run to a log file and serve it via GetLastPrepLog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:39:11 +02:00
mika kunsandClaude Opus 4.7 3a40e39fc8 refactor(ui): remove unused Sort button from MyDay header
It was a no-op placeholder command; removed the button, command,
locale keys, and now-unused Icon.Sort geometry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:25:04 +02:00
mika kunsandClaude Opus 4.7 2e73d3333d docs(daily-prep): add MyDay icons + terminal-reuse plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 09:11:40 +02:00
mika kunsandClaude Sonnet 4.6 c764b2bf6e feat(daily-prep): move Clear-day and Prep-log into MyDay header icon row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:10:33 +02:00
mika kunsandClaude Sonnet 4.6 f7d1b37343 feat(daily-prep): reuse SessionTerminal for prep log; fix invisible Sort icon; add Broom/List icons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:08:04 +02:00
mika kunsandClaude Opus 4.7 fab17720cc feat(ui): clear textbox focus on click outside any text box
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 08:42:49 +02:00
mika kunsandClaude Opus 4.7 9470c5b10b docs(daily-prep): add design specs and implementation plans
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 08:42:41 +02:00
mika kunsandClaude Sonnet 4.6 c45f892591 feat(daily-prep): add Prep-log and Clear-day buttons to MyDay header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 08:18:30 +02:00
mika kunsandClaude Sonnet 4.6 a8670ee23a feat(daily-prep): add live prep-output mode to the Details island
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 08:14:09 +02:00
mika kuns 7676ecf0d4 feat(daily-prep): expose prep stream events and ClearMyDay on the UI worker client 2026-06-04 08:09:41 +02:00
mika kuns fa83d7f441 feat(daily-prep): add ClearMyDay hub method 2026-06-04 08:05:33 +02:00
mika kunsandClaude Sonnet 4.6 e48475d6cd feat(daily-prep): stream prep output via PrepStarted/PrepLine/PrepFinished
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 08:02:24 +02:00
mika kunsandClaude Opus 4.7 46f42a4d93 fix(di): register IWorkerClient mapping for WeeklyReportModalViewModel
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 16:41:25 +02:00
mika kuns 46ac3fc930 feat(daily-prep): add Prepare-day button to MyDay header 2026-06-03 16:36:25 +02:00
mika kuns 5e0859fbb8 feat(daily-prep): add DailyPrepMaxTasks editor to Prime Claude settings 2026-06-03 16:33:00 +02:00
mika kuns 2d00160283 feat(daily-prep): add RunDailyPrepNow hub method and expose DailyPrepMaxTasks 2026-06-03 16:30:23 +02:00
mika kuns 20b3a29d08 feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools 2026-06-03 16:24:09 +02:00
mika kuns fd7f8ac78f feat(daily-prep): add set_my_day MCP tool with cap-guard 2026-06-03 16:19:36 +02:00
mika kuns 0bb809445e feat(daily-prep): add get_daily_prep_candidates MCP tool 2026-06-03 16:15:27 +02:00
mika kunsandClaude Sonnet 4.6 3c66d65160 feat(daily-prep): add DailyPrepMaxTasks app setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:11:30 +02:00
Mika Kuns ffe0fb9820 Improve Prime Time Picker 2026-06-03 14:27:06 +02:00
mika kunsandClaude Opus 4.7 00ef11ac33 fix(i18n): live-refresh smart/virtual list names on language change
Release / release (push) Successful in 35s
Smart-list nav labels were localized only at load; subscribe the singleton
ListsIslandViewModel to language changes and re-localize names in place.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:17:21 +02:00
mika kunsandClaude Opus 4.7 312b411654 i18n(de): add complete German translation
Full de.json mirroring en.json key-for-key (app + installer + VM strings);
enables Deutsch in the language switcher with live switching.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 13:14:23 +02:00
mika kunsandClaude Sonnet 4.6 364a037cb3 feat(i18n): localize installer with language picker and config write-through
- Init Localizer at app startup (before self-update prompt) and assign to TrExtension.Localizer
- Register ILocalizer in DI; inject into WizardViewModel and SettingsViewModel
- WizardViewModel: SelectedLanguage ComboBox binding with OnSelectedLanguageChanged -> SetLanguage + InstallContext.Language
- WizardWindow.xaml: DockPanel wraps step chips + language ComboBox (right-aligned)
- Localize all installer XAML: WizardWindow, WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage, SettingsWindow, SelfUpdatePromptWindow
- Localize page Title properties and WizardViewModel.NextButtonText via TrExtension.Localizer
- Persist chosen Language in WriteConfigStep and SettingsViewModel.Save into ui.config.json
- Append installer section to en.json (nav, welcome, paths, service, uiSettings, install, settings, selfUpdate)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:55:08 +02:00
mika kuns 2fbf054a57 feat(i18n): add WPF localization primitives and Language config to installer 2026-06-03 12:45:49 +02:00
mika kunsandClaude Sonnet 4.6 350a89f364 feat(i18n): localize ViewModel-built strings via ambient Loc accessor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 12:43:30 +02:00
mika kuns 086c6f6c45 feat(i18n): localize Avalonia view strings via loc:Tr markup
Extract ~165 hardcoded UI strings across islands, modals, planning and
shell views into en.json; replace with {loc:Tr} bindings.
2026-06-03 12:05:08 +02:00
mika kuns 070f5de1b1 feat(i18n): add language dropdown to settings and persist selection 2026-06-03 11:51:36 +02:00
mika kuns f529a5ff22 feat(i18n): initialize Localizer at app startup from config/OS culture 2026-06-03 11:46:33 +02:00
mika kuns 6a85d82fcf feat(i18n): add Language preference and Save() to AppSettings 2026-06-03 11:45:06 +02:00
mika kuns 35ad1715d3 feat(i18n): add Avalonia loc:Tr markup extension and LocalizedString 2026-06-03 11:44:16 +02:00
mika kuns 3c40bb5ea3 feat(i18n): seed en.json and wire locale copy to app output 2026-06-03 11:41:51 +02:00
mika kuns d95d55e6b8 feat(i18n): add CultureResolver for OS-culture mapping 2026-06-03 11:39:20 +02:00
mika kuns d22b50e171 feat(i18n): add Localizer with fallback chain and change event 2026-06-03 11:38:49 +02:00
mika kuns a83a0c41e8 feat(i18n): add LocaleStore folder discovery 2026-06-03 11:38:02 +02:00
mika kuns 9efde2bf88 feat(i18n): add ClaudeDo.Localization project with nested-JSON locale parser 2026-06-03 11:35:59 +02:00
mika kunsandClaude Opus 4.7 8dc8b8ba8e docs: localization implementation plan
Phased TDD plan: shared ClaudeDo.Localization lib, Avalonia + WPF markup
extensions, settings/installer pickers, parallel string-extraction batches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:32:06 +02:00
mika kunsandClaude Opus 4.7 baeea9c2a7 docs: localization (i18n) design spec
Live-switching, JSON locale files, shared ClaudeDo.Localization project,
English-only at launch with data-driven extensibility, installer parity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:19:03 +02:00
mika kuns a935bf9664 i18n(ui): English UI labels for weekly report and notes (report body stays German) 2026-06-03 10:44:36 +02:00
mika kuns 2d55f88a41 fix(ui): notes add row stays visible, English 'Add' label, Enter to add 2026-06-03 10:39:53 +02:00
mika kuns a8d8a8bd65 fix(worker): sanitize report model arg, fix multi-repo summary attribution and standup-weekday sentinel 2026-06-03 10:22:06 +02:00
mika kuns 0bc3d2a6c4 docs: document weekly report and daily notes feature 2026-06-03 10:15:40 +02:00
mika kuns b886d58c07 test: update fakes for new IWorkerClient members and WorkerHub/DetailsIslandViewModel ctor args 2026-06-03 10:13:56 +02:00
mika kunsandClaude Sonnet 4.6 a8943a9f7a feat(ui): pinned Notes row in My Day opens the notes editor
Add ShowNotesRow/OpenNotesCommand to TasksIslandViewModel; wire NotesRequested
event to Details.ShowNotes() in the shell; show a Notes button above the task
list when the My Day smart list is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:08:30 +02:00
mika kunsandClaude Sonnet 4.6 eccd06e182 feat(ui): notes mode in the Details island
Add IsNotesMode/Notes to DetailsIslandViewModel; ShowNotes() loads today's
notes and switches the island body to NotesEditorView via IsVisible toggling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 10:07:09 +02:00
mika kuns 731c291d61 feat(ui): NotesEditorView 2026-06-03 10:02:16 +02:00
mika kuns c8b5ed3912 feat(ui): NotesEditorViewModel with day navigation and bullet CRUD 2026-06-03 10:01:17 +02:00
mika kuns 9bf44da13b feat(ui): INotesApi wrapper for daily notes 2026-06-03 09:59:40 +02:00
mika kunsandClaude Sonnet 4.6 b748c1569e feat(ui): open Weekly Report modal from the menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:56:32 +02:00
mika kunsandClaude Sonnet 4.6 74fc39f1a6 feat(ui): WeeklyReportModalView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:55:10 +02:00
mika kunsandClaude Sonnet 4.6 ccd2ee2cc7 feat(ui): WeeklyReportModalViewModel with default-range logic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:54:10 +02:00
mika kunsandClaude Sonnet 4.6 5b89e3d03f feat(settings): persist report excluded paths and standup weekday
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 09:50:03 +02:00
mika kuns e106b00b16 feat(ui): WorkerClient methods for week report and daily notes 2026-06-03 09:46:39 +02:00
mika kuns d7558ef451 feat(worker): hub methods for week report and daily notes 2026-06-03 09:44:45 +02:00
mika kuns 4aa4353d11 feat(worker): register report reader and service in DI 2026-06-03 09:43:48 +02:00
mika kuns 50d84f12c9 feat(worker): WeekReportService orchestrates generate + store 2026-06-03 09:42:21 +02:00
mika kuns e2271b5a50 feat(worker): week report prompt builder (day-major pivot) 2026-06-03 09:40:57 +02:00
mika kuns bec87b3d6f feat(worker): ClaudeHistoryReader distills session logs 2026-06-03 09:37:40 +02:00
mika kuns 4cb7ad8dfa feat(worker): report activity models and reader interface 2026-06-03 09:35:49 +02:00
mika kuns 992fbf0763 feat(data): add WeekReportRepository with tests 2026-06-03 09:34:03 +02:00
mika kuns 1d7b86dbef feat(data): add DailyNoteRepository with tests 2026-06-03 09:32:08 +02:00
mika kuns 036586e736 feat(data): migration for daily notes and week reports 2026-06-03 09:28:50 +02:00
mika kuns d9e5d2600b feat(data): configure daily note + week report tables 2026-06-03 09:26:00 +02:00
mika kuns 10d86b4bd6 feat(data): add daily note + week report entities and report settings 2026-06-03 09:24:23 +02:00
mika kuns f72cfae7d9 docs: add weekly report implementation plan 2026-06-03 09:19:08 +02:00
mika kunsandClaude Opus 4.7 e5a2ed250d docs: add report prompt and day-major pivot to weekly report spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:52:25 +02:00
mika kunsandClaude Opus 4.7 536d819328 docs: add weekly report feature design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 08:40:19 +02:00
mika kunsandClaude Opus 4.7 869cf72abe feat(ui): use a 24h TimePicker for prime schedule time entry
Release / release (push) Successful in 35s
Replace the free-text time TextBox (which silently reset bad input to 07:00)
with Avalonia's TimePicker (24-hour, 5-minute steps), making invalid times
impossible. Drops the now-unused TimeSpanToHhmmConverter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:03:04 +02:00
mika kunsandClaude Opus 4.7 f1715a34fa fix(ui): manual modal dragging, maximize/restore icon, day-toggle style
- Drive modal title-bar dragging manually via pointer capture + Window.Position;
  Avalonia 12's BeginMoveDrag and VisualRoot-as-Window cast no longer work
  (VisualRoot is a TopLevelHost). Applies to ModalShell and WorktreeModalView.
- Toggle the MainWindow maximize button between maximize/restore glyphs on
  WindowState changes (adds Icon.WinRestore geometry).
- Add the ToggleButton.day-toggle style used by the Prime weekday picker row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:02:56 +02:00
mika kuns 26998f05ff docs: describe recurring-weekday Prime schedule 2026-06-02 16:46:41 +02:00
mika kunsandClaude Sonnet 4.6 7db8f213d8 feat(ui): replace prime date range with weekday toggle buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:43:28 +02:00
mika kunsandClaude Sonnet 4.6 37738e3c8f feat(ui): drive prime schedule rows from weekday toggles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:40:41 +02:00
mika kuns 81fd186fb2 feat(worker): map prime schedule weekday bitmask over the hub 2026-06-02 16:33:11 +02:00
mika kuns 3127930454 test(worker): adapt prime scheduler tests to weekday model 2026-06-02 16:33:02 +02:00
mika kuns bed4255a5e feat(worker): compute prime due-time from weekday bitmask
Also fixes PrimeScheduleRepository.ListAsync to sort client-side
(SQLite EF Core does not support TimeSpan in ORDER BY clauses).
2026-06-02 16:32:51 +02:00
mika kuns dff06d9e35 feat(data): migrate prime schedules to days_of_week bitmask 2026-06-02 16:12:08 +02:00
mika kuns 0efad7a004 feat(data): persist weekday bitmask in prime schedule repo 2026-06-02 16:09:49 +02:00
mika kuns eaf27e8b3a feat(data): model Prime schedule as weekday bitmask 2026-06-02 16:09:32 +02:00
mika kunsandClaude Opus 4.7 13c3393e3a docs: implementation plan for recurring-weekday Prime
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:48:51 +02:00
mika kunsandClaude Opus 4.7 4704a28e5d docs: spec for recurring-weekday Prime schedules
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:12:04 +02:00
mika kunsandClaude Opus 4.7 1cb5171fba fix(worker): harden review re-run, timestamps, and queue affordance
- Clear ReviewFeedback only after a successful re-run so a failed/cancelled
  run keeps it for a manual retry.
- Clear stale StartedAt/FinishedAt when rejecting a task back to the queue.
- Only non-planning standalone tasks gate on review (guard PlanningPhase).
- Hide "send to queue" for WaitingForReview tasks so review isn't bypassed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 08:00:13 +02:00
mika kunsandClaude Opus 4.7 4684a0af76 docs: document WaitingForReview state across project CLAUDE.md files
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:49:57 +02:00
mika kunsandClaude Opus 4.7 6c27ffbdca feat(ui): surface review actions and WaitingForReview status in task rows
Adds Approve/Reject/Park/Cancel buttons with a feedback flyout, a review
status chip, and a friendly status label for WaitingForReview tasks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:46:37 +02:00
mika kunsandClaude Opus 4.7 21f1cf2a85 feat(ui): add review hub methods and worker client wrappers
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:19:41 +02:00
mika kunsandClaude Opus 4.7 c88ed9d5eb feat(worker): add review_task MCP tool and status reference updates
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:17:56 +02:00
mika kunsandClaude Opus 4.7 9c1f20f2d9 feat(worker): route standalone success to review and resume on re-queue
Standalone tasks now enter WaitingForReview on success; re-queued tasks
carrying reviewer feedback resume the prior Claude session with that
feedback as the next turn.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:15:57 +02:00
mika kunsandClaude Opus 4.7 e8d018dd54 feat(worker): add review state transitions to TaskStateService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:10:34 +02:00
mika kunsandClaude Opus 4.7 1ca32a6bdd feat(data): add WaitingForReview status and review_feedback column
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:08:33 +02:00
mika kunsandClaude Opus 4.7 b86677d554 docs(plan): waiting-for-review implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:07:21 +02:00
mika kunsandClaude Opus 4.7 3e072fae66 docs(spec): waiting-for-review task state design
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 17:03:14 +02:00
Mika Kuns 4a36fbe5e0 feat(ui): replay run log in session terminal, drop per-row live tail
Release / release (push) Successful in 34s
Set the task's log path when the run is created (not at completion) so the
session terminal can replay live output when the user navigates away and back
mid-run. Remove the now-redundant inline per-row live tail (LiveTail /
HasLiveTail / TaskMessageEvent) and scroll the terminal to end after the next
layout pass so wrapping lines aren't clipped.
2026-06-01 16:25:14 +02:00
Mika Kuns 9e5a3fe962 merge: MCP surface — worktree/diff/merge/log tools + status-enum docs 2026-06-01 16:21:51 +02:00
Mika Kuns 3f98fd0ae5 merge: normalize list ID format to dashed UUID 2026-06-01 16:21:50 +02:00
Mika Kuns 8420b87bd1 merge: run reporting — token accounting + populate empty result 2026-06-01 16:21:50 +02:00
Mika Kuns c0978df19a feat(claude-do): MCP surface: worktree/diff/merge/log tools + status-enum doc
BUNDLE — all changes live in src/ClaudeDo.Worker/External/ExternalMcpService.cs only, so this is one worktree / one merge. Do NOT touch run-recording or data-layer code (those are separate tasks). Reuse the existing services behind the UI modals (WorktreesOverviewModalView, DiffModalView, MergeModalView) — do not reimplement git plumbing. Build green after each addition.

Add these MCP tools:
1. g

ClaudeDo-Task: f6bdfb5b-8cbf-4e65-93d4-6c758a160484
2026-06-01 16:15:26 +02:00
Mika Kuns 3ac9e030e2 chore(claude-do): Normalize list ID format
list_task_lists returns two different ID formats: dashed UUIDs (e.g. "caed660e-109f-4e2a-b055-2c2722bf6fb7") and compact 32-char hex (e.g. "5c2cafcb33f044069ac324ac3fd84a16"). Mixing formats makes equality checks, logging, and lookups error-prone.

Fix: pick one canonical format (recommend dashed UUID) and normalize on write + migrate existing records. Ensure all ID-returning tools emit the same f

ClaudeDo-Task: fa8b69e0-6f8d-41d7-9a41-88db1360544d
2026-06-01 16:06:59 +02:00
Mika Kuns 4c6e6594dc fix(claude-do): Run reporting: token accounting + populate empty result
BUNDLE — both fixes live in the Worker run-recording / persistence layer (where a TaskRun is written after an agent finishes), NOT in ExternalMcpService.cs. Keep this disjoint from the MCP-surface bundle so the two can run in parallel without worktree conflicts. The DTO fields (tokensIn, tokensOut, resultMarkdown) already exist and are surfaced by list_runs/get_run — the bug is at write time.

1.

ClaudeDo-Task: 49a6060a-5044-4f1b-8665-5cfc064b8a82
2026-06-01 16:01:11 +02:00
mika kunsandClaude Opus 4.7 5170914a7a feat(installer): optionally register ClaudeDo MCP server with Claude
Add an install step and welcome-page opt-in that registers the ClaudeDo
external MCP server with the Claude CLI. Failures are non-fatal and surface
the manual command so a missing or old CLI never blocks the install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:51:44 +02:00
mika kunsandClaude Opus 4.7 b1f4349dab feat(worker): configurable max parallel task executions
Add a "Max parallel executions" setting to the General settings tab so
the queue can run more than one task concurrently. QueueService now
tracks multiple active slots and reads the limit from app settings each
cycle, so changes take effect without restarting the worker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 15:51:12 +02:00
Mika Kuns 23326a1833 merge: return confirmation payload from delete_task and cancel_task 2026-06-01 15:29:30 +02:00
Mika Kuns ca0594328a merge: make add_task optional params actually optional 2026-06-01 15:29:29 +02:00
Mika Kuns 22d06acb35 merge: fix inconsistent timezone on timestamps (Z suffix) 2026-06-01 15:29:16 +02:00
Mika KunsandClaude Opus 4.8 ab44ba5e41 feat(ui): list reordering, quick actions, and resizable modals
- Drag-to-reorder user lists in the sidebar, persisted via a new
  list sort_order column (AddListSortOrder migration, backfilled by
  creation time) and ListRepository.ReorderAsync
- "Open in Explorer" / "Open in Terminal" context-menu actions on lists
- "Clear all completed" button on the Tasks island
- Inline-edit subtask titles (empty text deletes the step) and
  click-to-copy task ID in the Details island
- Make modal and planning windows resizable (BorderOnly decorations
  with min sizes) instead of fixed-size borderless

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:28:17 +02:00
mika kuns 6c3afce329 chore(claude-do): Return confirmation payload from delete_task and cancel_task
delete_task (and likely cancel_task) return no output on success. Silent success is indistinguishable from a no-op, so callers can't verify the action took effect.

Fix: return a small confirmation object, e.g. { deleted: true, id } / { cancelled: true, id }. Indicate not-found vs deleted distinctly.

ClaudeDo-Task: 97a87ebb-0d87-4ee0-800c-aa1a0b3a06c5
2026-06-01 15:20:20 +02:00
mika kuns f8e387bbc1 chore(claude-do): Make add_task optional params actually optional
add_task currently marks description, createdBy, and queueImmediately as required, forcing callers to invent values for fields that have obvious defaults.

Fix: make them optional with sensible defaults — description: null, queueImmediately: false, createdBy: server default like "mcp". Keep only listId and title as truly required.

ClaudeDo-Task: b9fadf0b-a20e-4deb-932d-29ef9c0b83f3
2026-06-01 15:18:27 +02:00
mika kuns 2a36998ac7 chore(claude-do): Fix inconsistent timezone on timestamps
Timestamps are serialized inconsistently across tools. add_task returns createdAt with a trailing 'Z' (e.g. "2026-06-01T13:03:56.1636946Z"), but get_task and list_runs return the same value WITHOUT the 'Z'. This is a timezone-ambiguity bug.

Fix: serialize all DateTime values as UTC with the 'Z' suffix consistently (use a single shared JSON serializer setting / DateTimeKind=Utc). Audit every tool

ClaudeDo-Task: 4bbc759e-ff05-45e3-a57f-b290c7e16264
2026-06-01 15:16:25 +02:00
mika kunsandClaude Opus 4.7 4148dcdb18 fix(installer): stop the running app before updating, not just the worker
Release / release (push) Successful in 34s
A running ClaudeDo.App.exe locks the install\app directory, so the extract
step's Directory.Move failed with "Access to the path '...\app' is denied"
during an update. StopWorkerStep now also terminates app processes scoped to
the install dir (benefits uninstall too).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:26:47 +02:00
mika kunsandClaude Opus 4.7 5783790733 fix(installer): keep step badges green and reset state on re-run
Step status and output lines arrive on two separate Progress<T> channels, so a
trailing "Running" line-message could be delivered after a step's terminal
Done/Failed and downgrade the badge back to orange. Guard against that
downgrade. Also reset each step's messages/status/expansion at the start of a
run so re-running no longer appends to the previous run's output.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:22:36 +02:00
mika kunsandClaude Opus 4.7 edfb702ecc fix(data): track EF migration Designer files (were gitignored)
Release / release (push) Successful in 33s
The `*.designer.cs` ignore rule silently excluded EF Core migration
*.Designer.cs files, so only 1 of 11 was committed. Without the Designer
(which carries the [Migration] attribute), EF does not register a migration,
so a fresh clone / CI release build could not apply migrations — e.g.
GetAppSettings failed with "no such column: repo_import_folders", which the
Settings modal surfaced as "Worker offline".

Adds a .gitignore negation for **/Migrations/*.Designer.cs and commits the
10 missing Designer files (incl. the newly authored AddRepoImportFolders).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:15:25 +02:00
882 changed files with 134666 additions and 6831 deletions
+3
View File
@@ -1,4 +1,7 @@
{
"worktree": {
"baseRef": "head"
},
"permissions": {
"allow": [
"Bash(git add:*)",
+101
View File
@@ -0,0 +1,101 @@
name: Dependency Audit
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch: {}
jobs:
audit:
runs-on: ubuntu-latest
env:
DOTNET_ROOT: /home/mika/.dotnet
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeDo
ISSUE_TITLE: 'Dependency audit: vulnerable packages detected'
steps:
- name: Checkout main
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
git clone --depth 1 --branch main \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
- name: Scan for vulnerable / outdated packages
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd src
: > audit.log
: > vuln.md
found=0
# .slnx tooling needs .NET 9; iterate per-project to stay on .NET 8.
while IFS= read -r proj; do
echo "==== $proj ====" | tee -a audit.log
dotnet restore "$proj" >/dev/null
vuln="$(dotnet list "$proj" package --vulnerable --include-transitive 2>&1)"
echo "$vuln" | tee -a audit.log
if echo "$vuln" | grep -qi "has the following vulnerable"; then
found=1
{
printf '#### `%s`\n\n```\n' "$proj"
echo "$vuln"
printf '```\n\n'
} >> vuln.md
fi
# Outdated is informational only — never fails the run.
dotnet list "$proj" package --outdated 2>&1 | tee -a audit.log || true
echo "" | tee -a audit.log
done < <(find . -name '*.csproj' | sort)
if [ "$found" -ne 0 ]; then
echo "::error::Vulnerable packages detected — see log above." >&2
exit 1
fi
echo "No vulnerable packages found."
- name: Report vulnerabilities to a Gitea issue
if: failure()
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
cd src
if [ -s vuln.md ]; then
DETAILS="$(cat vuln.md)"
else
DETAILS="The audit job failed before producing findings — check the run log."
fi
BODY="$(printf 'Automated weekly dependency audit found vulnerable packages.\n\n%s\n\n[View workflow run](%s)' \
"$DETAILS" "$RUN_URL")"
# Reuse an existing open issue if one is already tracking this.
EXISTING="$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${REPO}/issues?state=open&type=issues&limit=50" \
| jq -r --arg t "$ISSUE_TITLE" '.[] | select(.title==$t) | .number' | head -n1)"
if [ -n "$EXISTING" ]; then
echo "Commenting on existing issue #$EXISTING"
jq -n --arg body "$BODY" '{body:$body}' \
| curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @- \
"${GITEA_API}/repos/${REPO}/issues/${EXISTING}/comments" >/dev/null
else
echo "Creating new issue"
jq -n --arg title "$ISSUE_TITLE" --arg body "$BODY" '{title:$title, body:$body}' \
| curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @- \
"${GITEA_API}/repos/${REPO}/issues" >/dev/null
fi
+85
View File
@@ -0,0 +1,85 @@
name: Changelog
on:
push:
tags:
- 'v*'
jobs:
changelog:
runs-on: ubuntu-latest
env:
REPO: releases/ClaudeDo
steps:
- name: Checkout main (full history)
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
git clone "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
cd src
git fetch --tags --force
git checkout main
- name: Regenerate CHANGELOG.md
run: |
set -euo pipefail
cd src
emit_group() {
# $1 range, $2 conventional-type, $3 heading
local range="$1" type="$2" title="$3" lines
lines="$(git log "$range" --no-merges --pretty=format:'%s|%h' \
| grep -E "^${type}(\([^)]*\))?(!)?: " || true)"
[ -z "$lines" ] && return 0
printf '### %s\n\n' "$title"
while IFS='|' read -r subject hash; do
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
done <<< "$lines"
printf '\n'
}
emit_section() {
# $1 range, $2 tag, $3 date
printf '## %s — %s\n\n' "$2" "$3"
emit_group "$1" feat "Features"
emit_group "$1" fix "Fixes"
emit_group "$1" perf "Performance"
emit_group "$1" refactor "Refactoring"
emit_group "$1" docs "Documentation"
}
# Tags ascending by semver so we can pair each with its predecessor.
mapfile -t TAGS < <(git tag --sort=v:refname | grep -E '^v' || true)
{
printf '# Changelog\n\n'
for ((i=${#TAGS[@]}-1; i>=0; i--)); do
TAG="${TAGS[$i]}"
DATE="$(git log -1 --format=%ad --date=short "$TAG")"
if (( i > 0 )); then
RANGE="${TAGS[$((i-1))]}..${TAG}"
else
RANGE="$TAG"
fi
emit_section "$RANGE" "$TAG" "$DATE"
done
} > CHANGELOG.md
cat CHANGELOG.md
- name: Commit and push if changed
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
cd src
if git diff --quiet -- CHANGELOG.md; then
echo "CHANGELOG.md unchanged; nothing to commit."
exit 0
fi
git config user.name "ClaudeDo CI"
git config user.email "ci@kuns.dev"
git add CHANGELOG.md
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME}"
git push origin main
+79 -6
View File
@@ -5,6 +5,10 @@ on:
tags:
- 'v*'
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
@@ -38,11 +42,52 @@ jobs:
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 --branch "$TAG" \
# Full clone (with tags) so release notes can diff against the previous tag.
git clone --branch "$TAG" \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
"$WORK/src"
git -C "$WORK/src" log -1 --oneline
- name: Generate release notes
env:
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
cd "$WORK/src"
PREV="$(git tag --sort=v:refname | grep -E '^v' \
| awk -v t="$TAG" '$0==t{print prev} {prev=$0}')"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${TAG}"
else
RANGE="$TAG"
fi
emit_group() {
# $1 conventional-type, $2 heading
local lines
lines="$(git log "$RANGE" --no-merges --pretty=format:'%s|%h' \
| grep -E "^${1}(\([^)]*\))?(!)?: " || true)"
[ -z "$lines" ] && return 0
printf '### %s\n\n' "$2"
while IFS='|' read -r subject hash; do
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
done <<< "$lines"
printf '\n'
}
{
emit_group feat "Features"
emit_group fix "Fixes"
emit_group perf "Performance"
emit_group refactor "Refactoring"
emit_group docs "Documentation"
} > RELEASE_NOTES.md
echo "--- release notes ---"
cat RELEASE_NOTES.md
- name: Publish ClaudeDo.App (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
@@ -100,18 +145,19 @@ jobs:
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
# 2) Installer single-file exe (renamed)
# 2) Installer single-file exe — STABLE name (no version) so the download URL
# (…/releases/latest/download/ClaudeDo.Installer.exe) stays permanent.
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
if [ -z "$INSTALLER_EXE" ]; then
echo "::error::No .exe produced by installer publish" >&2
exit 1
fi
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe"
# 3) Checksums (sha256, relative filenames)
( cd assets && sha256sum \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
"ClaudeDo.Installer.exe" \
> checksums.txt )
echo "--- assets ---"
@@ -128,7 +174,8 @@ jobs:
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
--rawfile body "$WORK/src/RELEASE_NOTES.md" \
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
@@ -154,7 +201,7 @@ jobs:
cd "$WORK/src/assets"
for f in \
"ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \
"ClaudeDo.Installer.exe" \
"checksums.txt"
do
echo "Uploading: $f"
@@ -166,6 +213,32 @@ jobs:
done
echo "All assets uploaded."
- name: Publish release
env:
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
curl -sS --fail-with-body -X PATCH \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
> /dev/null
echo "Release ${RELEASE_ID} published."
- name: Delete draft release on failure
if: failure() && steps.release.outputs.release_id != ''
env:
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
curl -sS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
> /dev/null || true
echo "Cleaned up draft release ${RELEASE_ID}."
- name: Cleanup workspace
if: always()
env:
+6
View File
@@ -1,5 +1,9 @@
# Local dev worktrees (created by using-git-worktrees skill)
.worktrees/
.claude/worktrees/
# Brainstorming visual companion artifacts
.superpowers/
# .NET build output
bin/
@@ -45,6 +49,8 @@ artifacts/
# Avalonia / XAML designer
*.designer.cs
# ...but EF Core migration Designer files are real source and must be tracked
!**/Migrations/*.Designer.cs
# Project-specific
*.db
+1449
View File
File diff suppressed because it is too large Load Diff
+38 -9
View File
@@ -10,7 +10,14 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
- **ClaudeDo.Releases** — Gitea release client (`IReleaseClient`), used by the Ui update check and the Installer
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
Per-project `CLAUDE.md` files exist for **App, Data, Installer, Ui, Worker, and Worker.Tests**
those are the living per-project docs. Localization, Releases, and the other five test projects
have none; this file plus the code is all there is for them.
## Tech Stack
@@ -35,7 +42,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> Done | Failed | Cancelled
- Task status flow: `Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled`; a task with children passes through `WaitingForChildren` first. **Approve is the single review+merge action** (no separate "Merge all"), and in the detail pane it's gated behind opening the diff. Full transition table → `src/ClaudeDo.Worker/CLAUDE.md`; merge/review/gate mechanics → `docs/explore-notes/review-merge.md`.
- Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
@@ -44,18 +51,40 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- Views use compiled bindings (`x:DataType`)
- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
## Working style (autonomous)
For any non-trivial feature, bug, or change, run this loop without hand-holding:
1. **Brainstorm first** (superpowers:brainstorming) — ask clarifying questions one at a time, propose 23 options with a recommendation, present a short design, get approval before building.
2. **Write it down** — a spec in `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` and a step-by-step plan in `docs/superpowers/plans/` (superpowers:writing-plans). Commit the docs.
3. **Implement on main** with superpowers:subagent-driven-development — one subagent per task, TDD, build + test, commit per task with Conventional Commits. Once the plan is approved, do NOT pause for re-approval between tasks; only stop for genuine decisions or blockers.
4. **Trust but verify** — read each subagent's diff and run the build/tests yourself before marking a task done.
5. **Bugs** → superpowers:systematic-debugging (find the root cause before any fix).
6. **Never claim UI works without running it** — explicitly flag visual-verification gaps for the user to check.
Commit freely (per task + the spec/plan docs). Never push without asking.
## Building & Testing
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects instead.
`dotnet build ClaudeDo.slnx` requires .NET 9; on .NET 8 build individual projects with `-c Release` (a running Worker locks the `Debug` output).
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj # pulls in Ui + Data
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet test tests/ClaudeDo.Worker.Tests # also: Data.Tests, Ui.Tests, Installer.Tests, Releases.Tests
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release # pulls in Ui + Data
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release # also: Data.Tests, Ui.Tests, Localization.Tests, Installer.Tests, Releases.Tests
```
### Gotchas
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
- **Icons:** `PathIcon` *fills* its geometry. Line-art/stroke icons must be authored as filled geometry, or rendered with a stroked `Path` — otherwise they render invisible.
- **Localization:** `locales/en.json` and `locales/de.json` keys must stay in parity (Localization.Tests enforces it).
- **Test fakes:** changing `IWorkerClient` / `WorkerHub` / ViewModel constructors breaks hand-rolled fakes in both test projects — update them.
## Docs
- `docs/plan.md`full architecture and design spec
- `docs/open.md`verification checklist and improvement backlog
- `docs/improvement-plan.md`prioritized improvement items
- `docs/open.md`open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
- `docs/plan.md`original design spec (historical; tag-queue/schema.sql parts are outdated)
- `docs/improvement-plan.md`improvement snapshot from 2026-04-13 (historical)
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
- `docs/explore-notes/` — distilled maps of complex subsystems (detail too fine for a CLAUDE.md, read on demand). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`. Current notes: `worker-task-pipeline`, `usage-monitoring`, `external-mcp`, `review-merge`, `conpty-sessions`, `installer-preflight`.
+2
View File
@@ -6,6 +6,7 @@
<Project Path="src/ClaudeDo.Worker/ClaudeDo.Worker.csproj" />
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
@@ -13,5 +14,6 @@
<Project Path="tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj" />
<Project Path="tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj" />
<Project Path="tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj" />
<Project Path="tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj" />
</Folder>
</Solution>
+411 -88
View File
@@ -1,106 +1,429 @@
# ClaudeDo
A desktop task management app that executes tasks autonomously via [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) in isolated git worktrees.
A Windows desktop app that turns your to-do list into a work queue for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
Queue up coding tasks, and ClaudeDo picks them up one by one — each running in its own worktree so your main branch stays clean.
Write down what you want done. ClaudeDo picks the task up, runs Claude in an isolated git
worktree, and hands you back a diff to review. Your main branch is never touched until you
approve.
## Architecture
It looks and feels like a normal task app — lists, My Day, stars, due dates — except every
task can also be *executed*.
Two-process system communicating over SignalR:
---
| Project | Role |
|---|---|
| **ClaudeDo.App** | Avalonia desktop entry point, DI container setup |
| **ClaudeDo.Ui** | Views, ViewModels, SignalR client (MVVM) |
| **ClaudeDo.Data** | SQLite data layer, repositories, models, GitService |
| **ClaudeDo.Worker** | ASP.NET Core hosted service, task queue, Claude CLI runner |
## Contents
```
┌────────────────┐ SignalR ┌────────────────┐
│ ClaudeDo.App │◄───────────►│ ClaudeDo.Worker │
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core) │
│ │ :47821 │ │
│ ┌────────────┐│ │ ┌────────────┐ │
│ │ Ui ││ │ │ TaskQueue │ │
│ │(ViewModels)││ │ │ Claude CLI │ │
│ └────────────┘│ │ └────────────┘ │
└───────┬────────┘ └───────┬────────┘
│ │
└──────────────┬───────────────┘
┌───────┴───────┐
│ ClaudeDo.Data │
│ (SQLite) │
└───────────────┘
```
- [Is this for you?](#is-this-for-you)
- [Requirements](#requirements)
- [Install](#install)
- [The window](#the-window)
- [The core loop](#the-core-loop)
- [Lists and repositories](#lists-and-repositories)
- [Writing a task](#writing-a-task)
- [Ways to run a task](#ways-to-run-a-task)
- [Watching work happen](#watching-work-happen)
- [Reviewing and merging](#reviewing-and-merging)
- [Resolving conflicts](#resolving-conflicts)
- [Worktrees](#worktrees)
- [Staying inside your usage limits](#staying-inside-your-usage-limits)
- [Your daily rhythm](#your-daily-rhythm)
- [Settings](#settings)
- [Letting Claude drive ClaudeDo](#letting-claude-drive-claudedo)
- [Updates](#updates)
- [Where your data lives](#where-your-data-lives)
- [Keyboard shortcuts](#keyboard-shortcuts)
- [Troubleshooting](#troubleshooting)
- [For developers](#for-developers)
## Tech Stack
---
- .NET 8.0
- Avalonia 12.0.0 (Fluent theme)
- SQLite (WAL mode) via Entity Framework Core (EF Core + Migrations)
- SignalR for real-time IPC between UI and Worker
- CommunityToolkit.Mvvm for source-generated MVVM
- Git worktrees for task isolation
## Is this for you?
## Prerequisites
ClaudeDo is built for one person running many small-to-medium coding jobs across several
repositories, mostly unattended.
- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
It fits when you:
- have a backlog of contained changes ("rename this", "add that endpoint", "fix this bug")
- want them worked on while you do something else
- still want to read every diff before it lands on `main`
It is *not* a CI system, not a team tool, and not a chat window. There are no pull
requests, no reviewers but you, and no cloud component (unless you deliberately turn on the
optional online inbox).
## Requirements
- Windows 10/11
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code), installed and signed in
(ClaudeDo runs `claude` as *you* — it never handles your credentials)
- Git
- [.NET 8 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/8.0) — the installer
itself needs it, so install it first if the installer refuses to start
## Getting Started
## Install
Run `ClaudeDo.Installer.exe`. It downloads the current release, writes its config, and
starts the background worker.
The wizard asks for:
| Page | What it decides |
|---|---|
| **Welcome** | Install folder, and whether Claude may manage your tasks via MCP (see [below](#letting-claude-drive-claudedo)) |
| **Data paths** | Where the database, logs, sandboxes and worktrees live |
| **Worker** | Port, path to the `claude` binary, and whether the worker starts at logon |
Re-running the installer later gives you **Update**, **Repair** and **Uninstall** instead
of the full wizard. Uninstall asks separately before deleting your tasks and settings.
ClaudeDo has two parts: the window you see, and a background **worker** that does the
actual running. The worker starts at logon and keeps going even when the window is closed —
so a long task finishes whether you are watching or not.
## The window
Three panes ("islands"), plus a footer:
```
┌──────────────┬────────────────────────────┬───────────────────────────┐
│ LISTS │ TASKS │ DETAILS │
│ │ │ │
│ My Day │ ▸ Add a task… ENTER │ Fix login redirect │
│ Important │ │ ───────────────────── │
│ Planned │ OVERDUE │ Steps ▢ ▢ ▣ │
│ │ ● Fix login redirect │ Details (markdown) │
│ Queue 3 │ │ Files (drop here) │
│ Running 1 │ TASKS │ │
│ Review 2 │ ● Add CSV export RUNNING │ Worktree · Diff · Merge │
│ │ ● Bump deps QUEUED │ │
│ MY LISTS │ ● Write changelog │ Output │ Git │ Session │
│ LagerApp 7 │ ● Call the tax guy MANUAL │ ┌─────────────────────┐ │
│ LogX 2 │ │ │ live Claude output │ │
│ ClaudeDo 12 │ │ └─────────────────────┘ │
├──────────────┴────────────────────────────┴───────────────────────────┤
│ ● Online 5h 41% · 7d 22% worker log line… logs │
└───────────────────────────────────────────────────────────────────────┘
```
- **Lists** (left) — smart lists, virtual work lists, and your own lists (one per repo)
- **Tasks** (middle) — the selected list, grouped into Overdue / Tasks / Completed
- **Details** (right) — everything about the selected task, including its live output
- **Footer** — worker connection, usage pill, and the latest worker log line (clickable)
On a narrow window the side panes fold away automatically.
The grid icon in the title bar opens **Mission Control**, a separate window for watching
several running sessions at once.
## The core loop
```
write it down → queue it → Claude runs it → you review → merge
(Idle) (Queued) (Running) (Waiting for (Done)
Review)
```
1. **Capture.** Type into the add box. That is it — a task starts out as a plain reminder.
2. **Queue.** Right-click → *Send to queue*. The worker claims queued tasks in order, as
many at a time as *Max parallel executions* allows.
3. **Run.** ClaudeDo creates a git worktree off your repo (branch `claudedo/<id>`), runs
Claude there with your task as the prompt, and streams the output into the detail pane.
4. **Review.** On success the task lands in **Waiting for Review** with a committed
worktree and a diff. Nothing has touched your working copy.
5. **Merge.** *Approve & merge* merges the branch into the target you pick, then marks the
task Done. Or reject it with feedback, park it, or cancel it.
A task that fails is marked **Failed** and keeps its worktree and its log, so you can look
at what happened and either continue the same session or reset and retry from scratch.
## Lists and repositories
A list becomes *runnable* by giving it a **working directory** — a git repo. Tasks in that
list get worktrees off that repo; tasks in a list without a working directory can still be
run, but in a scratch sandbox with no git.
- **Add repos as lists** (`Repositories` menu, or the folder icon) scans folders you point
it at and creates one list per git repo it finds. This is the fastest way to get started.
- **List settings** (right-click a list) sets the name, working directory, default commit
type, the agent defaults for that list, and an optional verify command.
- **Manual list** — mark a list as reminders-only. New tasks in it start out manual, so
automation never touches them. Good for a "Errands" or "Phone calls" list.
- Drag a task onto another list to move it. ClaudeDo refuses to move a running task and
warns you when the two lists point at different repos.
**Smart lists** are always there:
| List | Contains |
|---|---|
| **My Day** | What you (or the daily prep) picked for today, plus a pinned Notes row |
| **Important** | Starred tasks |
| **Planned** | Everything with a date |
| **Queue / Running / Review** | Live work — waiting, in flight, and awaiting your review |
## Writing a task
The detail pane is where a one-line reminder becomes a brief Claude can act on:
- **Title + Details** — markdown, with an edit/preview toggle. This is the prompt.
- **Steps** — a checklist. Handy for you, and included when you copy the task out.
- **Files** — drag files onto the pane (or use *Add file…*) to attach reference material.
Attachments are handed to Claude as read-only paths, not pasted into the prompt.
- **Star / Schedule / Add to My Day** — the usual task-app affordances.
- **Agent settings** — per-task overrides for model, turn budget, extra system prompt,
agent file and skills. Anything you do not override shows an *inherited* badge pointing at
the list or global default.
- **Refine** — hand the task to Claude to sharpen it: it rewrites the title and details into
a proper brief. Useful when you captured something in three words.
- **Mark as manual** — a MANUAL badge; the queue, the daily prep and every automation skip
it. Use it for things only you can do.
## Ways to run a task
| Way | What it does | Use it when |
|---|---|---|
| **Send to queue** | Worker picks it up in order | The normal path |
| **Run now** | Skips the queue, starts immediately | You want this one first |
| **Continue** | Resumes the last session with a follow-up | "Almost right, now also…" |
| **Reset & retry** | Throws the worktree away, re-queues from scratch | The run went sideways |
| **Open ConPTY session** | Opens the *real* Claude terminal for this task in Mission Control | You want to drive it yourself, with the task as the starting brief |
| **Open planning session** | An interactive session whose job is to break the task into subtasks | The work is too big for one run |
| **Let Claude handle it** | Hands a whole list over in one go | You have a pile of small tasks |
### Planning sessions
A planning session is a conversation whose output is *structure*, not code. Claude creates
child tasks under the parent. You then:
1. **Finalize plan** — children are chained (each waits for the previous one) but stay Idle.
2. **Queue plan** — when you are happy with the list, this queues them all.
3. The parent waits in **Waiting for Subtasks** until every child is terminal, then surfaces
for review once — you review and merge the *whole unit*, not each child.
A parked planning session is remembered; the next launch offers to resume, finalize or
discard it.
### "Let Claude handle it"
Right-click a list → *Let Claude handle it*. You get a checkbox list of that list's open
tasks. Confirm, and one Claude session works the selection end to end: it reads them,
de-duplicates overlaps, enhances thin descriptions, queues the work, and merges the results.
It shows up as its own MANUAL task so you can see what a given run covered, and you review
the combined diff when it is finished.
## Watching work happen
- **Detail pane, three tabs** — *Output* (live Claude stream), *Git* (worktree, diff,
merge), *Session* (result, token/turn counts, subtask outcomes).
- **Mission Control** — one tile per running or interactive session, in Focus or Overview
mode. ConPTY tiles are the real Claude TUI, embedded: keyboard, colors, `/` commands, all
of it. *New session* opens an ad-hoc one that belongs to no task.
- **Roadblocks** — when a run gets stuck it reports a roadblock instead of silently failing.
The Session tab shows it as its own card with a reply box: answer the question and the
same session picks up where it stopped.
- **Claude is asking** — an interactive session can put a question in front of you mid-run;
answer it inline in Mission Control and the run continues.
- **Footer log strip** — the worker's latest event. Failures of your own actions flash here
too, instead of vanishing. Click it for the last 30 minutes of worker logs, with a
warnings-and-errors filter.
## Reviewing and merging
When a task reaches **Waiting for Review** you get four actions:
| Action | Result |
|---|---|
| **Approve & merge** | Merges the work into the target branch, then Done |
| **Reject** | Asks for feedback and re-runs the same session with it |
| **Park** | Back to Idle so you can rewrite the task yourself |
| **Cancel** | Done with it; the worktree stays until you clean it up |
**You have to look before you approve.** When there is a diff to inspect, *Approve & merge*
stays disabled until you have opened the diff viewer once. It re-locks after any new run.
(The quick-approve on the task row itself is a deliberate bypass for when you already know.)
The diff viewer shows a file tree on the left and the diff on the right, and can show a
dirty worktree, a branch against its base, or a commit range. For a parent with children,
*Review combined diff* shows per-subtask diffs plus a combined preview of the whole unit.
**Verify command** (optional, per list) — a command that must exit 0 after a merge lands
before the task is allowed to reach Done. Typically a build or a test run. If it fails, the
merge stays (nothing is rewritten behind your back) but the task is held out of Done and the
failure output is reported.
## Resolving conflicts
If a merge conflicts, ClaudeDo opens a three-pane merge editor in-app:
```
┌──────────────────┬──────────────────┬──────────────────┐
│ MAIN │ RESULT │ INCOMING │
│ merge target │ (editable) │ task branch │
│ │ ▐ │ │
│ › │ ← you build │ ‹ │
│ │ the result │ │
└──────────────────┴──────────────────┴──────────────────┘
```
- Whole files, syntax-highlighted, scrolling in sync
- Each conflict starts *empty*. The gutter arrows toggle each side in or out — take main,
incoming, both (in the order you click), or neither
- Only conflict regions are editable in the middle pane; type your own resolution if
neither side is right
- `F8` / `Shift+F8` jump between conflicts; the ruler beside the result pane maps every
conflict in the file
- A counter tells you how many files still need attention. *Resolve & continue* is disabled
until they are all done, and *Abort merge* always leaves the target branch as it was
Binary conflicts cannot be resolved here — ClaudeDo says so and lists the paths.
## Worktrees
Every run gets its own worktree, so parallel tasks never fight over your checkout.
The **Worktrees** overview (per list or global) lists them with state, diff size, age and
outcome. From there you can show a diff, jump to the task, merge, batch-merge a selection
into one target, mark one *Kept* or *Discarded*, or force-remove a leftover. Worktrees whose
directory is gone from disk are flagged as *phantom*.
Finished worktrees are auto-cleaned after a number of days you choose in Settings.
## Staying inside your usage limits
The footer pill shows your Claude usage: `5h 41% · 7d 22%`. Click it for the **Usage
Monitor**:
- Gauges for the 5-hour session window and the 7-day windows, each with a reset countdown
- **Models** — token usage per model over a range you pick, split into ClaudeDo's own
consumption and everything else
- **Tasks** — your biggest consumers, with run counts and totals
And the part that matters when you are asleep: the **usage limit stop**. Set a percentage
per window in Settings, and once usage reaches it the queue simply stops picking up new
tasks. Running tasks finish normally, and the queue resumes by itself as the window rolls
over. `0` turns a stop off. Manual actions — *Run now*, *Continue*, interactive and planning
sessions — are never blocked.
## Your daily rhythm
- **My Day** — the day's shortlist. *Clear day* empties it.
- **Prime Claude** (Settings → Prime Claude) — schedules for the days and times you pick.
At that time ClaudeDo runs a short prep session: Claude looks at your idle tasks, picks an
effort-aware subset up to your daily cap, and fills My Day. It also warms your usage
window for the day. Only runs while ClaudeDo is open; if you start the app within 30
minutes of a scheduled time it fires right away. *Plan day* runs it on demand.
- **Notes** — a pinned row in My Day: dated bullet notes with a day navigator, for the
thoughts that are not tasks.
- **Weekly report** (`Help` menu) — pick a range (defaults to "since your standup weekday")
and Claude writes up what actually happened, from your task and session history. Paths you
do not want in reports can be excluded in Settings.
## Settings
Most settings exist at three levels — **global → list → task** — and the more specific one
wins. Overridden fields carry an *override* badge with a one-click reset; inherited ones say
where they come from.
**General**
- Default instructions applied to every task
- Default model, and a **per-model table** of reasoning effort and turn budget — so `haiku`
can be cheap and short while `opus` gets room to think
- Permission mode for autonomous runs
- Max parallel executions
- Usage limit stops (see above)
- Session skills applied to every task
- Report exclusions, standup weekday
- Accent color (Moss / Peat / Sea) and language (English / German)
**Worktrees** — sibling or central placement, auto-cleanup age, and the manual cleanup and
force-remove-all buttons.
**Files** — open the prompt templates (system, planning, retry, daily prep, weekly report)
in your editor, and restore the bundled default agent files.
**Skills** — install a skill from a git URL, update it, remove it. Skills selected globally,
per list and per task combine.
**Prime Claude** — the schedules and the daily task cap.
**Online Inbox** (optional, off by default) — mirrors your idle backlog to a server so you
can capture tasks from your phone. Disabled means zero network traffic. Enabling it needs a
URL plus a sign-in; the refresh token is stored encrypted on your machine, never in a config
file.
## Letting Claude drive ClaudeDo
If you allowed it during install, ClaudeDo registers itself as an MCP server with the Claude
CLI. Any Claude session on your machine can then read and manage your tasks: list and create
tasks, add subtasks, queue and cancel runs, read logs and diffs, merge, review, manage lists
and per-list config.
Concretely, you can sit in a normal Claude Code session and say "put these five things in
the ClaudeDo backlog for the LagerApp list", or "check whether the CSV export task is done".
The MCP surface is deliberately narrow on the dangerous end: it can start and observe work,
but it cannot rewrite your app settings, and a task with an active worktree cannot be forced
to Done behind your back.
## Updates
ClaudeDo checks for new releases and shows a banner when one is out. *Update now* relaunches
the installer, which stops the worker, swaps the binaries and starts it again. Your database,
worktrees and settings are preserved. You can also check manually via `Help → Check for
updates`.
## Where your data lives
Everything is under `%USERPROFILE%\.todo-app`:
| Path | What |
|---|---|
| `todo.db` | Your tasks, lists, runs and worktree records (SQLite) |
| `ui.config.json` | Window and UI settings |
| `worker.config.json` | Worker settings — paths, port, worktree strategy |
| `logs/` | Worker and task logs |
| `agents/` | Your agent definition files |
| `attachments/` | Files you attached to tasks |
`Help → About` links straight to these folders. Nothing leaves your machine unless you turn
on the online inbox.
## Keyboard shortcuts
| Key | Action |
|---|---|
| `Ctrl+K` | Focus search |
| `Ctrl+N` | Focus the add-task box |
| `Enter` | Add the task |
| `Esc` | Leave the current text field / close a dialog |
| `F8` / `Shift+F8` | Next / previous conflict (merge editor) |
## Troubleshooting
**"Worker not reachable"** — the background worker is not running. The dialog offers to
start it; `Worker → Restart worker` does the same later. If it keeps happening, re-run the
installer and choose *Repair*.
**A task failed immediately** — usually the `claude` CLI is not signed in, or the list's
working directory is not a git repo. Check the Output tab and the footer log.
**A task is stuck in Running after a crash** — the worker sweeps orphaned runs to Failed on
startup. Restart the worker.
**Nothing is being picked up** — check the usage pill: if a usage stop is active the queue is
paused on purpose. Also check whether the tasks are MANUAL, blocked by a predecessor, or
scheduled for later.
## For developers
Architecture, build commands and per-project docs live in `CLAUDE.md` (root and one per
project under `src/`). Short version: .NET 8, Avalonia UI, SQLite via EF Core, and a
SignalR-connected worker process on `127.0.0.1:47821`.
```bash
# Build
dotnet build src/ClaudeDo.App
dotnet build src/ClaudeDo.Worker
# Run tests
dotnet test tests/ClaudeDo.Worker.Tests
# Run the app
dotnet run --project src/ClaudeDo.App
```
## How It Works
1. Create a task in the UI and tag it with **"agent"** to mark it for automated execution.
2. The Worker picks up queued tasks and runs each one via Claude CLI in an isolated git worktree.
3. When done, the worktree can be merged, kept for review, or discarded.
**Task status flow:** `Manual | Queued → Running → Done | Failed`
**Worktree state flow:** `Active → Merged | Discarded | Kept`
## Configuration
All data and config lives under `~/.todo-app/`:
| File | Purpose |
|---|---|
| `todo.db` | SQLite database |
| `ui.config.json` | UI settings |
| `worker.config.json` | Worker settings (worktree strategy, etc.) |
| `logs/` | Application logs |
## Project Structure
```
ClaudeDo.slnx
├── src/
│ ├── ClaudeDo.App/ # Desktop entry point
│ ├── ClaudeDo.Ui/ # Views & ViewModels
│ ├── ClaudeDo.Data/ # Data access layer
│ └── ClaudeDo.Worker/ # Background task runner
├── tests/
│ └── ClaudeDo.Worker.Tests/
├── schema/
│ └── schema.sql # Database schema
└── docs/
├── plan.md # Architecture & design spec
├── open.md # Verification checklist & backlog
└── improvement-plan.md # Prioritized improvements
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
## License
+50
View File
@@ -0,0 +1,50 @@
# Explore-notes
Distilled, reusable maps of complex subsystems, produced by deep code exploration.
The goal: stop re-exploring the same subsystem from scratch in every new session.
These sit **between** the CLAUDE.md files and the code:
- **CLAUDE.md** — high-level orientation, hand-maintained, always-loaded.
- **explore-notes** — deeper subsystem detail (flows, who-calls-whom, invariants) that is
too fine-grained for a CLAUDE.md but stable enough to be worth caching. Read on demand.
- **code** — the only source of truth.
## Index
| Note | Covers |
|---|---|
| [worker-task-pipeline](worker-task-pipeline.md) | `TaskRunner` end-to-end: config resolution, worktree, CLI invocation, streaming, commit |
| [usage-monitoring](usage-monitoring.md) | OAuth usage endpoint, gate, throttle, per-run token accounting, usage pill/modal |
| [external-mcp](external-mcp.md) | The `claudedo` MCP tool surface + its two test-enforced conventions |
| [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver |
| [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha |
| [installer-preflight](installer-preflight.md) | CLI version/login/auto-mode research, the `ExecutableResolver`/shim root cause, and the Installer's `Checks/`+`SystemCheckPage` implementation status |
| [list-virtualization-spike](list-virtualization-spike.md) | Phase 2a gate spike: virtualized `ListBox` + the existing ghost-drag `InputHitTest` model, verified headless |
## Rules
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
file paths. **No line numbers**, no exhaustive symbol dumps — those rot fastest.
- **Verify before trusting.** A note is a starting map, not authority. Always confirm
against current code before acting on it. Each note records the commit it was verified
against so you can diff for drift.
- **Not a substitute for CLAUDE.md.** If a fact belongs in orientation, put it there.
## Header every note must carry
```
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `<short-hash>` (<date>).
> Drift check: `git log --oneline <short-hash>..HEAD -- <paths this note covers>`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
```
## Workflow
1. **Before** deep-exploring a subsystem, check for a matching note here and read it first;
explore only to fill gaps or confirm.
2. **After** a deep explore, distill the durable findings into a new/updated note and bump
its "verified against" commit line.
3. If the drift check shows the covered paths changed a lot since the verified commit, treat
the note as suspect and re-verify the parts you rely on.
+303
View File
@@ -0,0 +1,303 @@
# ConPTY interactive sessions & launch specs
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `79b3580` (2026-08-11).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `InteractiveLaunchSpecService` and the four kinds of embedded ConPTY session the UI
process hosts (real `claude` TUI in a Mission Control tile).
Autonomous queue tasks are **not** covered here — they stay on the stream-json path via
`TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
## The four session kinds
| Kind | Hub spec method | Notes |
|---|---|---|
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
| List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) |
## ⚠️ Gotcha: never pass task free-text as a CLI argument
**No ConPTY path ever passes task free-text (title / description / brief) as a CLI argument.**
Every one of them writes it to a file first and hands `claude` a single-line kickoff pointing at
that file, exposed via `--add-dir`.
Two independent reasons:
1. The ConPTY host flattens `Args` into **one command line** to spawn the process, and `claude`
re-splits that line on whitespace. Any token starting with `-` in real task text (e.g. `->`,
`--abort`) is then misread as an unknown option.
2. A raw multi-line positional prompt truncates at its **first newline** regardless.
A fresh task session's brief lives at `~/.todo-app/task-sessions/<taskId>/brief.md`
(`InteractiveLaunchSpecService.BuildFreshTaskArgsAsync`). A task with neither title nor
description skips the file **and** the positional arg entirely — it still gets `--session-id`.
## Argument ordering
Every spec passes `--effort <level>` from the relevant model's preset. It **leads** the args —
except for a fresh task session with a brief, where `--add-dir <sessionDir>` must come first so
`--effort` (a single-value flag) can sit directly before the positional kickoff.
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
models in the TUI.
### ⚠️ Gotcha: no directory arg may end in a separator
`PtyTerminalSession` hands `Args` to `TerminalControl.Args`, which the library flattens into ONE
Windows command line with each token quoted. Windows argv rules read `\"` as an *escaped* quote,
so a token like `"C:\repo\"` never closes and every following argument is swallowed by the
preceding **variadic** flag. A list working dir stored as `C:\Dev\Repos\Bandel.Hub\` therefore
fed `--add-dir` the repo, `--append-system-prompt-file`, its value **and** the positional kickoff:
the CLI warned `brief.md is not a directory` and the session opened with no prompt at all
(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` run the repo through
`TrimTrailingSeparator`; session dirs the worker builds never carry one.
The same data bit the UI's "Open in terminal" on 2026-08-10 (`wt -d "C:\…\StaplerTracking\"`
`Could not access starting directory "C:\…\StaplerTracking""`), so the fix moved to the write side:
**`ListRepository.AddAsync`/`UpdateAsync` normalize `WorkingDir` via `Paths.TrimTrailingSeparator`**,
which covers every writer (UI create, repo import, hub `UpdateList`, MCP `CreateList`/`UpdateList`) —
the UI and worker keep their own defensive trim for rows written before that. Anything new that puts
a **user-supplied** path on a command line should use `Paths.TrimTrailingSeparator` and, on the
`ProcessStartInfo` side, `ArgumentList` rather than an interpolated `Arguments` string.
Diagnosing this from code or a PowerShell repro is a dead end — PowerShell quotes correctly, so
every repro passes. Read the real command line instead:
`Get-CimInstance Win32_Process -Filter "Name = 'claude.exe'"`.
## Resuming a task session (`TaskEntity.InteractiveSessionId`)
`claude --session-id <uuid>` lets the caller pre-assign a conversation's session id instead of
waiting for the CLI to generate one. `BuildForTaskAsync` uses this so a closed or aborted
interactive task session can be resumed even if it never got far enough to write anything to its
own transcript:
1. **Resume check.** If the task isn't on a freshly (re)created worktree, `BuildForTaskAsync`
picks a session to resume with `task.InteractiveSessionId ?? run?.SessionId` — this task's own
last *interactive* conversation takes precedence over the latest *autonomous* run's session,
since they're distinct conversations even against the same worktree. A task that has only ever
run autonomously still resumes into that run's session the first time it's opened interactively
(this is the pre-existing behavior `run?.SessionId` alone used to provide).
2. **Fresh path.** If neither is available (never run any way, or `isFreshWorktree`), a new
`Guid.NewGuid()` is generated and persisted to `TaskEntity.InteractiveSessionId` via
`TaskRepository.SetInteractiveSessionIdAsync`**before** the `LaunchSpec` is returned, i.e.
before the ConPTY host ever spawns `claude`. `BuildFreshTaskArgsAsync` then passes it as
`--session-id <guid>`, placed as the single-value flag directly before the positional kickoff
(or, with no brief, right after `--effort`).
3. **Fresh worktree wins.** `isFreshWorktree` forces `run` to `null` *and* is checked before
reading `task.InteractiveSessionId`, so a recreated worktree never resumes a stale id from
either source — it always takes the fresh path, which overwrites the stale
`InteractiveSessionId` with the new one.
Net effect: reopening an interactive session for a task (pane closed, process killed, whatever)
resumes the same claude conversation, because the id was committed to the DB before the previous
launch even started.
## List handler ("Let Claude handle it")
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
`PermissionModeResolver` (currently always `auto`, since none of the handler roles run on haiku)
so the session runs unattended. The `--allowedTools` allowlist is the security boundary:
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task` (`Task` lets the Merge
role delegate diff reviews to sonnet subagents).
The run now spans up to **five** phase-scoped sessions instead of two, chained via
`handoff_list_handler(taskId, survivingTaskIds, nextPhase)`: opus Triage → sonnet Wait → opus
Merge → (only if Merge started reruns) sonnet Wait(final) → opus Merge(final). `nextPhase` (`wait`
| `merge` | `wait_final` | `merge_final`, validated by `MergeHelperPhase.Validate`) picks both the
system prompt (`PromptKind.MergeHelperWait`/`MergeHelperMerge`) and the model
(`ModelRegistry.HandlerWaitAlias`/`HandlerMergeAlias`) for the next session; the `_final` variants
share the same prompt/model as their non-final counterpart and differ only in one extra line
rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round
and forbidding further reruns.
Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel.
OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`,
which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening
the next one — every tile is a live process with the full `mcp__claudedo__*` surface, and the
outgoing session is only ever told to end its turn, never to exit. This keeps the one-pane-per-
`TaskId` invariant that `ConPtySessions` relies on elsewhere (`OpenConPtySessionAsync`'s dedupe,
`OnPaneSubmitForReview`) — both use a plain `FirstOrDefault(s => s.TaskId == taskId)`, not a
"newest wins" lookup.
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
comfortably under it (see [external-mcp.md](external-mcp.md)).
### The host task and its commit range
The handler run **owns a real ClaudeDo task**, created by `CreateMergeHelperTask` (hub) →
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`, called by the UI *before* it opens
the tile:
- One new task per run in that list, `Idle` + `IsManual=true` (never queued).
- Title/description localized via `missionControl.mergeHelperTaskTitle` /
`mergeHelperTaskDescriptionHeader`.
- `TaskEntity.HandlerBaseCommit` stamped to the list repo's current HEAD.
The host task **never gets a worktree of its own** — the handler commits straight into the
list's working dir and merges the tasks it handles itself. Consequences:
- `SubmitTaskForReview` branches on whether the task has a `WorktreeEntity`: with one, it
commits the worktree; without one, it stamps `HandlerHeadCommit` to the list repo's current
HEAD. Both paths then flip the task `Idle`/`Failed``WaitingForReview`.
- `GetTaskDiff` and the UI's `DetailsIslandViewModel` / `MergeSectionViewModel` fall back to the
`HandlerBaseCommit`..`HandlerHeadCommit` range whenever `Worktree` is null.
### UI flow
`MergeHelperSelectionModalViewModel` — checkbox picker over one list's non-terminal, non-manual
tasks, pre-ticking the actionable ones. **List-scoped only** (`Configure(listId, listName)`, no
global scope). Opened from the list row's context menu, which is hidden when the list has no
working dir.
On confirm: `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell →
`MissionControlViewModel.OpenMergeHelperConPtySessionAsync`, which calls
`CreateMergeHelperTaskAsync` and then opens a **task-based** tile (deduped by `TaskId` like
`OpenConPtySessionAsync`, **not** `CreateAdHoc`) running the five-phase handler prompt.
## Tile lifecycle
`ConPtyPaneViewModel` resolves its own launch spec — the ctor takes a descriptor **factory**,
the host wires handlers and then calls `Start()`. So the tile appears **immediately** with its
spinner while the worker is still preparing the worktree. A failed launch keeps the tile with an
inline error banner instead of the tile never appearing.
`SubmitForReviewCommand.CanExecute` also gates on `Terminal.IsStarting` / `StartError` /
`HasExited` (not just `IsTaskBased`) — a starting or dead pane can't offer a review it would only
have the worker reject, and `MissionControlViewModel.OnPaneSubmitForReview` sets the pane's
`IsSubmitPending` flag for the duration of the round trip so a rapid double-click can't race two
`SubmitTaskForReviewAsync` calls. A failed launch also offers `RetryCommand` (visible whenever
`HasExited && StartError != null`) — it swaps in a fresh `InteractiveTerminalViewModel` and calls
`Start()` again on the **same** pane/`TaskId` dedupe slot, since `PtyTerminalSession` throws on a
second `StartAsync` call and can't be restarted in place.
### ⚠️ Gotcha: the terminal library kills its child on visual-tree detach
`Iciclecreek.Avalonia.Terminal`'s `TerminalView.OnDetachedFromLogicalTree` calls
`CleanupProcess()` (kills the PTY child) unless `BeginReparent()` suppressed it — and Mission
Control detaches pane views routinely (`RebuildOverviewGrid` recreates everything on any pane
add/remove/column change; focus-mode tab switches re-present content). Two-part defense (since
`aac84e4`):
1. `PtyTerminalSession.StartAsync` puts the control in **permanent reparent mode** right after
`LaunchProcess()``EndReparent` is deliberately never called. Teardown is explicit only:
`ConPtyPaneViewModel.Dispose``Terminal.Kill()` (pane close, VM disposal via DI on exit).
2. `ConPtyPaneHost` (the DataTemplate content for a pane) reparents **one long-lived
`ConPtyPaneView` per pane VM** (`ConditionalWeakTable`, view pins its own `DataContext`)
instead of letting the template instantiate a fresh view — a fresh view would render a dead,
empty terminal because the running session is bound to the original `TerminalControl`.
Hosts only steal the view while `IsEffectivelyVisible`; the layout toggle posts a reclaim
pass (`MissionControlView.ReclaimVisiblePaneHosts`) so the now-visible layout re-steals.
`Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner — used for a starting pane
(`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while
`TaskRowViewModel.IsRefining`.
### ⚠️ Gotcha: env-var launch race across sessions
`PtyTerminalSession.StartAsync` applies `TerminalLaunchDescriptor.Env` via
`Environment.SetEnvironmentVariable` onto the **whole UI process** (Porta.Pty has no per-launch
env seam — it always inherits the calling process's environment), then calls
`TerminalControl.LaunchProcess()`. Two sessions starting back-to-back (e.g. planning sessions for
two different tasks) could interleave: task B's `SetEnvironmentVariable` calls could land between
task A's env-set and its `LaunchProcess()` fork, so task A's `claude` process inherits B's env
(e.g. `CLAUDEDO_PLANNING_TOKEN`) and fails its own MCP auth. Fixed by serializing the
set-env-then-launch critical section behind a process-wide `static SemaphoreSlim(1,1)` in
`PtyTerminalSession`. Env leakage onto the whole process *after* a launch has forked remains a
documented limitation — only the fork-time race is closed.
### ⚠️ Gotcha: open-path dedupe races
`MissionControlViewModel.OpenConPtySessionAsync` / `OpenPlanningConPtySessionAsync` dedupe by
`TaskId` against `ConPtySessions`, but the check ran before an **awaited** DB title lookup and
only `AddConPtyPane` registers the pane — two rapid invocations for the same task (e.g. a
double-click) could both pass the dedupe check before either pane existed, opening two panes.
`OpenMergeHelperConPtySessionAsync` was worse: it awaits `CreateMergeHelperTaskAsync` (which mints
a brand-new task id every call) *before* any `TaskId` dedupe is even possible, so a double-trigger
always minted two host tasks in the DB.
Fixed with synchronous, pre-await claims: `_pendingTaskOpens` (shared by the two `TaskId`-keyed
open paths) and `_pendingMergeHelperLists` (keyed by `listId`, guarding the whole method since
there's no `TaskId` yet to dedupe on) are `HashSet<string>` fields checked-and-added at method
entry, before any `await`, and released in a `finally`. A second overlapping call for the same key
bails out immediately instead of racing past the collection-based dedupe.
## Focus / key handling
`InteractiveTerminalView` lives in `MissionControlWindow`, so the `FocusClearing` Escape handler
(scoped to `MainWindow` via `AddClassHandler<MainWindow>`) never runs there — **Escape always
reaches the PTY**. See the note in `src/ClaudeDo.Ui/CLAUDE.md`.
`TaskRowViewModel.HasInteractiveSession` shows an accent "Interactive" chip instead of "Parked";
tapping it jumps to that Mission Control pane. `TasksIslandViewModel.SyncInteractiveSessions`
mirrors Mission Control's open panes onto the rows.
### Queueing is gated on an open session (UI-only, since `d84607f`)
A task-based session leaves the row `Idle` (sessions never write `Status`), so nothing on the
worker side distinguishes it from a plain idle task. `TaskRowViewModel.CanSendToQueue` and
`MissionControlViewModel.EnqueueTaskAsync` (drag-to-queue onto the Command Center window) both
check for an open session before queueing — the row via `HasInteractiveSession`, the drag path via
`ConPtySessions.Any(s => s.TaskId == taskId)` (Mission Control's own authoritative pane list,
since the mirrored bool on the row could lag). Queueing a task open in a hand-driven ConPTY pane
would otherwise let the picker spawn an autonomous `claude` process into the same worktree the
user is editing. Both enqueue paths (`TasksIslandViewModel.SendToQueueAsync` and
`MissionControlViewModel.EnqueueTaskAsync`) also route through `IWorkerClient.SetTaskStatusAsync`
(hub `SetTaskStatus``TaskStateService.EnqueueAsync`) instead of a raw EF write, so the
manual/draft-child guards apply on both paths too.
## System-prompt matrix (autonomous vs. interactive)
Autonomous and interactive sessions do **not** share a system prompt. Per start path:
| Start path | Entry point | System prompt |
|---|---|---|
| Autonomous run/continue/retry | `TaskRunner.ResolveConfigAsync``ClaudeArgsBuilder.Build` | `--append-system-prompt <text>`, recomputed and re-sent on **every** invocation including a `--resume` continue (`PromptKind.System` + improvement/list/task overrides) |
| Interactive task session, fresh | `InteractiveLaunchSpecService.BuildForTaskAsync``BuildFreshTaskArgsAsync` | none — no `--append-system-prompt(-file)` at all |
| Interactive task session, resume | `InteractiveLaunchSpecService.BuildForTaskAsync``WindowsTerminalLauncher.BuildResumeArgs` | none — only `--resume <id>` (+ `--effort`) |
| Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none |
| Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart``WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file <path>` (`PromptKind.Planning`) |
| Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume``WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools <planning allowlist> --resume <id>` |
| List handler ("Let Claude handle it"), triage | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperTriage` — phases 02 only), always fresh — this path never resumes |
| List handler, post-handoff (wait/wait_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperWait` — phase 3 only), fresh session dir, same handler task id |
| List handler, post-handoff (merge/merge_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperMerge` — phases 45 only), fresh session dir, same handler task id |
So every interactive resume (task session and planning) drops the system prompt entirely — it's
not that they inherit the autonomous one, it's that **no** `claude` process on any resume path
ever passes `--append-system-prompt(-file)`.
### Does `--resume` bring back a prior `--append-system-prompt`? No.
Checked by reading real session transcripts (`~/.claude/projects/<cwd>/<sessionId>.jsonl`) for
several autonomous ClaudeDo task runs, including ones with multiple invocations (initial run +
`ContinueAsync`/retry on the same session id, confirmed via that project's `task_runs` history).
Grepped for the `PromptKind.System` default text ("You are completing one well-defined task
autonomously...") and for any `"type":"system"` entry or `message.role == "system"` anywhere in
those files: the prompt text only ever showed up as ordinary tool-result content (e.g. a task
that happened to read `PromptFiles.cs`'s own source), never as a persisted system/config entry.
No session transcript — autonomous or interactive — carries a system-role message or a
per-session record of the CLI flags it was launched with; there is no sidecar file next to the
`.jsonl` either. The system prompt is purely a per-process request parameter the CLI builds fresh
from that invocation's own flags, never replayed from a resumed session's history. This matches
why `TaskRunner.ContinueAsync` (autonomous) explicitly re-resolves and re-passes
`--append-system-prompt` on every continue instead of relying on `--resume` to carry it —
if inheritance worked, that re-resolution would be redundant.
**Conclusion: no leak.** An interactive resume (task or planning) does not pick up the autonomous
run's `--append-system-prompt` text — including the "commit your work" / `CLAUDEDO_BLOCKED`
instructions from `PromptKind.System`. It simply runs with the `claude` CLI's own baseline system
prompt, same as every other path in this table that passes no system-prompt flag. No code change
needed here.
## Related hub methods
`GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`, `GetMergeHelperLaunchSpec`,
`CreateMergeHelperTask`, `SubmitTaskForReview`.
Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`,
`FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`,
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`.
+255
View File
@@ -0,0 +1,255 @@
# External MCP tool surface
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `38af549` (2026-08-11).
> Drift check: `git log --oneline 38af549..HEAD -- src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
Claude sessions. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`.
Server name `claudedo`, registered globally by the installer's `RegisterMcpStep`, so callers
need no `--mcp-config`.
**Scope boundary:** these tools cover *starting* and *observing* sessions plus task/list CRUD
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
## Task ID resolution and numbering
Every tool parameter accepting a task id — `taskId`, `parentId`, `taskIds` arrays, and similar —
is wired through `TaskIdResolver`, which resolves:
- `#123` (with or without the `#` prefix) → look up by `TaskEntity.Number`
- Bare GUID string → use as-is
- Unknown number → `InvalidOperationException` (`no task with number 123`)
This is **not** ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
number, not a partial GUID. All task-returning tools (`GetTask`, `ListTasks`, `AddTask`,
`BatchGetTasks`, etc.) stamp the resolved task's `Number` in the DTO — both `TaskDto` and the
lean `TaskRefDto` carry an `int Number` field. **Branch names and worktree paths** (`claudedo/{id}`)
continue to use the GUID; the number is a display alias, never the identity.
Every tool description carries a shared boilerplate clause (defined in `McpToolDocs.TaskNumberHint`)
instructing the agent to **refer to tasks as `#<number>` when reporting results to the user**
without this the agent sees the number in every payload but never learns to speak it.
## Conventions
### Test-enforced
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
The MCP schema only marks a parameter optional when it has one — nullability alone does
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
### Not test-enforced, but strongly observed
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
an empty/omitted response apart from a dropped one.
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
true, <id>, ... }` (`DeleteListResult`, `RunTaskNowResult`, `ResetFailedTaskResult`,
`RemoveAttachmentResult`; `SetListConfigResult` / `SetTaskConfigResult` additionally echo
the resulting config so the caller can see which fields were set vs. cleared to null).
- *Read* tools that may have nothing to return use an explicit `Found` / `Available` flag
alongside the nullable payload (`TaskConfigResult`, `BatchGetTaskResult`, `TaskLogResult`).
- The same flag-alongside-nullable-payload idiom also covers "which of two shapes did you
get": `ListTasks`/`BatchGetTasks` take `includeDescription` (default `false`) and return
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
list of verbosely-described tasks from blowing past the response size limit by default.
3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate
lives there as `const` strings. Rules: the first sentence says what the tool does *and* when
to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind
return-shape prose); parameters are documented with `[Description]` **on the parameter**, not
in the tool description; result fields appear only where the caller must branch on them
before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or
"since this feature was introduced" history.
4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
*"An error occurred invoking 'X'."*
## Tool classes
### `ExternalMcpService` — task CRUD, execution, git
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`.
(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s
description is now the canonical place for what each status means.)
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
### Other classes
| Class | Tools |
|---|---|
| `BatchMcpTools` | `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees` |
| `ListMcpTools` | `CreateList`, `UpdateList`, `DeleteList` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`, `GetEffectiveRunConfig` |
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` |
| `AgentMcpTools` | `ListAgents` |
| `LifecycleMcpTools` | `ResetFailedTask` |
| `AppSettingsMcpTools` | `GetAppSettings` (read-only) |
| `TaskWaitMcpTools` | `WaitForTaskChange` |
| `QueueStateMcpTools` | `GetQueueState` |
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
## Per-tool behaviour worth knowing
**`ListTasks`** — `includeDescription=false` (default) returns lean `TaskRefDto` references in
`tasks` (`tasksFull` null); `includeDescription=true` returns full `TaskDto`s (incl.
Description/Result) in `tasksFull` instead (`tasks` null). Filtering by `createdBy`/`status`
happens before the lean/full projection either way.
**`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only.
- `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the
**only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on
`Idle` staying a no-op there by default, because a child parked back to `Idle` mid-chain is
a manual opt-out signal.
- `Done` goes through `TaskStateService.ForceSetStatusAsync` (the same unconditional write the
UI's "set status freely" affordance uses) but is **refused** for a task with an active
worktree, since that would skip `review_task`'s merge.
**`AddTask`** — always creates the task; also returns `possibleDuplicates` (up to 3, id/title/status
only, no descriptions) — open (non-terminal) tasks in the *same list* whose normalized title
overlaps strongly with the new one. Cheap word-overlap heuristic (`ExternalMcpService`'s
`FindPossibleDuplicatesAsync`/`NormalizeTitleWords`), no embeddings/LLM call, no blocking —
the caller just gets a heads-up to relay. `BatchAddTasks` carries the same field per item.
**`ReviewTask`** — `approve` / `reject_rerun` / `reject_park` / `cancel` for a
`WaitingForReview` task. Approve is review+merge exactly like the hub's `ApproveReview`: unit
merge for parents, worktree merge into optional `targetBranch` for childless tasks. Conflicts
are reported in `ReviewTaskResult`.
- A parent's approve also returns `emptyChildren`: the `Done` children about to be unit-merged
whose own review range contributed nothing (computed the same way as `PreviewMerge`'s
`isEmpty`, before the merge starts so it reflects what's about to be approved). Surfaces a
child that reported `CLAUDEDO_BLOCKED` and committed no code — previously that child reached
`Done` and merged silently with `changedFileCount: 0`, indistinguishable from a small-but-real
change. `TaskRefDto.roadblockCount` (on every task-returning tool, stamped by `TaskRunner` from
`result.Blocks.Count`) is the MCP-visible signal for *why* a child is empty.
**`PreviewMerge`** — non-destructive `git merge-tree --write-tree` mergeability check for one
task's worktree branch against `targetBranch` (default: the repo's current branch). Returns
status / conflictFiles / changedFileCount / `behind` / `isEmpty`. Unlike
`TaskMergeService.PreviewAsync`'s silent *"unavailable"*, this **throws a clear error** when the
task has neither an active worktree nor a handler commit range, or the list's working dir is
missing.
- `isEmpty` = the review range contributed nothing — zero files changed against the worktree's
base commit, or (for a worktree-less list-handler host task) `HandlerBaseCommit ==
HandlerHeadCommit`. Distinguishes a genuinely empty branch from one that merely made a small
change (`changedFileCount: 0` alone reads as "tiny", not "nothing to review") — the gap that
let two blocked planning children reach `Done` with unmerged empty branches unnoticed.
- A worktree-less handler task has no separate branch to `merge-tree`-preview (its commits
already sit in `list.WorkingDir`) — `PreviewMergeCoreAsync` falls back to a synthetic `clean`
preview over its own `HandlerBaseCommit..HandlerHeadCommit` diff-stat instead of throwing
"has no worktree".
**`PreviewMergeSet`** — same preview for a batch (each entry also carries `isEmpty`), plus a
file→tasks overlap report built from each task's own diff-stat. ⚠️ That overlap report is a
**same-file-name hint only** — it is blind to cross-file collisions (e.g. the CS0103 case that
motivated it). A task that fails to preview gets `error` set and is excluded from the overlap
instead of aborting the batch.
**`RevertMerge`** — undoes a previously merged task's merge commit on `targetBranch` via
`git revert -m 1`. Always a **new commit**, never a reset/rewrite, because the target working
directory is shared with other concurrent sessions.
- Requires the task to be `Done` with a `Merged` worktree carrying a recorded
`WorktreeEntity.MergeCommit`. A task merged before that field existed has none and is
**refused rather than guessed** via `git log`.
- On success the task returns to `WaitingForReview` and the worktree moves to `Kept` — not
`Active` (its directory/branch are typically already gone from the original merge's cleanup)
and not `Merged`/`Discarded` (`WorktreeMaintenanceService` sweeps those).
- A conflicting revert is aborted immediately; no half-resolved state is left in the tree.
**`BatchMcpTools`** — best-effort loops over the `ExternalMcpService` single-entity methods.
**Sequential**, because the scoped `DbContext` is not thread-safe. Merge/review stay
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
failing item never aborts the rest — and rejects batches over **100 items**.
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
`BatchGetTaskResult` carries `task` (lean `TaskRefDto`) or `taskFull`, never both. `taskFull` is
`BatchTaskDetailDto` — a batch-only shape, **not** `TaskDto` (`get_task` is untouched) — whose
Description/Result are cut to `descriptionMaxChars` (default 1500, was unlimited) with
`*Truncated`/`*FullLength` flagging it, and which `fields` (an optional name allow-list, e.g.
`['title','roadblockText']`) can narrow further; unrequested fields come back `null`.
`roadblockText` pulls just the bullet lines after `TaskRunner.ComposeReviewResult`'s roadblock
marker out of `Result`, without needing the rest of it. The whole per-call response is also
capped (`BatchMcpTools.MaxResponseChars`) — over that, the call throws naming which parameter to
adjust instead of shipping an oversized payload (the incident that prompted this: 8 tasks'
full Description/Result serialized to a single 51k-char line).
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
**`WaitForTaskChange(taskIds, timeoutSeconds = 60, treatWaitingForChildrenAsBusy = false)`** —
blocks until any given task leaves `Queued`/`Running`, or times out. Returns immediately for a
task already outside those two (unknown ids reported as status `"NotFound"`, also immediate).
- Implemented as an **async DB poll** (short-lived `DbContext` per check, 500 ms delay, no
held connection, no busy loop) rather than hooking `HubBroadcaster` — deliberately isolated
so it can't regress the existing broadcast callers.
- `timeoutSeconds` is clamped server-side to `TaskWaitMcpTools.MaxTimeoutSeconds` (900 s),
comfortably under the `MCP_TOOL_TIMEOUT` (930 s) every ClaudeDo-owned claude launcher sets —
`ClaudeProcess` for headless queue runs, `InteractiveLaunchSpecService` for every embedded
ConPTY session (list handler, planning, interactive resume) — so the tool reports
`timedOut: true` instead of racing the client's own abort. A caller running claude with a
different (or default: 60 s) `MCP_TOOL_TIMEOUT` will still see its own client-side timeout
fire first; the server has no way to detect or compensate for that.
- **`treatWaitingForChildrenAsBusy` pitfall (default `false`, backward-compatible):** a planning
parent with children goes `Running` → `WaitingForChildren` while children are still working,
and by default that already counts as "changed" (it's outside `Queued`/`Running`) — so waiting
on a parent returns immediately even though the unit isn't done. Set the flag to keep polling
through `WaitingForChildren`; the call then only reports changed once the parent reaches
`WaitingForReview` or a terminal status. Does not list or watch the parent's children —
callers still need their own ids for that.
- Replaced the list handler's old "sleep + poll `get_task` in a loop" Phase 3 instruction.
**`GetQueueState()`** — read-only snapshot so a caller doesn't have to infer queue state from
`maxParallelExecutions` or repeated `wait_for_task_change` rounds:
`{ configuredSlots, effectiveSlots, activeSlots: [{ slot, taskId, startedAt }], waitingTaskIds }`.
- `configuredSlots`/`effectiveSlots` reuse `QueueService.GetSlotCountsAsync` — the same
configured-vs-throttled computation `QueueService.ExecuteAsync` uses each tick (see
[usage-monitoring](usage-monitoring.md) for the throttle staging) — so this tool can't drift
from the queue's actual refill decision.
- `activeSlots` reuses `QueueService.GetActive()` (already the source for the Hub's `GetActive`):
`slot` is `"queue"` for a normal queue slot or `"override"` for the single
`run_task_now`/`continue_task` slot.
- `waitingTaskIds` is a fresh read-only query mirroring `QueuePicker.ClaimNextAsync`'s
eligibility filter and order (`Queued`, unblocked, non-manual, due, `sort_order` then
`created_at`) — it does not claim or mutate anything.
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
`Running` task.
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
helper in the same file.
**`SetMyDay`** — sets `IsMyDay` (+ optional `SortOrder`). A server-side cap-guard rejects
turning on MyDay beyond `DailyPrepMaxTasks` open (Idle) MyDay tasks.
**`GetEffectiveRunConfig`** — read-only report of what a task will *actually* run with (model,
max turns, effort, permission mode, agent path, whether a system prompt is set, skill names),
each with its source (`task`/`list`/`preset`/`global`); max turns additionally reports the raw
requested value and whether it was clamped to `AppSettings.MaxTurnsCeiling`. Unlike
`GetAppSettings`/`GetTaskConfig` (raw, possibly-unused config values), this goes through the same
`EffectiveRunConfigResolver.Resolve` that `TaskRunner` itself runs with — see
[worker-task-pipeline](./worker-task-pipeline.md)'s model/effort/max-turns section — so it can't
drift from the real run. Reads (not writes) `AppSettingsRepository.GetAsync`, which backfills
`model_presets` on first read after a null column; that backfill is pre-existing shared behavior,
not a new side effect introduced by this tool.
## Model / max-turns on task creation
Task-generating tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an
optional `model`, alias-validated via `ModelRegistry.NormalizeAlias` (`haiku`/`sonnet`/`opus`,
blank = inherit), so Claude can assign the cheapest capable model at creation time — the
planning/system/improvement prompts instruct it to do so, using
`ModelRegistry.ByCostAscending` as the cost order.
Planning's `CreateChildTask` **additionally** accepts an optional `maxTurns` (positive int;
`0`/negative rejected with `ArgumentException`, null = inherit list/global default) so the
planner can raise the turn budget for a subtask it knows will run long.
`SuggestImprovement` and `AddTask` do **not** expose it.
+252
View File
@@ -0,0 +1,252 @@
# Installer preflight: CLI version gate & environment checks
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `bdee731` (2026-08-05).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.App/ClaudeDo.App.csproj .gitea/workflows/release.yml src/ClaudeDo.Installer/Checks src/ClaudeDo.Data/Environment/ExecutableResolver.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
## Implementation status (as of 2026-08-05)
The research below (§15) led to an implementation, but it is **not on `main` yet** — it exists
on two unmerged task branches:
- `claudedo/06aca9b3afec4b939f59b627bfe21737``src/ClaudeDo.Installer/Checks/*`
(`GitCheck`, `GitIdentityCheck`, `WriteAccessCheck`, `PortCheck`, `ClaudeCliCheck`,
`ClaudeVersionCheck`, `ClaudeAuthCheck`, `PermissionModeAutoCheck`, `EnvironmentCheckService`,
`ClaudeCliLookup`) plus `SystemCheckPage` (the Fresh-Install wizard page hosting them) and its
own copy of `src/ClaudeDo.Data/Environment/ExecutableResolver.cs`.
- `claudedo/40272c0bb3b14562b59c022d09c382b6` — the original `ExecutableResolver.cs`, plus wiring
it into `ClaudeDo.Worker`'s `ClaudeCliPreflight` and `ClaudeProcess` (the actual root-cause fix:
both used to spawn `claude` with `UseShellExecute = false` and no `.cmd`/`.bat` shim resolution,
so an npm-installed `claude.cmd` was invisible to the Worker even though it worked in a shell).
The two branches were authored independently and each vendored its own copy of
`ExecutableResolver.cs` (identical except `06aca9b3` adds a `FallbackDirectories()` diagnostic
helper); merging both cleanly requires picking one copy, not literally running `git merge` twice.
Two planned follow-up tasks — a "Claude Help Me" button and a Config-mode Diagnose section —
never got past a blocked first step, precisely because this prerequisite work wasn't on `main`
when they ran. See `Environment Checks` in `src/ClaudeDo.Installer/CLAUDE.md` and `docs/open.md`
for the current gap and the manual verification checklist.
What's confirmed as **matching the research below**: `ClaudeVersionCheck.MinimumVersion` is
`2.1.220`, exactly the "verified-floor, not a proven minimum" constant from §3. `ClaudeAuthCheck`
uses `claude auth status --json` exactly as recommended in §4, parsing only the `loggedIn` field.
`PermissionModeAutoCheck` is the static "is `auto` listed in `--help`" check recommended in §2 —
real org/model/plan eligibility is deliberately **not** checked, matching the recommendation not
to build that (a real task run surfaces a startup rejection fast enough on its own). No .NET
Desktop Runtime check was implemented as an `IEnvironmentCheck` (§5's registry-key detection
remains a documented-but-unbuilt option, not currently gating anything).
---
Pure research, no code changed. Answers the five questions from the "Root Cause
`--permission-mode auto`" task. Sources: the locally installed CLI (`claude --version` /
`--help`), the official docs at `code.claude.com` (fetched 2026-08-05), and this repo's own
csproj/workflow files.
## 1. Why can `--permission-mode auto` fail?
**It is very rarely a CLI-version problem.** Per the official permission-modes doc
(`code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode`), auto mode
requires **all** of:
- **Plan**: any plan (Free/Pro/Max/Team/Enterprise) qualifies in principle.
- **Organization**: on Team/Enterprise, on by default; an admin can disable it org-wide via
`permissions.disableAutoMode: "disable"` in managed settings. When disabled this way, the CLI
**"rejects `--permission-mode auto` at startup"** (verbatim from the doc) — not a runtime
fallback, a hard reject.
- **Model**: on the Anthropic API / Claude Platform on AWS — Opus 4.6+, Sonnet 4.6+, or Fable 5.
On Bedrock / Vertex / Foundry / signed-in gateway sessions — only Sonnet 5, Opus 4.7+, Fable 5.
Older models (Sonnet 4.5, Opus 4.5, Haiku, claude-3-*) are **not supported on any provider**.
A per-org `availableModels` restriction that only allows an old model would silently make
auto mode unavailable even on a Team/Enterprise org that hasn't touched `disableAutoMode`.
- **Provider opt-in (historical)**: on Bedrock/Vertex/Foundry/gateway, CLI **v2.1.158v2.1.206**
required `CLAUDE_CODE_ENABLE_AUTO_MODE=1`; **v2.1.207** removed that requirement. This does
**not** apply to a normal claude.ai / Console (Anthropic API) login — only to those four
provider types.
A separate, easy-to-hit trap: `defaultMode: "auto"` in **project or local** settings
(`.claude/settings.json`, `.claude/settings.local.json`) is silently **ignored** since v2.1.142
— it must live in `~/.claude/settings.json` (user scope). A repo that ships `defaultMode: auto`
in its own `.claude/settings.json` will start in Manual mode with **no error at all**. This
doesn't apply to ClaudeDo's case (it passes `--permission-mode auto` as an explicit CLI flag,
not via a checked-in settings file), but is worth knowing if the failure mode was "silently
starts in Manual" rather than "CLI errors out".
Quoted from the docs, verbatim: *"If Claude Code reports auto mode as unavailable, one of these
requirements is unmet; this is not a transient outage."*
**Not verifiable**: which of the above actually hit the colleague — we have no diagnostic from
their machine (no `claude auth status --json` output, no `claude --version`, no org name). Do
not guess; if this recurs, capture `claude auth status --json` and `claude --version` from the
affected machine before further debugging.
## 2. How to reliably detect whether `auto` is supported
**Static, cheap, always safe (no API call):**
- `claude --version` — semver string, e.g. `2.1.220 (Claude Code)`.
- `claude --help` — the `--permission-mode <mode>` line lists the accepted enum. Confirmed by
testing an invalid value locally:
```
$ claude -p "test" --permission-mode bogus
error: option '--permission-mode <mode>' argument 'bogus' is invalid. Allowed choices are
acceptEdits, auto, bypassPermissions, manual, dontAsk, plan.
```
This is validated by the CLI's arg parser (Commander.js) **before any network call** — exits
1 immediately. So checking that `auto` is one of the listed choices is a legitimate, free,
fast static check — but it only proves the *flag* is recognized, **not** that auto mode is
actually usable (org/model/plan gating happens later, at session start, not at arg-parse time).
- `claude auth status --json` — cheap, local/fast, **does not send a prompt**. Returns:
```json
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
```
This is the answer to question 4 (see below) and also gives `subscriptionType`/`orgName` —
useful context but **still not a direct "is auto mode eligible" answer** (doesn't report the
active model or `disableAutoMode` policy).
**No cheap subcommand exists for full eligibility.** Checked `claude auto-mode --help`: it only
has `config` (effective auto-mode *rule* config — allow/deny lists, not eligibility),
`defaults` (same, shipped defaults), `critique`, and `reset`. None report plan/model/org
eligibility. `claude doctor` (non-interactive) does **not** report auto-mode eligibility either
— it explicitly says *"For a full setup checkup that can also fix issues, run `/doctor` in a
session"*; the in-session `/doctor` slash command is the one the docs say proposes
`defaultMode: auto` when eligible, but that requires an interactive session, not a scriptable
preflight.
**Dynamic (real probe) is the only way to fully confirm eligibility**, and per the docs an
org-disabled or otherwise-ineligible account **rejects at startup** (fast, before any model
turn) — so a probe doesn't have to be a full expensive run. A minimal probe such as
`claude -p "ok" --permission-mode auto --max-turns 1 --output-format json` would fail fast on
ineligibility (reject at startup) but still costs one real turn + tokens on the success path,
and still requires a working prompt/response round trip on the happy path. **Recommendation**:
don't build this into an automated preflight; a static version+flag check plus `auth status`
covers the reliably-detectable ground, and a real first task run will surface an auto-mode
rejection immediately and cheaply (fails at startup, not mid-task) if it's actually unavailable.
**Implemented as:** `PermissionModeAutoCheck` (Warning) — the static flag-listed check only.
## 3. Minimum CLI version for the flags ClaudeDo uses
Flags used (from `ClaudeArgsBuilder` per `src/ClaudeDo.Worker/CLAUDE.md`): `--permission-mode
auto`, `--effort`, `--agents`, `--json-schema`, `--append-system-prompt`, `--output-format
stream-json --verbose`, `--resume`, and (installer) `claude mcp add --transport http --scope
user`.
**Not verifiable precisely.** All eight of these are foundational, long-established flags.
The official changelog (`raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md`)
only retains roughly the last ~40 entries (oldest visible: `2.1.181`); auto mode itself, and
`--json-schema`/`--resume`/`mcp add --transport` all clearly predate that window (the earliest
found reference is a *bug fix* mentioning "sessions created before v2.1.85" for `--resume`,
implying `--resume` existed well before 2.1.85). There is no accessible source that pins an
"introduced in vX" date for any of these eight flags — guessing one would violate the task's
explicit instruction not to invent a source-less root cause.
What **is** sourced, from the docs fetched 2026-08-05:
- `--json-schema` + invalid-schema handling: before v2.1.205, an invalid schema was silently
ignored (returned unstructured text); v2.1.205 made it a hard `Error: --json-schema is not a
valid JSON Schema` exit. Not a "does it exist" gate, but changes error-handling behavior
ClaudeDo might currently rely on failing loudly.
- `--output-format stream-json --verbose` + `system/init.capabilities` array requires v2.1.205+
(absent before). Not currently consumed by ClaudeDo per the Worker CLAUDE.md's stream handling
description, so not a hard requirement today.
- `system/init.mcp_server_errors` field requires v2.1.219+. Not currently consumed by ClaudeDo.
- Manual-mode label/`manual` alias requires v2.1.200+ — irrelevant, ClaudeDo passes `auto`
explicitly, never `manual`.
- Auto mode's Bedrock/Vertex/Foundry/gateway opt-in-env-var requirement was removed in v2.1.207
— irrelevant for a direct claude.ai/Console login (this machine: `apiProvider: "firstParty"`).
**Decided constant** (see below) is therefore **the newest version we can positively confirm
works end-to-end on this machine** (`2.1.220`), not a proven theoretical minimum — because no
lower true minimum is derivable from available sources without guessing.
**Implemented as:** `ClaudeVersionCheck.MinimumVersion = new Version(2, 1, 220)` (Error).
## 4. Detecting "CLI is logged in" without sending a prompt
`claude auth status --json` (confirmed working, instant, no API/model call):
```json
{ "loggedIn": true, "authMethod": "claude.ai", "apiProvider": "firstParty",
"email": "...", "orgId": "...", "orgName": "...", "subscriptionType": "team" }
```
This is the CLI's own maintained answer — cheaper and more robust than parsing
`.credentials.json` directly (format may be internal/undocumented and is a hard-blocked file
for this task's own tooling; the docs page confirms it lives at
`%USERPROFILE%\.claude\.credentials.json` on Windows but say nothing about its schema being a
stable public contract). `claude auth status --text` is available for a human-readable variant;
`--json` is the default and the right one for a preflight to parse.
**Implemented as:** `ClaudeAuthCheck` (Error) — parses only the `loggedIn` boolean; any other
field, or a non-zero exit code, or unparseable JSON, becomes `Unknown` rather than `Failed`.
## 5. .NET runtimes required by the published `app\` / `worker\` artifacts
From `.gitea/workflows/release.yml` (the only build/publish pipeline in this repo) and the two
csproj files:
- **`ClaudeDo.App`** (`net8.0`, Avalonia, `WinExe`) — published via
`dotnet publish ... -r win-x64 --self-contained true`. **Self-contained**: bundles its own
.NET 8 runtime. **No .NET runtime needs to be pre-installed** on the target machine for the
app itself.
- **`ClaudeDo.Worker`** (`net8.0`, `Microsoft.NET.Sdk.Web`, ASP.NET Core) — same treatment:
`-r win-x64 --self-contained true`. Also fully self-contained; no ASP.NET Core runtime needs
to be pre-installed.
- **`ClaudeDo.Installer`** (`net8.0-windows`, WPF) is the one exception: published
`--self-contained false -p:PublishSingleFile=true`**framework-dependent**. The csproj
comment explains why: *"the WPF runtime pack isn't distributed for cross-compile on Linux CI,
which made self-contained bundles crash on startup with AV in the apphost."* The target
machine **must** have the **.NET 8 Desktop Runtime (x64)** installed before running
`ClaudeDo.Installer.exe` — this is the actual runtime-preflight gap, not the app/worker.
**How to check on a target machine**:
- `dotnet --list-runtimes` — look for a `Microsoft.WindowsDesktop.App 8.0.x` line (Desktop
Runtime, required by the installer). Requires the `dotnet` CLI itself to be on PATH; not
guaranteed present on a fresh machine that never installed the SDK, only the runtime — in
that case `dotnet` may not exist at all even though the runtime DLLs do.
- Registry fallback (works even without the `dotnet` CLI on PATH):
`HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App`
each installed version is a subkey/value here. This is the standard documented detection
mechanism for .NET Desktop Runtime presence on Windows and is what most installer-detection
tooling (e.g. Squirrel, WiX bundles) uses instead of shelling out to `dotnet`.
- **Not independently verified in this task**: the exact registry key shape wasn't inspected
live (would require reading `HKLM\SOFTWARE\dotnet\...` on this machine, which is standard
.NET installer-detection convention, but out of scope to screenshot/dump here since the task
is docs-only and this is a well-documented, non-project-specific Windows convention).
**Not implemented** as an `IEnvironmentCheck` — none of the shipped checks verify the Desktop
Runtime; if the Installer itself is running at all, .NET 8 Desktop Runtime is implicitly
present (framework-dependent publish would otherwise fail to launch).
## Beschlossene Konstanten
| Constant | Value | Confidence |
|---|---|---|
| Minimum CLI version | `2.1.220` | **Verified-floor, not a proven minimum.** This is the newest version confirmed installed and working end-to-end on a dev machine for every flag ClaudeDo uses. No lower true minimum could be sourced (see §3) — treat any lower value as a guess. |
| Credentials file path | `%USERPROFILE%\.claude\.credentials.json` (Windows) | Sourced from official docs; content/schema not inspected (hard-blocked secrets file). |
| Login-check command | `claude auth status --json` | Verified locally, instant, no model call. Fields: `loggedIn`, `authMethod`, `apiProvider`, `email`, `orgId`, `orgName`, `subscriptionType`. |
| App/Worker runtime requirement | **None** — self-contained win-x64 publish | Sourced from `.gitea/workflows/release.yml`. |
| Installer runtime requirement | **.NET 8 Desktop Runtime (x64)** must be pre-installed | Sourced from `.gitea/workflows/release.yml` comment + `ClaudeDo.Installer.csproj`. |
## Erkennungsstrategie pro Check
| Check | Type | Command | Expected pass output | Implemented as |
|---|---|---|---|---|
| git present + version | static | `git --version` (via `ExecutableResolver`) | resolves, exit 0 | `GitCheck` (Error) |
| git identity set | static | `git config --get user.name` / `user.email` | both non-empty | `GitIdentityCheck` (Warning) |
| install dir + data dir writable | static | probe-file write/delete | succeeds | `WriteAccessCheck` (Error) |
| SignalR/ExternalMcp ports free | static | `TcpListener` bind probe + owning-process lookup | free, or owned by running `ClaudeDo.Worker` | `PortCheck` (Warning) |
| CLI present + version | static | `claude --version` | `X.Y.Z (Claude Code)`; parse and compare `X.Y.Z >= 2.1.220` | `ClaudeCliCheck` (Error) / `ClaudeVersionCheck` (Error) |
| `auto` recognized as a flag value | static | `claude --help` (or trigger the parse error path) | `--permission-mode <mode>` help text lists `auto` among the choices | `PermissionModeAutoCheck` (Warning) |
| CLI logged in | static/cheap | `claude auth status --json` | exit 0, `loggedIn: true` | `ClaudeAuthCheck` (Error) |
| Auto mode actually eligible (org/model/plan) | **not statically detectable** | none exists | N/A — see §2; don't build this, let a real task run surface a fast startup rejection instead | not implemented (by design) |
| .NET Desktop Runtime present (installer only) | static | `dotnet --list-runtimes` (if `dotnet` on PATH) or registry `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x64\sharedfx\Microsoft.WindowsDesktop.App` | a `Microsoft.WindowsDesktop.App 8.0.x` entry exists | not implemented (see §5) |
| App/Worker runtime present | **not needed** | — | self-contained, nothing to check | not implemented (not needed) |
## Not verifiable (explicit)
- The actual root cause on the colleague's machine — no diagnostic data was captured from it.
- Exact stderr/exit-code wording the CLI prints when auto mode is rejected at startup for an
ineligible org/model (docs state the *behavior* — "rejects `--permission-mode auto` at
startup" — but not the literal message; this machine's account is eligible, so it couldn't be
reproduced locally).
- A true (not just "newest confirmed") minimum SemVer for `--effort`, `--agents`,
`--json-schema`, `--append-system-prompt`, `--resume`, `claude mcp add --transport http
--scope user` — all predate the retrievable changelog window.
- Live inspection of the `HKLM\SOFTWARE\dotnet\Setup\InstalledVersions` registry shape on this
machine (documented Windows convention, not independently screenshotted here).
@@ -0,0 +1,114 @@
# List virtualization spike (Phase 2a gate)
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `6a2a19c` (2026-08-10).
> Drift check: `git log --oneline 6a2a19c..HEAD -- src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs src/ClaudeDo.Ui/Views/Controls/TaskDragController.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Gate task for [ui-reaktivitaet-und-listen-performance-design](../superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md)
Phase 2, Risk 1. Answers the five open questions before any rework of the real
`TasksIslandView`/`TasksIslandViewModel`. No production code changed for this spike.
## Method
A throwaway Avalonia-headless xUnit test (`Avalonia.Headless` + `Avalonia.Themes.Fluent`,
temporarily added to `ClaudeDo.Ui.Tests`, removed again after this report was written — not
committed) built a plain `ListBox` with an explicit `VirtualizingStackPanel` and 1000 mixed
items (≈14% "complex" two-line/badge rows, mirroring the 68px/90110px split from the design
doc), then drove it programmatically: forced layout/render passes, scrolled the `ScrollViewer`,
called the same `TopLevel.InputHitTest` the production drag code uses, and counted realized
`ListBoxItem` containers via the visual tree. No screenshots — every number below came from an
actual headless run of that harness on 2026-08-10.
One harness-only wrinkle: `InputHitTest` walks the **compositor's rendered scene**, not just the
layout tree, so the test had to call `window.CaptureRenderedFrame()` after each layout-affecting
change before hit-testing. A real windowed app renders every frame continuously, so this isn't a
production concern — noted here only so the method is reproducible.
## Q1 — Does a virtualized `ListBox` actually bound the number of realized containers at 1000 items?
**Geht.** Realized `ListBoxItem` count stayed at 2223 across the whole scroll range (top, 25%,
50%, 75%, bottom) with 1000 source items in the list:
```
realized containers @ top: 22
realized containers @ 25% scroll: 22
realized containers @ 50% scroll: 22
realized containers @ 75% scroll: 23
realized containers @ 100% scroll: 22
```
Matches the design doc's estimate (~19 visible + overscan ≈ 2225) almost exactly. The
`ListBox`/`VirtualizingStackPanel` combination genuinely recycles containers instead of building
one per item.
## Q2 — Does the existing ghost-drag `InputHitTest` model survive container recycling?
**Geht.** Scrolled from top to 60% (forcing recycling), then called `TopLevel.InputHitTest` at the
*same screen point* both times — exactly what `RowButtonAt`/`ListItemUnder` in
`TasksIslandView.axaml.cs` do live during a drag:
```
hit-test @ top, screen point (450,600): Task[8]
hit-test @ 60% scroll, same screen point: Task[577]
expected DataContext at that point (geometry): Task[577]
```
The hit-test result matches the geometrically-correct row after recycling, and correctly changed
(it did *not* keep returning the stale top-of-list row). The production pattern —
`topLevel.InputHitTest(pt)` then walk ancestors to the row `Button`/`ListBoxItem` — is
recycling-safe: it's a live geometric query against whatever is currently realized, with no
per-row state to go stale.
## Q3 — Variable row heights (68px vs 90110px): does the scrollbar/`Extent` estimate drift?
**Geht, mit einer Einschränkung.** Measured actual template heights in this harness: header
57px, simple task row 53px, complex (two-line + badges) row 90px. Hand-summing all 1000 rows by
their real measured height gives 58275px; `VirtualizingStackPanel`'s reported `Extent.Height` was
58228px — **0.1% error**. Avalonia's own extent bookkeeping for variable-height virtualized rows
is not the "gross estimate that drifts" risk the design doc worried about.
The real risk is a **hand-rolled** pixel↔index conversion. Scrolling to `index * 68px` (a naive
"assume every row is 68px" offset) while targeting logical row #700 actually landed on row
**#790** — a 90-row drift, because ~14% of the preceding rows were taller. Anything that
computes a scroll offset from `index * fixedRowHeight` (a natural shortcut for a "scroll to
selected row" or a custom auto-scroll speed curve) will misfire. The existing
`ScrollSelectedIntoView` in `TasksIslandView.axaml.cs` already avoids this trap — it finds the
realized control by `DataContext` and calls `BringIntoView()`, not index-times-height math. Phase
2b must keep using `ListBox.ScrollIntoView(item)` / `Control.BringIntoView()`, never index
arithmetic, for any "jump to row" behavior.
## Q4 — Is auto-scroll-while-dragging feasible with the existing central-pointer-handler model?
**Geht.** Auto-scroll doesn't exist today and has to be built, but the mechanism it needs — the
same UI-thread pointer-moved handler (or a `DispatcherTimer` it starts) mutating
`ScrollViewer.Offset` directly while a drag is in progress — was exercised directly: nudging
`Offset` by 400px in one synchronous step kept the realized-container count constant (22 before,
22 after) and an immediate subsequent `InputHitTest` at the same screen point resolved correctly
to the new row under the cursor (`Task[388]`), with no async delay or stale state. Since
`TasksIslandView`'s drag handlers already run on the UI thread via tunnelled pointer events, a
`DispatcherTimer` (or a direct `Offset` nudge inside `OnPointerMovedDrag` when the cursor is near
the list's top/bottom edge) has no threading obstacle. This still needs to be built in Phase 2b;
this spike only confirms the approach isn't blocked by virtualization/recycling.
## Q5 — Mixed item types (header rows vs. task rows) via a template selector, under virtualization
**Geht.** Used `ListBox.DataTemplates` with two `FuncDataTemplate<T>` entries keyed by type
(`HeaderRow` / `TaskRow`) instead of an explicit `IDataTemplate.Match` selector — Avalonia's
implicit per-type template lookup is the idiomatic equivalent and needs no selector class. Both
types showed up among the realized containers throughout scrolling
(`Q5 distinct realized DataContext types: HeaderRow, TaskRow`), confirming the design's flat
`Rows` (`HeaderRow | TaskRowViewModel`) collection will virtualize and template correctly.
## Recommendation for Phase 2b
**Geht — proceed**, with two carry-overs into the implementation:
1. Any "scroll to row" logic (selection scroll, auto-scroll speed/targeting) must go through
`ScrollIntoView`/`BringIntoView` against the realized control, never `index * rowHeight` math
(Q3).
2. Auto-scroll is new work, not a retrofit risk — build it as an `Offset` nudge from the existing
UI-thread drag handlers (Q4).
No evidence surfaced against the Phase 2 design as written. The `ITaskListFilter` risk (#3 in the
design doc) is unrelated to virtualization and out of this spike's scope.
+280
View File
@@ -0,0 +1,280 @@
# Review, merge & conflict resolution
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against `fc9df7f` (2026-08-11), which added the conventional merge-commit default
> and the `MergeProgress` broadcast.
> Planning mode renders per file and `DiffLinesView` is retired.
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers the review→merge path: `TaskStateService` review transitions, `TaskMergeService`,
`PlanningMergeOrchestrator`, the post-merge verify gate, and the UI conflict resolver.
## Approve = merge the whole unit
`ApproveReview` (hub) and `review_task` approve (MCP) are the **single** review+merge action.
There is no separate "Merge all" entry.
- **Task with children** → drives `PlanningMergeOrchestrator`: merges the parent worktree if
`Active`, then each `Done` child in order, then sets the parent `Done`. A mid-merge conflict
pauses for `ContinuePlanningMerge` / `AbortPlanningMerge`.
- **Childless task** → `TaskMergeService.ApproveAndMergeAsync`. A conflict keeps the task in
`WaitingForReview`.
- **No active worktree** (sandbox run) → straight to `Done`.
Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`,
`SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`,
`ClearReviewFeedbackAsync`.
`ReviewFeedback` (nullable string on `TaskEntity`) is the reviewer's rejection comment: set by
`RejectToQueueAsync`, consumed and cleared by `QueueService` on the next re-run, where it
becomes the next-turn prompt of the resumed Claude session.
## Unified parent model
Every parent — planning **or** improvement — flows
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
`TaskStateService.TryAdvanceParentAsync`. It surfaces any `WaitingForChildren` parent for
review once all children are terminal; failed/cancelled children are **annotated on the
result, not wedged**.
- A planning parent enters `WaitingForChildren` at `FinalizePlanningAsync` (or
`WaitingForReview` directly if it has no children).
- An improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned children.
- Planning/improvement **children** go straight to `Done` — no individual review. Only the
parent is reviewed.
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not**
advance the parent — the parent stays in `WaitingForChildren` until every child is terminal.
The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children
need attention" band) so the roadblock is visible without forcing a transition.
A blocked planning/improvement child still goes straight to `Done` per the unified parent model
above — it committed nothing, but nothing prevents its (empty) branch from being unit-merged
like any other `Done` child once the parent is approved. The MCP surface has no UI equivalent of
`ChildOutcomes`, so `review_task`'s approve on a parent additionally returns `emptyChildren` (the
`Done` children whose review range is empty) and every task-returning tool exposes
`TaskRefDto.roadblockCount` → [external-mcp.md](external-mcp.md) → `ReviewTask`/`PreviewMerge`.
An empty branch is still mergeable by design (some tasks — e.g. an audit — legitimately produce
no diff); this is a visibility fix, not a merge gate.
## Cancel is blocked while a unit merge is draining
`ApproveReview` on a task with children awaits `PlanningMergeOrchestrator.StartAsync` /
`DrainAsync` synchronously — the parent sits in `WaitingForReview` for the whole (potentially
minutes-long, one-child-at-a-time) drain. Without a guard, a concurrent `CancelReview` (UI or
`update_task_status`/`cancel_task` via MCP) could flip the parent to `Cancelled` mid-drain while
the orchestrator kept merging children's worktrees onto the target branch; `FinalizeParentDoneAsync`
then finds the parent no longer `WaitingForReview` and gives up, leaving the merged children's
diffs stranded with no rollback.
`TaskStateService.CancelAsync` now rejects with a `TransitionResult` reason whenever
`PlanningMergeOrchestrator.HasActiveMerge(taskId)` is true for the task being cancelled, before any
DB write. Cycle note: `TaskStateService` can't take a direct constructor dependency on
`PlanningMergeOrchestrator` (which itself depends on `ITaskStateService`), so it takes a lazily-resolved
`Func<IActiveMergeState>` instead — same cycle-breaking shape as the existing `Func<ITaskStateService>`
handed to `PlanningChainCoordinator`. `IActiveMergeState` (`Planning/Interfaces/`) is `PlanningMergeOrchestrator`'s
only public surface `TaskStateService` needs.
The UI mirrors this as polish: `DetailsIslandViewModel.IsMergeDraining` (set on
`PlanningMergeStartedEvent`, cleared on `PlanningMergeAborted`/`PlanningCompleted` for the bound
task) gates `CancelReviewCommand`'s `CanExecute` — same shape as `WorktreesOverviewModalViewModel.IsMerging`
gating `CanMergeAll`. The worker-side guard remains the actual correctness fix; the button gate
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
## Merge commit message
`MergeAsync` takes a `commitMessage`, but **every caller passes it blank** and lets
`TaskMergeService.DefaultMergeMessage` build `CommitMessageBuilder.BuildMerge(task.CommitType,
list.Name, task.Title, task.Id)``fix(my-list): merge <title>` + `ClaudeDo-Task:` trailer. Same
`type(scope)` shape as the task's own worktree commits, so merges stay Conventional-Commits-clean.
The only non-blank caller is the user editing the field in the merge modal, which is prefilled from
`GetMergeTargets().DefaultCommitMessage` (the UI can't build it — it knows neither the commit type
nor the list name). Callers used to hand-roll `"Merge task: {title}"` / `"Merge {branch}"` /
`"Merge subtask"`; don't add a fourth.
## Post-merge verify gate
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
Null/blank (the default) = **no gate**, behavior bit-identical to before the feature existed.
When set, `TaskMergeService` runs it via `VerifyCommandRunner` (`cmd.exe /c <command>`,
10-minute fixed timeout, output tail-captured) in `list.WorkingDir` right after a successful
`MergeNoFfAsync` / `ContinueMergeAsync` **and** worktree cleanup, but **before** the task is
allowed to reach `Done`.
| Outcome | Effect |
|---|---|
| Exit 0 | Unchanged flow — worktree `Merged`, task `Done` if it was `WaitingForReview`. |
| Non-zero exit or timeout | The git merge is **deliberately left in place** (no auto-revert — that's a separate, unbuilt feature). The worktree is still marked `Merged` (it's already gone from disk when `removeWorktree` was requested), but the task stays out of `Done`. |
On failure `MergeResult.Status` comes back `TaskMergeService.StatusVerifyFailed`
(`"verify_failed"`) with an output excerpt in `ErrorMessage`. This flows through
`MergeResultDto` (hub) and `ReviewTaskResult` (`review_task`) unchanged, because both already
treat any non-`blocked`/`conflict` status generically. All three UI merge entry points handle it
explicitly (detail-pane Approve, merge modal, worktrees batch) — a generic fallback there showed
the raw status string instead of the failure.
**Worktree-less approvals are gated too.** A task with no active `WorktreeEntity` — a sandbox run,
or a list-handler task that commits straight into `list.WorkingDir` — skips the merge entirely,
but `ApproveAndMergeAsync` still runs the verify command (same per-repo gate, working dir =
`list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on
the target branch at once would be the one run nothing checks.
**The gate is what makes a merge look broken.** A verify command that builds and tests a solution
occupies the whole `MergeTask` hub call — measured at ~5m 46s on this repo. `TaskMergeService`
therefore broadcasts `MergeProgress(taskId, phase, elapsedSeconds)`: `PhaseMerging` before the
per-repo gate wait, `PhaseVerifying` when the verify starts and again on every
`ProgressReportInterval` tick (30 s, shared with the MCP progress reports), plus one
`WorkerLog` Info line so the footer strip shows it too. The merge modal renders that as a spinner
plus phase text; without it the only feedback was the disabled Submit button, which reads as a
dead app for minutes while the merge has in fact already landed.
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build.
### Verify in the preview, not just post-merge
`TaskMergeService.PreviewAsync(taskId, targetBranch, runVerify, ct)` can additionally build/test a
clean `git merge-tree` result *before* anything is merged. It never touches the real working tree:
the tree `PreviewMergeAsync` would write is wrapped in a throwaway commit
(`GitService.CommitTreeAsync`, parent = the target branch's current tip) and checked out into a
detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDetachedAsync`), which
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
not run; `-1` = timed out or failed to start; mirrors the post-merge gate's convention),
`VerifyDurationMs`, `VerifyOutputTail` (tail of output, only populated on non-zero exit, via the
same `TailOutput` helper the post-merge gate uses).
## `MergeCommit` and revert
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
produced on the target branch. Stamped by `TaskMergeService` the moment a merge/continue-merge
succeeds, written **only** by `WorktreeRepository.SetMergedAsync` (which atomically sets
`State=Merged` and stamps the SHA in one update).
It is the only thing that makes `revert_merge` possible without heuristically searching
`git log` — see [external-mcp.md](external-mcp.md) → `RevertMerge`. Null for any worktree
merged before the field existed.
## Review gate in the UI
**Approve & Merge is gated behind opening the diff.** When there is something to inspect
(worktree diff / merged range / children combined diff), the button stays disabled until the
diff or combined-diff viewer has been opened once. The gate **re-locks per run** — any state
change resets it. Tasks with nothing to inspect are never gated.
The row-level quick-approve in the task list is an **intentional bypass**.
Implementation: `MergeSectionViewModel` owns merge-target selection, the mergeability
indicator (`MergePreviewPresenter` over `PreviewMergeAsync`), and `OpenDiffAsync` /
`ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and
fire the `DiffViewed` callback. `HasReviewableDiff` reports whether anything is inspectable
and feeds the gate.
## Conflict resolver (in-app Rider-style 3-pane merge editor)
`ConflictResolverViewModel` + `Views/Conflicts/ConflictResolverView`. Handles **both**
single-task and planning unit-merge conflicts.
### Model
Single-task mode starts the conflict merge, then parses each conflicted file into stable and
conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`. Types live in
`ConflictModels`: `MergeFile` / `MergeFileSegment` / `MergeConflictBlock`.
Exposed per active file: `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText`
(reconstructed from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved
conflicts with Ours), plus `ActiveFile` / `SelectFileCommand` (multi-file switcher),
`Current` / `Next` / `Previous` (focused-conflict nav), a per-file `PositionText` readout,
per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on
**every file resolved + no binary**. Each file is written via `WriteConflictResolution`.
**Planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's
mid-merge conflicts **without re-starting the merge** and routes continue/abort to
`ContinuePlanningMerge` / `AbortPlanningMerge`, so a unit-merge conflict re-opens the editor
per subtask via the `PlanningMergeConflict` broadcast.
**Except when an MCP session is driving the merge.** `PlanningMergeOrchestrator.StartAsync` takes
an `externallyDriven` bool (default `false`; `ExternalMcpService.review_task`'s parent-with-children
path passes `true`, since a running Claude session — not the UI — will resolve conflicts via
`continue_merge`/`abort_merge`). The flag rides along on the `PlanningMergeConflict` broadcast
(4th arg); `IslandsShellViewModel.OnPlanningMergeConflict` only auto-opens the resolver when it's
`false` — otherwise it shows a persistent, non-auto-dismissing banner
(`IsExternalMergeBannerVisible`) with a manual "Open resolver" button, cleared on
`PlanningMergeAborted`/`PlanningCompleted`. This exists because two parties (a human and the
driving session) could otherwise end up editing the same shared checkout at once.
`PlanningMergeOrchestrator.GetActiveExternalConflictsAsync` (hub: `GetActiveExternalPlanningMergeConflicts`)
re-derives this state by checking `GitService.IsMidMergeAsync` rather than trusting the in-memory
flag alone, so a UI restart mid-merge (or a stale entry left behind if something resolved the
repo outside the normal Continue/Abort path) can't show a phantom banner — the Ui calls it on
`ConnectionRestoredEvent`. The childless single-task conflict path
(`TaskMergeService.ApproveAndMergeAsync`) has no equivalent broadcast — it only sends the generic
`TaskUpdated` — so it never auto-opened the resolver and needed no change.
### View
Three **AvaloniaEdit** panes showing the whole file: MAIN/ours (read-only) | editable Result |
INCOMING/theirs (read-only). TextMate highlighting by extension (theme `StyleInclude` in
`App.axaml`).
- A code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across
panes. Tints live in `Tokens.axaml` (`Merge*TintBrush`).
- An `IReadOnlySectionProvider` + `TextAnchor` regions keep **only conflict spans** editable in
Result; edits flow back to the block.
- Each unresolved conflict starts **EMPTY** (a thin marker bar).
- The between-pane gutter controls **toggle** each side in/out of the result: ``/`` add
MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a
conflict can take main, incoming, both, or **neither**.
- `FilesSummary` shows how many files still have conflicts. The three panes share a
proportional synced vertical scroll.
- A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the
file proportionally; click a tick to jump. Useful for long files.
### Entry points
Review **Approve** on conflict, and the **Merge** button in the Diff window (a conflicting
`MergeTask` hands off via `RequestConflictResolution`).
## Hub methods
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto`,
`ContinuePlanningMerge` / `AbortPlanningMerge`, `PreviewMerge(taskId, targetBranch) ->
MergePreviewDto`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`,
`GetMergeTargets`
- Single-task conflict resolver: `StartConflictMerge`, `GetMergeConflictDocuments`,
`WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` — note the
service-level `TaskMergeService.ContinueMergeAsync` / `AbortMergeAsync` keep their own names.
- Broadcast events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`,
`PlanningMergeAborted`, `PlanningCompleted`
## Diff stack (UI)
`UnifiedDiffParser` (static) parses `git diff` output into `DiffFileViewModel`s, detecting
added/deleted/renamed/binary files and per-line numbers. `DiffModels.cs` holds the shared types
(`DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
`DiffTreeNodeViewModel`, `DiffTree`).
`DiffViewerViewModel` is one unified read-only viewer with two modes:
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
file-tree left + per-file diff pane right, Merge button for a live branch source.
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + one
editor per file right (`PlanningFiles`), combined integration-branch toggle.
Both modes render through `DiffAlignment` (pure — pairs diff lines into side-by-side rows and
computes word-diff spans) and `DiffTextView` (AvaloniaEdit + TextMate highlighting keyed off the
file extension, unified/split layout, optional line wrap, synced scrolling). Files mode hosts one
`DiffTextView` for the selected file; Planning mode hosts one per file in `PlanningFiles` so each
gets its own grammar (one editor can only carry one TextMate grammar). The split/wrap toggles
persist to `ui.config.json` via `AppSettings` and reach the per-file editors in Planning mode
too.
+181
View File
@@ -0,0 +1,181 @@
# Usage monitoring, gate & throttle
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `f6cb825` (2026-08-05), plus the uncommitted per-bucket-throttle /
> draggable-gauge change of 2026-08-06 (this note already describes that newer state).
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/Usage/`, the queue's throttle/gate integration, per-run token
accounting, and the UI surfaces (usage pill + usage monitor modal).
## Data source
`GET https://api.anthropic.com/api/oauth/usage` — an **undocumented** Anthropic endpoint,
authenticated with the Bearer access token Claude Code itself keeps fresh at
`~/.claude/.credentials.json`. ClaudeDo reads that token, never refreshes it, never logs it.
Because the endpoint is undocumented and can change without notice, **every consumer
fails open**. That is the single most important invariant here.
## Components (`Usage/`)
| Type | Role |
|---|---|
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0100 percent — compare directly against thresholds, don't rescale. |
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
| `TranscriptUsageReader` | Aggregates token usage from Claude Code transcripts. |
Interfaces in `Usage/Interfaces/`: `IUsageClient`, `ITranscriptUsageReader`, `IUsageGate`.
## The gate (hard pause)
Thresholds: `AppSettings.UsageGateFiveHourPct` / `UsageGateSevenDayPct` (defaults 80/90).
Blocked once `five_hour >= UsageGateFiveHourPct` **or** `seven_day >= UsageGateSevenDayPct`
(`>=`, not `>`). Threshold `0` = that bucket never gates.
What it pauses: **only the queue's slot-fill loop** — new queued tasks don't start.
Unaffected: already-running runs, `RunNow`, `ContinueTask`, interactive ConPTY sessions,
planning sessions, daily prep (all bypass the queue).
**Fail-open**: no snapshot yet, a failed last poll, or an app-settings read error all
resolve to not-blocked.
There is **no persistent pause state**. Recovery is just the queue's 30 s backstop timer
(`queue_backstop_interval_ms`) re-evaluating the gate on its own once usage drops back
under the threshold. A blocked↔free transition is logged and broadcast (`WorkerLog`, Warn
on block / Info on resume) exactly **once per change**, not every tick.
## The throttle (staged parallelism)
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct,
sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the
per-bucket triple (same file).
Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` /
`usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill
at very different rates. Each bucket is staged independently and the **strictest** bucket wins —
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
that throttles:
| Utilization (per bucket) | That bucket's slots |
|---|---|
| below soft | full configured `max_parallel_executions` |
| `>= softPct` | capped at 2 |
| `>= hardPct` | capped at 1 |
| `>= gatePct` | 0 — same hard block as `UsageGate` |
A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never
throttles. The `0` return is deliberately kept in sync with `UsageGate`'s hard block because both
read the same gate thresholds — change one, change both.
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
runs to completion. Same fail-open policy: no snapshot means no throttling.
The effective stage (configured vs. effective slots + the decisive bucket, `ThrottleBucket`
= `"five_hour"` / `"seven_day"`) rides along on `UsageSnapshotDto` purely for UI display.
It does **not** change what the gate gates on.
## Queue integration (`Queue/QueueService`)
Per loop tick:
1. `GetEffectiveMaxParallelAsync` reads `AppSettings.MaxParallelExecutions` and steps it
down via `UsageThrottle.EffectiveSlots` against the current `UsageState` snapshot.
A missing/failed snapshot fails open to the configured value. A **stage change** (not
every tick) logs once.
2. Separately, `IUsageGate.EvaluateAsync` — if blocked, the slot-fill loop is skipped
entirely for that tick.
## Per-run token accounting
`task_runs` stores four raw token fields: `tokens_in` / `tokens_out` /
`cache_read_tokens` / `cache_write_tokens`.
These are **not** read from the stream-json `result` event's `usage.input_tokens` — that is
only the uncached remainder of a single API call and undercounts the real prompt size by
orders of magnitude once caching kicks in.
Instead `TaskRunner.ApplyUsageAsync` calls
`ITranscriptUsageReader.ReadSessionTotalsAsync(sessionId)` — the session transcript's
cumulative raw totals across every assistant message, located by `{sessionId}.jsonl` — and
stores the **delta** against prior `task_runs` rows sharing the same `session_id`, so a
`--resume`'d run doesn't double-count turns already billed to an earlier run.
A missing/unreadable transcript leaves all four fields `null`; it never fails the run.
### `TranscriptUsageReader` details
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
Other), deduped by `requestId`, with a per-file length+mtime cache.
`ReadAsync` **skips any file whose mtime predates the window start minus one day** — it cannot hold
a record inside the range, and the full history is large (measured 2026-08-06: 501 files / 230 MB /
77k lines ≈ 1.7 s to parse cold; a 7-day range touches ~190 files / ~106 MB). The one-day slack
absorbs local-vs-UTC skew between mtime and record timestamps. `ReadSessionTotalsAsync` is
unaffected — it looks up a single `{sessionId}.jsonl`.
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
## UI surfaces
- **`UsagePillViewModel`** — one shared instance backs the `UsagePill` control in both the
footer and the Mission Control header. Loads via `GetUsageSnapshotAsync`, updates live off
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before
loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because
the first `GetModelUsage` per worker process scans the whole transcript history.
- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate).
`UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work;
the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps
soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's
`CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the
dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only.
- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches
match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each
with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag`
(`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's
`CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same**
`UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited
stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the
`NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`.
Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag
doesn't replace the bound instance.
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
`null` on plans that don't have them; a fixed gauge layout would break. Also shows model
usage (`GetModelUsageAsync`, ClaudeDo-vs-Other split per model) and top-task usage
(`GetTaskUsageAsync`) over a 7d/30d preset or custom range.
## Hub surface
- `GetUsageSnapshot() -> UsageSnapshotDto` — percentages/limits/`FetchedAtUtc` are null and
`IsStale=true` when no snapshot has landed yet. `IsStale` also trips on a failed last poll
or a snapshot older than 3× `usage_poll_interval_seconds`.
- `GetModelUsage(from, to)` — thin wrapper over `ITranscriptUsageReader.ReadAsync`.
- `GetTaskUsage(from, to)` — top consumers from `task_runs` joined to task/list, grouped per
task. Null token columns count as **0**, never drop the row. `Model` comes from that task's
most recent run. Sorted by total tokens descending, capped at 100.
- `UsageUpdated` event carries the same `UsageSnapshotDto`.
## Settings columns
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per
bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` /
`usage_poll_interval_idle_seconds`.
The gate percentages are editable in **two** places that both write the same `app_settings` row:
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
can't reset a dragged value.
+177
View File
@@ -0,0 +1,177 @@
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `cc90600` (2026-08-06).
> Drift check: `git log --oneline cc90600..HEAD -- src/ClaudeDo.Worker`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
# Worker: Task Execution Pipeline
How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
(Queue, Runner, Lifecycle, State, Agents, Worktrees, Hub).
## End-to-End Flow (Queued → Terminal)
1. **Enqueue**`ITaskStateService.EnqueueAsync()` (State/TaskStateService.cs)
- Idle → Queued, then wakes the dispatcher via `IQueueWaker.Wake()`.
2. **Dispatch**`QueueService` loop (Queue/QueueService.cs)
- `BackgroundService`; waits for a wake signal or a backstop timer.
- Reads the max-parallel limit from settings; claims a free slot if under limit.
3. **Atomic Claim**`IQueuePicker.ClaimNextAsync()` (Queue/QueuePicker.cs)
- Raw SQL `UPDATE ... RETURNING` in one transaction: picks an eligible Queued task
(unblocked, due or unscheduled; sorted by sort_order/created_at), sets status→Running
+ started_at, returns the row. Prevents two workers claiming the same task (TOCTOU).
4. **Slot Execution**`QueueService.RunInSlotAsync()` (Queue/QueueService.cs)
- For review feedback: resume the prior session if one exists, else fold feedback into
the prompt. Calls `TaskRunner.RunAsync()` / `ContinueAsync()` with `alreadyClaimed=true`.
5. **Run Preparation**`TaskRunner.RunAsync()` (Runner/TaskRunner.cs)
- Loads task, list config, subtasks, attachments from the DB.
- `StartRunningAsync()` (only if not pre-claimed): atomic claim to Running, **before any
resource is created**. A rejected claim (task already Running) bails out immediately —
no worktree, no MCP token file. Broadcasts TaskStarted.
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
6. **Claude Execution**`TaskRunner.RunOnceAsync()` (Runner/TaskRunner.cs)
- Creates a TaskRunEntity, points the task at the run's log path.
- Builds claude CLI args (ClaudeArgsBuilder), spawns the process via `IClaudeProcess.RunAsync()`
with prompt + working dir + streaming callback.
- Stream lines → NDJSON log + broadcast via TaskMessage. MCP tools (AskUser, SuggestImprovement)
are scoped by the per-run token.
7. **Result Handling**`TaskRunner.HandleSuccess()` / `MarkFailed()` (Runner/TaskRunner.cs)
- Success (exit 0 + result markdown): if worktree, commit + broadcast WorktreeUpdated; then
transition to Done / WaitingForReview / WaitingForChildren (CompleteAsync / SubmitForReviewAsync
/ SubmitForChildrenAsync).
- Failure: if a session exists, auto-retry once via ContinueAsync; else MarkFailed → FailAsync.
- All terminal writes use `CancellationToken.None` so a task is never left Running.
8. **Terminal States**`ITaskStateService` transitions (State/TaskStateService.cs)
- **Done** CompleteAsync (Running → Done) — top-level success.
- **WaitingForReview** SubmitForReviewAsync (Running → WaitingForReview) — review gate.
- **WaitingForChildren** SubmitForChildrenAsync (Running → WaitingForChildren) — blocks on children.
Advances to WaitingForReview via `TryAdvanceParentAsync` once every remaining child is
terminal (Done/Failed/Cancelled) — including zero children left, e.g. after the last child
is deleted (`WorkerHub.DeleteTask` / `ExternalMcpService.DeleteTask` both call it).
- **Failed** FailAsync (Running/Queued → Failed).
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
## Model, effort & max-turns resolution
*(section added at commit `f6cb825`, 2026-08-05; resolver extraction added same day)*
The resolution below lives in `Runner/EffectiveRunConfigResolver.Resolve` (not inlined in
`TaskRunner` anymore) so `TaskRunner.ResolveConfigAsync` and the read-only
`get_effective_run_config` MCP tool (`External/ConfigMcpTools.cs`) share one codepath and can't
report different numbers for the same task. The tool additionally surfaces, per field, whether
it came from the task/list/preset/global layer, and — for max turns — the raw requested value
plus whether it was clamped.
Step 6 builds the CLI args. Model and turn budget resolve like this:
1. **Effective model** — task override → list config → `AppSettings.DefaultModel`.
2. **Preset row**`ModelPresets.For(global.ModelPresets, model, global.DefaultMaxTurns)`.
The model string is resolved through `ModelRegistry.TryNormalizeAlias` **first**, so a full
CLI model id (e.g. `claude-sonnet-4-6`, not just the bare `sonnet`/`opus`/`haiku`/`fable`
aliases) still hits its alias's preset row instead of missing every lookup. Only a model
that normalizes to nothing recognized falls back to a synthesized row using
`AppSettings.DefaultMaxTurns`**never a hardcoded number, and it never throws**: an
unknown model must not block a run.
3. The preset supplies `--effort` and the **global** max-turns default. Task/list `MaxTurns`
overrides still win over it.
4. **Ceiling clamp**`TaskRunner.ResolveMaxTurns` hard-clamps the resolved value to
`AppSettings.MaxTurnsCeiling` (default 80). An override above the ceiling still starts, just
capped, and a Warn logs the task id + requested + effective value.
⚠️ **Trap:** if `app_settings.model_presets` is somehow null, the fallback path decides the turn
budget — which is why `AppSettingsRepository.GetAsync` backfills shipping defaults on the first
read after null. Ship preset turns are low (haiku 20, sonnet 30, opus 40, fable 25), so a task
that genuinely needs a long run must set its own `MaxTurns`.
Prompt composition: `TaskPromptComposer.Compose` injects attachment **absolute paths** as a
read-only "## Reference files" section.
## Component Responsibilities
**Queue/**
- `QueueService` — main dispatch loop; slot limit; decides when to start tasks.
- `QueuePicker` — atomic Queued→Running claim via raw SQL.
- `QueueWaker` — semaphore for non-blocking, idempotent wake signals.
- `OverrideSlotService` — owns the RunNow / ContinueTask slot (bypasses the queue).
**Runner/**
- `TaskRunner` — orchestrates the run (prepare, execute, handle result).
- `WorktreeManager` — creates/manages git worktrees; self-heals stale branches.
- `ClaudeProcess` — spawns the claude CLI subprocess; manages streams/logs.
- `TaskRunMcpService` — runtime MCP tools (AskUser, SuggestImprovement).
- `TaskRunTokenRegistry` — per-run MCP identity for tool-access control.
- `InteractiveLaunchSpecService` — config for the task's claude run.
**State/**
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
**Lifecycle/** (startup recovery)
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed. The underlying
`TaskStateService.RecoverStaleRunningAsync` bulk-flips Running→Failed, then re-runs the same
chain/parent-advance side effects as a normal `FailAsync` (per recovered id, best-effort) so a
crash mid-chain-child or mid-improvement-child doesn't leave a successor blocked forever or a
`WaitingForChildren` parent wedged.
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
- `TaskResetService` — manual reset to Idle.
- `TaskMergeService` — conflict resolution for worktree merges.
**Hub/**
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/WorktreeUpdated…).
- `WorkerHub` — SignalR hub + client methods.
**Agents/**
- `AgentFileService` — file I/O for custom agents.
- `DefaultAgentSeeder` — seeds built-in agents on startup.
**Worktrees/**
- `WorktreeMaintenanceService` — cleanup, state tracking, overview reporting.
## Entry Points & Call Chain
```
Program.cs (DI setup)
├─ QueueService (BackgroundService) → ExecuteAsync loop
│ ├─ waits: IQueueWaker.WaitAsync() or timer
│ ├─ claims: IQueuePicker.ClaimNextAsync()
│ └─ runs: TaskRunner.RunAsync() / ContinueAsync()
├─ Hub clients → WorkerHub methods
│ ├─ Enqueue → ITaskStateService.EnqueueAsync() → Wake()
│ ├─ RunNow → OverrideSlotService.RunNow() → TaskRunner.RunAsync()
│ ├─ ContinueTask→ OverrideSlotService.ContinueTask()→ TaskRunner.ContinueAsync()
│ └─ CancelTask → QueueService.CancelTask()
├─ Lifecycle recovery (startup): StaleTaskRecovery / OrphanRecovery / AttachmentOrphanRecovery
└─ State transitions → HubBroadcaster.TaskUpdated()
```
## Invariants & Conventions
- **Atomic claiming** — QueuePicker's `UPDATE ... RETURNING` makes Queued→Running atomic.
- **Slot limit** — respects MaxParallelExecutions; a backstop timer wakes even if a Wake() is missed.
- **Pre-claimed tasks** — the dispatcher pre-claims via the picker; the override slot
(RunNow/ContinueTask) must call StartRunningAsync if a task is not pre-claimed.
- **Claim before create** — `TaskRunner.RunAsync`'s unclaimed path calls `StartRunningAsync`
*before* `PrepareRunDirectoryAsync`. RunNow racing the picker for the same Queued row used to
create the worktree first and only claim afterwards, so the losing dispatch could hit
WorktreeManager's branch-collision self-heal and force-remove the winner's live worktree
mid-run. `OverrideSlotService.RunNow` also fast-rejects a task already Running in the DB
(defense in depth; the picker's atomic SQL claim is the real arbiter either way).
`RunCancellationRegistry.Register` refuses (and logs) a second registration for the same task
id instead of silently overwriting the first, so a losing dispatch's cleanup can't unregister
the winner's CTS out from under it.
- **Terminal writes** — use `CancellationToken.None`; a task is never left Running after crash/cancel.
- **Per-run MCP tokens** — each run gets a unique token scoping tool access; unregistered on end.
- **Auto-retry** — one automatic retry if a session exists and the first run failed.
- **Worktree self-heal** — on branch collision, remove phantom worktrees, prune, delete branch, retry add.
- **Review feedback** — stored on the task; consumed once a run reaches a terminal state; a re-queued
task resumes the session or folds feedback into the prompt.
- **Child tasks** — planning creates draft children; finalization requires no Queued children remain;
OrphanRecovery dequeues children if the parent is not planning.
- **Lifecycle recovery** runs at startup: stale-Running → Failed; orphaned children → dequeued but attached.
+189
View File
@@ -0,0 +1,189 @@
# Fix-Plan — Verifikations-Findings (Stand 2026-07-24)
Einstiegspunkt für eine **frische Fix-Session**. Sammelt die in der manuellen Verifikation
(§7–§9 + Kanten §1/§3/§4) gefundenen Probleme, gruppiert nach **Fixbarkeit**. Volltext je
Finding (mit Kontext/Wiederholschritten) steht in `docs/open.md`; hier steht der Fix-Blick:
Root-Cause, konkreter Ansatz, Loc/Test-Hinweise, und **welche Punkte vor der Umsetzung eine
Entscheidung brauchen**.
**Immer zuerst:** file:line-Angaben gegen den aktuellen Code prüfen (können minimal driften).
Build/Test-Regeln + Gotchas s. Projekt-`CLAUDE.md` (u.a. `.slnx` braucht .NET 9 → einzelne
`.csproj -c Release`; Localization.Tests erzwingt en/de-Parität; Subagents `sonnet`, Dateien
pfad-scoped stagen). Pro Fix ein Conventional Commit.
---
## Bearbeitungsstand (Session 2026-07-24, nicht gepusht)
**Erledigt & committed:**
- Gruppe A #15 + beide Optional-Nits (Icon.Plus gefüllt, Gear-PathIcon, Skills-Empty-State,
„Subtasks"-Terminologie, Conflict-Continue-Hinweis, Rename-Darstellung, Turns/Tokens-Reload).
- Gruppe B #7 (Resume-Fehler surfacen) + #8 (Attachment-Drop-Diagnose).
- Gruppe C #9 (AskUser-Banner in Detail-Insel via geteiltem `TaskMonitorViewModel`),
#11 (OUTCOME zeigt `summary` statt rohem JSON: Worker-Unwrap + UI-Sicherheitsnetz).
- Gruppe D #13 (Kind-Rows live-refresh bei Parent-Planning-Transitionen) + #14 (Idle-Chip auf
Planning-Parents ausgeblendet). *Visual-Verification für #13 (Finalize/Discard live) offen.*
**#6** war im aktuellen Code bereits abgedeckt (Worker wirft `HubException` bei `blocked`
→ UI-Dialog); zusätzlich als ClaudeDo-Task `f9809a93` erfasst. Nicht angefasst.
**Offen:**
- **#10 + #12** — bewusst gebündelt mit dem ConPTY-Planning-Task `5d627df8` (dort lässt sich
die Session-Id sauber greifen bzw. das MCP-Permission-Verhalten klären). Sofort-Schutz für
#10 (Resume ausgrauen wenn keine Id) wurde NICHT gebaut — bräuchte Worker-Plumbing, das der
ConPTY-Umbau ohnehin liefert; der #7-Fix verhindert bereits das stille Scheitern.
- **Gruppe E** — nur noch design-/feature-behaftete Punkte. Der scheinbare Quick-Win
„Dequeue-X auf blockierten Kettengliedern" wurde bewusst NICHT umgesetzt: einzelnes Dequeue
eines Kettenglieds hinterlässt hängende Nachfolger (deren `BlockedByTaskId` zeigt weiter auf
das nun idle Glied) → braucht Chain-Repair-Design.
---
## Gruppe A — Mechanisch, sofort fixbar (keine Entscheidung nötig)
Ideale Kandidaten für den Start / parallele Subagents (disjunkte Dateien).
1. **„New session"-Button unsichtbar (Icon.Plus strich-only)**
`IslandStyles.axaml` (`Icon.Plus` = `M12 5v14M5 12h14`, reine Linie) wird in einem
`<PathIcon>` (MissionControlView.axaml, füllt Geometrie) unsichtbar gerendert.
**Fix:** `Icon.Plus` als gefüllte Geometrie authoren ODER als gestricheltes `Path`
rendern (vgl. `Path.plan-icon`). **Andere `Icon.Plus`-Verwendungen mitprüfen.**
2. **Agent-Settings-Gear weicht vom Listen-Gear ab**
`TaskHeaderBar.axaml:68` rendert `<TextBlock Text="⚙">`; überall sonst `Icon.Settings`
(PathIcon, IslandStyles.axaml:110). **Fix:** den `⚙`-TextBlock durch
`<PathIcon Data="{StaticResource Icon.Settings}" Width=".." Height=".."/>` ersetzen.
3. **Session-Skills-Tab ohne Empty-State**
Bei 0 Skills nur nackte Fläche. **Fix:** Empty-State-Text unter der Install-Zeile
(z.B. „No skills installed — paste a GitHub URL above"). **Loc:** neue Keys in en.json
**und** de.json (Parität!). Datei: `SessionSkillsSettingsTab*`.
4. **„Waiting for Improvements" für Planning-Parents (Terminologie)**
`en.json` `taskStatus.waitingForChildren`/`agentStatus.children`/`childOutcomesLabel`
sagen „Improvements". Seit unified-parent gilt `WaitingForChildren` auch für Planning.
**Fix:** auf neutrales „Waiting for Subtasks"/„Subtasks"/„SUBTASKS" umstellen — en **und**
de (Parität).
5. **Conflict-Resolver: Continue-Button klickbar trotz offener Konflikte**
Merge passiert korrekt erst nach Auflösung, aber der Button ist nicht disabled → früher
Klick = stummer No-op. **Fix:** `CanContinue`/`AllResolved` an `IsEnabled` binden (ggf.
Hinweis „N Konflikte in M Dateien offen"). Datei: `ConflictResolverView(.axaml)` +
`ConflictResolverViewModel`.
Optional-Nits (gleiche Gruppe, niedrige Prio):
- **Diff-Viewer Rename schwach dargestellt** (alt→neu-Pfad + „renamed"-Label statt „+0 0").
- **Header-TurnsText `0/max`** bei terminalem Reload — `Turns` aus `task_runs.turnCount`
restaurieren.
---
## Gruppe B — Error-Surfacing (klare Richtung: kein stiller/leerer Fehlerpfad)
Leitlinie `feedback_ui_error_surfacing`: User-Action-Fehler in den Footer
(`FlashFooterError`) bzw. Dialog, nie leerer `catch`/stiller No-op.
6. **Approve & Merge schluckt „blocked" still**
`DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `Status == "conflict"`; bei
`"blocked"` (z.B. dirty Ziel-Tree) passiert nichts. **Fix:** bei `blocked`/unerwartetem
Status `result.ErrorMessage` surfacen. *(Als ClaudeDo-Task `f9809a93` erfasst — koppelt
„Approve erzwingt Diff/Review vor Merge".)*
7. **„Resume planning session" verschluckt den Fehler (Teil-Fix hier, Rest → Gruppe C #10)**
`TasksIslandViewModel.ResumePlanningSessionAsync` (~Zeile 870) hüllt alles in `catch { }`.
**Sofort-Fix:** den Fehler surfacen statt schlucken. Der eigentliche Resume-Defekt braucht
eine Entscheidung → #10.
8. **Attachments: intermittenter erster-Drop-Fehler („An error occurred")**
Einmalig beobachtet (erster Drop der Session, nichts persistiert), nicht reproduzierbar.
**Fix (diagnostisch):** `AddFilesAsync`/`OnDrop` robustes Error-Logging geben (die genaue
Exception fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt) — damit der
nächste Fall auswertbar ist. Kandidaten-Ursachen: SQLite-Contention (UI schreibt `todo.db`
direkt, während der Worker sie hält) oder Drop-Stream-Pfad (`IStorageFile.OpenReadAsync`
im Code-Behind, außerhalb des `try`).
---
## Gruppe C — Erst Entscheidung/Brainstorm, DANN umsetzen (nicht blind fixen)
9. **AskUser-Interaktion auch in der Detail-Insel** *(von Mika ausdrücklich gewünscht)*
Der `ask_user`-Banner + Inline-Antwort existiert nur in Mission Control
(`MonitorPaneView`); die Detail-Insel zeigt für den laufenden Task nichts.
**Entscheidung:** wie den Zustand teilen — `TaskMonitorViewModel` hält ihn bereits; für
die Detail-Insel replizieren, teilen, oder ein gemeinsames Banner-Control? Danach:
Banner + `AnswerDraft`/`SubmitAnswer` in `DetailsIslandView(Model)` einhängen.
10. **„Resume planning session" grundsätzlich kaputt (Session-Id nie erfasst)**
`PlanningSessionManager.ResumeAsync:238` wirft immer „No Claude session ID captured yet",
weil `TaskRepository.UpdatePlanningSessionIdAsync:322` **keinen Aufrufer** hat →
`planning_session_id` bleibt NULL. **Entscheidung:** (a) claude-Session-Id der
wt-Planning-Session erfassen + via `UpdatePlanningSessionIdAsync` persistieren (echtes
Resume) ODER (b) Resume entfernen/deaktivieren, wenn keine Id vorliegt. Hängt mit der
Design-Entscheidung „Planning über embedded ConPTY statt wt" zusammen (ClaudeDo-Task
`5d627df8`) — dort ließe sich die Session-Id sauber greifen.
11. **OUTCOME-Karte rendert rohes Structured-Output-JSON**
`TaskMonitorViewModel.ApplyOutcome` setzt bei Tasks ohne Roadblock `SessionOutcome`
= `task.Result` wörtlich; der Worker legt dort rohes `{"summary":…}` ab.
**Entscheidung:** (a) UI parst JSON-Result und zeigt `summary`, oder (b) Worker schreibt
`summary`/`resultMarkdown` statt JSON in `task.Result`.
12. **Planning-Session prompted nach MCP-Tool-Permission**
Trotz `--allowedTools "mcp__claudedo__*,…"` + `--permission-mode plan` prompted die
wt-Planning-Session beim ersten `create_child_task`. **Untersuchen/Entscheiden:** matcht
der `mcp__claudedo__*`-Glob in CLI 2.1.207 nicht (Syntax evtl. ganzer Server-Name), oder
gated Plan-Mode MCP-Writes generell? Gekoppelt an ConPTY-Planning-Task `5d627df8`.
---
## Gruppe D — Erst Root-Cause pinnen (Investigation)
13. **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live**
Nach **Finalize** bleiben Kind-Badges „Draft" statt „Planned"; nach **Discard** bleiben
die (in der DB gelöschten) Kind-Rows sichtbar — bis Listen-Reload. Doppelt verifiziert §3.
**Untersuchen:** wie wird die Kinderliste/-gruppierung auf ein Parent-`TaskUpdated`
reagierend neu aufgelöst? Vermutlich fehlt ein Regroup/Refetch der Children beim
Parent-Broadcast (`TasksIslandViewModel` hierarchie-Regrouping). Fix danach: Children bei
Parent-Transition live neu auflösen.
14. **Planning-aktiver Parent zeigt weiter „Idle"**
Parent `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Chip
zeigt „Idle"; `PlanningBadge` überschreibt das nicht sichtbar. **Untersuchen/Design:** ein
klarer „Planning/Draft aktiv"-Zustand, der den Idle-Chip überschreibt. (Verwandt mit #13
Row-Statusdarstellung.)
---
## Gruppe E — UX-Nits / Feature-Wünsche (niedrige Prio, sammeln)
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar** — prominentere Datei-Liste
/ „x von y Dateien".
- **Blocked-by-Kette nicht visualisiert** — Reihenfolge/Abhängigkeit darstellen
(„wartet auf <Vorgänger>").
- **Dequeue-„X" fehlt auf blockierten Kettengliedern** — `CanRemoveFromQueue` erweitern
(`IsWaiting` einschließen).
- **„Open ConPTY session" erneut = Prompt wird neu gesendet** — Resume-Affordance / Re-Open-
Warnung (bewusst kein Session-Persist).
- **Conflict-Resolver: farbliches Hervorheben übernommener Zeilen im Result-Pane** (Feature).
---
## Nicht anfassen / Kontext
- **§1 DiffModal-Fehler-State** (`vm.diff.unavailable`) ist **defensiver, über die UI
unerreichbarer** Code — alle Aufrufer sind gegated (`CanDiffMergedRange` verlangt base+head
non-null; `ConfigureWorktree` nur mit existierendem Pfad). Kein Fix nötig.
- **`--permission-mode auto` + `haiku` denied Writes** — modellabhängiges Verhalten, keine
Regression; Default (sonnet) unbetroffen. Beobachten (Memory `auto_permission_haiku_footgun`).
- **§10 Daily Prep/Weekly** — Verifikation zurückgestellt bis zum geplanten Rework.
---
## Empfohlene Reihenfolge
1. **Gruppe A** (mechanisch, schnell, teils parallel) → sofort sichtbare Wins.
2. **Gruppe B** (Error-Surfacing, klein & risikoarm).
3. **Gruppe C** — pro Punkt kurz brainstormen/entscheiden, dann umsetzen (#10 + #12 zusammen
mit der ConPTY-Planning-Entscheidung betrachten).
4. **Gruppe D** — Investigation, dann Fix (#13 zuerst — betrifft mehrere Planning-Flows).
5. **Gruppe E** — nach Bedarf.
+226
View File
@@ -0,0 +1,226 @@
# Handoff — List-handler run on list "Claude do", 2026-08-05
> **✅ COMPLETED 2026-08-05 (follow-up session).** Everything in §1 and §2 is merged and `main`
> verified green. Read §0 below before §1–§8 — §5's diagnosis turned out to be **wrong** and the
> rest is now history. Still nothing pushed.
Repo: `C:\Private\ClaudeDo` · List id: `5f973815-050a-4136-94f0-1506a5d4560a` · Branch: `main` (nothing pushed)
---
## 0. What the follow-up session did (and what §5 got wrong)
**All merged, `main` green after every step** (Worker 811/811, Data 143/143, Ui 292/292,
Localization 16/16, builds 0 new warnings):
| Merged | Task | Merge commit |
|---|---|---|
| §1 #1 | `9e307199` revert_merge + merge-SHA persistence | `3e7126b` |
| §1 #2 | `0b2fbb48` post-merge verification gate | (conflict-resolved) |
| §1 #3 | `8c1c2130` roadblock reply box | `519ea5a` |
| §2 #42 | `c1df5b9a` Hub surface for usage | `6e2158d` |
| §2 #43 | `f74b44d9` Usage pill | `7661129` |
| §2 #45 | `06068810` gate thresholds in settings | `5115cfc` |
| §2 #44 | `82488d2a` Usage Monitor modal | `338fc39` |
| §2 #46 | `9c8cffe0` docs | `5872666` |
| parent | `439a4daf` Usage Monitor unit | approved → Done (empty unit merge; all children already `Merged`) |
Plus one hand-fix on `main`: `677a4c1``UsagePillViewModelTests` never set `Loc.Current`
(defaults to a key-echo localizer) and only passed because another test class happened to
install a real `Localizer` first; #44's new tests changed the ordering and broke it on `main`.
Classic "both branches green, `main` red".
### §5 is wrong — `"exited with code 1 and no result"` is NOT (only) a CLI crash
It is a **catch-all** hiding at least three causes. The truth is in the run log's last NDJSON
line (`{"type":"result", …}``terminal_reason` / `errors` / `result`):
- **`max_turns`** — what actually killed #40 and #42. `app_settings.model_presets` is `NULL`, so
`ModelPresets.Parse` falls back to the shipping defaults, and **sonnet's default is 30 turns**.
`AppSettings.DefaultMaxTurns` (100) is only the fallback for an *unrecognized* alias, so it
never applies. Every task without an explicit `maxTurns` override got 30 turns.
→ Fix used here: `set_task_config(taskId, model="sonnet", maxTurns=200)` before queueing.
- **`api_error`** + `"You've hit your session limit · resets 1pm (Europe/Berlin)"` — the account's
5-hour limit, which took out #43's first attempt. Nothing to fix; wait for the reset, re-queue.
- Genuine process death — the case §5 describes.
§5's *operational* advice still holds and is what saved #42: **never `reset_failed_task`** on one
of these; check the worktree, build/test it, then set the task `Queued` so the agent resumes its
own session and commits. #42's worktree held a complete green implementation (773/773).
Follow-up tasks: `ca6e55c0` (surface the real failure reason) is new; the turn-budget/presets side
is already covered by the Idle task `2de2f008` (`b0317ec7` fixed only the unknown-alias half).
### Still open
- **Visual passes** (nobody has looked at these in a running app): usage pill in the footer *and*
the Mission Control header; Usage Monitor modal (gauges, tables, stale/blocked bands, dark/light);
the roadblock reply box; the verify-command field in the List Settings modal. See `docs/open.md`.
- **`~/.todo-app/prompts/planning.md` still shadows the compiled default** (§7.1) — unchanged.
- **`wait_for_task_change` is merged but not in the running Worker**, so it isn't callable over MCP
until the Worker is restarted. §3's sqlite poll was used instead.
- One rough edge in the verify gate: if the verify command fails, the worktree has already been
removed and its state set `Merged` while the task stays `WaitingForReview` — re-approving is then
refused. Recovery is `update_task_status(..., "Done")` once `main` is fixed.
Predecessor session ran the five-phase list handler over 11 briefed tasks and, along the way,
absorbed the 9-child "Usage Monitor" unit. **Phases 03 are complete for the brief.** What is
left is Phase 4 (review + merge) for three tasks, plus the Usage chain.
---
## 1. Do this first — three brief tasks sit in WaitingForReview
Merge in **this order** (the order was chosen with the user and matters):
| # | Task | Id | Note |
|---|------|----|------|
| 1 | Feat: Merge zurücknehmen — Merge-Commit festhalten + `revert_merge` | `9e307199-2eca-4eb7-9057-d2c675cc57ca` | Migration + new tool. Merge **before** #2 |
| 2 | Feat: Verifikations-Gate nach dem Merge | `0b2fbb48-d44c-4155-8c21-d3464c0bd5c2` | Depends on #1's merge-SHA persistence; both edit `TaskMergeService.cs` |
| 3 | Feat: Antwortfeld auf der Roadblock-Karte | `8c1c213004574c4fad6beb75b84b70d7` | UI + localization (en **and** de) |
For each one:
1. `get_task_diff(taskId, stat=true)`, then the full diff if non-trivial. Sanity-check against
the task description (they are long and precise — the acceptance criteria are the checklist).
2. `review_task(taskId, decision="approve", leaveConflictsInTree=true)`.
3. On conflict: open the files under the returned `repoPath`, resolve keeping **both** sides'
intent, then `continue_merge(taskId)`. Conflicts are expected and normal here.
4. **After every merge, verify `main`** (see §4). This is non-negotiable — see §5.
Expected conflicts: `TaskMergeService.cs` between #1 and #2; `src/ClaudeDo.Worker/CLAUDE.md`
and `src/ClaudeDo.Data/CLAUDE.md` in nearly every merge (doc bullet lists — trivial, keep both
sides' entries).
## 2. Then the Usage Monitor unit
Parent `439a4daf166f4ab5b0fa415693d2c80d` ("Usage Monitor hinzufügen") is `WaitingForChildren`
and has **no worktree of its own**. It has 9 children. Four are merged, one is in flight, four
are Idle.
| Child | Id | State |
|---|---|---|
| #38 Data: Usage-Gate-Schwellen + Modell-Spalte | `c1c999b6-b800-4b6b-a821-fbc028c15772` | merged `b1efcdc` |
| #39 Worker: OAuth-Usage-Client + Poller | `f657e316-ad72-4f45-8036-460841fc8997` | merged `b126a21` |
| #40 Worker: UsageGate | `06a7cc32-6ab7-4758-98f4-bee77149b2bf` | merged `1ee21b5` |
| #41 Worker: TranscriptUsageReader | `840fdb98-1c0e-4219-8062-c8769233fc14` | merged `334cf1e` |
| #42 Worker: Hub-Surface für Usage | `c1df5b9a-b911-4fe8-aab4-5876d9d85793` | **re-queued, in flight — read §5 before touching** |
| #43 UI: Usage-Pill | `f74b44d9-7e48-4bfe-9d89-075e194d1fc9` | Idle — queue once #42 is merged |
| #45 UI: Gate-Schwellen im Settings-Modal | `06068810-5b5c-4635-80dd-62eeba89fb8c` | Idle — queue once #42 is merged (parallel with #43) |
| #44 UI: Usage-Monitor-Modal | `82488d2a-8ff7-41b8-b791-367959a8f827` | Idle — needs #42 **and** #43 merged |
| #46 Docs: Usage Monitor | `9c8cffe0-8f7b-401e-a4f0-33b937047082` | Idle — last, after everything is merged |
**The chain is strictly serial and you must respect it.** Every child forks from `main`, and each
one's own description hard-requires the earlier ones. Queueing them all at once is exactly what
produced the original roadblock: #40 ran, found its prerequisite types only on unmerged sibling
branches, and returned `Done` having written **zero** code. So: merge a child → then queue the
next → verify `main` → repeat.
When the last child is merged the parent surfaces for review by itself; approve it to close the
unit (it has no worktree, so it approves straight to Done).
## 3. Cheap status polling — important
`list_tasks` and `batch_get_tasks` return full descriptions and **blow the token limit** on this
list (`list_tasks` over 52 tasks = ~206,000 chars; that is literally one of the bugs this run
fixed). Do not poll with them. Poll the DB read-only instead:
```bash
PYTHONIOENCODING=utf-8 python - <<'EOF'
import sqlite3
c=sqlite3.connect("file:C:/Users/mika.kuns/.todo-app/todo.db?mode=ro",uri=True)
for i,s in c.execute("select id,status from tasks"):
print(s, i)
EOF
```
`list_worktrees` is also compact and safe. **New this run:** `wait_for_task_change(taskIds,
timeoutSeconds)` is now merged and is the proper primitive — it returns as soon as any listed
task leaves Queued/Running (server-clamped to 170 s). Prefer it over sleeping.
## 4. Verify main after every merge
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
```
For the UI/localization task (#3 above, and children #43#45) also:
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
`.slnx` needs .NET 9 — build individual csproj files, `-c Release` (a running Worker locks Debug).
Baseline as of this handoff: Worker **753/753**, Data **143/143**, build 0 warnings.
## 5. The trap that cost this run the most time
Two children "failed" with `"Claude exited with code 1 and no result"`. **That is a CLI crash,
not bad code.** In both cases the worktree held complete work that built with 0 warnings and
passed the full suite (#40: 732/732, #42: 766/766) — the run just died before the auto-commit.
- **Never `reset_failed_task` on such a task** — it discards the worktree and destroys the work.
- Instead: `cd` into the worktree, `git status`, build + test it. If green, set the task
`Queued`. The worktree is preserved and the agent resumes its own session (`--resume`),
finds its work and commits it. That is how #40 was recovered.
- #42 is mid-recovery right now via exactly this route. If it failed again, verify its worktree
(`C:\Private\.claudedo-worktrees\claude-do\c1df5b9a-b911-4fe8-aab4-5876d9d85793`) before
doing anything destructive.
Second trap: git merges cleanly and the **compiler** still breaks. It happened again this run —
`Usage/UsageModels.cs` was an add/add conflict, and `src/ClaudeDo.Worker/CLAUDE.md` merged
"cleanly" into a file with the `Usage/` folder documented **twice**. Always read what a clean
merge produced, and always run §4.
## 6. Phase 03 decisions already made (do not redo)
Dedupe: four candidate pairs examined, **nothing cancelled**. Decisions:
- `05827da5``81e37801` — kept both, and `05827da5` was **re-scoped**: its "lean status query"
half was removed because `81e37801`'s wait tool covers it. `05827da5` now owns only the
brief-description rendering. Both are merged.
- `a76d9547``99732497` — kept both, `99732497` merged first. Done.
- `0b2fbb48``9e307199` — kept both, `9e307199` merges first. **This is item #1/#2 in §1.**
- `20c78c95``a76d9547`(b) — kept both, different actors. Done.
Phase 2: all 11 tasks carry acceptance criteria, real file+line references and out-of-scope
sections. Three that were one-liners were researched and rewritten after asking the user
(ConPTY fix approach, maxTurns-only scope, roadblock reply-box design).
Run config: `maxParallelExecutions` = **3**. No list config exists, so effective max turns was
the global **100**; it was raised to **200** per-task on the five heaviest via `set_task_config`.
`0b2fbb48`, `9e307199` and `8c1c2130` still carry that override.
## 7. Open follow-ups worth new tasks
1. **`~/.todo-app/prompts/planning.md` shadows the planning prompt.** `PromptFiles.EnsureExists`
only writes a default when the file is absent, and that file exists (dated Jun 2). The
`maxTurns` guidance merged in `65db1cd` therefore **does not reach real planning sessions**
until that file is updated by hand. `system.md` and `agent.md` are shadowed too.
`merge-helper-system.md`/`merge-helper-initial.md` do **not** exist, so this run's handler
prompt changes are live.
2. **MCP task DTOs expose no parent/child link.** The 9-child Usage unit had to be reconstructed
from `sortOrder` and creation timestamps. `get_task`/`list_tasks` should return
`parentTaskId` / `blockedByTaskId`.
3. **Visual verification open** on: the ConPTY fix (open a tile on a task whose description
contains `->`), and — once merged — the roadblock reply box and the verify-gate field in the
list settings modal.
4. **`"exited with code 1 and no result"` is too common.** Three runs died that way today, two
with finished work. Worth investigating whether the auto-commit step can be made to survive
a late CLI crash.
5. Nothing has been **pushed**. `main` is 22 commits ahead of `8d7ba1e`.
## 8. Rules this session operated under
- Drive merges through the MCP tools. Never raw `git merge` / `reset` / `checkout`.
- Hand-resolve only markers the tools left behind, then `continue_merge`. When committing by
hand is unavoidable, stage **explicit paths** — never `git add -A`: the main checkout is
shared with other sessions.
- For a parent/children unit merge, pass the **parent** id to `continue_merge` / `abort_merge`.
- Ask the user on anything ambiguous, risky, or destructive.
- Never `delete_task` to dedupe — `Cancelled` keeps it visible and resettable.
+2
View File
@@ -1,5 +1,7 @@
# ClaudeDo — Improvement Plan (Session 2026-04-13)
> **Hinweis (2026-06-09):** Historischer Snapshot — bewusst nicht nachgepflegt. U.a. erledigt/überholt: IP-1 (Auto-Reconnect ist implementiert), `schema.sql` → EF-Core-Migrations, `StatusBarViewModel` existiert nicht mehr (Connection-State lebt in `IslandsShellViewModel`), Tags sind Junction-Tabellen statt JSON-Spalten. Offene Punkte stehen in `open.md`.
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
---
+5 -1
View File
@@ -1,6 +1,10 @@
# Task Mailbox — Push Messages Into Running Sessions
**Status:** proposal
**Status:** PARKED (2026-06-04) — not building this.
**Why parked:** The generic Claude-Mailbox plugin (the `mcp__mailbox__*` tools used in normal sessions) already covers the core need — cross-session messaging, inbox checks, a sender — at the harness level for any project. Integrating it directly into ClaudeDo (task/worktree-scoped inboxes, per-worktree CLAUDE.md + hook seeding, UI badges, `send_to_peer`) is a sizable build (migration + MCP tools + SignalR + UI + hooks) for marginal gain over the plugin. Revisit only if the generic plugin proves insufficient for the parallel-session workflow. The original proposal is kept below for reference.
---
**Context:** the user runs parallel Claude sessions (e.g. backend + frontend) and wants to push messages into a session while it's busy inside a subagent. A shared folder works for one-offs; this turns it into a first-class ClaudeDo feature so every future parallel-session project gets it for free.
## Problem
+173
View File
@@ -0,0 +1,173 @@
# ClaudeDo Online Inbox — API Contract & VPS build prompt
Status: handoff doc. The **server side** (API + minimal web client) is built and deployed
VPS-side by a separate Claude instance. This file is the source of truth for the contract
both ends implement against. The desktop client in this repo is built to match it.
---
## 1. Concept
ClaudeDo is a local desktop app that runs tasks autonomously via the Claude CLI; it is
normally fully local (SQLite). The **Online Inbox** is an optional service that lets the
single owner view their task lists and add new tasks from a phone/browser. The desktop app
syncs against it.
**Governing rule:** the online store mirrors EXACTLY the desktop's `Idle` backlog — nothing
else. A task is present online only while it is `Idle` on the desktop. The moment the user
queues it locally, the desktop removes it from the online store. Running / WaitingForReview /
Done / Failed / Cancelled tasks never appear online.
Sync directions (each one-way per entity → no conflict resolution needed):
- **Lists**: desktop → online only. Desktop is the source of truth (full-replace catalog).
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
desktop pulls down and then owns.
Single user today. Both the desktop and the web client authenticate as the **same Zitadel
user**.
**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
stamps its own `sub` (decoded from the access token) onto everything it pushes, and
defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
multiple users **without enforcing isolation client-side** — the server remains the
authority that scopes every request by the token's `sub`. When the server goes multi-user it
should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
`https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS`
env var is gone). The access token carries the role in the claim
`urn:zitadel:iam:org:project:roles` (or the project-scoped variant
`urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g.
`{ "user": { "<orgId>": "<orgDomain>" } }`. The desktop OIDC client
(id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued
after login/refresh includes the claim automatically — no extra scopes are needed.
Granting/revoking access is purely a Zitadel role grant, nothing app-side.
## 2. Idle backlog definition (desktop side)
The desktop mirrors only "real" backlog items, not planning internals:
- `Status == Idle`
- `ParentTaskId == null` (no planning/improvement children)
- `PlanningPhase == None`
- `BlockedByTaskId == null`
## 3. Data model (Postgres)
```
lists
id text primary key -- GUID supplied by the desktop; reuse verbatim
name text not null
updated_at timestamptz not null default now()
tasks
id text primary key -- GUID; SHARED id space (see below)
list_id text not null references lists(id) on delete cascade
title text not null
description text
imported boolean not null default false -- false = web-created, awaiting desktop pull
-- true = desktop-owned (mirrored or handed off)
created_at timestamptz not null default now()
updated_at timestamptz not null default now()
```
**Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports
under that SAME id, so it never duplicates. Desktop-mirrored tasks arrive with their own GUID.
All task writes are idempotent upserts keyed on id.
**`imported` flag = ownership.**
- Web `POST /tasks` inserts `imported=false`.
- Desktop pulls `imported=false`, creates the task locally (reusing the id), then `POST
/tasks/{id}/imported` flips it to `true`. From then on the task belongs to the desktop
mirror.
- `PUT /tasks/mirror` only ever inserts/updates/deletes within the `imported=true` partition.
It never touches `imported=false` rows (those are pending handoff).
## 4. Endpoints
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`) that
carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid
token without the role → `401`. No anonymous access (imported tasks can trigger code
execution on the user's machine). The desktop client treats a `401` as: force a
refresh-token exchange and retry once; if a freshly issued token is still rejected, it
surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again.
> **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
> with the Zitadel authority/audience/client id to wire `JwtBearer` validation + CORS for
> the web client origin. (`KunsZitadel` is server-side token *validation* only; the desktop
> client acquires tokens via its own OIDC flow.)
| Method & path | Caller | Body | Response |
|---|---|---|---|
| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
and ignores pulled tasks owned by a different user; the server should derive/validate it from
the token rather than trust the client value.
Semantics:
- **`PUT /lists`** — full replace: upsert all supplied, DELETE any list not in the payload
(cascades its tasks). Idempotent.
- **`POST /tasks`** — `listId` must exist (`400`/`404` otherwise). Server generates the id.
- **`PUT /tasks/mirror`** — full replace of the `imported=true` partition: upsert every task
in the payload (insert with `imported=true`, or update), and DELETE any `imported=true`
task whose id is not in the payload. `imported=false` rows are untouched. Idempotent.
- All task ids are client-trusted within the shared space; the server never rewrites an id.
## 5. Reconcile loop (desktop, runs each poll cycle)
```
1. PULL: GET /tasks?imported=false
for each: if no local task with that id → create local TaskEntity
{ Id = remote.id, ListId = remote.listId, Title, Description,
Status = Idle, CreatedBy = "online" }
(skip + log if remote.listId has no local list)
then POST /tasks/{id}/imported
2. PUSH LISTS: PUT /lists with the full local catalog [{id, name}]
3. PUSH TASKS: PUT /tasks/mirror with the current local Idle backlog set (§2)
```
Ordering matters: pull+import+flag first, so the just-imported tasks are part of the local
Idle set computed in step 3 and survive the mirror replace.
## 6. Minimal web client
Integrate into the existing Nuxt app at claudedo.kuns.dev if present; else a minimal page.
- Zitadel login.
- Show lists (`GET /lists`); select one to see its Idle tasks (`GET /lists/{id}/tasks`).
- Add-task form → `POST /tasks`.
- Mobile-first (main use: jotting ideas from a phone).
- **Create + read only.** No editing, reordering, status changes, or deletes.
## 7. Security
- Every route auth-gated (`401` on bad token); only static assets / login are public.
- Validate `listId` on task creation; parameterized queries only.
- CORS restricted to the web client origin.
- Don't log task titles/descriptions at info level (user content).
## 8. Deliverables from the VPS build
Report back so the desktop can be configured:
1. **API base URL.**
2. **Zitadel app/client config the desktop must use**: issuer/authority, client id, scopes,
and the OAuth flow to use for a desktop app (device-code or auth-code + PKCE), plus how
refresh tokens are issued.
3. Any env vars / README.
Out of scope server-side: task execution (the desktop runs Claude), any task state other
than the Idle mirror, multi-user / sharing / notifications.
+219 -258
View File
@@ -1,286 +1,247 @@
# ClaudeDo — Offene Punkte
Stand: 2026-04-30. Neu erstellt nach Code-Audit gegen `plan.md`, `improvement-plan.md` und `mailbox-proposal.md`.
Die alte Version dieses Dokuments war auf 2026-04-13 ("nach Slice F") datiert und ignorierte die seither gelandeten Slices (Planning Sessions, Prime Claude, Self-Update, Externe MCP-Tools, editierbare Status/Tags, BlockedBy-Chains). Diese Version trennt sauber zwischen **erledigt**, **teilweise**, **offen** — und listet das, was inzwischen gebaut wurde, explizit als „shipped" auf, damit es nicht verloren geht.
Legende: ✅ DONE — 🟡 PARTIAL — ⬜ OPEN — ⛔ DROPPED
Stand: 2026-08-06. Der Findings-Block von 2026-07-24 wurde am 2026-08-06 **gegen den Code
nachverifiziert** (statisch, nicht in der laufenden App); alles, was inzwischen gefixt ist, ist
hier entfernt — Erledigtes steht in den Commits/im Code, nicht hier. Die alte Verifikations-
Checkliste lebt in `docs/verification-handoff.md`.
---
## 0. Was seit dem 2026-04-13 dazugekommen ist
## Bugs (offen)
Diese Slices gab es im alten Dokument noch nicht (oder nur als Platzhalter). Sie sind **fertig im Code**, brauchen aber jeweils noch ein paar Polish-Punkte (siehe Sektion 2/3).
- **Verwaiste git-Worktrees sind für die App unsichtbar** (gefunden 2026-08-06). In `C:\Private\ClaudeDo` waren nach dem Cleanup 22 git-registrierte Worktrees + 26 `claudedo/*`-Branches vorhanden, die ClaudeDo-DB kannte davon nur zwei. Die Worktrees-Übersicht listet ausschließlich Zeilen aus `worktrees`, also kann der Nutzer diese Reste nicht über die App entfernen — und es gibt keinen Sweep dafür (`OrphanRecovery` räumt nur Task-Zeilen auf, keine Worktrees). Wunsch: entweder ein Startup-Abgleich `git worktree list` ↔ DB, der Unbekannte als „untracked" in die Übersicht aufnimmt, oder mindestens eine Warnung mit Anzahl.
| Slice | Worker-Anker | UI-Anker | Status |
|---|---|---|---|
| **Planning Sessions** (Plan B+C) | `Planning/PlanningSessionManager`, `PlanningChainCoordinator`, `PlanningMcpService` | `Views/Planning/PlanningDiffView`, `ConflictResolutionView`, `UnfinishedPlanningModalView` | ✅ Code, manuelle Verifikation siehe §1.1 |
| **Prime Claude** (geplante Recurrence) | `Prime/PrimeScheduler`, `NextDueCalculator`, `PrimeRunner` | `ViewModels/Modals/PrimeClaudeTabViewModel`, `Views/Controls/ThemedDatePicker` | ✅ Code, manuelle Verifikation siehe §1.2 |
| **Self-Update System** (Gitea Releases) | — | `ClaudeDo.Releases` (`ReleaseClient`, `SelfUpdater`, `ChecksumVerifier`, `VersionComparer`), `ClaudeDo.Installer` (Pages/Steps/Core) | ✅ Code, manuelle Verifikation siehe §1.3 |
| **Externes MCP-Endpoint** (11 Tools für Drittsessions) | `External/ExternalMcpService` (`ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`), `ExternalMcpAuthMiddleware` (X-ClaudeDo-Key) | — | ✅ Code, ohne Tests am Endpoint selbst |
| **Editierbare Status & Tags** (entkoppelt vom `agent`-Tag) | `WorkerHub.SetTaskStatus`, `SetTaskTags`, `UpdateTaskAgentSettings`; Queue-Picker filtert nicht mehr nach `agent`-Tag | `DetailsIslandViewModel`, Status-/Tag-Kontextmenü in `TasksIslandView` | ✅ Code |
| **BlockedBy-Chains** (sequenzielle Subtask-Ausführung) | `TaskStateService.BlockOn`/`UnblockAsync`, `QueuePicker` filter `BlockedByTaskId IS NULL`, `PlanningChainCoordinator.OnChildFinishedAsync` | Drittes Feld neben `Status` und `PlanningPhase` | ✅ Code, Migration `20260423154708_AddPlanningSupport` |
| **Worker-State-Konsolidierung** | `TaskStateService` ist alleiniger Owner von `Status`/`PlanningPhase`/`BlockedByTaskId`-Writes; `OverrideSlotService` ausgelagert; `QueueWaker` + `QueuePicker` getrennt | — | ✅ Code |
| **MarkdownView / Tabbed Settings / About-Modal / Prime-Status-Footer / Doppelklick-Edit** | — | `Views/MarkdownView`, `SettingsModalView` als `TabControl`, `AboutModalView`, transient Prime-Status in Footer, `DoubleTapped` an List/Task-Rows | ✅ Code |
## UX / Nits (offen)
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks` (TaskRowViewModel.cs:99), `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind.
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Im `sessionSkillsTab`-Locale-Namespace (en.json:674) gibt es keinen `empty`-Key.
- **Modal-Bodies sind unten abgeschnitten** (Sichtprüfung 2026-08-06, beobachtet in den Listen-Settings: der Hinweistext unter „Verify command" ist mitten in der Zeile vom Footer weggeschnitten und lässt sich nicht herunterscrollen; laut Mika „fast überall" in den Settings-Modals). Gemeinsames Muster: `<ScrollViewer Padding="20,16">` als Modal-Body — in `ListSettingsModalView:36`, `MergeModalView:33`, `WorktreesOverviewModalView:131`, `AboutModalView:20`, `MergeHelperSelectionModal:47`, `RepoImportModalView:45`, `LogVisualizerView:45` sowie `WorkConsole:295/395`. **Vermutete** Ursache (nicht verifiziert): das untere `Padding` des ScrollViewers zählt nicht zum scrollbaren Extent, die letzten Pixel sind also unerreichbar. Erst am echten Fall nachmessen, dann ggf. einheitlich das Padding vom ScrollViewer auf ein `Margin` des inneren Inhalts umziehen.
- **Usage-Monitor-Modal: Analyse-Tabellen ausbaufähig** (Sichtprüfung 2026-08-06; Gauges, Info-Bänder, Presets/Custom-Range und Refresh-Button sind in Ordnung). Models-Tab: die Spaltenköpfe „OTHER CACHE" und „SHARE" kollidieren zu `OTHER CACHISHARE`; `claude-haiku-4-5-20251001` läuft in die IN-Spalte; alle Zahlen linksbündig und ohne Tausendertrenner (`506830708`). Tasks-Tab: Task-Titel wird ohne Ellipse hart an der LIST-Spalte abgeschnitten, der LIST-Text ebenfalls; MODEL ist bei Runs von vor der `task_runs.model`-Spalte leer (besser „—"). Wunsch: Zahlen rechtsbündig + `#,##0` (oder k/M), Spaltenbreiten/Truncation fixen.
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien".
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal. Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen.
## Feature-Wünsche
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen).
- **Die Handler-Session soll „Submit for review" selbst auslösen können** (Sichtprüfung 2026-08-06). Aktuell ist der Abschluss eines List-Handler-Laufs ein reiner Handgriff über die Schaltfläche in der Mission-Control-Kachel — die Session selbst hat kein Werkzeug dafür, obwohl sie am besten weiß, wann sie fertig ist. Wunsch: ein MCP-Tool auf der In-Task-Oberfläche, das denselben Pfad wie der Button nimmt.
- **Handoff-Kachel besser beschriften** (Sichtprüfung 2026-08-06). Die zweite Kachel heißt `Merge Helper — <Liste> (Handoff)` bzw. „(Übergabe)" (`missionControl.mergeHelperHandoffTitleSuffix`, en.json/de.json:297). „Handoff" ist internes Vokabular und sagt nicht, was die Session tut. Besser etwas, das die Rolle nennt (Ausführen + Mergen der verbliebenen Tasks, Phasen 3-5).
## Design-Entscheidungen (27.07.-Batch, Sichtprüfung am 2026-08-06 abgeschlossen)
Der Sichtprüfungs-Block dieses Batches (Ctrl+K/`#`, Titel-Edit, ConPTY-/Refine-Spinner,
Interactive-Chip, Diff-Viewer-Abstände, Manual-Tasks, Vorgaben-pro-Modell-Tabelle) ist von Mika
verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran hängen:
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
- `AppSettings.DefaultMaxTurns` ist jetzt tatsächlich verdrahtet (`ModelPresets.For(..., global.DefaultMaxTurns)` in `TaskRunner.ResolveConfigAsync`): reiner Fallback für ein Modell, das auch nach `ModelRegistry.TryNormalizeAlias` auf keine Preset-Zeile trifft — vorher war das Feld tot (hartkodierte 30). Hat weiterhin keinen eigenen Editor mehr (nur die Preset-Tabelle pro Modell); Spalte könnte später entfallen, falls das nie zutrifft.
## Beobachtung (offen — Entscheidung Mika)
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
- ~~**`QueueServiceTests.UsageGate_TransitionLogging_FiresOncePerChange` ist zeitbasiert flaky**~~ — **am 2026-08-06 gefixt**: der feste `Task.Delay(200)` ist durch die schon im selben File vorhandene `AssertStableCountAsync` ersetzt (pollt bis der erste Backstop-Tick geloggt hat, wartet dann eine Karenzzeit und stellt sicher, dass kein weiterer Tick nachlegt). Historischer Befund zur Einordnung: Schlägt reproduzierbar fehl (`Expected 1, Actual 0` Warn-Log-Aufrufe), sowohl solo (`--filter`) als auch im Vollauf, auf einem sauberen `git worktree add` gegen `main` (bdee731) — also **kein** durch diese Abschluss-Session verursachter Regress (die Session hat keine `.cs`-Datei angefasst). Ursache: der Test verlässt sich auf einen festen `Task.Delay(200)`, um mehrere 50-ms-Backstop-Ticks abzuwarten (Kommentar im Test: „Several backstop ticks (50ms interval) all observe the same blocked state"); auf einer stark ausgelasteten Maschine (hier: viele parallele ClaudeDo-Worktrees/Builds) reicht das Fenster nicht immer. Zum Vergleich: derselbe Test lief in einer zweiten, isolierten Verifikation (Scratch-Merge für den Environment-Checks-Task) sauber durch (876/876). Fix wäre ein Poll-basiertes Warten statt fixem Sleep — aber außerhalb des Scopes dieser Doku/Verifikations-Session (keine Code-Änderung angefasst).
---
## 1. Verification (vor allem anderen)
## Offene Verifikation (2026-07-27)
Der Großteil der Verification-Steps aus `plan.md` ist im Code abgedeckt — was fehlt ist die **manuelle Bestätigung mit explizit notiertem Pass-Kriterium**. Ohne falsifizierbare Observable produziert ein Manual-Run nur "sah ok aus".
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber
gemerged; nur die Sichtprüfung ist noch offen.)
### 1.0 Plan-Verification 113
## Offene Verifikation (2026-08-05)
| # | Item | Status | Pass-Kriterium (was muss konkret zu sehen sein) |
|---|------|---|---|
| 1 | Schema-Init | ✅ | `~/.todo-app/todo.db` + `*-wal` + `*-shm` existieren; EF-Migrationsverlauf in `__EFMigrationsHistory` enthält alle 8 Migrationen; Worker-Log: „listening on …" |
| 1a | SignalR-Endpoint | ✅ | `curl http://127.0.0.1:47821/hub` → HTTP 400 (kein Handshake) |
| 1b | Hub-Roundtrip `Ping` | 🟡 | UI-Statusbar zeigt „Connected"; `WorkerClient.PingAsync()` liefert `"pong"` (UI-Test fehlt) |
| 2 | `claude --version` Preflight | ✅ | `Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Kaputter `claude_bin``LogCritical(...) + Environment.Exit(1)`. Skip via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs` |
| 3 | Smoke-Spawn (`claude -p` Prompt „ping") | ⬜ | `task_runs`-Row mit `session_id NOT NULL`, `result` non-empty, `output_tokens > 0` |
| 4 | E2E Happy Path (Non-Worktree) | ⬜ | Liste „Test" anlegen → Task „Schreibe ein Haiku über Intralogistik" → `tasks.status='Done'`, `tasks.result IS NOT NULL`, Logfile unter `~/.todo-app/logs/<taskId>.ndjson`, UI-Row mit Done-Badge |
| 5 | Worktree Happy Path | ⬜ | Liste mit `working_dir` auf temp-Repo, Task mit Codeänderung → `worktrees.state='active'`, `head_commit IS NOT NULL`, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk |
| 6 | No-Changes-Run | ⬜ | Prompt der nichts ändert → `tasks.status='Done'` aber `worktrees.head_commit IS NULL`, `diff_stat IS NULL` |
| 7 | Kein Git-Repo | ⬜ | `working_dir=C:\Temp` (kein Repo) → `tasks.status='Failed'`, **keine** `worktrees`-Row, Worker-Log enthält Git-Fehler |
| 8 | Merge-UI | 🟡 | `MergeTask`-Hub-Methode + `MergeModalView` vorhanden, manueller Run nicht durchgespielt → `worktrees.state='merged'`, im Ziel-Repo `git log` zeigt Commit, `git worktree list` ohne Branch |
| 9 | Override-Parallelität | 🟡 | `OverrideSlotService`-Tests grün; UI-E2E nicht durchgespielt → `WorkerHub.GetActive` ≥ 2 Einträge bei Run+RunNow |
| 10 | Schedule | 🟡 | `QueuePicker`-Tests grün; UI-E2E nicht → `scheduled_for=now+2min` bleibt Queued, dann automatisch Running, `started_at >= scheduled_for` |
| 11 | Worker-Offline-Erkennung | 🟡 | `WorkerClient.OnServerConnectionClosed` + Auto-Reconnect implementiert (`WithAutomaticReconnect`); visuell prüfen: nach `taskkill` der Worker-Exe wechselt Statusbar in ≤ 5s auf „Offline", `RunNow`-Buttons disabled |
| 12 | Live-Stream | 🟡 | `ClaudeProcess` streamt NDJSON via `TaskMessage`-Event, UI hat `LiveTail`; visuell prüfen: während Run laufen ndjson-Zeilen ein |
| 13 | Wake-up (`WakeQueue` nach Anlage) | 🟡 | `QueueWaker.Wake()` wird bei Enqueue aufgerufen; visuell prüfen: Task wechselt in ≤ 1s auf Running (statt nach `queue_backstop_interval_ms`=30s) |
- **List handler owns a task (2026-08-05)** — **visuell verifiziert am 2026-08-06** (Lauf über die
Liste `ClaudeDoTests` mit 4 Tasks, davon 2 absichtliche Dubletten): genau ein neuer Task, Mission-
Control-Tile task-basiert mit „Submit for review", Dedupe hat die Dublette erkannt,
Beschreibungen wurden angereichert, Submit → `WaitingForReview`, und die Diff-Karte zeigte alle
drei Dateien des Laufs. Korrektur zur Erwartung oben: der Task-Chip liest sich **„Interactive"**,
nicht `Idle` — das ist korrekt so (`HasInteractiveSession` überschreibt den Status-Chip, solange
die ConPTY-Session läuft). Die drei dabei gefundenen Punkte stehen unter „Bugs" bzw.
„Feature-Wünsche".
- **Roadblock reply field (2026-08-05)** — **Kernfunktion am 2026-08-06 verifiziert**: auf einem
Task mit gemeldetem Roadblock erscheint das Antwortfeld unter dem Roadblock-Text, die Antwort
setzt die Session fort, und der Task landet danach sauber auf `WaitingForReview`. Dabei ist der
Bug oben aufgefallen (Feld deaktiviert, solange der Task seit vor dem Lauf offen ist). Layout der
Roadblock-Karte ist in Ordnung. **Noch offen:** Enter-zum-Senden, und dass ein „Override-Slot
besetzt"-Fehler im Footer-Strip landet (nicht als Modal) mit dem getippten Text weiterhin im
Feld. Design-Entscheidung dazu: der Review-Bereich lebt als zwei nullable Spalten
direkt auf `TaskEntity` (kein Phantom-`WorktreeEntity`), damit `list_worktrees`/die
Worktrees-Übersicht ihn nie sehen.
**Empfohlener Sprint:** Steps 37 in einem Rutsch durchspielen (alles non-UI), parallel daneben 813 visuell beim normalen App-Lauf abhaken.
- **Post-merge verify gate (2026-08-05)** — build + unit tests all green (incl. real-process
`VerifyCommandRunner` exit-code/output/timeout tests and `TaskMergeService` success/failure/
timeout paths via a fake runner), but **not visually verified**: open a list's Settings modal,
confirm the new "VERIFICATION" section renders below Agent with a settable/clearable
`VerifyCommand` field; approve a task on a list with a failing command configured and confirm
the footer/error surfacing (`ShowErrorAsync`) actually shows the verify failure message instead
of silently looking like nothing happened. Also no real-build smoke test (a real `dotnet build`/
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
`ping` for timeout) were exercised.
### 1.1 Planning Sessions — Manual Verification (unverändert relevant)
## Offene Verifikation (2026-08-11, Task numbers in UI)
Bedingt durch Slice "Planning B/C". Ablauf identisch zur alten open.md:
Slice 4 der Task-Numbers-Features (UI-Display) ist gemerged (commit 38af549); Build + Tests grün,
aber **nicht visuell verifiziert**:
1. Manual-Task mit Title + TODO-Description anlegen.
2. Rechtsklick → **Open planning Session** → Windows Terminal mit Claude CLI öffnet.
3. In CLI: zwei Children via `mcp__claudedo__create_child_task` anlegen.
4. UI: Drafts erscheinen eingerückt, italic, mit `DRAFT`-Badge; Parent zeigt `PLANNING`-Badge.
5. Chevron klappt ein/aus.
6. CLI `finalize` → Children werden Queued (erste) bzw. Queued+BlockedBy (Rest); Parent flippt von `Active` auf `Finalized` (`PLANNED`-Badge); erste Child startet automatisch.
7. Neuer Planning-Task, Terminal ohne Finalize schließen → Rechtsklick öffnet Resume/Finalize-now/Discard-Modal.
8. Delete-Versuch auf Parent mit Children → freundlicher Fehlerdialog, kein Delete.
- **Task Row Display:** `TaskRowViewModel.Number` (Zeile 39, TaskRowViewModel.cs) zeigt die
Nummer als `#<number>` dimmed vor dem Titel in der Row an (`TaskRowView.axaml` Zeile 45+). Prüfen:
offene Task in der Übersicht hat sichtbar `#<number>` vor dem Titel.
- **Detail Pane Header:** `DetailsIslandViewModel.TaskIdBadge` (Zeile 77, DetailsIslandViewModel.cs)
zeigt jetzt `#<number>` statt des alten GUID-Präfix. Prüfen: Task-Detail-Chip zeigt
`#<number>`.
- **Worker Log Messages:** Geschäftsereignisse in `TaskRunner` (Zeile 199+), `TaskMergeService`
(Zeile 174+), und `TaskResetService` (Zeile 84+) prefixen Task-Titel mit `#<number>`. Prüfen:
Footer Worker-Log zeigt Task-Messages wie „`#42 finished`" statt nur dem GUID.
**Bekannte Follow-ups (non-blocking):**
-`Border.badge.planned` (blau) wird jetzt bei `Finalized` angewendet — `TaskRowView` nutzt `Classes.planning`/`Classes.planned` gebunden an `IsPlanActive`/`IsPlanFinalized`; der Child-„PLANNED"-Badge nutzt direkt `planned`.
- ✅ Tote `Instance`-Statics auf `BoolToItalicConverter` und `BoolToDraftOpacityConverter` entfernt (Registrierung läuft über das Resource-Dictionary in `App.axaml`).
-`Ui.Tests` IWorkerClient-Fakes auf gemeinsame Basis `StubWorkerClient` rebased — kein Constructor-Drift mehr; die drei Fakes überschreiben nur ihre relevanten Member.
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
### 1.2 Prime Claude — Manual Verification
Fünf Findings der Sichtprüfung sind gefixt, Build + Tests grün, aber **noch nicht in der App
nachgeprüft** (der laufende Build ist älter — erst nach Neuinstallation testbar):
Slice "Prime" (Recurrence-Scheduler).
- **NumericUpDown-Leeren wirft nicht mehr:** neuer `KeepLastNumberConverter` bildet beim
ConvertBack `null` auf `BindingOperations.DoNothing` ab, an allen acht nicht-nullbaren
NumericUpDowns im Settings-Modal verdrahtet. Prüfen: Wert löschen und neu tippen — keine
Exception, alter Wert bleibt stehen, bis eine echte Zahl kommt. Damit ist auch der offene Rest
von „MaxTurnsCeiling ändern → Hinweistexte ziehen mit" nachholbar.
- **Verify-Gate greift jetzt auch ohne Worktree:** Approve auf einem List-Handler-Task mit
fehlschlagendem `VerifyCommand` muss `verify_failed` liefern und den Task aus `Done` halten
(vorher ging er kommentarlos auf `Done`).
- **`verify_failed` im Merge-Modal und in der Worktrees-Übersicht:** Modal zeigt statt „Unknown
status" den echten Fehlertext und schließt sich **nicht** automatisch; die Batch-Übersicht
zeigt `VerifyFailed` und markiert die Zeile trotzdem als `Merged` (der Merge ist ja gelandet).
- **Roadblock-Reply/Continue live:** Task offen lassen, während er in den Roadblock läuft — das
Antwortfeld und Continue müssen **ohne** erneutes Öffnen aktiv werden.
- **Cleanup-Log:** ein Worktree-Cleanup über veraltete DB-Zeilen darf keine WARN-Flut mehr im
Footer erzeugen (die „schon erledigt"-Fälle gehen auf Debug).
- **Handoff schließt die alte Kachel:** beim Übergang von Phase 2 auf Phase 3 muss die alte
Merge-Helper-Kachel verschwinden und die neue an ihrer Stelle stehen — keine leere Geister-Pane
mehr. (Entscheidung Mika 2026-08-06: automatisch schließen statt Output erhalten.)
1. Settings → Prime-Claude-Tab → Schedule mit `at: 09:00`, `every: workday`, `task_template: "Daily Standup"` anlegen.
2. Test mit verschobenem `IPrimeClock` (oder Schedule mit `at: now+1min`) → bei Trigger erscheint Toast/Footer-Notification „Prime fired", neuer Task entsteht in der Ziel-Liste.
3. Worker-Restart innerhalb des Schedule-Fensters → Catch-up läuft genau einmal (kein Doppelfeuer).
4. Schedule editieren → `next_due_at` wird neu berechnet; UI-Anzeige aktualisiert.
5. Schedule löschen → keine weiteren Trigger, keine ghost-Tasks.
## Offene Verifikation (2026-08-06, Resume planning session)
### 1.3 Self-Update — Manual Verification (aus alter open.md, weiterhin gültig)
`planning_session_id` wurde nie befüllt (der Setter hatte keinen Aufrufer), weil die interaktive
Planning-Session ihre claude-Session-Id nie zurückmeldet — Resume konnte deshalb nie
funktionieren. `PlanningSessionManager.ResumeAsync` liest die Id jetzt beim ersten Resume aus dem
Transkript, das Claude Code unter `~/.claude/projects/<encodiertes cwd>/<sessionId>.jsonl` für den
Planning-Worktree ablegt (`PlanningTranscriptLocator`), und persistiert sie. Unit-Tests grün,
**nicht visuell verifiziert**:
Voraussetzung: funktionierendes Gitea-Release unter `git.kuns.dev/releases/ClaudeDo` mit drei Assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, `checksums.txt`.
- Planning-Session starten, Fenster/Pane schließen, Task erneut öffnen → „Resume" muss die
ConPTY-Pane mit der **fortgesetzten** Unterhaltung öffnen (nicht mit leerem Verlauf).
- Ohne Transkript (z. B. Session nie wirklich gestartet): Fehlermeldung „No Claude session
transcript found…" landet sichtbar im Footer-Error-Strip (`Terminal.StartError`
`ErrorReported`), nicht still.
- **Risiko:** das Verzeichnis-Encoding (`~/.claude/projects/`, jedes Nicht-Alphanumerische wird
`-`) ist undokumentiertes CLI-Verhalten — ändert es sich, findet der Locator nichts und Resume
meldet sauber „cannot resume" (fail-safe, kein falscher Resume).
1. Baseline-Version (z.B. `0.2.x`) normal installieren.
2. Neues Release `v0.3.0` mit frischem Installer + App-Zip + Checksums veröffentlichen.
3. App starten → Banner erscheint: `Update available: v0.2.x → v0.3.0`.
4. **Update now** klicken → App schließt, Installer öffnet im Update-Mode, läuft, restartet Worker.
5. App neu starten → Banner weg; `Help → Check for updates` zeigt kurz „You're up to date (v0.3.0)".
6. `v0.2.x`-Installer manuell starten → bietet Self-Update auf v0.3.0 an. **Update** → laufende Exe wird ersetzt, Wizard öffnet auf neuer Version.
7. Schritt 6 mit **Continue anyway** → Wizard öffnet ohne Self-Update.
8. Schritt 6 mit **Cancel** → Installer beendet ohne Aktion.
9. Network-Kill in App und Installer beim Start → silent fallback (kein Error, kein Banner).
## Offene Verifikation (2026-08-05, Usage Monitor)
> **Kein Light-Theme.** `App.axaml:6` setzt `RequestedThemeVariant="Dark"` fest, es gibt keinen
> Umschalter und `Tokens.axaml` kennt keine Light-Variante. „Dark/Light"-Checks sind deshalb
> überall aus dieser Datei entfernt — sie waren nie erfüllbar.
- **Visueller Pass Usage-Pill** (Footer **und** Mission-Control-Header) — **am 2026-08-06
verifiziert**: Text/Tooltip lesbar, Dot-Zustände plausibel, Pill in Footer und
Mission-Control-Header identisch.
- **Visueller Pass Usage-Monitor-Modal** — **am 2026-08-06 verifiziert**: Gauges (dynamisch aus
`limits[]`), Info-Bänder (der Throttle-Hinweis „Queue throttled: 1/3 slots (7d)" stand real an),
7d/30d-Presets + Custom-Range. Die Analyse-Tabellen sind ausbaufähig → siehe „UX / Nits".
- **E2E Gate**: das Gate greift real, sobald ein Bucket (`five_hour`/`seven_day`) die
konfigurierte Schwelle reißt — Queue-Nachschub pausiert, laufende Runs/`RunNow`/ConPTY/
Planning/Prime bleiben unberührt — und die Queue nimmt nach dem Reset selbstständig wieder
auf (30s-Backstop, kein persistenter Pause-Zustand).
- **Risiko:** der Usage-Endpoint (`GET https://api.anthropic.com/api/oauth/usage`) ist
undokumentiert und kann sich ändern; bei Ausfall/Formatänderung ist das Gate wirkungslos
(fail-open by design — kein Blocker, aber der Schutz fällt dann aus, ohne dass es auffällt).
### Nachtrag 2026-08-05: 429-Fix (Poll-Kadenz + Refresh-Button)
Der 60s-Poll lief in 429s. Neu: aktivitätsabhängige Kadenz (5 Min. solange ein Task `Running`
ist, sonst 15 Min.), 429-Backoff mit `Retry-After`, und ein „Jetzt aktualisieren"-Button im
Usage-Monitor-Modal (`RefreshUsage``UsageMonitorService.RefreshNowAsync`, 10s-Cooldown).
Unit-Tests grün, **offen**:
- **Visueller Pass Refresh-Button** im Modal — **am 2026-08-06 verifiziert** (Button, Hinweiszeile
„Polled every 5 min while a task runs, otherwise every 15 min.").
- **E2E:** über ≥20 Min. mit und ohne laufenden Task beobachten, dass keine 429s mehr im
Worker-Log auftauchen und die Pill trotzdem aktuell bleibt.
- **Beachten:** die Pill wird jetzt erst nach 3× 15 Min. als `stale` markiert — ein echter
Endpoint-Ausfall fällt vorher nur über `LastError` auf (der `IsStale` sofort setzt).
## Offene Verifikation (2026-08-05, Max-Turns-Ceiling)
Build + unit tests grün (`ResolveMaxTurns`-Klemmung, Repository-Backfill von `model_presets`,
Migration `AddMaxTurnsCeiling` gegen eine Scratch-DB angewendet), aber **nicht visuell
verifiziert**:
- Agent-Settings-Editor (Task **und** Liste): Max-Turns-Feld auf einen Wert über der Ceiling
(Default 80) setzen, Hinweistext unter dem `NumericUpDown` erscheint ("Runs are capped at
{N} turns…").
- Settings → Allgemein → Vorgaben pro Modell: eine Zeile über 80 setzen, derselbe Hinweistext
erscheint unter der Zeile.
- `MaxTurnsCeiling` ist seit `2975f90` selbst editierbar (Settings → Allgemein,
`SettingsModalView.axaml`) — Feld prüfen: Wert ändern, speichern, Hinweistexte oben ziehen mit.
## Offene Verifikation (2026-08-05, Environment Checks / SystemCheckPage)
Checks + SystemCheckPage (`claudedo/06aca9b3…`) und das ExecutableResolver-Wiring im Worker
(`claudedo/40272c0b…`) sind seit 2026-08-06 auf `main` gemerged; die frühere „erst mergen"-
Voraussetzung ist erledigt. Details → `installer-preflight` in `docs/explore-notes/README.md`
und der Abschnitt „Environment Checks" in `src/ClaudeDo.Installer/CLAUDE.md`.
**Update (2026-08-06):** beide Folge-Features sind jetzt implementiert und auf `main`.
- Diagnose-Sektion (Config-Modus/`SettingsWindow`): `Pages/DiagnosePage/` + geteilte
`Checks/CheckListViewModel.cs`/`Checks/CheckListView.xaml` (auch von `SystemCheckPage`
genutzt, keine zweite Implementierung). Unit-getestet
(`tests/ClaudeDo.Installer.Tests/Pages/DiagnosePage/DiagnosePageViewModelTests.cs`).
- „Claude Help Me"-Button: `Core/ClaudeHelpLauncher.cs` + `SystemCheckPageViewModel`/-View,
unit-getestet (`tests/ClaudeDo.Installer.Tests/Core/ClaudeHelpLauncherTests.cs`).
Beides **nicht visuell verifiziert**:
- [x] Help-Me-Button ist deaktiviert, wenn `claude-cli` nicht `Ok` ist oder `claude-auth`
`Failed` ist (bleibt aktiv bei `Unknown`), mit erklärendem Tooltip — unit-getestet.
- [ ] „Claude Help Me" öffnet tatsächlich ein Terminal mit laufender Claude-Session, und die
Session hat den Diagnose-Report gelesen — **nicht verifiziert** (der eigentliche
Terminal-Start/`wt.exe`-Zusammenspiel und die Session-Qualität sind nur über die
injizierte `IProcessLauncher`-Fake getestet, nie mit einem echten Terminal/CLI).
- [ ] Platzierung des Help-Me-Buttons: er sitzt seit dem Merge der Diagnose-Sektion in einer
**eigenen Zeile unter** dem geteilten Check-Listen-Footer (der reservierte Slot *im*
Footer entfiel mit der Extraktion nach `CheckListView.xaml`). Optisch prüfen, ob das
so bleiben soll oder ob der Button in den geteilten Footer gehört.
- [ ] Diagnose-Sektion im Config-Modus: Öffnen von SettingsWindow löst keinen Prüflauf aus, Klick
auf „Erneut prüfen" schon; zeigt die echten installierten Pfade/Ports (nicht die
InstallContext-Defaults), und der laufende Worker auf dem konfigurierten SignalR-Port gilt
nicht als Konflikt — **unit-verifiziert, visueller Durchlauf noch offen.**
Weitere Punkte, gebaut + unit-getestet auf `main`, aber **nicht visuell verifiziert**:
- [ ] SystemCheckPage: Layout, Icon-/Farbwirkung der vier Status (Ok grün / Warnung orange /
Fehler rot / Unbekannt grau — `StatusGreenBrush`/`StatusOrangeBrush`/`StatusRedBrush`/
`StatusGrayBrush`), Lesbarkeit der Hint-Texte, DE und EN.
- [ ] Weiter-Button gesperrt bei einem echten blockierenden Fehler (z. B. `claude` nicht im
PATH → `claude-cli` Error/Failed), und der Grund ist in der Zusammenfassungszeile
sichtbar (nennt den/die blockierenden Check(s) namentlich).
- [ ] „Erneut prüfen" wechselt einen Status live (z. B. git-Identity setzen → Warnung
verschwindet), ohne dass ein zweiter paralleler Lauf startet, wenn währenddessen erneut
geklickt wird.
- [ ] Update-Modus zeigt die SystemCheckPage **nicht** (Wizard bleibt Welcome + Install).
- [ ] Auf einem Rechner mit npm-installiertem `claude.cmd`: `claude-cli`-Check findet es
(Detail-Text „Resolved via a shim…"), und ein Task läuft im Worker durch (bestätigt, dass
`ClaudeProcess`/`ClaudeCliPreflight` den Shim über `cmd.exe /c` tatsächlich startet, nicht
nur, dass der Check ihn findet).
---
## 2. UI-Polish
## Bewusst verworfen (nicht erneut vorschlagen)
### 2.1 Folder-Picker für `Working Directory` ⬜
- **Datei:** `Views/ListSettingsModalView.axaml` + zugehöriges VM
- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
- **Soll:** Button „…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
- **Aufwand:** klein.
### 2.2 Delete-Confirmation ⬜
- **Aktuell:** Listen/Tasks-Delete läuft direkt ohne Rückfrage. Datenverlust-Risiko.
- **Soll:** generischer `ConfirmDialog` (1× bauen, mehrfach nutzen), Mini-Dialog „Wirklich löschen?".
- **Aufwand:** klein.
### 2.3 Markdown-Rendering Result + Description ✅
- `Views/MarkdownView.axaml` + Detail-Pane verwenden Markdown.Avalonia.
### 2.4 Live-Log Auto-Scroll ⬜
- **Datei:** `Views/DetailsIslandView.axaml(.cs)` (Live-Tail-Section)
- **Aktuell:** ndjson-Zeilen werden angehängt, Scrollposition bleibt stehen.
- **Soll:** Sticky-Bottom-Pattern — bei jeder neuen Zeile `ScrollToEnd()`, solange User nicht manuell hochgescrollt hat. Attached-Behavior reicht.
### 2.5 Diff-Viewer 🟡
- `DiffModalView.axaml` + `PlanningDiffView` existieren; integriert für Planning-Merges.
- **Offen:** Task-Level-Diff (Worktree vs. main) noch nicht im Modal-Flow geprüft. Verwenden statt `Process.Start("cmd /k git diff …")`.
### 2.6 Status-Bar Active-Tasks Live-Update ⬜
- **Datei:** `ViewModels/StatusBarViewModel`
- **Risiko:** `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei Connection-Change.
- **Soll:** `WeakReferenceMessenger`-Connection-Change-Message; alle `TaskRowViewModel` lauschen.
- **Aufwand:** klein, muss sauber gemacht werden.
### 2.7 Settings-Dialog ✅
- `SettingsModalView` als `TabControl`, Tabs: General, Prime Claude, etc. Persistiert in `~/.todo-app/ui.config.json` und `worker.config.json`.
### 2.8 (NEU) Planning-Phase Badge-Farbe für `Finalized` ✅
`Finalized` zeigt jetzt den blauen `planned`-Badge (Class-Binding in `TaskRowView`).
### 2.9 (NEU) Tote Converter-Statics entfernen ✅
`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance` entfernt.
---
## 3. Worker-Robustheit
### 3.1 CLI-Preflight beim Worker-Start ✅
- `src/ClaudeDo.Worker/Lifecycle/ClaudeCliPreflight.cs` + Wiring in `Program.cs`. Tests: `tests/.../Lifecycle/ClaudeCliPreflightTests.cs`. Skippable via `CLAUDEDO_SKIP_CLI_PREFLIGHT=1`.
### 3.2 Worktree-Cleanup beim Anlege-Failed ⬜
- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
- **Soll:** try/finally — bei Fehler zwischen `git worktree add` und DB-Insert `git worktree remove --force` als Best-Effort-Cleanup.
- **Aufwand:** klein.
### 3.3 Logging über file-Sink ⬜
- ILogger ist überall verdrahtet, aber kein File-Sink konfiguriert.
- **Soll:** Serilog oder `Karambolage.Extensions.Logging.File` — für Service-Modus zwingend, console-only ist im SCM-Fenster verloren.
- **Aufwand:** klein.
### 3.4 Tag-Negation / Exclusion ⬜
- Tags sind weiterhin rein additiv (`list_tags task_tags`). Nach Slice „Editierbare Tags" weniger dringend, aber nicht gelöst.
- **Soll:** entweder neue Tabelle `task_tag_exclusions` oder Prefix `!tag` im task_tags-Eintrag.
- **Aufwand:** mittel — Schema + Repo + Tests + UI.
---
## 4. Service-Deployment
### 4.1 Worker-Autostart via Startup-Shortcut ✅ (ersetzt Scheduled Task + Windows-Service)
- Der Worker läuft als `WinExe` (kein Konsolenfenster) + Serilog-File-Sink (`~/.todo-app/logs/worker-*.log`) + Single-Instance-Mutex.
- Autostart über eine **Startup-Ordner-Verknüpfung** `ClaudeDo Worker.lnk` (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`), die der Installer via `AutostartShortcut`/`ShortcutFactory` COM-Helper anlegt. Kein Scheduled Task, kein Windows-Service.
- `StartWorkerStep` startet den Worker per `Process.Start`; `StopWorkerStep` beendet ihn per prozessbasiertem Kill.
- Die App (`IslandsShellViewModel`) startet den Worker nicht selbst. Bei offline-Worker ~12s nach App-Start: einmaliges `WorkerConnectionModal` (Start Worker / Rerun Installer / Dismiss); Connection-Status-Pill in der Fußzeile ist ein Button zum erneuten Öffnen des Modals.
- `UninstallRunner` löscht die Startup-`.lnk`; migriert ältere Installs durch best-effort-Löschen des Legacy-Scheduled-Tasks „ClaudeDoWorker" und des Legacy-Windows-Service.
- **Manuelle E2E-Verifikation am Gerät ausstehend** (Logoff/Logon-Autostart, Update-Pfad, Uninstall).
### 4.2 Pfad-Auflösung absolut ✅
- `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
### 4.3 Install-Skripte / Doku ⬜
- **Datei (neu):** `docs/install-service.md` ODER `scripts/install-service.cmd`
- **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
- **Aufwand:** klein.
### 4.4 Installer-Projekt ✅
- `ClaudeDo.Installer` (WPF) + `ClaudeDo.Releases` mit Pages/Steps/Core/Theme — Self-Update funktioniert (siehe §1.3).
---
## 5. Tests / CI
### 5.1 CI-Pipeline (Gitea Actions) ⬜
- **Datei (neu):** `.gitea/workflows/ci.yml`
- **Inhalt:** `dotnet restore``dotnet build` (csproj-weise wegen `.slnx`-Bug auf .NET 8) → `dotnet test`. Auf Push + PR.
- **Achtung:** Pipeline darf NICHT die `.slnx` als Build-Target nehmen — explizite csproj-Liste in einem checked-in Build-Skript.
- **Aufwand:** klein.
### 5.2 SignalR-Hub-Tests ✅
- `tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs`, `AgentSettingsHubTests.cs` testen Hub-Methoden via Fakes (kein realer SignalR-Roundtrip, aber alle Code-Pfade abgedeckt).
- **Optional verbleibt:** echter Roundtrip-Test mit `WebApplicationFactory<Program>` + `HubConnectionBuilder` für End-to-End-Validierung der SignalR-Pipeline. Niedriger Mehrwert solange Fakes alle Methoden treffen.
### 5.3 Smoke-Test gegen echten `claude` ⬜
- **Datei (neu):** `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
- **Soll:** Real-CLI-Test mit `[Fact(Skip="...")]` ausgegraut, nur lokal aktiviert wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
- **Aufwand:** klein.
### 5.4 (NEU) ExternalMcpService-Tests ⬜
- `External/ExternalMcpService` hat 11 Tools, aber nur partielle Coverage in `tests/.../External/ExternalMcpServiceTests.cs`. Für jedes Tool mindestens einen Happy-Path + einen Error-Pfad ergänzen.
---
## 6. Dokumentation
### 6.1 README.md ⬜
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config, wie Self-Update.
### 6.2 docs/architecture.md 🟡
- In `plan.md` enthalten — entweder konsolidieren oder explizit ausgliedern. CLAUDE.md-Dateien pro Projekt sind aktuell de-facto-Architecture-Doc.
### 6.3 ADRs ⬜
- Vorschläge: „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „TaskStateService als alleiniger State-Owner", „BlockedByTaskId statt Status='Waiting'", „External MCP als zweite WebApplication".
- **Aufwand:** klein, hilfreich für später.
### 6.4 (NEU) Mailbox-Proposal ⬜
- `docs/mailbox-proposal.md` ist als Vorschlag vorhanden, nicht implementiert. Entscheidung: bauen, droppen oder parken? Wenn droppen → Datei entfernen, sonst klare Roadmap.
---
## 7. Bekannte Code-Schulden / Smells
| Stelle | Issue | Status |
|---|---|---|
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte expliziter `ActiveTaskDto` sein, den Worker UND UI teilen | ✅ (gibt bereits `IReadOnlyList<ActiveTaskDto>` zurück) |
| `TaskRunner` führt eine `if (list.WorkingDir != null)`-Verzweigung mitten in der Methode | Strategy-Pattern wenn die Methode wächst, aktuell noch klein genug | ⬜ |
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern, toleriert weil nur in `App.OnFrameworkInitializationCompleted` | ⬜ |
| Embedded `schema.sql` ohne Versionierung | Durch EF-Core-Migrationen ersetzt | ✅ |
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer | ✅ (`.gitattributes` angelegt) |
| Tote Converter-Instances (`BoolToItalicConverter.Instance`, `BoolToDraftOpacityConverter.Instance`) | Per Resource-Dictionary registriert, Statics ungenutzt | ✅ (entfernt) |
| 1 unausgeführter `// TODO` in `DetailsIslandViewModel` (`SendPromptAsync` ohne Hub-Methode) | Entweder Hub-Methode bauen oder TODO entfernen | ✅ (im Main-Code nicht mehr vorhanden) |
---
## 8. Improvement Plan (improvement-plan.md, Stand 2026-04-13)
| ID | Item | Status | Bemerkung |
|---|---|---|---|
| IP-1 | UI ↔ Worker Auto-Reconnect | ✅ | `WorkerClient` mit `WithAutomaticReconnect()` + Reconnect-Handler |
| IP-2 | Listen-Modus „Notes" (non-autonomous) | ⬜ | Nach Slice „editierbare Status/Tags" weniger dringend (man kann jetzt einen Task ohne `agent`-Tag idle lassen), aber `lists.kind` als sauberer Mode-Switch fehlt. |
| IP-3 | Doppelklick öffnet Edit-Dialog | ✅ | `DoubleTapped`-Handler in `ListsIslandView`, `TasksIslandView` |
| IP-4 | Tag Multi-Select Control | ⬜ | Tags sind via Picker im Detail-Pane editierbar, aber kein dediziertes Multi-Select-Control mit Auto-Vervollständigung in Editor-Dialogen. |
| IP-5 | Rechtsklick-Kontextmenü | ✅ | Listen + Tasks haben Context-Menüs (Edit, Delete, Run Now, Show Diff, Merge, Cancel, Status, Tags) |
| IP-6 | Schema-Migration-Mechanismus | ✅ | EF-Core-Migrations + `__EFMigrationsHistory` |
| IP-7 | Status-Bar Reconnect-States | ✅ | `connected`/`connecting`/`reconnecting`/`offline` farbcodiert |
| IP-8 | Tag-Repository `GetAllKnownTagsAsync` | ✅ | `TagRepository.GetAllAsync` + `WorkerClient.GetAllTagsAsync` |
---
## 9. Empfohlene Reihenfolge für die nächsten Sessions
**Block 1 — Verification durchspielen** (kein neuer Code, nur Beweis):
1. §1.0 Steps 37 manuell (Smoke + E2E + Worktree + No-Changes + Kein-Repo) — ist die Pipeline wirklich lebendig?
2. §1.1 Planning-Walkthrough — nach den uncommitted Coordinator-Änderungen einmal durchspielen.
3. §1.2 Prime-Walkthrough — Schedule-Trigger einmal beobachten.
**Block 2 — Niedrig hängende UI-Polish** (eine Session):
4. §2.1 Folder-Picker
5. §2.2 Delete-Confirmation
6. §2.4 Live-Log Auto-Scroll
7. §2.6 Status-Bar Live-Update
8. §2.8 Planning-Badge-Farbe + §2.9 tote Converter weg
**Block 3 — Robustheit & Service-Deployment**:
9. §3.2 Worktree-Cleanup
10. §3.3 File-Sink-Logging
11. §4.3 Install-Skripte/Doku
**Block 4 — Sicherheitsnetz**:
12. §5.1 Gitea-Actions CI-Pipeline (csproj-weise)
13. §5.3 Smoke-Test gegen echten claude
14. §5.4 ExternalMcpService-Tests vervollständigen
**Block 5 — Dokumentation & Aufräumen**:
15. §6.1 README
16. §6.3 ADRs (mind. die fünf wichtigsten)
17. §6.4 Mailbox-Proposal: bauen/droppen entscheiden
18. §7 Smells: `ActiveTaskDto`, `.gitattributes`, TODO-Comment
**Block 6 — Optional / wenn Bedarf konkret wird**:
19. §3.4 Tag-Negation
20. §IP-2 Notes-Modus
21. §IP-4 Tag Multi-Select Control
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check. Tests nutzen `FakeClaudeProcess`.
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku.
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (`mailbox-proposal.md`).
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt.
+2
View File
@@ -1,5 +1,7 @@
# ToDo-App mit autonomem Agent-Worker — Design
> **Hinweis (2026-06-09):** Historisches Design-Dokument vom Projektstart — bewusst nicht nachgepflegt. Überholt sind insbesondere: die Tag-basierte Queue (entfernt; der Picker nutzt `Status=Queued` + `BlockedByTaskId IS NULL`), `schema.sql` (Schema läuft über EF-Core-Migrations) und das Projektlayout (inzwischen sechs Testprojekte). Lebende Doku sind die `CLAUDE.md`-Dateien pro Projekt.
## Context
Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt.
+16
View File
@@ -7,6 +7,22 @@ Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface
Date: 2026-04-24
> **Update 2026-06-04 — prompts externalized.** All prose prompts now live as
> editable files under `~/.todo-app/prompts/`, each seeded from a bundled default in
> `src/ClaudeDo.Data/PromptFiles.cs` (read via `ReadOrDefault` / `Render`, which
> substitutes only named `{tokens}`):
> `system.md`, `planning-system.md`, `planning-initial.md` (`{title}`/`{description}`),
> `retry.md`, `daily-prep.md` (`{date}`/`{maxTasks}`), `weekly-report.md`
> (`{start}`/`{end}`; German output). The old `agent.md` and `planning.md` are
> retired — `system.md` is the single appended system prompt (the agent/manual split
> is gone), and the planning system prompt is `planning-system.md`. Daily-prep and
> retry prompts are now English; retry leans on the resumed session and appends the
> captured stderr only when it's a real error (not the generic "exited with code N").
> The system prompt instructs the agent to emit `CLAUDEDO_BLOCKED: <reason>` on its
> own line for any true blocker; `StreamAnalyzer` collects every marker, strips them
> from the result, and `TaskRunner` folds them into the review result as a
> "⚠ Roadblocks" section. All six prompt files are editable from Settings → Files.
---
## 1. Task-execution prompts (agent-tagged tasks → Claude CLI)
@@ -0,0 +1,175 @@
# Waiting for Review — Task State — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `WaitingForReview` lifecycle state that standalone tasks enter after a successful run, with approve / reject-rerun / reject-park / cancel exits, exposed via UI and MCP.
**Architecture:** New enum value + nullable `ReviewFeedback` column. `TaskStateService` gains review transitions. `TaskRunner.HandleSuccess` routes standalone-task success to review. `QueueService.RunInSlotAsync` resumes the Claude session when re-running a rejected task. New MCP `review_task` tool + UI commands.
**Tech Stack:** .NET 8, EF Core (SQLite, TEXT enum), SignalR, Avalonia MVVM, xUnit.
**Scope decision (locked):** Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview`. Planning **child** tasks continue to `Done` on success so the sequential planning chain (which advances on *terminal* states) is unaffected. Flagged for user confirmation.
---
## Task 1: Data layer — enum, converter, column
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
- Create: EF migration via CLI
- [ ] **Step 1:** Add `WaitingForReview` to `TaskStatus` enum (after `Running`) and add `public string? ReviewFeedback { get; set; }` to `TaskEntity`.
- [ ] **Step 2:** In `TaskEntityConfiguration`, add `TaskStatus.WaitingForReview => "waiting_for_review"` to `StatusToString` and `"waiting_for_review" => TaskStatus.WaitingForReview` to `StatusFromString`; map the column: `builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");`
- [ ] **Step 3:** Create migration: `dotnet ef migrations add AddReviewFeedback --project src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Verify it only adds the `review_feedback` TEXT column (nullable). If `dotnet ef` unavailable, hand-write the migration + designer following the latest migration in `Migrations/`.
- [ ] **Step 4:** Build `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`. Expected: success.
- [ ] **Step 5:** Commit.
## Task 2: Worker — review transitions in TaskStateService
**Files:**
- Modify: `src/ClaudeDo.Worker/State/TaskStateService.cs`
- Modify: `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs` (add new method signatures)
- Test: `tests/ClaudeDo.Worker.Tests/...` (state transition tests)
New methods (all return `TransitionResult`, broadcast `TaskUpdated`):
- `SubmitForReviewAsync(taskId, finishedAt, result, ct)` — guard `Status == Running`; set `Status=WaitingForReview, FinishedAt, Result`. Does NOT call `OnChildTerminalAsync` (review is non-terminal; only invoked for standalone tasks anyway).
- `ApproveReviewAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Done`.
- `RejectToQueueAsync(taskId, feedback, ct)` — reject empty/whitespace feedback (`TransitionResult(false, "Feedback is required to reject for re-run.")`); guard `Status == WaitingForReview`; set `Status=Queued, ReviewFeedback=feedback`; `_waker.Wake()`.
- `RejectToIdleAsync(taskId, ct)` — guard `Status == WaitingForReview`; set `Status=Idle, ReviewFeedback=null` (leave `Result` intact).
- `ClearReviewFeedbackAsync(taskId, ct)` — set `ReviewFeedback=null` (no status change, no guard); used by the runner after consuming feedback.
- Extend `CancelAsync` guard: `(Status == Running || Status == Queued || Status == WaitingForReview)`.
- [ ] **Step 1:** Write failing tests in a new `tests/ClaudeDo.Worker.Tests/State/ReviewTransitionTests.cs` (follow existing TaskStateService test setup). Cover: submit-for-review from Running; approve from WaitingForReview→Done; reject-to-queue stores feedback + status Queued; empty feedback rejected; reject-to-idle clears feedback + keeps Result; cancel from WaitingForReview→Cancelled; invalid (approve from Idle) returns `!Ok`.
- [ ] **Step 2:** Run tests, expect FAIL (methods missing).
- [ ] **Step 3:** Implement the methods + interface signatures + CancelAsync guard.
- [ ] **Step 4:** Run tests, expect PASS.
- [ ] **Step 5:** Commit.
## Task 3: Worker — route standalone success to review
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess`)
- [ ] **Step 1:** In `HandleSuccess`, after commit, branch:
```csharp
var finishedAt = DateTime.UtcNow;
if (task.ParentTaskId is null)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
```
- [ ] **Step 2:** Build worker. Expected: success.
- [ ] **Step 3:** Commit.
## Task 4: Worker — resume-aware re-run in QueueService
**Files:**
- Modify: `src/ClaudeDo.Worker/Queue/QueueService.cs` (`RunInSlotAsync`)
- Test: `tests/ClaudeDo.Worker.Tests/...`
- [ ] **Step 1:** In `RunInSlotAsync`, after loading `task`:
```csharp
if (!string.IsNullOrWhiteSpace(task.ReviewFeedback))
{
var feedback = task.ReviewFeedback!;
string? sessionId;
using (var ctx = _dbFactory.CreateDbContext())
sessionId = (await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct))?.SessionId;
await _state.ClearReviewFeedbackAsync(taskId, ct); // inject ITaskStateService
if (sessionId is not null)
{
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
return;
}
task.Description = string.IsNullOrWhiteSpace(task.Description)
? $"Reviewer feedback: {feedback}"
: $"{task.Description}\n\nReviewer feedback: {feedback}";
}
await _runner.RunAsync(task, "queue", ct);
```
Inject `ITaskStateService _state` into `QueueService` (add to ctor + DI already provides it).
- [ ] **Step 2:** Build worker, expect success.
- [ ] **Step 3:** Commit.
## Task 5: MCP — review_task tool + status reference
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- [ ] **Step 1:** Add `review_task` tool:
```csharp
[McpServerTool, Description(
"Review a task that is WaitingForReview. decision: 'approve' (→ Done), " +
"'reject_rerun' (→ Queued, resumes the agent session with feedback — feedback required), " +
"'reject_park' (→ Idle for manual editing), 'cancel' (→ Cancelled). ")]
public async Task<TaskDto> ReviewTask(string taskId, string decision, string? feedback, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
TransitionResult r = decision.ToLowerInvariant() switch
{
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException($"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
if (!r.Ok) throw new InvalidOperationException(r.Reason ?? "Review action failed.");
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
}
```
- [ ] **Step 2:** Add `WaitingForReview` to `GetTaskStatusValues` list; update the validation strings in `ListTasks` and the lifecycle text in `GetTask`/`UpdateTaskStatus` to include `WaitingForReview`.
- [ ] **Step 3:** Build worker, expect success.
- [ ] **Step 4:** Commit.
## Task 6: UI — client + hub methods
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- [ ] **Step 1:** Hub: add `ApproveReview(taskId)`, `RejectReviewToQueue(taskId, feedback)`, `RejectReviewToIdle(taskId)`, `CancelReview(taskId)` — each calls the matching `_state` method via `HubGuard`-style mapping (`if (!result.Ok) throw new HubException(...)`).
- [ ] **Step 2:** `IWorkerClient` + `WorkerClient`: add `ApproveReviewAsync`, `RejectReviewToQueueAsync(taskId, feedback)`, `RejectReviewToIdleAsync`, `CancelReviewAsync` invoking the hub methods. Add no-op/stub impls to `StubWorkerClient`.
- [ ] **Step 3:** Build App + Ui.Tests. Expected: success.
- [ ] **Step 4:** Commit.
## Task 7: UI — converter, row VM, view buttons
**Files:**
- Modify: `src/ClaudeDo.Ui/Converters/StatusColorConverter.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (commands)
- Modify: the task row/detail AXAML to surface Approve / Reject / Park / Cancel when `IsWaitingForReview`
- [ ] **Step 1:** `StatusColorConverter`: add `"waiting_for_review" => Brushes.MediumPurple,` (placeholder — user does visual pass).
- [ ] **Step 2:** `TaskRowViewModel`: add `public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;`, raise it in `OnStatusChanged`, and add `(TaskStatus.WaitingForReview, _) => "review"` to `StatusChipClass`.
- [ ] **Step 3:** `TasksIslandViewModel`: add relay commands `ApproveReview`, `RejectReviewRerun` (prompts for feedback), `RejectReviewPark`, `CancelReview` operating on the selected/target row, calling the new client methods.
- [ ] **Step 4:** Add buttons to the relevant view bound to those commands, visible when `IsWaitingForReview`. Reject-rerun uses a text-input flyout/dialog for required feedback.
- [ ] **Step 5:** Build App + Ui.Tests. Expected: success. (Visual layout: flagged for user's visual pass — cannot render here.)
- [ ] **Step 6:** Commit.
## Task 8: Docs + full verification
**Files:**
- Modify: root `CLAUDE.md`, `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Worker/CLAUDE.md`
- [ ] **Step 1:** Update status flow lines + worker transition table to include `WaitingForReview` and the new transitions.
- [ ] **Step 2:** Build all projects (csproj individually — `.slnx` needs .NET 9) and run `dotnet test tests/ClaudeDo.Worker.Tests`, `tests/ClaudeDo.Ui.Tests`, `tests/ClaudeDo.Data.Tests`. Expected: all green.
- [ ] **Step 3:** Commit.
## Self-Review notes
- Spec coverage: §1 state machine → Tasks 2,3; §2 data → Task 1; §3 transitions → Task 2; §4 resume → Task 4; §5 MCP → Task 5; §6 hub → Task 6; §7 UI → Tasks 6,7; §8 docs → Task 8; testing → Tasks 2,4,8.
- Method names consistent across tasks: `SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync` (state); `ApproveReview`/`RejectReviewToQueue`/`RejectReviewToIdle`/`CancelReview` (hub); `ApproveReviewAsync`/`RejectReviewToQueueAsync`/`RejectReviewToIdleAsync`/`CancelReviewAsync` (client).
@@ -0,0 +1,983 @@
# Prime Recurring Weekday Schedule — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Prime schedule's date-range model with a recurring weekday model — pick a set of weekdays plus a time, and the ping fires on the next eligible day the worker is running.
**Architecture:** A `[Flags] PrimeDays` weekday bitmask stored as a single `days_of_week` int column replaces `StartDate`/`EndDate`/`WorkdaysOnly`. `NextDueCalculator` walks forward to the next selected weekday; the existing 30-minute catch-up and already-fired-today logic are untouched. UI swaps the range picker + MonFri checkbox for seven toggle buttons. Both SignalR DTO copies carry a single `int Days`.
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), SignalR, xUnit.
**Spec:** `docs/superpowers/specs/2026-06-02-prime-recurring-weekdays-design.md`
**Build/test note:** `dotnet build ClaudeDo.slnx` needs .NET 9; on .NET 8 build individual csproj. Commands in this plan use the per-project form.
---
## File Structure
- `src/ClaudeDo.Data/Models/PrimeDays.cs`**new**, `[Flags]` enum.
- `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs` — swap fields.
- `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs` — column mapping.
- `src/ClaudeDo.Data/Migrations/*` — new migration + snapshot.
- `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs` — upsert fields + ordering.
- `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs``int Days`.
- `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs` — weekday eligibility.
- `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs``ToDto` mapping.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — list/upsert mapping.
- `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs``int Days`.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs` — 7 day bools.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` — defaults + validation.
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``day-toggle` style class.
- `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml` — row template.
- Tests: `NextDueCalculatorTests`, `PrimeSchedulerTests`, `PrimeScheduleRepositoryTests`, `PrimeClaudeTabViewModelTests`.
- Docs: `src/ClaudeDo.Data/CLAUDE.md`, root `CLAUDE.md`.
---
## Task 1: PrimeDays enum + entity + configuration
**Files:**
- Create: `src/ClaudeDo.Data/Models/PrimeDays.cs`
- Modify: `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`
- [ ] **Step 1: Create the flags enum**
`src/ClaudeDo.Data/Models/PrimeDays.cs`:
```csharp
namespace ClaudeDo.Data.Models;
[Flags]
public enum PrimeDays
{
None = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 4,
Thursday = 8,
Friday = 16,
Saturday = 32,
Sunday = 64,
Weekdays = Monday | Tuesday | Wednesday | Thursday | Friday, // 31
All = Weekdays | Saturday | Sunday, // 127
}
```
- [ ] **Step 2: Swap entity fields**
In `src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs`, remove `StartDate`, `EndDate`, `WorkdaysOnly` and add `Days`. Result:
```csharp
namespace ClaudeDo.Data.Models;
public sealed class PrimeScheduleEntity
{
public Guid Id { get; set; } = Guid.NewGuid();
public PrimeDays Days { get; set; } = PrimeDays.Weekdays;
public TimeSpan TimeOfDay { get; set; }
public bool Enabled { get; set; } = true;
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
```
- [ ] **Step 3: Update entity configuration**
In `src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs`, replace the `start_date`/`end_date`/`workdays_only` property lines with a `days_of_week` mapping (EF maps the enum to INTEGER automatically):
```csharp
builder.Property(s => s.Days).HasColumnName("days_of_week")
.IsRequired().HasDefaultValue(PrimeDays.Weekdays);
builder.Property(s => s.TimeOfDay).HasColumnName("time_of_day").IsRequired();
builder.Property(s => s.Enabled).HasColumnName("enabled").IsRequired().HasDefaultValue(true);
```
Leave `Id`, `LastRunAt`, `PromptOverride`, `CreatedAt` mappings unchanged. Add `using ClaudeDo.Data.Models;` if not present (it already is).
- [ ] **Step 4: Build the Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: FAILS — `PrimeScheduleRepository`, snapshot, etc. still reference removed fields. That is expected; Tasks 23 fix it. (If you prefer a clean build gate, proceed to Task 2 before building.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/PrimeDays.cs src/ClaudeDo.Data/Models/PrimeScheduleEntity.cs src/ClaudeDo.Data/Configuration/PrimeScheduleEntityConfiguration.cs
git commit -m "feat(data): model Prime schedule as weekday bitmask"
```
---
## Task 2: Repository
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs`
- [ ] **Step 1: Update `ListAsync` ordering**
The old ordering used `StartDate`. Order by `TimeOfDay`:
```csharp
public async Task<IReadOnlyList<PrimeScheduleEntity>> ListAsync(CancellationToken ct = default)
{
var rows = await _context.PrimeSchedules.AsNoTracking()
.OrderBy(s => s.TimeOfDay)
.ToListAsync(ct);
return rows;
}
```
- [ ] **Step 2: Update `UpsertAsync` field copy**
Replace the three removed-field assignments with `Days`:
```csharp
else
{
existing.Days = entity.Days;
existing.TimeOfDay = entity.TimeOfDay;
existing.Enabled = entity.Enabled;
existing.PromptOverride = entity.PromptOverride;
}
```
Leave `GetAsync`, `DeleteAsync`, `UpdateLastRunAsync` unchanged.
- [ ] **Step 3: Commit** (build verified after migration in Task 3)
```bash
git add src/ClaudeDo.Data/Repositories/PrimeScheduleRepository.cs
git commit -m "feat(data): persist weekday bitmask in prime schedule repo"
```
---
## Task 3: EF migration
**Files:**
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_PrimeWeekdays.cs` (generated)
- Modify: `src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs` (generated)
- [ ] **Step 1: Generate the migration**
Run from repo root:
```bash
dotnet ef migrations add PrimeWeekdays --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: a new `*_PrimeWeekdays.cs` file and an updated snapshot. (If `dotnet ef` is unavailable, hand-write the migration using the body below.)
- [ ] **Step 2: Replace the generated `Up` body with an explicit backfill**
EF's auto-generated drop/add would discard existing schedules' weekday intent. Edit the new migration's `Up` to add the column, backfill from `workdays_only`, then drop the old columns:
```csharp
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "days_of_week",
table: "prime_schedules",
type: "INTEGER",
nullable: false,
defaultValue: 31);
migrationBuilder.Sql(
"UPDATE prime_schedules SET days_of_week = CASE WHEN workdays_only = 1 THEN 31 ELSE 127 END;");
migrationBuilder.DropColumn(name: "start_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "end_date", table: "prime_schedules");
migrationBuilder.DropColumn(name: "workdays_only", table: "prime_schedules");
}
```
- [ ] **Step 3: Replace the generated `Down` body**
```csharp
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "start_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2000, 1, 1));
migrationBuilder.AddColumn<DateOnly>(
name: "end_date", table: "prime_schedules",
type: "TEXT", nullable: false, defaultValue: new DateOnly(2099, 12, 31));
migrationBuilder.AddColumn<bool>(
name: "workdays_only", table: "prime_schedules",
type: "INTEGER", nullable: false, defaultValue: true);
migrationBuilder.Sql(
"UPDATE prime_schedules SET workdays_only = CASE WHEN days_of_week = 127 THEN 0 ELSE 1 END;");
migrationBuilder.DropColumn(name: "days_of_week", table: "prime_schedules");
}
```
Add `using System;` at the top of the migration file if `DateOnly` defaults require it (the existing AddPrimeSchedules migration already imports `System`).
- [ ] **Step 4: Build the Data project**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Migrations
git commit -m "feat(data): migrate prime schedules to days_of_week bitmask"
```
---
## Task 4: Worker DTO + NextDueCalculator (TDD)
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`
- Modify: `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`
- [ ] **Step 1: Update the Worker DTO**
`src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs`:
```csharp
namespace ClaudeDo.Worker.Prime;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
```
- [ ] **Step 2: Rewrite the calculator tests**
Replace the entire body of `tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs`. Note: 2026-05-05 is a Tuesday; 2026-05-08 is a Friday; 2026-05-09/10 are Sat/Sun; 2026-05-11 is a Monday.
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
namespace ClaudeDo.Worker.Tests.Prime;
public class NextDueCalculatorTests
{
private static PrimeScheduleDto Schedule(
PrimeDays days, TimeSpan time,
bool enabled = true, DateTimeOffset? lastRun = null) =>
new(Guid.NewGuid(), (int)days, time, enabled, lastRun, null);
[Fact]
public void Disabled_Schedule_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0), enabled: false);
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void No_Days_Selected_Returns_Null()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.None, new(7, 0, 0));
Assert.Null(NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30)));
}
[Fact]
public void Future_Same_Day_Returns_Today_At_Target()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateTimeOffset(2026, 5, 5, 7, 0, 0, now.Offset), r!.At);
Assert.False(r.FireImmediately);
}
[Fact]
public void Within_CatchUp_Window_Fires_Immediately()
{
var now = new DateTimeOffset(2026, 5, 5, 7, 15, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.True(r!.FireImmediately);
}
[Fact]
public void Past_CatchUp_Window_Skips_To_Next_Eligible_Day()
{
var now = new DateTimeOffset(2026, 5, 5, 9, 0, 0, TimeSpan.FromHours(2)); // Tue
var s = Schedule(PrimeDays.All, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Weekdays_Only_Skips_Weekend()
{
var now = new DateTimeOffset(2026, 5, 8, 8, 0, 0, TimeSpan.FromHours(2)); // Fri, past catch-up
var s = Schedule(PrimeDays.Weekdays, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Monday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026, 5, 11), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Single_Day_Schedule_Targets_That_Weekday()
{
var now = new DateTimeOffset(2026, 5, 5, 8, 0, 0, TimeSpan.FromHours(2)); // Tue, past catch-up
var s = Schedule(PrimeDays.Friday, new(7, 0, 0));
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(DayOfWeek.Friday, r!.At.LocalDateTime.DayOfWeek);
Assert.Equal(new DateOnly(2026, 5, 8), DateOnly.FromDateTime(r.At.LocalDateTime));
}
[Fact]
public void Already_Fired_Today_Skips_To_Next_Eligible_Day()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var lastRun = new DateTimeOffset(2026, 5, 5, 7, 1, 0, TimeSpan.FromHours(2));
var s = Schedule(PrimeDays.All, new(7, 0, 0), lastRun: lastRun);
var r = NextDueCalculator.Compute(new[] { s }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(new DateOnly(2026, 5, 6), DateOnly.FromDateTime(r!.At.LocalDateTime));
}
[Fact]
public void Multiple_Schedules_Returns_Earliest()
{
var now = new DateTimeOffset(2026, 5, 5, 6, 0, 0, TimeSpan.FromHours(2));
var early = Schedule(PrimeDays.All, new(7, 0, 0));
var late = Schedule(PrimeDays.All, new(9, 0, 0));
var r = NextDueCalculator.Compute(new[] { late, early }, now, TimeSpan.FromMinutes(30));
Assert.NotNull(r);
Assert.Equal(early.Id, r!.Schedule.Id);
}
}
```
- [ ] **Step 3: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
Expected: FAIL — `PrimeScheduleDto` no longer has `StartDate`/`EndDate`/`workdaysOnly`, and the calculator still references them (compile errors).
- [ ] **Step 4: Rewrite the calculator**
Replace the entire body of `src/ClaudeDo.Worker/Prime/NextDueCalculator.cs`:
```csharp
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Prime;
public sealed record NextDue(PrimeScheduleDto Schedule, DateTimeOffset At, bool FireImmediately);
public static class NextDueCalculator
{
public static NextDue? Compute(
IEnumerable<PrimeScheduleDto> schedules,
DateTimeOffset now,
TimeSpan catchUp)
{
NextDue? best = null;
foreach (var s in schedules)
{
if (!s.Enabled) continue;
var due = ComputeFor(s, now, catchUp);
if (due is null) continue;
if (best is null || due.At < best.At) best = due;
}
return best;
}
private static NextDue? ComputeFor(PrimeScheduleDto s, DateTimeOffset now, TimeSpan catchUp)
{
if ((PrimeDays)s.Days == PrimeDays.None) return null;
var todayLocal = DateOnly.FromDateTime(now.LocalDateTime);
var alreadyFiredToday = s.LastRunAt is { } last &&
DateOnly.FromDateTime(last.LocalDateTime) == todayLocal;
if (!alreadyFiredToday && IsEligibleDay(s, todayLocal))
{
var todayTarget = ToOffset(todayLocal, s.TimeOfDay, now.Offset);
if (todayTarget >= now)
return new NextDue(s, todayTarget, false);
if (now <= todayTarget + catchUp)
return new NextDue(s, now, true);
}
var d = todayLocal.AddDays(1);
for (int i = 0; i < 7; i++)
{
if (IsEligibleDay(s, d))
return new NextDue(s, ToOffset(d, s.TimeOfDay, now.Offset), false);
d = d.AddDays(1);
}
return null;
}
private static bool IsEligibleDay(PrimeScheduleDto s, DateOnly d) =>
((PrimeDays)s.Days & ToFlag(d.DayOfWeek)) != PrimeDays.None;
private static PrimeDays ToFlag(DayOfWeek dow) => dow switch
{
DayOfWeek.Monday => PrimeDays.Monday,
DayOfWeek.Tuesday => PrimeDays.Tuesday,
DayOfWeek.Wednesday => PrimeDays.Wednesday,
DayOfWeek.Thursday => PrimeDays.Thursday,
DayOfWeek.Friday => PrimeDays.Friday,
DayOfWeek.Saturday => PrimeDays.Saturday,
DayOfWeek.Sunday => PrimeDays.Sunday,
_ => PrimeDays.None,
};
private static DateTimeOffset ToOffset(DateOnly day, TimeSpan time, TimeSpan offset) =>
new(day.Year, day.Month, day.Day, time.Hours, time.Minutes, time.Seconds, offset);
}
```
- [ ] **Step 5: Run the calculator tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter FullyQualifiedName~NextDueCalculatorTests`
Expected: still FAILS to build — `PrimeScheduler.ToDto` and `WorkerHub` mappings reference removed fields. Proceed to Tasks 56, then re-run.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/PrimeScheduleDto.cs src/ClaudeDo.Worker/Prime/NextDueCalculator.cs tests/ClaudeDo.Worker.Tests/Prime/NextDueCalculatorTests.cs
git commit -m "feat(worker): compute prime due-time from weekday bitmask"
```
---
## Task 5: PrimeScheduler.ToDto + scheduler tests
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/PrimeScheduler.cs:104-105`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
- [ ] **Step 1: Update the `ToDto` mapping**
Replace the `ToDto` method in `PrimeScheduler.cs`:
```csharp
private static PrimeScheduleDto ToDto(Data.Models.PrimeScheduleEntity e) =>
new(e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride);
```
- [ ] **Step 2: Update scheduler test fixtures**
In `tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`, every `new PrimeScheduleEntity { ... }` initializer sets `StartDate`/`EndDate`/`WorkdaysOnly`. Replace those three lines in each of the three initializers (lines ~48-52, ~89-94, ~131-136) with a single `Days` assignment. Each initializer becomes:
```csharp
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
Add `using ClaudeDo.Data.Models;` to the file's usings if not already present (it is, via line 1).
- [ ] **Step 3: Run scheduler + calculator tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Prime"`
Expected: still build-fails until `WorkerHub` (Task 6) compiles. After Task 6, this command must PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/PrimeScheduler.cs tests/ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs
git commit -m "test(worker): adapt prime scheduler tests to weekday model"
```
---
## Task 6: WorkerHub mapping + repository tests
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:488-518`
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`
- [ ] **Step 1: Update `ListPrimeSchedules`**
```csharp
public async Task<List<PrimeScheduleDto>> ListPrimeSchedules()
{
using var ctx = _dbFactory.CreateDbContext();
var rows = await new PrimeScheduleRepository(ctx).ListAsync();
return rows.Select(e => new PrimeScheduleDto(
e.Id, (int)e.Days, e.TimeOfDay, e.Enabled, e.LastRunAt, e.PromptOverride)).ToList();
}
```
- [ ] **Step 2: Update `UpsertPrimeSchedule`**
```csharp
public async Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
var repo = new PrimeScheduleRepository(ctx);
var existing = await repo.GetAsync(dto.Id);
var entity = new ClaudeDo.Data.Models.PrimeScheduleEntity
{
Id = dto.Id == Guid.Empty ? Guid.NewGuid() : dto.Id,
Days = (ClaudeDo.Data.Models.PrimeDays)dto.Days,
TimeOfDay = dto.TimeOfDay,
Enabled = dto.Enabled,
PromptOverride = dto.PromptOverride,
CreatedAt = existing?.CreatedAt ?? DateTimeOffset.UtcNow,
LastRunAt = existing?.LastRunAt,
};
await repo.UpsertAsync(entity);
_primeSignal.Signal();
return new PrimeScheduleDto(entity.Id, (int)entity.Days, entity.TimeOfDay,
entity.Enabled, entity.LastRunAt, entity.PromptOverride);
}
```
`DeletePrimeSchedule` is unchanged.
- [ ] **Step 3: Update repository tests**
In `tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs`, replace each entity initializer's `StartDate`/`EndDate`/`WorkdaysOnly` lines with `Days = PrimeDays.Weekdays,` (drop them where only `StartDate`/`EndDate` appear). The three initializers become:
```csharp
// Upsert_Then_List_RoundTrips
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
```csharp
// UpdateLastRunAt_Persists
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.Weekdays,
TimeOfDay = new TimeSpan(7, 0, 0),
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
```csharp
// Delete_Removes_Row
await new PrimeScheduleRepository(ctx).UpsertAsync(new PrimeScheduleEntity
{
Id = id,
Days = PrimeDays.All,
TimeOfDay = TimeSpan.Zero,
Enabled = true,
CreatedAt = DateTimeOffset.UtcNow,
});
```
Add an assertion in `Upsert_Then_List_RoundTrips` after the existing time assertion:
```csharp
Assert.Equal(PrimeDays.Weekdays, rows[0].Days);
```
- [ ] **Step 4: Build worker + run all worker tests**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS (all Prime + repository tests green).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Repositories/PrimeScheduleRepositoryTests.cs
git commit -m "feat(worker): map prime schedule weekday bitmask over the hub"
```
---
## Task 7: UI DTO + ViewModels + tests (TDD)
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
- [ ] **Step 1: Update the UI DTO**
`src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs` (keep `PrimeFiredEvent` unchanged):
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record PrimeScheduleDto(
Guid Id,
int Days,
TimeSpan TimeOfDay,
bool Enabled,
DateTimeOffset? LastRunAt,
string? PromptOverride);
public sealed record PrimeFiredEvent(
Guid ScheduleId,
bool Success,
string Message,
DateTimeOffset FiredAt);
```
- [ ] **Step 2: Rewrite the row VM**
Replace the body of `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs`:
```csharp
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
{
private const int Mon = 1, Tue = 2, Wed = 4, Thu = 8, Fri = 16, Sat = 32, Sun = 64;
public Guid Id { get; }
public bool IsExisting { get; }
[ObservableProperty] private bool _enabled;
[ObservableProperty] private bool _monday;
[ObservableProperty] private bool _tuesday;
[ObservableProperty] private bool _wednesday;
[ObservableProperty] private bool _thursday;
[ObservableProperty] private bool _friday;
[ObservableProperty] private bool _saturday;
[ObservableProperty] private bool _sunday;
[ObservableProperty] private TimeSpan _timeOfDay;
[ObservableProperty] private DateTimeOffset? _lastRunAt;
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
partial void OnLastRunAtChanged(DateTimeOffset? value) => OnPropertyChanged(nameof(LastRunLabel));
public PrimeScheduleRowViewModel(PrimeScheduleDto dto, bool isExisting)
{
Id = dto.Id;
IsExisting = isExisting;
Enabled = dto.Enabled;
Monday = (dto.Days & Mon) != 0;
Tuesday = (dto.Days & Tue) != 0;
Wednesday = (dto.Days & Wed) != 0;
Thursday = (dto.Days & Thu) != 0;
Friday = (dto.Days & Fri) != 0;
Saturday = (dto.Days & Sat) != 0;
Sunday = (dto.Days & Sun) != 0;
TimeOfDay = dto.TimeOfDay;
LastRunAt = dto.LastRunAt;
}
public int DaysMask()
{
int m = 0;
if (Monday) m |= Mon;
if (Tuesday) m |= Tue;
if (Wednesday) m |= Wed;
if (Thursday) m |= Thu;
if (Friday) m |= Fri;
if (Saturday) m |= Sat;
if (Sunday) m |= Sun;
return m;
}
public PrimeScheduleDto ToDto() =>
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
}
```
- [ ] **Step 3: Update the tab VM**
In `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`, replace `Validate` and `AddSchedule`:
```csharp
public string? Validate()
{
foreach (var r in Rows)
{
if (r.DaysMask() == 0)
return $"Schedule {r.TimeOfDay:hh\\:mm}: select at least one day.";
if (r.TimeOfDay < TimeSpan.Zero || r.TimeOfDay >= TimeSpan.FromDays(1))
return "Time must be between 00:00 and 23:59.";
}
return null;
}
```
```csharp
[RelayCommand]
private void AddSchedule()
{
var dto = new PrimeScheduleDto(
Id: Guid.NewGuid(),
Days: 31, // MonFri
TimeOfDay: new TimeSpan(7, 0, 0),
Enabled: true,
LastRunAt: null,
PromptOverride: null);
Rows.Add(new PrimeScheduleRowViewModel(dto, isExisting: false));
}
```
`LoadAsync`, `SaveAsync`, `RemoveSchedule`, `ApplyFiredEvent` are unchanged.
- [ ] **Step 4: Rewrite the tab VM tests**
Replace the body of `tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class PrimeClaudeTabViewModelTests
{
private sealed class FakeApi : IPrimeScheduleApi
{
public List<PrimeScheduleDto> Stored { get; } = new();
public List<PrimeScheduleDto> Upserts { get; } = new();
public List<Guid> Deletes { get; } = new();
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(Stored.ToList());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto)
{
Upserts.Add(dto);
return Task.FromResult<PrimeScheduleDto?>(dto);
}
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
}
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
new(id, days, time, true, null, null);
[Fact]
public async Task Load_Populates_Rows()
{
var api = new FakeApi();
api.Stored.Add(Dto(Guid.NewGuid(), 31, new TimeSpan(7, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
Assert.Single(vm.Rows);
}
[Fact]
public void AddSchedule_Appends_Row_With_Defaults()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Single(vm.Rows);
Assert.True(vm.Rows[0].Enabled);
Assert.True(vm.Rows[0].Monday);
Assert.True(vm.Rows[0].Friday);
Assert.False(vm.Rows[0].Saturday);
Assert.Equal(new TimeSpan(7, 0, 0), vm.Rows[0].TimeOfDay);
}
[Fact]
public void Row_Decomposes_And_Recomposes_Days()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
Assert.Equal(31, row.DaysMask());
row.Saturday = true;
Assert.Equal(63, row.DaysMask());
}
[Fact]
public async Task Save_Diffs_New_And_Removed_Rows()
{
var api = new FakeApi();
var keptId = Guid.NewGuid();
var deletedId = Guid.NewGuid();
api.Stored.Add(Dto(keptId, 31, new TimeSpan(7, 0, 0)));
api.Stored.Add(Dto(deletedId, 31, new TimeSpan(8, 0, 0)));
var vm = new PrimeClaudeTabViewModel(api);
await vm.LoadAsync();
vm.RemoveScheduleCommand.Execute(vm.Rows.Single(r => r.Id == deletedId));
vm.AddScheduleCommand.Execute(null);
await vm.SaveAsync();
Assert.Contains(deletedId, api.Deletes);
Assert.Equal(2, api.Upserts.Count);
}
[Fact]
public void Validate_Reports_No_Days_Selected()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
var row = vm.Rows[0];
row.Monday = row.Tuesday = row.Wednesday = row.Thursday = row.Friday = false;
Assert.NotNull(vm.Validate());
}
[Fact]
public void Validate_Passes_With_One_Day()
{
var vm = new PrimeClaudeTabViewModel(new FakeApi());
vm.AddScheduleCommand.Execute(null);
Assert.Null(vm.Validate());
}
}
```
- [ ] **Step 5: Run UI tests**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter FullyQualifiedName~PrimeClaudeTabViewModelTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Services/PrimeScheduleDto.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeScheduleRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs
git commit -m "feat(ui): drive prime schedule rows from weekday toggles"
```
---
## Task 8: XAML — toggle-button row
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- [ ] **Step 1: Add a `day-toggle` style class**
Append to `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (inside the root `<Styles>` element, alongside the other style selectors). Uses existing dynamic-resource tokens — no hardcoded colors:
```xml
<Style Selector="ToggleButton.day-toggle">
<Setter Property="MinWidth" Value="34"/>
<Setter Property="Padding" Value="6,4"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Background" Value="{DynamicResource DeepBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="4"/>
</Style>
<Style Selector="ToggleButton.day-toggle:checked /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
</Style>
```
If `AccentBrush` is not a defined token, use the brush the project uses for primary/selected affordances (check the `primary` button style in this file and reuse that brush). Final visual pass is the user's.
- [ ] **Step 2: Replace the Prime row template**
In `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`, replace the `<Grid ...>` inside the Prime `DataTemplate` (currently columns `Auto,*,Auto,Auto,Auto,Auto` with the `ThemedDatePicker` and MonFri checkbox) with:
```xml
<Grid ColumnDefinitions="Auto,*,Auto,Auto,Auto" ColumnSpacing="8">
<CheckBox Grid.Column="0" IsChecked="{Binding Enabled, Mode=TwoWay}" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<ToggleButton Classes="day-toggle" Content="Mo" IsChecked="{Binding Monday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Tu" IsChecked="{Binding Tuesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="We" IsChecked="{Binding Wednesday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Th" IsChecked="{Binding Thursday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Fr" IsChecked="{Binding Friday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Sa" IsChecked="{Binding Saturday, Mode=TwoWay}"/>
<ToggleButton Classes="day-toggle" Content="Su" IsChecked="{Binding Sunday, Mode=TwoWay}"/>
</StackPanel>
<TextBox Grid.Column="2" Width="64"
Text="{Binding TimeOfDay, Mode=TwoWay, Converter={StaticResource TimeSpanToHhmm}}"
VerticalAlignment="Center"/>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>
```
- [ ] **Step 3: Update the explainer text**
Replace the intro `TextBlock` Text in the Prime tab (`SettingsModalView.axaml`):
```xml
Text="Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately."/>
```
- [ ] **Step 4: Remove the now-unused range converter (only if unreferenced)**
The `DateOnlyToDateTime` resource on line 23 was used only by the range picker. Grep the file: if `DateOnlyToDateTime` has no other reference, remove the `<conv:DateOnlyToDateTimeConverter x:Key="DateOnlyToDateTime"/>` line. Keep `TimeSpanToHhmm` (still used).
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 5: Manual UI check**
Start the worker, then the app. Open Settings → Prime Claude. Verify: a row shows 7 toggle buttons with MonFri lit by default; toggling Sat/Sun persists after Save+reopen; clearing all days shows the validation error on Save. (UI correctness can only be confirmed in the running app — state so explicitly if it cannot be run.)
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml
git commit -m "feat(ui): replace prime date range with weekday toggle buttons"
```
---
## Task 9: Docs
**Files:**
- Modify: `src/ClaudeDo.Data/CLAUDE.md`
- Modify: `CLAUDE.md`
- [ ] **Step 1: Update the Data CLAUDE.md**
In `src/ClaudeDo.Data/CLAUDE.md`, the Models section has no PrimeSchedule line today; add one under Models, and confirm the `prime_schedules` table mention in the Schema section stays accurate:
```markdown
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
```
- [ ] **Step 2: Update the root CLAUDE.md if Prime is described**
Grep `CLAUDE.md` for "Prime"; if there is a Prime description mentioning a date range, update it to "recurring weekday schedule". If there is no such line, make no change.
- [ ] **Step 3: Full test sweep**
Run: `dotnet test tests/ClaudeDo.Worker.Tests && dotnet test tests/ClaudeDo.Ui.Tests`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Data/CLAUDE.md CLAUDE.md
git commit -m "docs: describe recurring-weekday Prime schedule"
```
---
## Self-Review Notes
- **Spec coverage:** data model (T1), scheduling logic (T4), UI toggles (T7T8), migration+backfill (T3), both DTOs (T4/T7), tests (T4T7), out-of-scope items excluded. ✓
- **Type consistency:** entity `PrimeDays Days`; both DTOs `int Days`; hub/scheduler cast `(int)`/`(PrimeDays)` at boundaries; calculator casts `(PrimeDays)s.Days`; row VM exposes 7 bools + `DaysMask()`. ✓
- **Build ripple:** a single type change breaks several projects at once, so some intermediate steps note expected build failures; the gating green builds are T3 Step 4 (Data), T6 Step 4 (Worker + tests), T8 Step 4 (App). ✓
```
@@ -0,0 +1,517 @@
# Daily Prep — Live Output View + Clear Day — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stream the daily-prep run's output into a live, human-readable view (a new mode in the Details island), and add a "Clear Day" button that empties MyDay.
**Architecture:** The worker broadcasts `PrepStarted/PrepLine/PrepFinished` over SignalR (mirroring `TaskStarted/TaskMessage/TaskFinished`). `PrimeRunner` forwards each Claude stdout line instead of discarding it. The UI `WorkerClient` re-raises these as events; `DetailsIslandViewModel` gains a `PrepLog` + `IsPrepMode` panel rendered with the existing terminal renderer. A `ClearMyDay` hub method bulk-clears `IsMyDay`. MyDay header gets "Vorbereitungs-Log" and "Tag leeren" buttons.
**Tech Stack:** .NET 8, ASP.NET Core SignalR, EF Core (SQLite), Avalonia + CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-06-03-daily-prep-live-view-design.md`
---
## Build & test commands
`.slnx` needs .NET 9; build/test individual csproj with `-c Release` (a running Worker may lock Debug).
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
```
UI cannot be GUI-smoke-tested headlessly — note that explicitly where it applies; the human verifies visuals.
## Reference anchors (verify before editing — line numbers drift)
- `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs` — currently only `PrimeFiredAsync`.
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:13-57` — broadcast methods; `PrimeFired` at ~52-56.
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs:31-79``FireAsync`; discard lambda at ~55-60; ctor at ~19-29.
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs:542-549``RunDailyPrepNow` (uses `_broadcaster`); DailyNote CRUD at 559-583 (shows the db-context pattern this hub uses).
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:19``TaskMessageEvent`; `:55``RunDailyPrepNowAsync`.
- `src/ClaudeDo.Ui/Services/WorkerClient.cs:99-122``TaskStarted/Finished/Message` hub.On; `:170-173``PrimeFired` hub.On (the pattern to copy).
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``IsNotesMode` ~56, `Log` ~193, ctor/subscriptions ~272-337, `OnTaskMessage` ~339-363 (stdout→`StreamLineFormatter``Log`), `ShowNotes` ~478-483.
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml:131-302` — body grid; task panel `IsVisible="{Binding !IsNotesMode}"`, notes panel `IsVisible="{Binding IsNotesMode}"`; `SessionTerminalView` embedded ~295.
- `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml:54-75``ItemsControl ItemsSource="{Binding Log}"` + the `LogLineViewModel` item template to reuse.
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs``NotesRequested` ~29, `OpenNotesCommand`+`PrepareDayCommand` ~33-45, `ShowNotesRow`/`IsMyDayList` ~65-66, both set in `LoadForList` ~212-213.
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml:69-84` — Notes + PrepareDay buttons (styling to copy).
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs:199-201` — island event wiring; `:225``PrimeFired` subscription.
- Fakes to keep in sync: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`).
---
## Task 1: Worker — prep output broadcast + streaming
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
- [ ] **Step 1: Write the failing test.** Extend `PrimeRunnerTests` with a fake `IPrimeBroadcaster` that records calls. The fake `IClaudeProcess` should invoke `onStdoutLine` with two sample lines and return `RunResult { ExitCode = 0, ResultMarkdown = "ok" }`.
```csharp
[Fact]
public async Task FireAsync_streams_started_lines_and_finished()
{
var broadcaster = new RecordingPrimeBroadcaster();
var claude = new FakeClaudeProcess(emitLines: new[] { "{\"a\":1}", "{\"b\":2}" }, exitCode: 0, result: "ok");
var runner = NewRunner(claude, broadcaster); // build with temp-sqlite dbFactory + fake clock + logger + broadcaster
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
var outcome = await runner.FireAsync(schedule, CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal(1, broadcaster.StartedCount);
Assert.Equal(new[] { "{\"a\":1}", "{\"b\":2}" }, broadcaster.Lines);
Assert.Single(broadcaster.FinishedResults);
Assert.True(broadcaster.FinishedResults[0]);
}
```
`RecordingPrimeBroadcaster` implements `IPrimeBroadcaster`: `StartedCount`, `List<string> Lines`, `List<bool> FinishedResults`, and a no-op `PrimeFiredAsync`. If the existing `FakeClaudeProcess` cannot emit lines, add an optional `emitLines` parameter that loops `await onStdoutLine(line)` before returning.
- [ ] **Step 2: Run — expect FAIL** (interface methods + ctor param missing).
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner
```
- [ ] **Step 3: Extend `IPrimeBroadcaster`:**
```csharp
public interface IPrimeBroadcaster
{
Task PrimeFiredAsync(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
Task PrepStartedAsync();
Task PrepLineAsync(string line);
Task PrepFinishedAsync(bool success);
}
```
(Keep the existing `PrimeFiredAsync` signature exactly as it is in the current file.)
- [ ] **Step 4: Implement in `HubBroadcaster`** (add next to `PrimeFired`):
```csharp
public Task PrepStarted() => _hub.Clients.All.SendAsync("PrepStarted");
public Task PrepLine(string line) => _hub.Clients.All.SendAsync("PrepLine", line);
public Task PrepFinished(bool success) => _hub.Clients.All.SendAsync("PrepFinished", success);
Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted();
Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line);
Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success);
```
(Match the existing explicit-interface style used for `PrimeFiredAsync`.)
- [ ] **Step 5: Wire `PrimeRunner`.** Add `IPrimeBroadcaster _broadcaster` as a ctor param (and field). Rewrite the body of `FireAsync` after the gate check to:
```csharp
if (!await _gate.WaitAsync(0, ct))
return new PrimeRunOutcome(false, "Daily prep already running");
var success = false;
try
{
await _broadcaster.PrepStartedAsync();
var cwd = Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
int maxTasks;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
{
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
}
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today);
var args = DailyPrepPrompt.BuildArgs(MaxTurns);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(FireTimeout);
var result = await _claude.RunAsync(
arguments: args,
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: line => _broadcaster.PrepLineAsync(line),
ct: timeoutCts.Token);
success = result.IsSuccess;
return success
? new PrimeRunOutcome(true, "Daily prep complete")
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Daily prep run failed");
return new PrimeRunOutcome(false, ex.Message);
}
finally
{
await _broadcaster.PrepFinishedAsync(success);
_gate.Release();
}
```
DI is unchanged: `AddSingleton<IPrimeRunner, PrimeRunner>()` resolves `IPrimeBroadcaster` (registered as `sp => sp.GetRequiredService<HubBroadcaster>()`).
- [ ] **Step 6: Update existing `PrimeRunnerTests` ctor calls** to pass the recording broadcaster; build + run.
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter PrimeRunner
```
- [ ] **Step 7: Commit.**
```bash
git add src/ClaudeDo.Worker/Prime src/ClaudeDo.Worker/Hub/HubBroadcaster.cs tests/ClaudeDo.Worker.Tests/Prime
git commit -m "feat(daily-prep): stream prep output via PrepStarted/PrepLine/PrepFinished"
```
---
## Task 2: Worker — `ClearMyDay` hub method
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Test: a new/existing hub test under `tests/ClaudeDo.Worker.Tests/Hub/` (mirror an existing hub test that seeds a real SQLite db and constructs `WorkerHub`)
- [ ] **Step 1: Write the failing test.** Seed three tasks: two with `IsMyDay=true` (one Idle, one Done), one with `IsMyDay=false`. Construct `WorkerHub` the way existing hub tests do (the same `null!` argument list, plus a recording `HubBroadcaster`/clients). Call `ClearMyDay()`; assert both MyDay rows are now `false`, the third is untouched, and the returned count is 2.
```csharp
[Fact]
public async Task ClearMyDay_clears_all_isMyDay_tasks()
{
// seed via the test's db helper ...
var hub = NewHub(/* ... */);
var cleared = await hub.ClearMyDay();
Assert.Equal(2, cleared);
await using var ctx = NewContext();
Assert.False(await ctx.Tasks.AnyAsync(t => t.IsMyDay));
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Add the method** to `WorkerHub` (use the same db-context acquisition the neighbouring hub methods use — e.g. `_dbFactory`/repository field name found in the file — and the existing `_broadcaster` field):
```csharp
public async Task<int> ClearMyDay()
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var ids = await ctx.Tasks.Where(t => t.IsMyDay).Select(t => t.Id).ToListAsync();
if (ids.Count == 0) return 0;
await ctx.Tasks.Where(t => t.IsMyDay)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.IsMyDay, false));
foreach (var id in ids)
await _broadcaster.TaskUpdated(id);
return ids.Count;
}
```
If `WorkerHub` does not already have an `IDbContextFactory<ClaudeDoDbContext>` field, use whatever data-access dependency the other hub methods use (read the file). Do NOT add a new ctor param unless unavoidable (it would break hub-test fakes — if you must, update all `new WorkerHub(...)` call sites).
- [ ] **Step 4: Run — expect PASS.** Build Worker.
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs tests/ClaudeDo.Worker.Tests/Hub
git commit -m "feat(daily-prep): add ClearMyDay hub method"
```
---
## Task 3: UI — WorkerClient prep events + ClearMyDayAsync
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (FakeWorkerClient)
- [ ] **Step 1: Declare on `IWorkerClient`** (near `TaskMessageEvent` / `RunDailyPrepNowAsync`):
```csharp
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
Task ClearMyDayAsync();
```
- [ ] **Step 2: Implement in `WorkerClient`.** Add the events; register hub callbacks mirroring the `PrimeFired` registration (~line 170):
```csharp
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
// in the hub-wiring section:
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
public Task ClearMyDayAsync() => _connection.InvokeAsync("ClearMyDay");
```
(Use the exact connection field name and async-call style of neighbouring methods like `RunDailyPrepNowAsync` / `GenerateWeekReport`. `ClearMyDay` returns `int` on the hub; invoking it as a void `InvokeAsync("ClearMyDay")` is fine, or `InvokeAsync<int>` if you want the count.)
- [ ] **Step 3: Update the fakes.** Add the three events (as `public event …` auto-implemented) and `ClearMyDayAsync() => Task.CompletedTask` to both `StubWorkerClient` and `FakeWorkerClient`. For the ClearDay command test (Task 5), give `StubWorkerClient` a `ClearMyDayCalls` counter incremented in `ClearMyDayAsync`.
- [ ] **Step 4: Build App + both test projects; fix any remaining fake gaps.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Ui/Services tests
git commit -m "feat(daily-prep): expose prep stream events and ClearMyDay on the UI worker client"
```
---
## Task 4: UI — Details island prep mode + live log
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- Test: `tests/ClaudeDo.Ui.Tests/...DetailsIslandViewModel...` (mirror existing Details VM tests; if none, add a small test file)
- [ ] **Step 1: Write the failing test.** Construct `DetailsIslandViewModel` with a `StubWorkerClient` (mirror existing construction). Then:
```csharp
[Fact]
public void PrepLine_event_appends_to_PrepLog()
{
var stub = new StubWorkerClient();
var vm = NewDetailsVm(stub);
stub.RaisePrepLine("{\"type\":\"assistant\",\"text\":\"hi\"}"); // helper that invokes PrepLineEvent
Assert.NotEmpty(vm.PrepLog);
}
[Fact]
public void ShowPrep_sets_prep_mode_and_clears_notes_mode()
{
var vm = NewDetailsVm(new StubWorkerClient());
vm.ShowPrep();
Assert.True(vm.IsPrepMode);
Assert.False(vm.IsNotesMode);
}
```
Add `RaisePrepStarted/RaisePrepLine/RaisePrepFinished` helpers to `StubWorkerClient` that invoke the corresponding events.
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement in `DetailsIslandViewModel`:**
- Add `[ObservableProperty] private bool _isPrepMode;` and `[ObservableProperty] private bool _isPrepRunning;`.
- Add `public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();`.
- In the ctor, subscribe: `_worker.PrepStartedEvent += OnPrepStarted; _worker.PrepLineEvent += OnPrepLine; _worker.PrepFinishedEvent += OnPrepFinished;` (guard with the same `_worker is not null` pattern used for other events).
- Handlers:
```csharp
private void OnPrepStarted()
{
PrepLog.Clear();
IsPrepRunning = true;
}
private void OnPrepLine(string line) => AppendStdoutLine(PrepLog, line);
private void OnPrepFinished(bool success) => IsPrepRunning = false;
```
- Factor the stdout-formatting currently inside `OnTaskMessage` into a reusable
`private void AppendStdoutLine(ObservableCollection<LogLineViewModel> target, string line)`
that runs the line through `StreamLineFormatter` and appends `LogLineViewModel`(s).
Have `OnTaskMessage`'s stdout branch call `AppendStdoutLine(Log, strippedLine)` so both
paths share one implementation. (Events arrive already on the UI thread via
`Dispatcher.UIThread.Post` in `WorkerClient`, so direct collection mutation is correct.)
- Add `public void ShowPrep()` mirroring `ShowNotes()`: call `Bind(null)`, set
`IsNotesMode = false`, `IsPrepMode = true`.
- In `ShowNotes()` add `IsPrepMode = false`. In `Bind(...)` reset both `IsNotesMode` and
`IsPrepMode` to false (find where `IsNotesMode` is reset; add `IsPrepMode` beside it).
- [ ] **Step 4: Update `DetailsIslandView.axaml`.**
- Change the task-details panel visibility from `IsVisible="{Binding !IsNotesMode}"` to a
converter-free multi-condition. Avalonia lacks `&&` in bindings, so add a computed
property `public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode;` to the VM
(raise its change notification from the `OnIsNotesModeChanged`/`OnIsPrepModeChanged`
partial methods generated by `[ObservableProperty]`) and bind the task panel to
`IsVisible="{Binding IsTaskDetailVisible}"`.
- Add a third panel after the notes panel:
```xml
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
<TextBlock DockPanel.Dock="Top" Margin="16,12"
Text="{loc:Tr details.prepTitle}" Classes="h2"/>
<ScrollViewer>
<ItemsControl ItemsSource="{Binding PrepLog}"/>
</ScrollViewer>
</DockPanel>
</Panel>
```
The `ItemsControl` reuses the implicit `LogLineViewModel` `DataTemplate` that
`SessionTerminalView` relies on. If that template is defined locally inside
`SessionTerminalView.axaml` (not in a shared resource), either move it to a shared
`ResourceDictionary` (e.g. App resources) and reference it from both, or set the
`ItemsControl.ItemTemplate` to a copy of that template. Prefer sharing over copying.
Add `details.prepTitle` ("Daily prep" / "Tagesvorbereitung") to both locale json files.
- [ ] **Step 5: Run UI tests — expect PASS; build App.**
```bash
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 6: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization tests/ClaudeDo.Ui.Tests
git commit -m "feat(daily-prep): add live prep-output mode to the Details island"
```
---
## Task 5: UI — MyDay buttons + shell wiring
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
- Test: `tests/ClaudeDo.Worker.Tests/UiVm/...` or `tests/ClaudeDo.Ui.Tests/...` (TasksIslandViewModel)
- [ ] **Step 1: Write the failing tests.**
```csharp
[Fact]
public async Task ClearDayCommand_calls_worker()
{
var stub = new StubWorkerClient();
var vm = NewTasksVm(stub);
await vm.ClearDayCommand.ExecuteAsync(null);
Assert.Equal(1, stub.ClearMyDayCalls);
}
[Fact]
public async Task PrepareDayCommand_raises_PrepRequested()
{
var vm = NewTasksVm(new StubWorkerClient());
var raised = false;
vm.PrepRequested += () => raised = true;
await vm.PrepareDayCommand.ExecuteAsync(null);
Assert.True(raised);
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Implement in `TasksIslandViewModel`:**
- Add `public event Action? PrepRequested;` next to `NotesRequested`.
- In `PrepareDayAsync` (the existing `[RelayCommand]`), raise `PrepRequested?.Invoke();`
in addition to the existing `RunDailyPrepNowAsync()` call.
- Add:
```csharp
[RelayCommand]
private void ShowPrepLog() => PrepRequested?.Invoke();
[RelayCommand]
private async Task ClearDayAsync()
{
if (_worker is null) return;
try { await _worker.ClearMyDayAsync(); }
catch { /* worker offline; broadcast will reconcile on return */ }
}
```
- [ ] **Step 4: Add the two buttons** to the MyDay header in `TasksIslandView.axaml`,
immediately after the existing "Prepare day" button (~line 84), copying its styling
(`DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Margin="16,0,16,8" IsVisible="{Binding IsMyDayList}"`):
```xml
<Button DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Margin="16,0,16,8"
IsVisible="{Binding IsMyDayList}"
Command="{Binding ShowPrepLogCommand}"
Content="{loc:Tr tasks.prepLog}"/>
<Button DockPanel.Dock="Top" Classes="btn" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Margin="16,0,16,8"
IsVisible="{Binding IsMyDayList}"
Command="{Binding ClearDayCommand}"
Content="{loc:Tr tasks.clearDay}"/>
```
Add `tasks.prepLog` (en "Prep log" / de "Vorbereitungs-Log") and `tasks.clearDay`
(en "Clear day" / de "Tag leeren") to both locale json files.
- [ ] **Step 5: Wire the shell.** In `IslandsShellViewModel` where `Tasks.NotesRequested`
is wired (~line 201), add:
```csharp
Tasks.PrepRequested += () => Details.ShowPrep();
```
- [ ] **Step 6: Run tests + build App.**
```bash
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 7: Manual smoke (human, not headless):** start Worker + App, open MyDay, click
"Tag vorbereiten" → Details island opens in prep mode and streams readable lines; click
"Tag leeren" → MyDay empties; after a scheduled run, "Vorbereitungs-Log" opens the filled
log. Confirm the three buttons only appear on MyDay.
- [ ] **Step 8: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization tests
git commit -m "feat(daily-prep): add Prep-log and Clear-day buttons to MyDay header"
```
---
## Final verification
- [ ] Build Worker + App (Release).
- [ ] `dotnet test` Worker.Tests, Ui.Tests, Localization.Tests — all green.
- [ ] Manual: prep streams live into the Details island (manual opens it; scheduled fills it silently, opened via the button); Clear Day empties MyDay immediately.
## Notes / risks
- Mode flags `IsNotesMode` / `IsPrepMode` are mutually exclusive; the task-details panel
uses the computed `IsTaskDetailVisible`. Verify all three modes switch cleanly.
- Reusing the `LogLineViewModel` template: prefer promoting it to a shared resource over
copying, to avoid drift between the session terminal and the prep log.
- `ClearMyDay` broadcasts one `TaskUpdated` per affected id; MyDay is small (capped), so
this is fine.
- Keep `PrimeRunner`'s "already running" early-return emitting no prep events.
@@ -0,0 +1,736 @@
# Daily Prep ("Prime Claude") Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the Prime Time warm-up into a daily preparation where Claude reads open tasks and moves an effort-aware, capped subset into MyDay, triggered by the Prime schedule and a manual button.
**Architecture:** Agentic. Two new tools on the always-on `ExternalMcpService` (`get_daily_prep_candidates`, `set_my_day` with a server-side cap-guard). The existing `PrimeRunner` is rewritten to launch a headless `claude -p` run with a fixed parameterized prompt and `--allowedTools` for those two tools, relying on the already-registered `claudedo` MCP (no separate `--mcp-config`). A new `DailyPrepMaxTasks` app setting drives the cap. A manual hub method reuses the same runner with a single-flight guard.
**Tech Stack:** .NET 8, ASP.NET Core, EF Core (SQLite), SignalR, ModelContextProtocol, Avalonia (CommunityToolkit.Mvvm), xUnit.
**Spec:** `docs/superpowers/specs/2026-06-03-daily-prep-design.md`
---
## Deviation from spec (deliberate, to minimize churn)
The spec proposed renaming `IPrimeRunner`/`PrimeRunner`/`PrimeScheduler``DailyPrep*`. **We keep the existing names and the `FireAsync(PrimeScheduleDto, ct)` signature** and only rewrite the runner body. This avoids touching the scheduler, DI registration, `IPrimeBroadcaster`, and the existing Prime tests for a pure rename. The per-schedule `PromptOverride` field becomes unused by the runner (left in the DB/UI untouched).
## Build & test commands (this repo)
`.slnx` needs .NET 9; on .NET 8 build/test individual projects. Use `-c Release` if a running Worker locks `Debug`.
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
```
Tests use **real SQLite + real git** (project convention). Mirror the setup already present in the test file you are extending.
---
## File Structure
**Create**
- `src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs` (+ Designer, via `dotnet ef`)
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — pure prompt + args builder (easy to unit-test)
**Modify**
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — add `DailyPrepMaxTasks`
- `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs` — map column
- `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs` — persist field in `UpdateAsync`
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add 2 tools + DTOs
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs` — rewrite body to daily prep + single-flight
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `DailyPrepMaxTasks` to AppSettings DTO + `RunDailyPrepNow`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — mirror `DailyPrepMaxTasks` in the UI AppSettings DTO + add `RunDailyPrepNow` call
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs` (+ its view) — numeric editor for `DailyPrepMaxTasks`
- MyDay list header view + its ViewModel — "Tag vorbereiten" button + command
**Test**
- `tests/ClaudeDo.Data.Tests/...AppSettings...` — new field persists / default 5
- `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` — candidate filter + set_my_day + cap-guard
- `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs` — prompt/args content
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs` (if present) — single-flight + success/failure via `IClaudeProcess` fake
---
## Task 1: `DailyPrepMaxTasks` app setting
**Files:**
- Modify: `src/ClaudeDo.Data/Models/AppSettingsEntity.cs`
- Modify: `src/ClaudeDo.Data/Configuration/AppSettingsEntityConfiguration.cs`
- Modify: `src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs`
- Create (via `dotnet ef`): `src/ClaudeDo.Data/Migrations/<timestamp>_DailyPrepMaxTasks.cs`
- Test: `tests/ClaudeDo.Data.Tests` (extend existing AppSettings repository test, or add `AppSettingsRepositoryTests.cs`)
- [ ] **Step 1: Write the failing test**
In a Data.Tests file (mirror the existing repo test harness that opens a real SQLite `ClaudeDoDbContext`):
```csharp
[Fact]
public async Task DailyPrepMaxTasks_defaults_to_5_and_persists()
{
await using var ctx = NewContext(); // existing helper that migrates a temp sqlite db
var repo = new AppSettingsRepository(ctx);
var initial = await repo.GetAsync();
Assert.Equal(5, initial.DailyPrepMaxTasks);
initial.DailyPrepMaxTasks = 8;
await repo.UpdateAsync(initial);
var reloaded = await repo.GetAsync();
Assert.Equal(8, reloaded.DailyPrepMaxTasks);
}
```
- [ ] **Step 2: Run it — expect FAIL** (`AppSettingsEntity` has no `DailyPrepMaxTasks`).
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter DailyPrepMaxTasks_defaults_to_5_and_persists
```
- [ ] **Step 3: Add the property** to `AppSettingsEntity.cs` after `StandupWeekday`:
```csharp
// Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
public int DailyPrepMaxTasks { get; set; } = 5;
```
- [ ] **Step 4: Map the column** in `AppSettingsEntityConfiguration.cs`, after the `StandupWeekday` mapping (before `builder.HasData(...)`):
```csharp
builder.Property(s => s.DailyPrepMaxTasks)
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
```
- [ ] **Step 5: Persist it** in `AppSettingsRepository.UpdateAsync`, after the `StandupWeekday` assignment:
```csharp
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
```
- [ ] **Step 6: Generate the migration** (regenerates the model snapshot — do NOT hand-edit the snapshot):
```bash
dotnet ef migrations add DailyPrepMaxTasks \
-p src/ClaudeDo.Data/ClaudeDo.Data.csproj \
-s src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Verify the generated `Up` contains an `AddColumn<int>("daily_prep_max_tasks", ... defaultValue: 5)` and an `UpdateData` setting the singleton row's `daily_prep_max_tasks` to 5. If `dotnet ef` is unavailable, hand-write the migration mirroring `20260603072822_WeeklyReport.cs` **and** add the matching `Property<int>("DailyPrepMaxTasks").HasColumnName("daily_prep_max_tasks")` line to `ClaudeDoDbContextModelSnapshot.cs` under the `AppSettingsEntity` builder.
- [ ] **Step 7: Run the test — expect PASS.**
- [ ] **Step 8: Commit.**
```bash
git add src/ClaudeDo.Data tests/ClaudeDo.Data.Tests
git commit -m "feat(daily-prep): add DailyPrepMaxTasks app setting"
```
---
## Task 2: `get_daily_prep_candidates` MCP tool
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
Read `ExternalMcpServiceTests.cs` first and reuse its existing harness (how it builds an `ExternalMcpService` with a real SQLite context, `ListRepository`, `TaskRepository`, fake `HubBroadcaster`, etc.). The new tool reads **all** lists/tasks itself via the injected `_dbFactory`, so it needs no new constructor args.
- [ ] **Step 1: Write the failing test.** Seed: a list with `WorkingDir = @"D:\work\repo"` holding two `Idle` tasks (one blocked, one not) and one `Done` task; a second list with `WorkingDir = @"C:\Private\secret"` holding one `Idle` task; a third list with `WorkingDir = null` holding one `Idle` task; and one `Idle` task with `IsMyDay = true` in the first list. Set `AppSettings.ReportExcludedPaths = "[\"C:\\\\Private\"]"`.
```csharp
[Fact]
public async Task GetDailyPrepCandidates_filters_by_status_block_and_excluded_repo()
{
// ... seed as described, using the file's existing seed helpers ...
var svc = NewService();
var result = await svc.GetDailyPrepCandidates(CancellationToken.None);
// Only the non-blocked, Idle, non-MyDay task in the non-excluded repo is a candidate.
Assert.Single(result.Candidates);
Assert.Equal("idle-unblocked", result.Candidates[0].Id);
// The Idle MyDay task is reported separately, not as a candidate.
Assert.Single(result.CurrentMyDay);
Assert.Equal(1, result.MaxTasks > 0 ? 1 : 1); // MaxTasks comes from AppSettings (default 5)
Assert.Equal(5, result.MaxTasks);
}
```
- [ ] **Step 2: Run it — expect FAIL** (method missing).
- [ ] **Step 3: Add the DTOs** near the other record declarations at the top of `ExternalMcpService.cs`:
```csharp
public sealed record DailyPrepCandidateDto(
string Id, string ListId, string ListName, string Title, string? Description,
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
public sealed record DailyPrepDataDto(
int MaxTasks,
IReadOnlyList<DailyPrepCandidateDto> Candidates,
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
```
- [ ] **Step 4: Add the tool method** to the `ExternalMcpService` class body:
```csharp
[McpServerTool, Description(
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
var idle = await ctx.Tasks
.AsNoTracking()
.Include(t => t.List)
.Where(t => t.Status == TaskStatus.Idle)
.ToListAsync(cancellationToken);
var currentMyDay = idle
.Where(t => t.IsMyDay)
.OrderBy(t => t.SortOrder)
.Select(ToCandidate)
.ToList();
var candidates = idle
.Where(t => !t.IsMyDay
&& t.BlockedByTaskId == null
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
.OrderBy(t => t.CreatedAt)
.Select(ToCandidate)
.ToList();
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
}
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
t.Id, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
t.IsStarred, t.ScheduledFor, t.CreatedAt);
```
- [ ] **Step 5: Add the filter helper** as a small static class at the bottom of `ExternalMcpService.cs` (single-consumer helper lives beside its consumer, per repo convention):
```csharp
internal static class DailyPrepFilter
{
public static string[] ParseExcludes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
}
catch (System.Text.Json.JsonException) { return []; }
}
public static bool IsIncludedRepo(string? workingDir, string[] excludes)
{
if (string.IsNullOrWhiteSpace(workingDir)) return false; // not a repo → excluded
var norm = Normalize(workingDir);
return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
private static string Normalize(string path) =>
path.Trim().Replace('/', '\\').TrimEnd('\\');
}
```
Add `using ClaudeDo.Data.Repositories;` if not already present (it is, via existing usings).
- [ ] **Step 6: Run the test — expect PASS.**
- [ ] **Step 7: Commit.**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(daily-prep): add get_daily_prep_candidates MCP tool"
```
---
## Task 3: `set_my_day` MCP tool with cap-guard
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests.**
```csharp
[Fact]
public async Task SetMyDay_sets_flag_and_sort_order()
{
var svc = NewService();
var id = await SeedIdleTask("My task"); // existing/added helper returning task id
var dto = await svc.SetMyDay(id, isMyDay: true, sortOrder: 3, CancellationToken.None);
Assert.True(dto.IsMyDay);
Assert.Equal(3, dto.SortOrder);
}
[Fact]
public async Task SetMyDay_rejects_when_cap_reached()
{
// AppSettings.DailyPrepMaxTasks = 1 (set in seed)
var svc = NewService();
var first = await SeedIdleTask("a");
var second = await SeedIdleTask("b");
await svc.SetMyDay(first, true, null, CancellationToken.None);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.SetMyDay(second, true, null, CancellationToken.None));
Assert.Contains("limit", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SetMyDay_unset_is_always_allowed()
{
var svc = NewService();
var id = await SeedIdleTask("a");
await svc.SetMyDay(id, true, null, CancellationToken.None);
var dto = await svc.SetMyDay(id, false, null, CancellationToken.None);
Assert.False(dto.IsMyDay);
}
```
`SetMyDay` returns the existing `TaskDto`. Add a `SortOrder` field to `TaskDto` — see Step 3a. (`SeedIdleTask` / the `DailyPrepMaxTasks=1` seed reuse the file's existing seeding helpers.)
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3a: Add `SortOrder` to `TaskDto`** (record + `ToDto`) so the result reflects ordering:
In the `TaskDto` record add `int SortOrder` as the last positional member, and in `ToDto(TaskEntity t)` add `t.SortOrder` as the last argument. (Update any test that constructs `TaskDto` positionally — search the test project.)
- [ ] **Step 3b: Add the tool method:**
```csharp
[McpServerTool, Description(
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
"(use consecutive sortOrder values to keep related tasks together). " +
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
"clearing (isMyDay=false) is always allowed.")]
public async Task<TaskDto> SetMyDay(
string taskId,
bool isMyDay,
int? sortOrder,
CancellationToken cancellationToken)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (isMyDay && !task.IsMyDay)
{
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
var openMyDay = await ctx.Tasks.CountAsync(
t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
if (openMyDay >= max)
throw new InvalidOperationException(
$"MyDay limit {max} reached. Clear a task before adding another.");
}
task.IsMyDay = isMyDay;
if (sortOrder is not null) task.SortOrder = sortOrder.Value;
await ctx.SaveChangesAsync(cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToDto(task);
}
```
- [ ] **Step 4: Run — expect PASS.**
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests
git commit -m "feat(daily-prep): add set_my_day MCP tool with cap-guard"
```
---
## Task 4: Rewrite `PrimeRunner` to run the daily prep
**Files:**
- Create: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs`, and extend `PrimeRunnerTests.cs` if it exists
The runner needs the cap `X` (read from `AppSettings`) and today's date. Inject `IDbContextFactory<ClaudeDoDbContext>` into `PrimeRunner` (it is resolvable in the main app DI) and an `IPrimeClock` for the date (already registered).
- [ ] **Step 1: Write failing prompt/args tests.**
```csharp
public class DailyPrepPromptTests
{
[Fact]
public void Build_prompt_contains_cap_and_date()
{
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3));
Assert.Contains("5", prompt);
Assert.Contains("2026-06-03", prompt);
Assert.Contains("get_daily_prep_candidates", prompt);
Assert.Contains("set_my_day", prompt);
}
[Fact]
public void Build_args_allows_only_the_two_tools()
{
var args = DailyPrepPrompt.BuildArgs(maxTurns: 30);
Assert.Contains("--output-format stream-json", args);
Assert.Contains("--max-turns 30", args);
Assert.Contains("--allowedTools", args);
Assert.Contains("mcp__claudedo__get_daily_prep_candidates", args);
Assert.Contains("mcp__claudedo__set_my_day", args);
}
}
```
- [ ] **Step 2: Run — expect FAIL.**
- [ ] **Step 3: Create `DailyPrepPrompt.cs`:**
```csharp
namespace ClaudeDo.Worker.Prime;
public static class DailyPrepPrompt
{
public const string CandidatesTool = "mcp__claudedo__get_daily_prep_candidates";
public const string SetMyDayTool = "mcp__claudedo__set_my_day";
public static string BuildArgs(int maxTurns) =>
"-p --output-format stream-json --verbose --permission-mode acceptEdits " +
$"--max-turns {maxTurns} " +
$"--allowedTools {CandidatesTool} {SetMyDayTool}";
public static string BuildPrompt(int maxTasks, DateOnly today) =>
$"""
Du bereitest meinen Arbeitstag fuer {today:yyyy-MM-dd} vor.
1. Rufe {CandidatesTool} auf.
2. Behalte bereits als MyDay markierte offene Tasks (currentMyDay) — entferne sie nicht.
3. Fuelle bis maximal {maxTasks} offene Tasks GESAMT in MyDay auf (currentMyDay zaehlt mit). Niemals mehr.
4. Schaetze pro Kandidat grob den Aufwand und waehle eine machbare Mischung (nicht nur Grossbrocken).
Priorisiere isStarred, faellige (scheduledFor) und aeltere Tasks.
5. Lege thematisch verwandte Tasks durch aufeinanderfolgende sortOrder-Werte nebeneinander.
6. Setze die Auswahl via {SetMyDayTool}(taskId, true, sortOrder). Markiere nichts ausserhalb der Kandidatenliste.
Wenn es keine Kandidaten gibt, tue nichts.
""";
}
```
- [ ] **Step 4: Run prompt tests — expect PASS.**
- [ ] **Step 5: Rewrite `PrimeRunner.cs`:**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Prime;
public sealed class PrimeRunner : IPrimeRunner
{
private static readonly TimeSpan FireTimeout = TimeSpan.FromMinutes(5);
private const int MaxTurns = 30;
private readonly IClaudeProcess _claude;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IPrimeClock _clock;
private readonly ILogger<PrimeRunner> _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
public PrimeRunner(
IClaudeProcess claude,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IPrimeClock clock,
ILogger<PrimeRunner> logger)
{
_claude = claude;
_dbFactory = dbFactory;
_clock = clock;
_logger = logger;
}
public async Task<PrimeRunOutcome> FireAsync(PrimeScheduleDto schedule, CancellationToken ct)
{
if (!await _gate.WaitAsync(0, ct))
return new PrimeRunOutcome(false, "Daily prep already running");
try
{
var cwd = Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
int maxTasks;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
{
var settings = await new AppSettingsRepository(dbCtx).GetAsync(ct);
maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
}
var today = DateOnly.FromDateTime(_clock.Now.LocalDateTime);
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks, today);
var args = DailyPrepPrompt.BuildArgs(MaxTurns);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(FireTimeout);
var result = await _claude.RunAsync(
arguments: args,
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: _ => Task.CompletedTask,
ct: timeoutCts.Token);
return result.IsSuccess
? new PrimeRunOutcome(true, "Daily prep complete")
: new PrimeRunOutcome(false, $"exit code {result.ExitCode}");
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return new PrimeRunOutcome(false, $"timed out after {FireTimeout.TotalMinutes:0} min");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Daily prep run failed");
return new PrimeRunOutcome(false, ex.Message);
}
finally
{
_gate.Release();
}
}
}
```
- [ ] **Step 6: Fix the DI registration is unchanged** (`AddSingleton<IPrimeRunner, PrimeRunner>()` already works — the new ctor deps `IDbContextFactory` and `IPrimeClock` are registered). Build the Worker.
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
- [ ] **Step 7: Update/extend `PrimeRunnerTests.cs`** (if present) to match the new ctor: construct `PrimeRunner` with a fake `IClaudeProcess`, a real temp-SQLite `IDbContextFactory`, a fake `IPrimeClock`, and a logger. Add:
```csharp
[Fact]
public async Task FireAsync_returns_already_running_when_gate_held()
{
var runner = NewRunner(claudeDelay: TimeSpan.FromSeconds(2));
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
var first = runner.FireAsync(schedule, CancellationToken.None);
var second = await runner.FireAsync(schedule, CancellationToken.None);
Assert.False(second.Success);
Assert.Contains("already running", second.Message, StringComparison.OrdinalIgnoreCase);
await first;
}
```
If no `PrimeRunnerTests.cs` exists, create one. The fake `IClaudeProcess` should optionally delay (to keep the gate held) and return a successful `RunResult { ExitCode = 0, ResultMarkdown = "ok" }`.
- [ ] **Step 8: Run — expect PASS.**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "DailyPrepPrompt|PrimeRunner"
```
- [ ] **Step 9: Commit.**
```bash
git add src/ClaudeDo.Worker/Prime tests/ClaudeDo.Worker.Tests/Prime
git commit -m "feat(daily-prep): run daily prep from PrimeRunner via allowed MCP tools"
```
---
## Task 5: Hub — `RunDailyPrepNow` + expose `DailyPrepMaxTasks`
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
Read `WorkerHub.cs` first. It already exposes a `GetAppSettings`/`UpdateAppSettings` pair backed by a DTO record (the one carrying `ReportExcludedPaths`, `StandupWeekday`).
- [ ] **Step 1: Add `DailyPrepMaxTasks` to the hub AppSettings DTO record** (the record near the top of `WorkerHub.cs` that lists `ReportExcludedPaths`). Add `int DailyPrepMaxTasks` as a member. In the read mapping (`GetAppSettings`, where `row.ReportExcludedPaths` is read) add `row.DailyPrepMaxTasks`; in the write mapping (`UpdateAppSettings`, where `ReportExcludedPaths = dto.ReportExcludedPaths`) add `DailyPrepMaxTasks = dto.DailyPrepMaxTasks`.
- [ ] **Step 2: Add the hub method.** Inject `IPrimeRunner` and `HubBroadcaster` if the hub does not already have them (the hub is constructed by SignalR via DI; both are registered singletons). Then:
```csharp
public async Task<bool> RunDailyPrepNow()
{
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
var firedAt = DateTimeOffset.Now;
var outcome = await _primeRunner.FireAsync(schedule, Context.ConnectionAborted);
await _broadcaster.PrimeFired(Guid.Empty, outcome.Success, outcome.Message, firedAt);
return outcome.Success;
}
```
Add `using ClaudeDo.Worker.Prime;` to `WorkerHub.cs` if missing.
> **Caution (memory):** changing the `WorkerHub` constructor breaks hand-rolled hub-test fakes in `ClaudeDo.Worker.Tests` and possibly `ClaudeDo.Ui.Tests`. After editing, build the test projects and fix every `new WorkerHub(...)` / fake `IWorkerClient` construction the compiler flags.
- [ ] **Step 3: Mirror the DTO in the UI** (`WorkerClient.cs`, the AppSettings DTO around line 498): add `int DailyPrepMaxTasks` to the record (same position as in the hub DTO). Add a `RunDailyPrepNow` client call:
```csharp
public Task<bool> RunDailyPrepNowAsync() =>
_connection.InvokeAsync<bool>("RunDailyPrepNow");
```
(Match the exact connection field/name and the async-wrapper style used by neighbouring calls like `GenerateWeekReport`.)
- [ ] **Step 4: Build Worker + App + test projects; fix any broken fakes.**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests
git commit -m "feat(daily-prep): add RunDailyPrepNow hub method and expose DailyPrepMaxTasks"
```
---
## Task 6: Settings UI — edit `DailyPrepMaxTasks`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/PrimeClaudeTabViewModel.cs`
- Modify: the Prime Claude tab markup in `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` (load/save wiring, where other AppSettings fields are mapped)
Read these three files first; mirror how an existing numeric AppSetting (e.g. `MaxParallelExecutions` or `WorktreeAutoCleanupDays`) is loaded from the hub DTO, bound, and saved back.
- [ ] **Step 1: Add an observable property** to `PrimeClaudeTabViewModel.cs`:
```csharp
[ObservableProperty] private int _dailyPrepMaxTasks = 5;
```
- [ ] **Step 2: Wire load/save** in `SettingsModalViewModel.cs`: where the AppSettings DTO is read into the tabs, set `PrimeClaude.DailyPrepMaxTasks = dto.DailyPrepMaxTasks;`. Where the DTO is written, include `DailyPrepMaxTasks = PrimeClaude.DailyPrepMaxTasks`. (Use the exact tab property name for the Prime Claude tab in that VM.)
- [ ] **Step 3: Add the editor** in the Prime Claude tab of `SettingsModalView.axaml`, near the schedule list:
```xml
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="{x:Static loc:L.Settings_DailyPrepMaxTasks}" VerticalAlignment="Center"/>
<NumericUpDown Minimum="1" Maximum="50" Increment="1" Width="100"
Value="{Binding PrimeClaude.DailyPrepMaxTasks}"/>
</StackPanel>
```
Add the `Settings_DailyPrepMaxTasks` key to both `locales/en.json` and `locales/de.json` (en: "Max tasks per day", de: "Max. Aufgaben pro Tag"). If the tab does not use localized labels yet, use a plain `Text="Max tasks per day"` string to match its current style.
- [ ] **Step 4: Build the App; smoke-build the UI.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 5: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization
git commit -m "feat(daily-prep): add DailyPrepMaxTasks editor to Prime Claude settings"
```
---
## Task 7: MyDay header — "Tag vorbereiten" button
**Files:**
- Modify: the ViewModel backing the MyDay list view (the one that exposes the smart-list header/toolbar; find it under `src/ClaudeDo.Ui/ViewModels/Islands/` — likely the tasks/list island VM that has access to `IWorkerClient`)
- Modify: the corresponding view (`.axaml`) that renders the list header
Read the island VM + view first. Find where the active list is known to be `smart:my-day` so the button can be shown only there (mirror any existing conditional header content). The VM already holds a worker-client reference used by other commands (e.g. RunNow) — reuse it.
- [ ] **Step 1: Add the command** to the island VM:
```csharp
[RelayCommand]
private async Task PrepareDayAsync()
{
await _workerClient.RunDailyPrepNowAsync();
}
```
(Use the VM's existing worker-client field name. The MyDay list refreshes automatically via the `TaskUpdated` broadcast the tools emit, so no manual reload is needed.)
- [ ] **Step 2: Add an `IsMyDayList` (or reuse existing selected-list) guard** so the button only appears on the MyDay smart list. If the VM already exposes the selected list id, add:
```csharp
public bool IsMyDayList => SelectedListId == "smart:my-day";
```
and raise its change notification wherever `SelectedListId` changes (mirror existing patterns; if a `[NotifyPropertyChangedFor]` or manual `OnPropertyChanged` is already used for the selection, add this property to it).
- [ ] **Step 3: Add the button** to the list header in the view, visible only on MyDay:
```xml
<Button Content="{x:Static loc:L.MyDay_PrepareDay}"
Command="{Binding PrepareDayCommand}"
IsVisible="{Binding IsMyDayList}"/>
```
Add `MyDay_PrepareDay` to `locales/en.json` ("Prepare day") and `locales/de.json` ("Tag vorbereiten"), or a plain string if the view is not localized.
- [ ] **Step 4: Build the App.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
- [ ] **Step 5: Manual smoke (cannot be unit-tested):** start the Worker and App, open MyDay, click "Tag vorbereiten", confirm tasks appear (capped) and the button is hidden on other lists. Report results explicitly — do not claim UI success without running it.
- [ ] **Step 6: Commit.**
```bash
git add src/ClaudeDo.Ui src/ClaudeDo.Localization
git commit -m "feat(daily-prep): add Prepare-day button to MyDay header"
```
---
## Final verification
- [ ] `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
- [ ] `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
- [ ] `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
- [ ] `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
- [ ] `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
- [ ] End-to-end manual run: schedule fires (or button) → Claude calls the two tools → MyDay gets a capped subset; re-run keeps existing MyDay and tops up without exceeding the cap.
## Notes / risks
- Relies on the globally registered `claudedo` MCP (installer `RegisterMcpStep`). If absent, the prep run produces 0 changes — acceptable for v1.
- `--permission-mode acceptEdits` + explicit `--allowedTools` pre-approves exactly the two tools so the headless run never blocks on a permission prompt.
- The cap-guard counts `Idle && IsMyDay` tasks; it is the source of truth for the "never move everything in" invariant regardless of Claude's behavior.
- Future phase (out of scope): external ticket sources (Jira) feed into `get_daily_prep_candidates` behind a task-source abstraction.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,994 @@
# Approve = Merge → Done + Conflict Preview — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Approving a `WaitingForReview` task merges its worktree into the target branch first and only marks the task `Done` on a clean merge; conflicts keep it in review and are surfaced. Add a non-destructive "merges cleanly / conflicts" indicator and a direct single-task Merge button.
**Architecture:** A new `GitService.PreviewMergeAsync` probes mergeability via `git merge-tree --write-tree` (no working-tree mutation). `TaskMergeService` gains `PreviewAsync` and `ApproveAndMergeAsync` (merge first, then delegate the `Done` flip to `ITaskStateService`). `WorkerHub` exposes `PreviewMerge` and a result-returning `ApproveReview(taskId, targetBranch)`. The UI loads merge targets whenever a worktree exists, shows the preview, and reacts to conflict results.
**Tech Stack:** .NET 8, Avalonia, EF Core/SQLite, SignalR, xUnit with real git (`GitRepoFixture`) and real SQLite (`DbFixture`).
**Conventions for the implementer:**
- Use the **sonnet** model.
- **Stage files explicitly by path** — never `git add -A` (parallel sessions leave unrelated WIP).
- Build with `-c Release` (a running Worker locks `Debug` output).
- Conventional Commit messages: `type(scope): description`.
- New UI strings use **plain English literals** to match the surrounding merge controls (no `loc:Tr`) — this avoids Localization.Tests parity churn.
- Ignore anything under `.claude/worktrees/` — those are stale worktrees, not the build tree.
---
## File map
| File | Change |
|------|--------|
| `src/ClaudeDo.Data/Git/GitService.cs` | Add `MergePreview` record + `PreviewMergeAsync` + `CountChangedFilesAsync` |
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | Inject `ITaskStateService`; add `MergePreviewResult` + `PreviewAsync` + `ApproveAndMergeAsync` |
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | Add `MergePreviewDto` + `PreviewMerge`; change `ApproveReview` to `(taskId, targetBranch) → MergeResultDto` |
| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` | Change `ApproveReviewAsync`; add `PreviewMergeAsync`, `MergeTaskAsync` |
| `src/ClaudeDo.Ui/Services/WorkerClient.cs` | Implement the above; add UI `MergePreviewDto` record |
| `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs` | New pure presenter (text + color flags) |
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` | Load targets for worktree tasks; preview props; approve conflict handling; `MergeCommand` |
| `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` | Update the list-level approve call to new signature |
| `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` | Mergeability status line + Merge button |
| `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` | New — git-backed preview tests |
| `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` | Update `BuildService`; add preview + approve-merge tests |
| `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` | Update `FakeWorkerClient` |
| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | Update fake |
| `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` | Update the `ApproveReviewAsync` override |
| `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` | New — presenter unit tests |
---
## Task 1: GitService non-destructive merge probe
**Files:**
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` (create)
Behaviour verified on git 2.50: `git merge-tree --write-tree --name-only <target> <source>` exits `0` when clean (stdout = a single tree-OID line) and `1` on conflict (stdout = tree-OID line, then conflicted file names, then a blank line, then informational messages). It writes only loose objects — the working tree, index, and refs are untouched.
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs`:
```csharp
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Runner;
public class GitServicePreviewMergeTests : IDisposable
{
private readonly List<GitRepoFixture> _repos = new();
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } }
[Fact]
public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat");
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.True(preview.Supported);
Assert.True(preview.Clean);
Assert.Empty(preview.ConflictFiles);
var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.Equal(1, count);
}
[Fact]
public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme");
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.True(preview.Supported);
Assert.False(preview.Clean);
Assert.Contains("README.md", preview.ConflictFiles);
// Non-destructive: HEAD unchanged, no mid-merge state.
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
}
}
```
- [ ] **Step 2: Run the tests, verify they fail to compile**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
Expected: build error — `PreviewMergeAsync`/`CountChangedFilesAsync` do not exist.
- [ ] **Step 3: Implement the probe**
In `src/ClaudeDo.Data/Git/GitService.cs`, add this record just under `namespace ClaudeDo.Data.Git;`:
```csharp
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
```
Add these methods inside the `GitService` class (e.g. after `ListConflictedFilesAsync`):
```csharp
/// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched.
/// </summary>
public async Task<MergePreview> PreviewMergeAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
if (exitCode == 0)
return new MergePreview(true, true, Array.Empty<string>());
if (exitCode == 1)
{
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
var lines = stdout.Split('\n');
var files = new List<string>();
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) break;
files.Add(line.Trim());
}
return new MergePreview(true, false, files);
}
// Any other exit (e.g. git too old: "unknown option --write-tree").
return new MergePreview(false, false, Array.Empty<string>());
}
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
public async Task<int> CountChangedFilesAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
if (exitCode != 0) return 0;
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Count(s => s.Length > 0);
}
```
- [ ] **Step 4: Run the tests, verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs
git commit -m "feat(git): add non-destructive merge-tree conflict probe"
```
---
## Task 2: TaskMergeService preview + approve-merge orchestration
**Files:**
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
`ApproveAndMergeAsync` merges first (reusing `MergeAsync`, `removeWorktree:false`) and only then delegates the `Done` flip to `ITaskStateService.ApproveReviewAsync` (the sole owner of Status writes). Conflicts/blocks return without flipping status. No DI cycle: `TaskStateService` and `PlanningChainCoordinator` do not depend on `TaskMergeService`.
- [ ] **Step 1: Update `BuildService` and add failing tests**
In `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`, replace the `BuildService` helper so it also constructs a real `TaskStateService` (existing merge tests still pass — they only inspect the merge service's own broadcaster proxy):
```csharp
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
{
var fakeHub = new MergeRecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
var svc = new TaskMergeService(
db.CreateFactory(),
new GitService(),
broadcaster,
state,
NullLogger<TaskMergeService>.Instance);
return (svc, fakeHub.Proxy);
}
```
Add these tests to the class:
```csharp
[Fact]
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.True(preview.ChangedFileCount >= 1);
}
[Fact]
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
Assert.Contains("README.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
[Fact]
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
```
- [ ] **Step 2: Run the tests, verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
Expected: build error — `ITaskStateService` ctor arg, `PreviewAsync`, `ApproveAndMergeAsync`, `PreviewClean/PreviewConflict/PreviewUnavailable` do not exist.
- [ ] **Step 3: Implement in TaskMergeService**
In `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`:
Add `using ClaudeDo.Worker.State;` to the usings.
Add the preview-result record beside `MergeTargets`:
```csharp
public sealed record MergePreviewResult(
string Status,
IReadOnlyList<string> ConflictFiles,
int ChangedFileCount);
```
Add the status constants beside the existing `StatusMerged` etc.:
```csharp
public const string PreviewClean = "clean";
public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable";
```
Add the field and constructor param (inject `ITaskStateService`):
```csharp
private readonly ITaskStateService _state;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
HubBroadcaster broadcaster,
ITaskStateService state,
ILogger<TaskMergeService> logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
_state = state;
_logger = logger;
}
```
Add the two methods (e.g. after `GetTargetsAsync`):
```csharp
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
if (wt is null || wt.State != WorktreeState.Active)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
if (!preview.Supported)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
if (!preview.Clean)
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
}
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review");
// No worktree to merge (sandbox run, or an improvement parent whose children own
// the worktrees) — approve straight to Done.
if (wt is null || wt.State != WorktreeState.Active)
{
var done = await _state.ApproveReviewAsync(taskId, ct);
return done.Ok
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
: Blocked(done.Reason ?? "approve failed");
}
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
if (merge.Status != StatusMerged)
return merge; // conflict or blocked — leave the task in WaitingForReview
var approve = await _state.ApproveReviewAsync(taskId, ct);
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
}
```
- [ ] **Step 4: Run the tests, verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
Expected: PASS (all existing + 6 new).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): approve merges worktree before marking task done"
```
---
## Task 3: WorkerHub — PreviewMerge + result-returning ApproveReview
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
This is SignalR wiring (no unit test); verify by building the Worker.
- [ ] **Step 1: Add the DTO**
Beside the existing `MergeResultDto`/`MergeTargetsDto` records (around line 56):
```csharp
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
```
- [ ] **Step 2: Add `PreviewMerge` and replace `ApproveReview`**
Add a `PreviewMerge` method beside `GetMergeTargets`:
```csharp
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
});
```
Replace the existing `ApproveReview` method (currently lines ~383-387, delegating to `_state.ApproveReviewAsync`) with:
```csharp
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "approve failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
```
(Conflicts are returned, not thrown, so the UI can display the conflicting files; only hard blocks throw.)
- [ ] **Step 3: Build the Worker, verify green**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded. (DI resolves the new `ITaskStateService` dependency of `TaskMergeService` automatically — it is already registered.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): expose PreviewMerge hub method and merge-on-approve"
```
---
## Task 4: UI client + interface + test fakes
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (caller at line 648)
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`)
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- Modify: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (override)
Note: `DetailsIslandViewModel.ApproveReviewAsync` (line 1368) is updated in Task 5, not here — but the interface change forces it to compile, so Task 5 must follow before the Ui project builds. To keep this task self-contained and green on its own, update that call site here too (the conflict-handling logic lands in Task 5).
- [ ] **Step 1: Add the UI DTO**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, beside the existing `MergeResultDto`/`MergeTargetsDto` records (lines 521-522):
```csharp
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
```
- [ ] **Step 2: Update the interface**
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, replace `Task ApproveReviewAsync(string taskId);` (line 40) with:
```csharp
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
```
(`MergeTaskAsync` already exists on the concrete `WorkerClient` — this only adds it to the interface.)
- [ ] **Step 3: Update the concrete client**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, replace the existing `ApproveReviewAsync` (line ~389) and add `PreviewMergeAsync`. Mirror the existing `GetMergeTargetsAsync` pattern (it uses the `TryInvokeAsync<T>` helper which returns `null` when disconnected):
```csharp
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
```
Ensure the existing `public async Task<MergeResultDto> MergeTaskAsync(...)` signature matches the interface exactly (params: `string taskId, string targetBranch, bool removeWorktree, string commitMessage`). Leave its body as-is.
- [ ] **Step 4: Update the two callers**
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` line 648 — the list-level quick approve has no merge-target selector, so it merges into the repo's current branch (empty string resolves server-side):
```csharp
try { await _worker.ApproveReviewAsync(row.Id, ""); }
```
`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 1368 — update to the new signature for now (full conflict handling is added in Task 5):
```csharp
try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); }
```
- [ ] **Step 5: Update the three test fakes**
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` line 53 — replace and add:
```csharp
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
```
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` line 45 (`FakeWorkerClient`) — replace and add:
```csharp
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
```
(Confirm whether `FakeWorkerClient` already implements `MergeTaskAsync`; if so, only change `ApproveReviewAsync` and add `PreviewMergeAsync`. Add `using` for the DTO namespace if needed — same namespace as `IWorkerClient`.)
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` line 77 — update the override signature:
```csharp
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
/* keep whatever recording/behavior this override had, now returning Task<MergeResultDto?> */
Task.FromResult<MergeResultDto?>(null);
```
(Preserve any side effect the existing override performed — e.g. recording the call — just change the signature and return type.)
- [ ] **Step 6: Build UI + run both UI-touching test projects**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandViewModelPlanning`
Expected: all green.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
git commit -m "feat(ui): wire merge-aware approve and preview into the worker client"
```
---
## Task 5: Mergeability presenter + DetailsIslandViewModel wiring
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` (create)
- [ ] **Step 1: Write the failing presenter tests**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class MergePreviewPresenterTests
{
[Fact]
public void Clean_Plural()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
Assert.Equal("Merges cleanly · 3 files", text);
Assert.True(clean);
Assert.False(conflict);
}
[Fact]
public void Clean_Singular()
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
Assert.Equal("Merges cleanly · 1 file", text);
}
[Fact]
public void Conflict_ListsUpToThree()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
Assert.Equal("Conflicts in a.cs, b.cs", text);
Assert.False(clean);
Assert.True(conflict);
}
[Fact]
public void Conflict_TruncatesWithMore()
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
}
[Fact]
public void Unavailable_IsMuted()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
Assert.Equal("Mergeability unknown", text);
Assert.False(clean);
Assert.False(conflict);
}
[Fact]
public void Null_IsEmpty()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
Assert.Equal("", text);
Assert.False(clean);
Assert.False(conflict);
}
}
```
- [ ] **Step 2: Run, verify it fails to compile**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
Expected: build error — `MergePreviewPresenter` does not exist.
- [ ] **Step 3: Create the presenter**
Create `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`:
```csharp
using System.Linq;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// Pure mapping from a merge-preview DTO to display text + color flags.
public static class MergePreviewPresenter
{
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
{
if (dto is null) return ("", false, false);
switch (dto.Status)
{
case "clean":
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
case "conflict":
var names = string.Join(", ", dto.ConflictFiles.Take(3));
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
return ($"Conflicts in {names}{more}", false, true);
default:
return ("Mergeability unknown", false, false);
}
}
}
```
- [ ] **Step 4: Run, verify the presenter tests pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
Expected: PASS (6 tests).
- [ ] **Step 5: Wire the presenter into DetailsIslandViewModel**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`:
(a) Add observable properties (near the other merge properties, ~line 334):
```csharp
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private string _mergePreviewText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsClean;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsConflict;
public bool ShowMergePreviewMuted =>
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
public bool ShowSingleMerge =>
WorktreePath != null && Task?.IsPlanningParent != true;
```
(b) Add the refresh method:
```csharp
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
{
if (Task is null || WorktreePath is null)
{
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
return;
}
// Only probe Active worktrees; terminal states show their label instead.
if (WorktreeStateLabel is { } label && label != "Active")
{
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
return;
}
var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? "");
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
}
```
(c) Recompute when the merge target changes — add (or extend) the generated partial:
```csharp
partial void OnSelectedMergeTargetChanged(string? value)
{
_ = RefreshMergePreviewAsync();
}
```
(d) Notify `ShowSingleMerge` when the worktree path changes. In the existing `OnWorktreePathChanged` (line ~1141) add:
```csharp
OnPropertyChanged(nameof(ShowSingleMerge));
```
(e) Load merge targets for standalone worktree tasks. In `BindAsync`, after the `if (entity.PlanningPhase != None) {...} else {...}` block (~line 814), add:
```csharp
if (entity.Worktree != null
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
&& MergeTargetBranches.Count == 0)
{
var targets = await _worker.GetMergeTargetsAsync(row.Id);
if (targets != null)
{
MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
}
}
await RefreshMergePreviewAsync();
```
(f) Replace the body of `ApproveReviewAsync` (line ~1362) to surface conflicts:
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
try
{
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
if (result?.Status == "conflict")
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
catch { /* stale review action; broadcast reconciles */ }
}
```
(g) Add the single-task `MergeCommand` (place near `OpenDiffAsync`):
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task MergeAsync()
{
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
try
{
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
if (result.Status == "conflict")
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
else
{
await RefreshMergePreviewAsync();
}
}
catch { /* broadcast reconciles */ }
}
```
- [ ] **Step 6: Build UI + run the UI tests**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: green. (If `OnSelectedMergeTargetChanged` already exists, merge the new line into it instead of duplicating.)
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs
git commit -m "feat(ui): show mergeability and surface approve conflicts in the work console"
```
---
## Task 6: WorkConsole — status line + Merge button
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
No unit test (XAML); verified by build + manual visual check in Task 7.
- [ ] **Step 1: Add the mergeability status line and the Merge button**
In the `MERGE & WORKTREE` `StackPanel` (starts line 196), insert the status line **between** the merge-target `StackPanel` (ends line 203) and the `<WrapPanel>` (line 204). Three single-line `TextBlock`s, one visible at a time by color:
```xml
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
```
In the `<WrapPanel>` (line 204), add a **Merge** button immediately after the "Open Diff" button (line 206):
```xml
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
```
- [ ] **Step 2: Build UI, verify green**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded (XAML compiles; all bound members exist from Task 5).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "feat(ui): add mergeability indicator and Merge button to work console"
```
---
## Task 7: Full build, full test, manual verification
**Files:** none (verification only)
- [ ] **Step 1: Build the whole app + worker**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: both succeed.
- [ ] **Step 2: Run all touched test projects**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: all green.
- [ ] **Step 3: Manual verification (cannot be automated — no real Claude in tests)**
Start the Worker, then the App. Pick a list whose `WorkingDir` is a real git repo and use a task that already has an Active worktree (or create one).
Verify each acceptance criterion:
1. **Clean approve:** Open a `WaitingForReview` task whose worktree merges cleanly → the Session tab shows green "Merges cleanly · N files". Click **Approve** → the worktree merges into the target, the task becomes **Done**, and the worktree state becomes **Merged** (check the worktree overview).
2. **Conflicting approve:** Open a task whose worktree conflicts with the target → the Session tab shows red "Conflicts in …". Click **Approve** → the task stays **WaitingForReview** (NOT Done), the conflict line remains, and the target branch is unchanged.
3. **Done task preview:** Open a previously-Done task that was never merged (worktree still Active) → the merge/conflict status appears without any tree mutation; the **Merge** button merges it on demand.
Report the result of each check explicitly. If any visual issue appears (colors, layout, missing controls), note it for the user — do not claim the UI works without running it.
---
## Self-review notes
- **Spec coverage:** Approve-merge (Task 2/3/5), conflict-keeps-review (Task 2 test + Task 5 surfacing), non-destructive preview (Task 1/2 + indicator in Task 5/6), real single-task Merge button (Task 5/6), standalone target-loading gap (Task 5e). All spec sections map to a task.
- **Type consistency:** `MergePreview` (Data) → `MergePreviewResult` (Worker service) → `MergePreviewDto` (hub + UI). Status strings `clean`/`conflict`/`unavailable` and merge statuses `merged`/`conflict`/`blocked` are used consistently across worker, client, presenter, and VM.
- **No new statuses, no DB migration, no localization keys** (literals match the surrounding controls).
- **External MCP unchanged:** `ExternalMcpService.ReviewTask` keeps calling `TaskStateService.ApproveReviewAsync` directly (its documented scope excludes merges); that method's signature is unchanged.
@@ -0,0 +1,972 @@
# Bundled Prompts Overhaul Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Externalize every bundled prose prompt into editable files with strong defaults, collapse system+agent, and add an inline `CLAUDEDO_BLOCKED:` roadblock protocol surfaced at review.
**Architecture:** `PromptFiles` becomes the single source of prompt defaults + a pure token renderer. Each consumer (TaskRunner, PlanningSessionManager, DailyPrepPrompt, WeekReportPromptBuilder) reads its prompt via `PromptFiles`. `StreamAnalyzer` collects roadblock markers from streamed assistant text; the runner folds them into the review result.
**Tech Stack:** .NET 8, xUnit, EF Core (no schema change in this plan).
Spec: `docs/superpowers/specs/2026-06-04-bundled-prompts-overhaul-design.md`
---
## File structure
- `src/ClaudeDo.Data/PromptFiles.cs` — new `PromptKind` members, new defaults, `RenderTemplate` + `ReadOrDefault` + `Render`.
- `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs` — collect `Blocks` from assistant text.
- `src/ClaudeDo.Worker/Runner/RunResult.cs` — carry `Blocks`.
- `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs` — pass `Blocks`; expose no-result prefix const.
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — drop agent file; retry via `retry.md`; fold blocks into review result.
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — read planning prompts via `PromptFiles`.
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — read `daily-prep.md`.
- `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs` — read `weekly-report.md`.
- `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs` + its view — expose new prompt files, drop agent.
- Tests in `tests/ClaudeDo.Data.Tests` and `tests/ClaudeDo.Worker.Tests`.
Build commands (this repo is on .NET 8 — build per project, not the .slnx):
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
---
## Task 1: PromptFiles — kinds, defaults, pure renderer
**Files:**
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
- Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs` (create)
- [ ] **Step 1: Write failing tests for the pure renderer**
Create `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs`:
```csharp
using ClaudeDo.Data;
namespace ClaudeDo.Data.Tests;
public class PromptFilesTests
{
[Fact]
public void RenderTemplate_replaces_known_tokens()
{
var outp = PromptFiles.RenderTemplate(
"Plan for {date}, cap {maxTasks}.",
new Dictionary<string, string> { ["date"] = "2026-06-04", ["maxTasks"] = "5" });
Assert.Equal("Plan for 2026-06-04, cap 5.", outp);
}
[Fact]
public void RenderTemplate_leaves_unknown_braces_intact()
{
var outp = PromptFiles.RenderTemplate(
"## {Wochentag}, {dd.MM.yyyy} — {start}",
new Dictionary<string, string> { ["start"] = "01.06.2026" });
Assert.Equal("## {Wochentag}, {dd.MM.yyyy} — 01.06.2026", outp);
}
[Fact]
public void DefaultFor_system_mentions_blocked_marker_and_scope()
{
var d = PromptFiles.DefaultFor(PromptKind.System);
Assert.Contains("CLAUDEDO_BLOCKED:", d);
Assert.Contains("unattended", d, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void DefaultFor_planning_initial_has_title_and_description_tokens()
{
var d = PromptFiles.DefaultFor(PromptKind.PlanningInitial);
Assert.Contains("{title}", d);
Assert.Contains("{description}", d);
}
[Fact]
public void PathFor_planning_is_planning_system_file()
{
Assert.EndsWith("planning-system.md", PromptFiles.PathFor(PromptKind.Planning));
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
Expected: FAIL — `RenderTemplate`/`DefaultFor` don't exist, `PromptKind.PlanningInitial` undefined.
- [ ] **Step 3: Rewrite PromptFiles.cs**
Replace the entire contents of `src/ClaudeDo.Data/PromptFiles.cs` with:
```csharp
using System.Text;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport }
public static class PromptFiles
{
public static string Root => Path.Combine(Paths.AppDataRoot(), "prompts");
public static string PathFor(PromptKind kind) => kind switch
{
PromptKind.System => Path.Combine(Root, "system.md"),
PromptKind.Planning => Path.Combine(Root, "planning-system.md"),
PromptKind.PlanningInitial => Path.Combine(Root, "planning-initial.md"),
PromptKind.Retry => Path.Combine(Root, "retry.md"),
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
public static void EnsureExists(PromptKind kind)
{
Directory.CreateDirectory(Root);
var path = PathFor(kind);
if (File.Exists(path)) return;
File.WriteAllText(path, DefaultFor(kind));
}
public static string? ReadOrNull(PromptKind kind)
{
var path = PathFor(kind);
if (!File.Exists(path)) return null;
var content = File.ReadAllText(path).Trim();
return string.IsNullOrEmpty(content) ? null : content;
}
/// <summary>File content if present and non-empty, otherwise the bundled default.</summary>
public static string ReadOrDefault(PromptKind kind) => ReadOrNull(kind) ?? DefaultFor(kind);
/// <summary>Render a prompt: read file-or-default, then substitute named tokens.</summary>
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
=> RenderTemplate(ReadOrDefault(kind), values);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
{
var sb = new StringBuilder(template);
foreach (var (key, val) in values)
sb.Replace("{" + key + "}", val);
return sb.ToString();
}
public static string DefaultFor(PromptKind kind) => kind switch
{
PromptKind.System => SystemDefault,
PromptKind.Planning => PlanningSystemDefault,
PromptKind.PlanningInitial => PlanningInitialDefault,
PromptKind.Retry => RetryDefault,
PromptKind.DailyPrep => DailyPrepDefault,
PromptKind.WeeklyReport => WeeklyReportDefault,
_ => ""
};
private const string SystemDefault = """
# Working Agreement
You are completing one well-defined task autonomously in a git repository.
## Scope
- Do exactly what the task asks — no unrequested refactors, renames, dependency
changes, or "while I'm here" cleanup.
- If intent is ambiguous, state the assumption you're making and proceed with the
most reasonable reading. Stop only if you genuinely cannot move forward.
- Prefer three similar lines over a premature abstraction. Don't build for
hypothetical future needs.
## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase —
they override generic defaults.
- Prefer editing existing files to creating new ones. Don't write comments that
just restate the code.
- Validate only at real boundaries (user input, external APIs).
## Finishing
- Before claiming done, verify: run the build and relevant tests, confirm they
pass, and report what you ran. If you couldn't verify something, say so plainly.
- Make focused commits using the repository's existing commit-message convention.
## Safety
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
without being asked.
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
## You are running unattended
You run autonomously with no human watching. There is no one to answer mid-task
questions, so never stop to ask — make the most reasonable decision, note the
assumption, and continue.
## When you are blocked
If something genuinely prevents you from completing part of the task (missing
credentials, contradictory requirements, a destructive action you won't take
unasked), do NOT silently give up. Write this marker on its own line, then keep
working on whatever else you can:
CLAUDEDO_BLOCKED: <one short sentence describing what blocked you>
Emit it as many times as needed — once per distinct blocker. Use it only for true
blockers, not for routine decisions you can make yourself.
""";
private const string PlanningSystemDefault = """
You are the planning assistant for ClaudeDo. Your job is to break a task into
smaller, independently executable subtasks — the session ends by creating those
subtasks.
Start every session by invoking the `superpowers:brainstorming` skill (Skill
tool) and follow it end to end: clarifying questions one at a time, then 23
approaches with a recommendation, then a short design. Do not create any subtasks
until the user has approved the design.
You can ONLY shape this task's plan — you cannot edit files or touch other tasks.
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear
done-state, ordered so dependencies come first.
""";
private const string PlanningInitialDefault = """
# Task to plan: {title}
{description}
""";
private const string RetryDefault = """
The task did not complete on the previous attempt — you may have run out of
turns, hit an error, or stopped before finishing.
Review the work already done in this session and the current state of the
repository, identify what is still incomplete or broken, and finish the task.
Don't restart from scratch or repeat a failed approach. Verify the result
(build + tests) before you stop.
""";
private const string DailyPrepDefault = """
You are preparing my workday for {date}.
1. Call mcp__claudedo__get_daily_prep_candidates.
2. Keep tasks already marked MyDay (currentMyDay) — never remove them.
3. Fill MyDay to at most {maxTasks} open tasks TOTAL (currentMyDay counts). Never exceed it.
4. Estimate each candidate's effort and pick a feasible mix — not only big items.
Prioritize isStarred, due (scheduledFor), and older tasks.
5. Place related tasks next to each other using consecutive sortOrder values.
6. Apply via mcp__claudedo__set_my_day(taskId, true, sortOrder). Never mark anything
outside the candidate list.
If there are no candidates, do nothing.
""";
private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
activity (German weekday names). Omit days with no activity.
- Within each day: 35 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The notes are authoritative — never omit or contradict them.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
Two sections follow below: an activity log derived from Claude session history,
and the developer's own notes. Base the report on both; the notes are
authoritative where they conflict with the derived activity.
""";
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release`
Expected: PASS (5 new tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
git commit -m "feat(prompts): externalize prompt kinds with defaults and token renderer"
```
---
## Task 2: TaskRunner — drop agent file from system prompt merge
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:382-386`
- [ ] **Step 1: Remove the agent-file read and merge**
In `ResolveConfigAsync`, replace:
```csharp
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
var agentFile = PromptFiles.ReadOrNull(PromptKind.Agent);
var instructions = MergeInstructions(
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt, agentFile);
```
with:
```csharp
var systemFile = PromptFiles.ReadOrNull(PromptKind.System);
var instructions = MergeInstructions(
systemFile, global.DefaultClaudeInstructions, listConfig?.SystemPrompt, task.SystemPrompt);
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: PASS (no reference to `PromptKind.Agent` remains).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "refactor(prompts): collapse agent prompt into system prompt"
```
---
## Task 3: Retry prompt from file + conditional stderr append
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:101-103` (expose prefix const)
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (add `BuildRetryPrompt`, use it at ~L107)
- Test: `tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs` (create)
- [ ] **Step 1: Write failing tests for the retry-prompt helper**
Create `tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs`:
```csharp
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public class RetryPromptTests
{
[Fact]
public void Generic_no_result_error_is_not_appended()
{
var prompt = TaskRunner.BuildRetryPrompt($"{ClaudeProcess.NoResultPrefix} 1 and no result.");
Assert.DoesNotContain("Captured error", prompt);
Assert.Contains("did not complete", prompt);
}
[Fact]
public void Real_error_is_appended()
{
var prompt = TaskRunner.BuildRetryPrompt("error CS1002: ; expected");
Assert.Contains("Captured error", prompt);
Assert.Contains("CS1002", prompt);
}
[Fact]
public void Null_error_yields_bare_prompt()
{
var prompt = TaskRunner.BuildRetryPrompt(null);
Assert.DoesNotContain("Captured error", prompt);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RetryPromptTests`
Expected: FAIL — `BuildRetryPrompt` / `NoResultPrefix` don't exist.
- [ ] **Step 3: Expose the no-result prefix in ClaudeProcess**
In `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs`, add the const near the top of the class and use it in the error fallback. Replace:
```csharp
var error = lastStderr.Length > 0
? lastStderr.ToString().Trim()
: $"Claude exited with code {exitCode} and no result.";
```
with:
```csharp
var error = lastStderr.Length > 0
? lastStderr.ToString().Trim()
: $"{NoResultPrefix} {exitCode} and no result.";
```
and add inside the class (e.g. just below the fields):
```csharp
public const string NoResultPrefix = "Claude exited with code";
```
- [ ] **Step 4: Add BuildRetryPrompt to TaskRunner and use it**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add this static method (next to `MergeInstructions`):
```csharp
public static string BuildRetryPrompt(string? capturedError)
{
var basePrompt = PromptFiles.ReadOrDefault(PromptKind.Retry);
var isReal = !string.IsNullOrWhiteSpace(capturedError)
&& !capturedError!.StartsWith(ClaudeProcess.NoResultPrefix, StringComparison.Ordinal);
return isReal
? $"{basePrompt}\n\nCaptured error from the failed run:\n\n{capturedError!.Trim()}"
: basePrompt;
}
```
Then replace the inline retry prompt at ~L107:
```csharp
var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
```
with:
```csharp
var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RetryPromptTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/ClaudeProcess.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/RetryPromptTests.cs
git commit -m "feat(prompts): retry prompt from file, append only real captured errors"
```
---
## Task 4: PlanningSessionManager reads planning prompts from files
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (`BuildSystemPrompt` ~L366, `BuildInitialPrompt` ~L392)
- [ ] **Step 1: Replace BuildSystemPrompt body**
Replace the whole method body of `BuildSystemPrompt()` with:
```csharp
private static string BuildSystemPrompt() => PromptFiles.ReadOrDefault(PromptKind.Planning);
```
(Delete the inline fallback string literal that followed.)
- [ ] **Step 2: Replace BuildInitialPrompt body**
Replace the whole method body of `BuildInitialPrompt(TaskEntity task)` with:
```csharp
private static string BuildInitialPrompt(TaskEntity task) =>
PromptFiles.Render(PromptKind.PlanningInitial, new Dictionary<string, string>
{
["title"] = task.Title,
["description"] = task.Description ?? "",
});
```
Ensure `using ClaudeDo.Data;` is present (it is — `PromptFiles` lived there already via `ReadOrNull`).
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(prompts): planning prompts read from editable files"
```
---
## Task 5: DailyPrepPrompt reads from file
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs`
- [ ] **Step 1: Update DailyPrepPromptTests to assert the English default render**
Replace the `Build_prompt_contains_cap_and_date` test body with:
```csharp
[Fact]
public void Build_prompt_contains_cap_and_date()
{
var prompt = DailyPrepPrompt.BuildPrompt(maxTasks: 5, today: new DateOnly(2026, 6, 3));
Assert.Contains("5", prompt);
Assert.Contains("2026-06-03", prompt);
Assert.Contains("get_daily_prep_candidates", prompt);
Assert.Contains("set_my_day", prompt);
Assert.Contains("preparing my workday", prompt);
}
```
(The new assertion pins the English default; the file-read path is exercised by the same default when no `daily-prep.md` exists.)
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DailyPrepPromptTests`
Expected: FAIL — current German prompt has no "preparing my workday".
- [ ] **Step 3: Rewrite BuildPrompt to read the file**
In `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs`, replace the `BuildPrompt` method with:
```csharp
public static string BuildPrompt(int maxTasks, DateOnly today) =>
ClaudeDo.Data.PromptFiles.Render(
ClaudeDo.Data.PromptKind.DailyPrep,
new Dictionary<string, string>
{
["date"] = today.ToString("yyyy-MM-dd"),
["maxTasks"] = maxTasks.ToString(),
});
```
Leave `BuildArgs`, `LogPath`, and the tool-name consts unchanged.
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DailyPrepPromptTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs tests/ClaudeDo.Worker.Tests/Prime/DailyPrepPromptTests.cs
git commit -m "feat(prompts): daily-prep prompt from file, English default"
```
---
## Task 6: WeekReportPromptBuilder reads instructions from file
**Files:**
- Modify: `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs`
- Check: `tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs`
- [ ] **Step 1: Replace the inline Instructions with a file read**
In `WeekReportPromptBuilder.Build`, replace:
```csharp
var sb = new StringBuilder();
sb.AppendLine(string.Format(CultureInfo.InvariantCulture, Instructions,
start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture)));
sb.AppendLine();
```
with:
```csharp
var sb = new StringBuilder();
sb.AppendLine(ClaudeDo.Data.PromptFiles.Render(
ClaudeDo.Data.PromptKind.WeeklyReport,
new Dictionary<string, string>
{
["start"] = start.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
["end"] = end.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture),
}));
sb.AppendLine();
```
Then delete the now-unused `private const string Instructions = ...` block. (The `{Wochentag}`/`{dd.MM.yyyy}` literals inside the default survive because `RenderTemplate` only replaces `{start}`/`{end}`.)
- [ ] **Step 2: Verify the existing builder test still passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WeekReportPromptBuilderTests`
Expected: PASS. If a test asserted exact old wording, update it to assert the date appears and that activity/notes sections render (the new default keeps German output rules).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs tests/ClaudeDo.Worker.Tests/Report/WeekReportPromptBuilderTests.cs
git commit -m "feat(prompts): weekly-report instructions from file, point at data sections"
```
---
## Task 7: StreamAnalyzer collects roadblock markers
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs`
- [ ] **Step 1: Write failing tests**
Append to `StreamAnalyzerTests`:
```csharp
[Fact]
public void Collects_Blocked_Markers_From_Assistant_Text()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"working\nCLAUDEDO_BLOCKED: missing API key\nmoving on"}]}}""");
analyzer.ProcessLine("""{"type":"assistant","message":{"content":[{"type":"text","text":"CLAUDEDO_BLOCKED: cannot reach db"}]}}""");
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.Equal(2, result.Blocks.Count);
Assert.Equal("missing API key", result.Blocks[0]);
Assert.Equal("cannot reach db", result.Blocks[1]);
}
[Fact]
public void Strips_Blocked_Markers_From_Result_Text()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"All set.\nCLAUDEDO_BLOCKED: no creds\nDone.","session_id":"s1"}""");
var result = analyzer.GetResult();
Assert.DoesNotContain("CLAUDEDO_BLOCKED", result.ResultMarkdown);
Assert.Single(result.Blocks);
Assert.Equal("no creds", result.Blocks[0]);
}
[Fact]
public void No_Markers_Means_Empty_Blocks()
{
var analyzer = new StreamAnalyzer();
analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
Assert.Empty(analyzer.GetResult().Blocks);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StreamAnalyzerTests`
Expected: FAIL — `Blocks` doesn't exist.
- [ ] **Step 3: Implement marker collection in StreamAnalyzer**
In `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`:
Add to `StreamResult`:
```csharp
public IReadOnlyList<string> Blocks { get; set; } = Array.Empty<string>();
```
Add a field and a constant to `StreamAnalyzer`:
```csharp
private readonly List<string> _blocks = new();
private const string BlockedPrefix = "CLAUDEDO_BLOCKED:";
```
In the `case "result":` branch, after `_resultMarkdown` is assigned, scan and strip:
```csharp
if (root.TryGetProperty("result", out var resultProp))
_resultMarkdown = StripAndCollect(resultProp.GetString());
```
In the `case "assistant":` branch, collect from text content (keep `_turnCount++`):
```csharp
case "assistant":
_turnCount++;
CollectFromAssistant(root);
break;
```
Add these helpers to the class:
```csharp
private void CollectFromAssistant(JsonElement root)
{
if (!root.TryGetProperty("message", out var msg)) return;
if (!msg.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) return;
foreach (var block in content.EnumerateArray())
if (block.TryGetProperty("type", out var t) && t.GetString() == "text"
&& block.TryGetProperty("text", out var txt))
ScanForBlocks(txt.GetString());
}
private void ScanForBlocks(string? text)
{
if (string.IsNullOrEmpty(text)) return;
foreach (var line in text.Split('\n'))
{
var trimmed = line.Trim();
if (trimmed.StartsWith(BlockedPrefix, StringComparison.Ordinal))
_blocks.Add(trimmed[BlockedPrefix.Length..].Trim());
}
}
private string? StripAndCollect(string? text)
{
if (string.IsNullOrEmpty(text)) return text;
ScanForBlocks(text);
var kept = text.Split('\n')
.Where(l => !l.Trim().StartsWith(BlockedPrefix, StringComparison.Ordinal));
return string.Join('\n', kept).Trim();
}
```
Add `Blocks = _blocks` to the `GetResult()` initializer:
```csharp
public StreamResult GetResult() => new()
{
ResultMarkdown = FallbackResult(),
StructuredOutputJson = _structuredOutputJson,
SessionId = _sessionId,
TurnCount = _turnCount,
TokensIn = _tokensIn,
TokensOut = _tokensOut,
ApiRetryCount = _apiRetryCount,
Blocks = _blocks,
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StreamAnalyzerTests`
Expected: PASS (all old + 3 new).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
git commit -m "feat(roadblock): collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer"
```
---
## Task 8: RunResult + ClaudeProcess carry Blocks
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/RunResult.cs`
- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:89-113`
- [ ] **Step 1: Add Blocks to RunResult**
In `src/ClaudeDo.Worker/Runner/RunResult.cs`, add inside the class:
```csharp
public IReadOnlyList<string> Blocks { get; init; } = Array.Empty<string>();
```
- [ ] **Step 2: Populate Blocks in both RunResult returns**
In `ClaudeProcess.RunAsync`, add `Blocks = streamResult.Blocks,` to **both** the success `RunResult { ... }` (after `TokensOut`) and the error `RunResult { ... }` initializer.
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/RunResult.cs src/ClaudeDo.Worker/Runner/ClaudeProcess.cs
git commit -m "feat(roadblock): carry blocks through RunResult"
```
---
## Task 9: Fold roadblocks into the review result
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs` (`HandleSuccess` ~L319-352; add `ComposeReviewResult`)
- Test: `tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs` (create)
- [ ] **Step 1: Write failing tests for the compose helper**
Create `tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs`:
```csharp
using ClaudeDo.Worker.Runner;
namespace ClaudeDo.Worker.Tests.Runner;
public class ReviewResultTests
{
[Fact]
public void No_blocks_returns_result_unchanged()
{
Assert.Equal("done", TaskRunner.ComposeReviewResult("done", Array.Empty<string>()));
}
[Fact]
public void Blocks_are_appended_as_a_section()
{
var outp = TaskRunner.ComposeReviewResult("done", new[] { "no creds", "db down" });
Assert.Contains("⚠ Roadblocks", outp);
Assert.Contains("- no creds", outp);
Assert.Contains("- db down", outp);
Assert.Contains("done", outp);
}
[Fact]
public void Null_result_with_blocks_still_lists_them()
{
var outp = TaskRunner.ComposeReviewResult(null, new[] { "x" });
Assert.Contains("⚠ Roadblocks", outp);
Assert.Contains("- x", outp);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ReviewResultTests`
Expected: FAIL — `ComposeReviewResult` doesn't exist.
- [ ] **Step 3: Add ComposeReviewResult and use it in HandleSuccess**
In `TaskRunner`, add:
```csharp
public static string? ComposeReviewResult(string? result, IReadOnlyList<string> blocks)
{
if (blocks.Count == 0) return result;
var section = "⚠ Roadblocks reported during the run:\n"
+ string.Join('\n', blocks.Select(b => $"- {b}"));
return string.IsNullOrWhiteSpace(result) ? section : $"{result}\n\n{section}";
}
```
In `HandleSuccess`, compute the composed result once and pass it to both terminal writes:
```csharp
var finishedAt = DateTime.UtcNow;
var reviewResult = ComposeReviewResult(result.ResultMarkdown, result.Blocks);
if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
```
(Make sure `using System.Linq;` is available — it is, via implicit usings.)
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ReviewResultTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/ReviewResultTests.cs
git commit -m "feat(roadblock): surface reported roadblocks in the review result"
```
---
## Task 10: Files-settings UI exposes the new prompt files
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs`
- Modify: the Files settings view (find with: `Grep "SystemPromptPath" src/ClaudeDo.Ui` → the `.axaml` binding to `OpenPromptCommand`)
- [ ] **Step 1: Replace the prompt-path properties**
In `FilesSettingsTabViewModel`, replace the three path properties with the new set (drop Agent, add the rest):
```csharp
public string SystemPromptPath { get; } = PromptFiles.PathFor(PromptKind.System);
public string PlanningPromptPath { get; } = PromptFiles.PathFor(PromptKind.Planning);
public string PlanningInitialPromptPath { get; } = PromptFiles.PathFor(PromptKind.PlanningInitial);
public string RetryPromptPath { get; } = PromptFiles.PathFor(PromptKind.Retry);
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
```
(`OpenPromptCommand` already parses the `PromptKind` name from its parameter, so no command change is needed.)
- [ ] **Step 2: Update the view**
Open the Files settings `.axaml`. For the existing System/Planning/Agent rows: keep System, keep Planning, **remove the Agent row**, and add four rows mirroring the System row's markup — each binding its label/path to the new property and passing the matching `PromptKind` name as the `OpenPromptCommand` parameter:
- `Planning` (system) → "Planning system prompt", `PlanningPromptPath`, parameter `Planning`
- `PlanningInitial` → "Planning kickoff prompt", `PlanningInitialPromptPath`, parameter `PlanningInitial`
- `Retry` → "Retry prompt", `RetryPromptPath`, parameter `Retry`
- `DailyPrep` → "Daily-prep prompt", `DailyPrepPromptPath`, parameter `DailyPrep`
- `WeeklyReport` → "Weekly-report prompt", `WeeklyReportPromptPath`, parameter `WeeklyReport`
Use the exact same control template as the existing System row (same button + `CommandParameter` shape); only the bound property, label text, and parameter string differ.
- [ ] **Step 3: Build the UI project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: PASS.
- [ ] **Step 4: Visual check (manual — flag for user)**
Start the app, open Settings → Files tab. Confirm six "Open" prompt buttons appear (System, Planning system, Planning kickoff, Retry, Daily-prep, Weekly-report), no Agent row, and each opens/seeds the right file under `~/.todo-app/prompts/`. **This step cannot be verified by the agent — ask the user to confirm visually.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/Settings/FilesSettingsTabViewModel.cs src/ClaudeDo.Ui/Views/**/*Files*.axaml
git commit -m "feat(ui): expose all editable prompt files, drop agent prompt"
```
---
## Task 11: Full build + test sweep
- [ ] **Step 1: Build worker + app**
Run:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
Expected: PASS.
- [ ] **Step 2: Run all affected test projects**
Run:
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
Expected: PASS.
- [ ] **Step 3: Update docs**
Update `docs/prompts-inventory.md` to note the externalized files and that `agent.md`/`planning.md` are retired in favor of `system.md`/`planning-system.md`. Note `CLAUDEDO_BLOCKED:` in the inventory.
```bash
git add docs/prompts-inventory.md
git commit -m "docs: refresh prompt inventory for externalized prompts + roadblock marker"
```
---
## Self-review notes
- **Spec coverage:** system.md collapse (T2), planning prompts (T4), retry (T3), daily-prep English (T5), weekly-report + data pointer (T6), templating/`Render` (T1), roadblock detect/strip/route (T7T9), file layout + migration via `EnsureExists`/new `PathFor` (T1), UI surface (T10). The "Out-of-scope improvements" system.md section is intentionally **deferred to the child-tasks plan** (it depends on the `SuggestImprovement` tool).
- **Migration:** old `planning.md`/`agent.md` go inert automatically — `TaskRunner` no longer reads agent (T2), planning now reads `planning-system.md` (T1 PathFor). No code deletes the old files; harmless.
- **Determinism:** content tests target `DefaultFor`/`RenderTemplate` (pure, no disk). Consumers fall back to the same default when no user file exists.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,725 @@
# Debug Logging & Frontend↔Backend Traceability Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build-configuration-driven logging — verbose in Debug builds (Rider run button), minimal `Warning`+ in Release (installed app) — with both processes writing one shared `claudedo-.log` and a `TaskId` correlation key threading UI→Worker→UI.
**Architecture:** A new `ClaudeDo.Logging` library owns all Serilog setup: a `BuildConfig.IsDebug` runtime check (via the entry assembly's `DebuggableAttribute`, no `#if DEBUG`), a default-`TaskId` enricher, and a `LoggingSetup.Configure` method that branches sinks/levels on `IsDebug`. Worker and App both call it. `TaskId` rides Serilog `LogContext`, pushed at the per-task entry points on each side.
**Tech Stack:** .NET 8, Serilog (core + File + Console sinks), Serilog.Extensions.Logging (App bridge), Serilog.AspNetCore (Worker, already present), xUnit.
---
### Task 1: Create the `ClaudeDo.Logging` project
**Files:**
- Create: `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj`
- Create: `src/ClaudeDo.Logging/Placeholder.cs` (temporary, removed in Task 2)
- Modify: `ClaudeDo.slnx`
- [ ] **Step 1: Create the csproj**
Create `src/ClaudeDo.Logging/ClaudeDo.Logging.csproj`:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup>
</Project>
```
> If NuGet reports a version conflict between `Serilog 4.1.0` and the `Serilog` core pulled transitively by `Serilog.AspNetCore 8.0.3` (Worker), align this `Serilog` version to whatever `Serilog.AspNetCore 8.0.3` resolves (check `dotnet list package --include-transitive`) and rebuild.
- [ ] **Step 2: Add a temporary placeholder so the project compiles**
Create `src/ClaudeDo.Logging/Placeholder.cs`:
```csharp
namespace ClaudeDo.Logging;
internal static class Placeholder;
```
- [ ] **Step 3: Register the project in the solution**
Edit `ClaudeDo.slnx` — add inside the `/src/` folder, after the `ClaudeDo.Localization` line:
```xml
<Project Path="src/ClaudeDo.Logging/ClaudeDo.Logging.csproj" />
```
- [ ] **Step 4: Build the new project**
Run: `dotnet build src/ClaudeDo.Logging/ClaudeDo.Logging.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Logging/ClaudeDo.Logging.csproj src/ClaudeDo.Logging/Placeholder.cs ClaudeDo.slnx
git commit -m "build(logging): scaffold ClaudeDo.Logging project"
```
---
### Task 2: `DefaultTaskIdEnricher` (TDD)
Adds `TaskId = "-"` to any log event that doesn't already carry a `TaskId` property, so the `[{TaskId}]` column never renders the raw token. A pushed `LogContext` value takes precedence (because `Enrich.FromLogContext()` runs first and the property is then already present).
**Files:**
- Create: `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs`
- Delete: `src/ClaudeDo.Logging/Placeholder.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` (add project reference)
- [ ] **Step 1: Reference `ClaudeDo.Logging` from the test project**
Edit `tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — add to the existing `ProjectReference` ItemGroup:
```xml
<ProjectReference Include="..\..\src\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
```
- [ ] **Step 2: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs`:
```csharp
using ClaudeDo.Logging;
using Serilog;
using Serilog.Context;
using Serilog.Core;
using Serilog.Events;
namespace ClaudeDo.Worker.Tests.Logging;
public sealed class DefaultTaskIdEnricherTests
{
private sealed class CollectingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = new();
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
[Fact]
public void AddsDash_WhenNoTaskIdInScope()
{
var sink = new CollectingSink();
using var logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.With(new DefaultTaskIdEnricher())
.WriteTo.Sink(sink)
.CreateLogger();
logger.Information("hello");
var prop = Assert.Single(sink.Events).Properties["TaskId"];
Assert.Equal("\"-\"", prop.ToString());
}
[Fact]
public void KeepsPushedTaskId_WhenInScope()
{
var sink = new CollectingSink();
using var logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.With(new DefaultTaskIdEnricher())
.WriteTo.Sink(sink)
.CreateLogger();
using (LogContext.PushProperty("TaskId", "task-42"))
logger.Information("hello");
var prop = Assert.Single(sink.Events).Properties["TaskId"];
Assert.Equal("\"task-42\"", prop.ToString());
}
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests`
Expected: FAIL — `DefaultTaskIdEnricher` does not exist (compile error).
- [ ] **Step 4: Implement the enricher and remove the placeholder**
Delete `src/ClaudeDo.Logging/Placeholder.cs`.
Create `src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs`:
```csharp
using Serilog.Core;
using Serilog.Events;
namespace ClaudeDo.Logging;
/// <summary>Ensures every log event carries a TaskId property (defaulting to "-")
/// so the output template's [{TaskId}] column never renders the raw token.</summary>
public sealed class DefaultTaskIdEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (!logEvent.Properties.ContainsKey("TaskId"))
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-"));
}
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter DefaultTaskIdEnricherTests`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs tests/ClaudeDo.Worker.Tests/Logging/DefaultTaskIdEnricherTests.cs tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
git rm src/ClaudeDo.Logging/Placeholder.cs
git commit -m "feat(logging): default TaskId enricher with passing tests"
```
---
### Task 3: `BuildConfig.IsDebug`
Detects whether the entry assembly was compiled in the Debug configuration (JIT optimizer disabled) — the runtime replacement for `#if DEBUG`.
**Files:**
- Create: `src/ClaudeDo.Logging/BuildConfig.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs`
- [ ] **Step 1: Write the failing test**
The test asserts the property returns *some* bool without throwing, and that the underlying detection logic agrees with the test assembly's own `DebuggableAttribute` (the test runs under whatever config `dotnet test` used). We assert the helper's result equals a locally-computed expectation so it passes under both Debug and Release test runs.
Create `tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs`:
```csharp
using System.Diagnostics;
using System.Reflection;
using ClaudeDo.Logging;
namespace ClaudeDo.Worker.Tests.Logging;
public sealed class BuildConfigTests
{
[Fact]
public void IsDebug_MatchesEntryAssemblyDebuggableAttribute()
{
var entry = Assembly.GetEntryAssembly();
var expected = entry?
.GetCustomAttribute<DebuggableAttribute>()
?.IsJITOptimizerDisabled ?? false;
Assert.Equal(expected, BuildConfig.IsDebug);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests`
Expected: FAIL — `BuildConfig` does not exist (compile error).
- [ ] **Step 3: Implement `BuildConfig`**
Create `src/ClaudeDo.Logging/BuildConfig.cs`:
```csharp
using System.Diagnostics;
using System.Reflection;
namespace ClaudeDo.Logging;
/// <summary>Runtime build-configuration detection — the replacement for #if DEBUG.
/// Debug builds compile with the JIT optimizer disabled; Release builds enable it.</summary>
public static class BuildConfig
{
public static bool IsDebug { get; } =
Assembly.GetEntryAssembly()
?.GetCustomAttribute<DebuggableAttribute>()
?.IsJITOptimizerDisabled ?? false;
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter BuildConfigTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Logging/BuildConfig.cs tests/ClaudeDo.Worker.Tests/Logging/BuildConfigTests.cs
git commit -m "feat(logging): runtime Debug-build detection via DebuggableAttribute"
```
---
### Task 4: `LoggingSetup.Configure`
The single shared configuration entry point. Applies enrichers, the output template, and branches sinks/levels on `BuildConfig.IsDebug`.
**Files:**
- Create: `src/ClaudeDo.Logging/LoggingSetup.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs`
- [ ] **Step 1: Write the failing test**
Verifies a configured logger actually writes a `Warning` (emitted in both build configs) to a `claudedo-*.log` file under the given log root.
Create `tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs`:
```csharp
using ClaudeDo.Logging;
using Serilog;
namespace ClaudeDo.Worker.Tests.Logging;
public sealed class LoggingSetupTests
{
[Fact]
public void Configure_WritesSharedLogFile()
{
var logRoot = Path.Combine(Path.GetTempPath(), "claudedo-logtest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(logRoot);
try
{
var logger = LoggingSetup.Configure(new LoggerConfiguration(), "test", logRoot).CreateLogger();
logger.Warning("marker-{Marker}", "xyz");
logger.Dispose(); // flush + release the file handle
var files = Directory.GetFiles(logRoot, "claudedo-*.log");
var file = Assert.Single(files);
var contents = File.ReadAllText(file);
Assert.Contains("marker-", contents);
Assert.Contains("test/", contents); // {Process} tag in the template
}
finally
{
try { Directory.Delete(logRoot, recursive: true); } catch { /* best effort */ }
}
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests`
Expected: FAIL — `LoggingSetup` does not exist (compile error).
- [ ] **Step 3: Implement `LoggingSetup`**
Create `src/ClaudeDo.Logging/LoggingSetup.cs`:
```csharp
using Serilog;
using Serilog.Events;
namespace ClaudeDo.Logging;
public static class LoggingSetup
{
private const string OutputTemplate =
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}";
/// <summary>Apply the shared ClaudeDo logging configuration.
/// Debug builds: Debug level, console + shared file. Release builds: Warning level, shared file only.</summary>
/// <param name="processTag">"worker" or "app" — tags every line so the interleaved file is readable.</param>
/// <param name="logRoot">Directory for the shared claudedo-.log (created if missing).</param>
public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot)
{
Directory.CreateDirectory(logRoot);
var logFile = Path.Combine(logRoot, "claudedo-.log");
cfg.Enrich.FromLogContext()
.Enrich.WithProperty("Process", processTag)
.Enrich.With(new DefaultTaskIdEnricher());
if (BuildConfig.IsDebug)
{
cfg.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: OutputTemplate)
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
else
{
cfg.MinimumLevel.Warning()
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
return cfg;
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter LoggingSetupTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Logging/LoggingSetup.cs tests/ClaudeDo.Worker.Tests/Logging/LoggingSetupTests.cs
git commit -m "feat(logging): shared LoggingSetup with build-config sink branching"
```
---
### Task 5: Wire the Worker to the shared setup
**Files:**
- Modify: `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- Modify: `src/ClaudeDo.Worker/Program.cs:34-40`
- [ ] **Step 1: Add the project reference**
Edit `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — add to the existing `ProjectReference` ItemGroup (the one with `ClaudeDo.Data`):
```xml
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
```
- [ ] **Step 2: Replace the inline Serilog config**
In `src/ClaudeDo.Worker/Program.cs`, replace lines 34-40:
```csharp
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true));
```
with:
```csharp
builder.Host.UseSerilog((ctx, lc) =>
ClaudeDo.Logging.LoggingSetup.Configure(lc, "worker", logRoot));
```
- [ ] **Step 3: Build the Worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded. (If the Worker is running and locks the Debug output, this Release build is unaffected.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/ClaudeDo.Worker.csproj src/ClaudeDo.Worker/Program.cs
git commit -m "feat(logging): route Worker logging through shared LoggingSetup"
```
---
### Task 6: Wire the App/Ui (currently log-silent) to the shared setup
The App uses a plain `ServiceCollection` with **no** logging registered. Add the Serilog→`ILogger` bridge so all `ILogger<T>` injections across App/Ui flow to the shared sinks, and flush on shutdown.
**Files:**
- Modify: `src/ClaudeDo.App/ClaudeDo.App.csproj`
- Modify: `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1: Add packages and the project reference**
Edit `src/ClaudeDo.App/ClaudeDo.App.csproj` — add to the package `ItemGroup`:
```xml
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
```
and to the `ProjectReference` ItemGroup:
```xml
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
```
- [ ] **Step 2: Add the logging registration in `BuildServices`**
In `src/ClaudeDo.App/Program.cs`, inside `BuildServices()`, immediately after the `var sc = new ServiceCollection();` line (currently line 78), insert:
```csharp
var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs");
var serilogLogger = ClaudeDo.Logging.LoggingSetup
.Configure(new Serilog.LoggerConfiguration(), "app", logRoot)
.CreateLogger();
sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true));
```
Add these usings to the top of `Program.cs` (the `AddSerilog` `ILoggingBuilder` extension lives in the `Serilog` namespace; `AddLogging` lives in `Microsoft.Extensions.DependencyInjection`, already imported):
```csharp
using Serilog;
using Microsoft.Extensions.Logging;
```
> `dbPath` is already computed just above (`var dbPath = Paths.Expand(settings.DbPath);`). Its parent directory is `~/.todo-app`, so `logs` sits beside the Worker's log root.
- [ ] **Step 3: Build the App**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded (pulls in Ui + Data + Logging).
- [ ] **Step 4: Verify manually from Rider (visual-verification gap)**
This is a Debug-build behavior that cannot be asserted in a Release test run. Launch the App from Rider's run button and confirm:
- A `claudedo-*.log` appears in `~/.todo-app/logs/`.
- Console output (Rider run window) shows `Debug`-level lines tagged `app/...`.
Flag to the user that this step needs their eyes.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.App/ClaudeDo.App.csproj src/ClaudeDo.App/Program.cs
git commit -m "feat(logging): wire App/Ui logging to shared LoggingSetup"
```
---
### Task 7: Push `TaskId` into `LogContext` in the Worker
Wraps the two per-task entry points so every nested log line (runner, state service, worktree, planning) carries the task's id automatically.
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:47` (`RunAsync`) and `:171` (`ContinueAsync`)
- [ ] **Step 1: Add the using directive**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add to the top usings:
```csharp
using Serilog.Context;
```
- [ ] **Step 2: Push TaskId at the top of `RunAsync`**
In `RunAsync` (line 47), insert as the very first statement of the method body (before `string? mcpToken = null;`):
```csharp
using var _taskScope = LogContext.PushProperty("TaskId", task.Id);
```
- [ ] **Step 3: Push TaskId at the top of `ContinueAsync`**
In `ContinueAsync` (line 171), insert as the very first statement of the method body (before `TaskEntity task;`). The parameter is `taskId`:
```csharp
using var _taskScope = LogContext.PushProperty("TaskId", taskId);
```
- [ ] **Step 4: Build the Worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
git commit -m "feat(logging): tag Worker task execution with TaskId for traceability"
```
---
### Task 8: Push `TaskId` and add trace lines on the App side
`WorkerClient` currently logs nothing. Inject `ILogger<WorkerClient>`, add a small helper that pushes `TaskId` + emits a `Debug` trace line, and route the fire-and-forget task-targeted hub calls through it. This produces the UI half of the UI→Worker→UI trace under a shared `TaskId`.
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.App/Program.cs:101` (registration)
- [ ] **Step 1: Add usings and the logger field/ctor param**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add to the usings:
```csharp
using Microsoft.Extensions.Logging;
using Serilog.Context;
```
Add a field beside `private readonly HubConnection _hub;` (line 32):
```csharp
private readonly ILogger<WorkerClient> _logger;
```
Change the constructor signature (line 68) from:
```csharp
public WorkerClient(string signalRUrl)
{
```
to:
```csharp
public WorkerClient(string signalRUrl, ILogger<WorkerClient> logger)
{
_logger = logger;
```
- [ ] **Step 2: Add the task-scoped invoke helper**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add this private method next to `TryInvokeAsync` (after line 241):
```csharp
/// <summary>Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line.</summary>
private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args)
{
using (LogContext.PushProperty("TaskId", taskId))
{
_logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId);
await _hub.InvokeCoreAsync(method, args);
}
}
```
- [ ] **Step 3: Route the fire-and-forget task actions through the helper**
In the same file, replace each of these method bodies:
`RunNowAsync` (line 243):
```csharp
public Task RunNowAsync(string taskId)
=> InvokeForTaskAsync(taskId, "RunNow", taskId);
```
`ContinueTaskAsync` (line 248):
```csharp
public Task ContinueTaskAsync(string taskId, string followUpPrompt)
=> InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt);
```
`ResetTaskAsync` (line 253):
```csharp
public Task ResetTaskAsync(string taskId)
=> InvokeForTaskAsync(taskId, "ResetTask", taskId);
```
`CancelTaskAsync` (line 267):
```csharp
public Task CancelTaskAsync(string taskId)
=> InvokeForTaskAsync(taskId, "CancelTask", taskId);
```
`ApproveReviewAsync` (line 389):
```csharp
public Task ApproveReviewAsync(string taskId)
=> InvokeForTaskAsync(taskId, "ApproveReview", taskId);
```
`RejectReviewToQueueAsync` (line 394):
```csharp
public Task RejectReviewToQueueAsync(string taskId, string feedback)
=> InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback);
```
`RejectReviewToIdleAsync` (line 399):
```csharp
public Task RejectReviewToIdleAsync(string taskId)
=> InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId);
```
`CancelReviewAsync` (line 404):
```csharp
public Task CancelReviewAsync(string taskId)
=> InvokeForTaskAsync(taskId, "CancelReview", taskId);
```
> These all previously did `await _hub.InvokeAsync(method, ...)` with no return value, so converting them to expression-bodied delegations preserves behavior. Do **not** touch methods that return DTOs (e.g. `MergeTaskAsync`) or the planning methods — keep this change scoped to the void task actions above.
- [ ] **Step 4: Update the DI registration to pass the logger**
In `src/ClaudeDo.App/Program.cs`, replace line 101:
```csharp
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
```
with:
```csharp
sc.AddSingleton(sp => new WorkerClient(
sp.GetRequiredService<AppSettings>().SignalRUrl,
sp.GetRequiredService<ILogger<WorkerClient>>()));
```
Add `using Microsoft.Extensions.Logging;` to the top of `Program.cs` if not already present.
- [ ] **Step 5: Build the App**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
> Note: `WorkerClient` is faked in tests via the `IWorkerClient` *interface* (hand-rolled fakes implement the interface, they do not subclass `WorkerClient`). This change adds a ctor parameter to the concrete class only and does not alter `IWorkerClient`, so the fakes are unaffected. Confirm by building the test projects in the next step.
- [ ] **Step 6: Build the test projects to confirm fakes still compile**
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: Build succeeded for both.
- [ ] **Step 7: Run the full Worker.Tests suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
Expected: PASS (all existing tests + the 4 new logging tests).
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.App/Program.cs
git commit -m "feat(logging): tag UI task actions with TaskId + debug trace lines"
```
---
## Final verification
- [ ] **Build the whole desktop + worker stack in Release:**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
- [ ] **Run the logging tests:**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~Logging"
```
Expected: PASS (DefaultTaskIdEnricher × 2, BuildConfig × 1, LoggingSetup × 1).
- [ ] **Manual smoke test (visual-verification gap — needs the user):**
1. Run the Worker and App from Rider (Debug build). Confirm both write to one `~/.todo-app/logs/claudedo-*.log` with `app/...` and `worker/...` lines.
2. Run a task; grep that file for the task's id — confirm UI (`UI invoking RunNow…`) and Worker lines share the same `[<taskId>]`.
3. Build/install the Release app; confirm the log is near-silent (no `Debug`/`Information` noise, `Warning`+ only) and no console window logging.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
# MyDay Icon Buttons + Terminal Reuse + Sort Icon Fix — Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Move the "Clear day" and "Prep log" actions into the MyDay header icon row as icon buttons (broom + list), render the prep log in the real `SessionTerminalView` ("cool terminal") by making that control reusable, and fix the invisible Sort icon.
**Approved design (chat):**
- Header icon row (`TasksIslandView.axaml`, the Sort/Eye/Settings `icon-btn` StackPanel) gets two more `icon-btn`, both `IsVisible="{Binding IsMyDayList}"`, inserted after the Eye button: **broom** (`Icon.Broom`) → `ClearDayCommand`, **list** (`Icon.List`) → `ShowPrepLogCommand`. The two full-width text buttons "Prep log" and "Clear day" are removed. "Tag vorbereiten" stays as the full-width button (already opens the prep view via `PrepRequested`).
- `SessionTerminalView` becomes reusable via StyledProperties so it renders both the task `Log` and the prep `PrepLog` with the same terminal look. The prep panel in `DetailsIslandView` embeds it instead of the copied `ItemsControl`.
- **Sort icon bug:** `PathIcon` fills geometry; `Icon.Sort` is an open-line path (no enclosed area) → invisible. Replace with a filled geometry. New icons (Broom, List) are authored as filled geometries too.
**Tech:** Avalonia (PathIcon/StreamGeometry, StyledProperty), CommunityToolkit.Mvvm, xUnit.
## Build/test
`.slnx` needs .NET 9 — build the csproj. Use `-c Release` if a Worker locks Debug.
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
GUI cannot be smoke-tested headlessly — note it; the human verifies visuals.
---
## Task A: Icons + reusable SessionTerminalView
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml` (icon geometries)
- Modify: `src/ClaudeDo.Ui/Views/Islands/SessionTerminalView.axaml` + `SessionTerminalView.axaml.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (both embeds)
- [ ] **Step 1: Fix `Icon.Sort` + add `Icon.Broom`, `Icon.List`** as filled geometries in `IslandStyles.axaml` (in the `Styles.Resources` icon block). Replace the existing `Icon.Sort` line and add the two new ones:
```xml
<!-- Icon.Sort (filled bars, decreasing width) -->
<StreamGeometry x:Key="Icon.Sort">M4 6 H20 V8 H4 Z M4 11 H16 V13 H4 Z M4 16 H11 V18 H4 Z</StreamGeometry>
<!-- Icon.Broom (filled: handle + binding band + flared bristles) -->
<StreamGeometry x:Key="Icon.Broom">M11 3 H13 V10 H11 Z M8.5 10 H15.5 V12 H8.5 Z M9 12 H15 L17 21 H7 Z</StreamGeometry>
<!-- Icon.List (filled: square bullets + lines) -->
<StreamGeometry x:Key="Icon.List">M4 5 H6 V7 H4 Z M8 5 H20 V7 H8 Z M4 11 H6 V13 H4 Z M8 11 H20 V13 H8 Z M4 17 H6 V19 H4 Z M8 17 H20 V19 H8 Z</StreamGeometry>
```
- [ ] **Step 2: Add StyledProperties to `SessionTerminalView`** (code-behind `SessionTerminalView.axaml.cs`). Add public StyledProperties and CLR wrappers:
```csharp
public static readonly StyledProperty<System.Collections.IEnumerable?> EntriesProperty =
AvaloniaProperty.Register<SessionTerminalView, System.Collections.IEnumerable?>(nameof(Entries));
public static readonly StyledProperty<string?> LabelProperty =
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(Label));
public static readonly StyledProperty<bool> IsRunningProperty =
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsRunning));
public static readonly StyledProperty<bool> IsDoneProperty =
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsDone));
public static readonly StyledProperty<bool> IsFailedProperty =
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsFailed));
public System.Collections.IEnumerable? Entries { get => GetValue(EntriesProperty); set => SetValue(EntriesProperty, value); }
public string? Label { get => GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
public bool IsRunning { get => GetValue(IsRunningProperty); set => SetValue(IsRunningProperty, value); }
public bool IsDone { get => GetValue(IsDoneProperty); set => SetValue(IsDoneProperty, value); }
public bool IsFailed { get => GetValue(IsFailedProperty); set => SetValue(IsFailedProperty, value); }
```
Replace the existing auto-scroll hook (which cast `DataContext as DetailsIslandViewModel` and watched `.Log.CollectionChanged`) with one that watches whichever collection `Entries` points at: in `OnPropertyChanged`, when `change.Property == EntriesProperty`, detach the old `INotifyCollectionChanged.CollectionChanged` handler and attach to the new value (if it implements `INotifyCollectionChanged`); the handler scrolls the existing ScrollViewer to the end (reuse the existing scroll logic / named ScrollViewer). Keep the named ScrollViewer's `x:Name`.
- [ ] **Step 3: Repoint `SessionTerminalView.axaml` internal bindings to the control's own properties.** Give the root `UserControl` `x:Name="Root"`. Change:
- the `ItemsControl ItemsSource="{Binding Log}"``ItemsSource="{Binding #Root.Entries}"`
- the label `TextBlock` `Text="{Binding BranchLine, StringFormat='claude-session · {0}'}"` (or whatever it is) → `Text="{Binding #Root.Label}"`
- the LIVE chip `IsVisible="{Binding IsRunning}"``{Binding #Root.IsRunning}`; DONE → `#Root.IsDone`; FAILED → `#Root.IsFailed`.
Keep the `LogLineViewModel` item template as-is (it binds the item, not the VM). The `x:DataType` can stay `DetailsIslandViewModel` (element-name bindings to `#Root` don't depend on it) or be removed if it causes compile issues — verify the build.
- [ ] **Step 4: Update both embeds in `DetailsIslandView.axaml`.**
- Task embed (currently `<islands:SessionTerminalView MaxHeight="420"/>`):
```xml
<islands:SessionTerminalView MaxHeight="420"
Entries="{Binding Log}"
Label="{Binding BranchLine, StringFormat='claude-session · {0}'}"
IsRunning="{Binding IsRunning}" IsDone="{Binding IsDone}" IsFailed="{Binding IsFailed}"/>
```
(Use the exact label binding the old internal header used — match the prior `StringFormat` text precisely so the task view is visually unchanged.)
- Prep panel: replace the whole copied `ItemsControl` (and its surrounding `ScrollViewer`/title) with:
```xml
<islands:SessionTerminalView
Entries="{Binding PrepLog}" Label="daily-prep"
IsRunning="{Binding IsPrepRunning}"/>
```
Keep the panel wrapper `<Panel IsVisible="{Binding IsPrepMode}">`. Drop the now-redundant `details.prepTitle` title TextBlock (the terminal header shows the `daily-prep` label). Leave the `details.prepTitle` locale key in place (harmless) OR remove it from both en/de if you prefer — if removing, run the localization test.
- [ ] **Step 5: Build the App; confirm no binding/compile errors.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
```
(The existing DetailsIsland prep tests must still pass — `PrepLog`/`IsPrepMode`/`ShowPrep` are unchanged.)
- [ ] **Step 6: Commit** (stage only Task A files; do NOT `git add -A`):
```bash
git commit -m "feat(daily-prep): reuse SessionTerminal for prep log; fix invisible Sort icon; add Broom/List icons"
```
---
## Task B: MyDay header icon buttons
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
Depends on Task A (uses `Icon.Broom` / `Icon.List`).
- [ ] **Step 1: Add two `icon-btn` to the header icon StackPanel** (the one with Sort/Eye/Settings), inserted right after the Eye button and before Settings, both MyDay-only:
```xml
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
</Button>
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
Command="{Binding ShowPrepLogCommand}" ToolTip.Tip="{loc:Tr tasks.prepLogTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.List}"/>
</Button>
```
- [ ] **Step 2: Remove the two full-width buttons** "Prep log" (`ShowPrepLogCommand`) and "Clear day" (`ClearDayCommand`) from the DockPanel button stack. Keep the "Prepare day" (`PrepareDayCommand`) full-width button and the Notes pinned-row button.
- [ ] **Step 3: Locales.** Add `tasks.clearDayTip` (en "Clear day", de "Tag leeren") and `tasks.prepLogTip` (en "Prep log", de "Vorbereitungs-Log") to both json files. Remove the now-unused `tasks.clearDay` and `tasks.prepLog` keys from both (keep en/de in parity).
- [ ] **Step 4: Build + test.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
- [ ] **Step 5: Manual smoke (human):** on MyDay the header shows Sort (now visible) + Eye + Broom + List + Settings; broom clears the day; list opens the prep terminal; "Tag vorbereiten" opens the prep terminal and streams; the three MyDay-only controls hide on other lists; the task session terminal still renders normally.
- [ ] **Step 6: Commit** (stage only Task B files):
```bash
git commit -m "feat(daily-prep): move Clear-day and Prep-log into MyDay header icon row"
```
## Notes / risks
- Element-name bindings (`#Root.*`) require the `UserControl` to have `x:Name="Root"`; verify compiled bindings accept them (they do in Avalonia).
- The auto-scroll hook must re-subscribe when `Entries` changes; without it the prep log won't auto-scroll.
- `ClearDayCommand` / `ShowPrepLogCommand` already exist on `TasksIslandViewModel` — no VM changes; existing VM tests remain valid.
@@ -0,0 +1,120 @@
# Move "Plan day" into the Prep-Log Window — Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Guard daily-prep planning behind a second click. The MyDay header's full-width "Tag vorbereiten" button is removed; instead the user opens the prep-log window (list icon), sees the last run or an empty-state hint, and clicks a **"Plan day"** button inside that window to run the prep.
**Approved flow:** Header list-icon (`ShowPrepLogCommand`) opens the prep window → if empty, an empty-state hint shows → "Plan day" button in the window runs `RunDailyPrepNowAsync()`.
**Tech:** Avalonia + CommunityToolkit.Mvvm, xUnit.
## Build/test
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
GUI not headlessly verifiable — note it; human verifies visuals.
---
## Task: relocate planning trigger + empty-state
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (remove PrepareDay)
- Modify: `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml` (remove header button)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` (PlanDayCommand + empty-state)
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` (prep panel toolbar + empty hint)
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs`, and the existing `TasksIslandDailyPrepTests.cs` (remove the obsolete prepare test)
- [ ] **Step 1: Write/adjust tests first.**
- In `DetailsIslandPrepModeTests.cs` add:
```csharp
[Fact]
public async Task PlanDayCommand_calls_worker()
{
var stub = new StubWorkerClient();
var vm = NewDetailsVm(stub);
await vm.PlanDayCommand.ExecuteAsync(null);
Assert.Equal(1, stub.RunDailyPrepNowCalls);
}
[Fact]
public void ShowPrepEmptyState_true_when_empty_and_not_running()
{
var vm = NewDetailsVm(new StubWorkerClient());
Assert.True(vm.ShowPrepEmptyState);
}
```
`StubWorkerClient` needs a `RunDailyPrepNowCalls` counter incremented in `RunDailyPrepNowAsync` (add if missing; it currently likely returns `Task.FromResult(true)` — keep that and bump a counter).
- In `TasksIslandDailyPrepTests.cs` **remove** `PrepareDayCommand_raises_PrepRequested` (the command is being deleted). Keep `ClearDayCommand_calls_worker`.
- [ ] **Step 2: Run — expect FAIL/compile error.**
- [ ] **Step 3: `TasksIslandViewModel` — remove planning trigger.**
- Delete the `PrepareDayAsync` `[RelayCommand]` entirely.
- Keep the `PrepRequested` event and `ShowPrepLog` command (the list icon still raises `PrepRequested` to open the window).
- Grep the VM for any remaining `PrepareDay` references and remove them.
- [ ] **Step 4: `TasksIslandView.axaml` — remove the header button.** Delete the full-width "Prepare day" `<Button … Command="{Binding PrepareDayCommand}" …>`. Leave the Notes pinned-row button, and the header icon buttons (broom = ClearDay, list = ShowPrepLog) untouched.
- [ ] **Step 5: `DetailsIslandViewModel` — add PlanDayCommand + empty-state.**
- Add:
```csharp
[RelayCommand]
private async Task PlanDayAsync()
{
if (_worker is null) return;
try { await _worker.RunDailyPrepNowAsync(); }
catch { /* worker offline; PrepStarted/PrepLine will reconcile */ }
}
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
```
- Notify `ShowPrepEmptyState`: in the constructor add `PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));`, and add `partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));`.
- [ ] **Step 6: `DetailsIslandView.axaml` — prep panel toolbar + empty hint.** In the `<Panel IsVisible="{Binding IsPrepMode}">`, wrap the existing `SessionTerminalView` in a `DockPanel`; dock a top toolbar row with the Plan-day button, and overlay/stack an empty-state hint:
```xml
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
<Border DockPanel.Dock="Top" Padding="12,8">
<Button Classes="btn primary"
Command="{Binding PlanDayCommand}"
IsEnabled="{Binding !IsPrepRunning}"
Content="{loc:Tr details.planDay}"/>
</Border>
<Panel>
<islands:SessionTerminalView
Entries="{Binding PrepLog}" Label="daily-prep"
IsRunning="{Binding IsPrepRunning}"/>
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource TextMuteBrush}"
Text="{loc:Tr details.prepEmpty}"/>
</Panel>
</DockPanel>
</Panel>
```
(Match the surrounding view's class names/brushes; use the existing button class style seen elsewhere, e.g. `Classes="btn"` — verify `primary` exists, else plain `btn`.)
- [ ] **Step 7: Locales.** Add `details.planDay` (en "Plan day", de "Tag planen") and `details.prepEmpty` (en "No prep run today yet — click Plan day", de "Heute noch keine Vorbereitung — klick Tag planen") to both json files. Remove the now-unused `tasks.prepareDay` key from both (grep first to confirm no other reference). Keep en/de key parity.
- [ ] **Step 8: Build + tests.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
- [ ] **Step 9: Manual smoke (human):** on MyDay there is no "Tag vorbereiten" button; the list icon opens the prep window showing the empty hint; "Plan day" runs the prep and streams; the hint disappears while running; after restart the persisted last run shows and "Plan day" is available to re-run.
- [ ] **Step 10: Commit:**
```bash
git commit -m "feat(daily-prep): trigger planning from inside the prep-log window with an empty-state hint"
```
## Notes / risks
- `PrepRequested` and `ShowPrepLogCommand` stay — only `PrepareDayCommand` and its header button are removed.
- `ShowPrepEmptyState` must re-notify on both `PrepLog` changes and `IsPrepRunning` changes, else the hint won't hide when a run starts or lines arrive.
- Removing `tasks.prepareDay`: confirm via grep it has no remaining references before deleting (keep locale parity or the Localization.Tests parity check fails).
@@ -0,0 +1,208 @@
# Persist Daily-Prep Log Across Restarts — Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** The prep log currently lives only in memory (`DetailsIslandViewModel.PrepLog`), so after an app restart the prep terminal is empty. Persist the last prep run's output to a file in the worker and load it into the prep terminal when opened.
**Root cause (confirmed):** `PrimeRunner.FireAsync` streams stdout lines via `_broadcaster.PrepLineAsync(line)` only — it writes no file and stores no record. `PrepLog` is an in-memory `ObservableCollection` populated only by live `PrepLine` events. Nothing persists → empty after restart.
**Approach:** Worker writes each streamed line to `<appdata>/logs/daily-prep.log` (truncated at run start = last run only) using the existing `LogWriter`. A new hub method `GetLastPrepLog()` returns the file (tail-capped, like `get_task_log`). The UI loads it into `PrepLog` when the prep view opens, but only when `PrepLog` is empty and no run is in progress.
**Tech:** ASP.NET Core SignalR, Avalonia + CommunityToolkit.Mvvm, xUnit.
## Build/test
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
```
GUI not headlessly verifiable — note it; human verifies visuals.
## Shared constant
The prep-log path must be identical in `PrimeRunner` (writer) and `WorkerHub` (reader). Define it once and reference from both:
`Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log")`.
Add a small static helper so both sides agree, e.g. in `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` (already the prep "home"):
```csharp
public static string LogPath() =>
System.IO.Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log");
```
---
## Task 1: Worker — write the prep log + serve it
**Files:**
- Modify: `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` (add `LogPath()` helper)
- Modify: `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
- [ ] **Step 1: Add `DailyPrepPrompt.LogPath()`** (code above).
- [ ] **Step 2: Write the failing test.** Extend the existing streaming test (or add one) asserting that after `FireAsync` with emitted stdout lines, the file at `DailyPrepPrompt.LogPath()` contains those lines, and that a prior run's content is replaced (truncate-on-start). Since the path is the real app-data logs dir, the test should delete the file first and clean up after; assert exact line content.
```csharp
[Fact]
public async Task FireAsync_writes_last_run_to_prep_log_file()
{
var path = DailyPrepPrompt.LogPath();
if (File.Exists(path)) File.Delete(path);
var claude = new FakeClaudeProcess(emitLines: new[] { "lineA", "lineB" }, exitCode: 0, result: "ok");
var runner = NewRunner(claude, new RecordingPrimeBroadcaster());
await runner.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
var contents = await File.ReadAllTextAsync(path);
Assert.Contains("lineA", contents);
Assert.Contains("lineB", contents);
// Truncation: a second run with different lines replaces the file.
var claude2 = new FakeClaudeProcess(emitLines: new[] { "lineC" }, exitCode: 0, result: "ok");
var runner2 = NewRunner(claude2, new RecordingPrimeBroadcaster());
await runner2.FireAsync(new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null), CancellationToken.None);
var after = await File.ReadAllTextAsync(path);
Assert.DoesNotContain("lineA", after);
Assert.Contains("lineC", after);
}
```
- [ ] **Step 3: Run — expect FAIL.**
- [ ] **Step 4: Write the file in `PrimeRunner.FireAsync`.** After the gate is acquired and before `RunAsync`: compute `var logPath = DailyPrepPrompt.LogPath();`, delete it if present (truncate → last run only), then create `await using var logWriter = new LogWriter(logPath);`. Change the stream callback to write AND broadcast:
```csharp
var logPath = DailyPrepPrompt.LogPath();
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { /* best effort */ }
await using var logWriter = new LogWriter(logPath);
await _broadcaster.PrepStartedAsync();
// ... build prompt/args/timeoutCts ...
var result = await _claude.RunAsync(
arguments: args, prompt: prompt, workingDirectory: cwd,
onStdoutLine: async line =>
{
await logWriter.WriteLineAsync(line);
await _broadcaster.PrepLineAsync(line);
},
ct: timeoutCts.Token);
```
Keep the existing `success`/`finally`/`PrepFinishedAsync`/gate logic. `using ClaudeDo.Worker.Runner;` is already present (LogWriter lives there). The `await using` LogWriter disposes (flushes) before the method returns.
- [ ] **Step 5: Run — expect PASS.** Build the Worker.
- [ ] **Step 6: Add `WorkerHub.GetLastPrepLog()`** (no ctor change — reads the static path):
```csharp
public Task<string> GetLastPrepLog()
{
var path = DailyPrepPrompt.LogPath();
if (!File.Exists(path)) return Task.FromResult(string.Empty);
const int maxBytes = 256 * 1024;
var bytes = File.ReadAllBytes(path);
var text = bytes.Length <= maxBytes
? System.Text.Encoding.UTF8.GetString(bytes)
: System.Text.Encoding.UTF8.GetString(bytes, bytes.Length - maxBytes, maxBytes);
return Task.FromResult(text);
}
```
Add `using ClaudeDo.Worker.Prime;` to `WorkerHub.cs` if not present.
- [ ] **Step 7: Build Worker; run the full Worker.Tests project.**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 8: Commit** (stage only Task 1 files):
```bash
git commit -m "feat(daily-prep): persist last prep run to a log file and serve it via GetLastPrepLog"
```
---
## Task 2: UI — load the persisted prep log when opening
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (FakeWorkerClient)
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPrepModeTests.cs`
- [ ] **Step 1: Declare on `IWorkerClient`:** `Task<string> GetLastPrepLogAsync();`
- [ ] **Step 2: Implement in `WorkerClient`:** `public Task<string> GetLastPrepLogAsync() => _hub.InvokeAsync<string>("GetLastPrepLog");` (match neighbouring call style; if there is a `TryInvokeAsync` helper for resilience, mirror `GetWeekReportAsync` and return `?? string.Empty`).
- [ ] **Step 3: Update fakes.** Add `public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);` to both fakes. In `StubWorkerClient`, make it return a settable backing field, e.g. `public string LastPrepLog = ""; public Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);`.
- [ ] **Step 4: Write the failing test.**
```csharp
[Fact]
public async Task ShowPrep_loads_persisted_log_when_empty()
{
var stub = new StubWorkerClient { LastPrepLog = "{\"type\":\"assistant\",\"text\":\"restored\"}" };
var vm = NewDetailsVm(stub);
vm.ShowPrep();
await Task.Delay(50); // allow the async load to run; or expose the load task to await deterministically
Assert.NotEmpty(vm.PrepLog);
}
```
Prefer determinism over `Task.Delay`: have `ShowPrep` start the load and expose the in-flight `Task` (e.g. a `LoadLastPrepLogAsync()` method the test can call/await directly), then assert. Use whichever the existing test style favors.
- [ ] **Step 5: Implement load in `DetailsIslandViewModel`.** Add a method and call it from `ShowPrep`:
```csharp
public void ShowPrep()
{
Bind(null);
IsNotesMode = false;
IsPrepMode = true;
_ = LoadLastPrepLogIfEmptyAsync();
}
private async Task LoadLastPrepLogIfEmptyAsync()
{
if (_worker is null || IsPrepRunning || PrepLog.Count > 0) return;
string text;
try { text = await _worker.GetLastPrepLogAsync(); }
catch { return; }
if (IsPrepRunning || PrepLog.Count > 0) return; // a live run may have started meanwhile
foreach (var line in text.Split('\n'))
{
var trimmed = line.TrimEnd('\r');
if (trimmed.Length > 0) AppendStdoutLine(PrepLog, trimmed);
}
}
```
This reuses the existing `AppendStdoutLine(PrepLog, line)` formatter path, so persisted NDJSON renders identically to the live stream. The guards ensure it never overwrites a live run (`PrepStarted` clears `PrepLog` and sets `IsPrepRunning`) or an already-loaded log.
- [ ] **Step 6: Build App + run UI tests.**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
- [ ] **Step 7: Manual smoke (human):** run a prep, restart the app, open the prep log on MyDay → the last run's output is shown.
- [ ] **Step 8: Commit** (stage only Task 2 files):
```bash
git commit -m "feat(daily-prep): load persisted prep log into the terminal on open"
```
## Notes / risks
- `PrimeRunner` writes via the same `LogWriter` pattern `TaskRunner` uses; concurrency behavior matches existing code (no new locking introduced).
- Path is shared via `DailyPrepPrompt.LogPath()` so writer and reader never diverge.
- Load is guarded (`PrepLog empty && !IsPrepRunning`) to avoid clobbering a live stream — the order of `ShowPrep`'s flag set vs. the async load matters; re-check the guard after the await.
- Last run only (file truncated each run); history is out of scope.
@@ -0,0 +1,801 @@
# Refine Task Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Subagents use the `sonnet` model and stage files explicitly by path (never `git add -A`).
**Goal:** Add a one-click "Refine Task" button to each Idle task card that spawns a headless Claude session which rewrites the task's description and adds subtasks (steps), then updates the task live in the UI.
**Architecture:** A new headless `RefineRunner` (modeled on `PrimeRunner`) runs `claude -p` read-only in the list's working dir, using the globally-registered `claudedo` MCP. Claude calls `update_task` (existing) and a new `add_subtask` tool. The task stays `Idle`; refine only mutates Title/Description/subtasks. UI shows a busy state via new `RefineStarted`/`RefineFinished` SignalR events; content updates arrive via the existing `TaskUpdated` events.
**Tech Stack:** .NET 8, ASP.NET Core + SignalR, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), ModelContextProtocol server tools, xUnit.
**Spec:** `docs/superpowers/specs/2026-06-04-refine-task-design.md`
**Build/test reminders:** Build individual csproj with `-c Release` (a running Worker locks Debug). `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`, `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`, `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`. Keep `locales/en.json` and `locales/de.json` keys in parity.
---
## File structure
**Create:**
- `src/ClaudeDo.Worker/Refine/RefineRunner.cs` — headless refine run orchestrator
- `src/ClaudeDo.Worker/Refine/RefinePrompt.cs` — prompt + CLI args + log path helper
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs` — interface + `RefineRunOutcome`
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs``RefineStartedAsync`/`RefineFinishedAsync`
**Modify:**
- `src/ClaudeDo.Data/PromptFiles.cs` — add `Refine` to `PromptKind`, path, default
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add `add_subtask` tool
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — implement `RefineStarted`/`RefineFinished` + `IRefineBroadcaster`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `RefineTask(string taskId)` method
- `src/ClaudeDo.Worker/Program.cs` — register `IRefineRunner`/`IRefineBroadcaster`
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs``RefineTaskAsync` + `RefineStartedEvent`/`RefineFinishedEvent`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — implement call + subscribe events
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs``IsRefining` + `CanRefine`
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs``RefineTaskCommand` + event wiring
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``Icon.Refine` geometry
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — refine button
- `locales/en.json`, `locales/de.json` — tooltip key
- Test fakes implementing `IWorkerClient` in `tests/ClaudeDo.Ui.Tests` (and any other project that hand-rolls it)
**Test:**
- `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
- `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
- `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
---
## Task 1: `add_subtask` MCP tool
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
The `ExternalMcpService` already injects `IDbContextFactory<ClaudeDoDbContext> _dbFactory`, `TaskRepository _tasks`, and `HubBroadcaster _broadcaster`. Reuse them; new up a `SubtaskRepository` from a fresh context (matching the `SetMyDay`/`GetDailyPrepCandidates` pattern in the same file).
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`. Follow the existing External tool test setup in that test project (look at a sibling test, e.g. an `ExternalMcpService`/`UpdateTask` test, for the in-memory-real-SQLite fixture + broadcaster fake construction; reuse that exact fixture pattern).
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
public class AddSubtaskToolTests
{
[Fact]
public async Task AddSubtask_appends_row_with_next_order()
{
await using var f = new ExternalMcpServiceFixture(); // reuse the project's existing fixture helper
var list = await f.SeedListAsync();
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Idle);
await f.Service.AddSubtask(task.Id, "First step", orderNum: null, CancellationToken.None);
await f.Service.AddSubtask(task.Id, "Second step", orderNum: null, CancellationToken.None);
await using var ctx = f.CreateContext();
var subs = await new SubtaskRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(new[] { "First step", "Second step" }, subs.Select(s => s.Title));
Assert.Equal(new[] { 0, 1 }, subs.Select(s => s.OrderNum));
Assert.All(subs, s => Assert.False(s.Completed));
}
[Fact]
public async Task AddSubtask_refuses_running_task()
{
await using var f = new ExternalMcpServiceFixture();
var list = await f.SeedListAsync();
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Running);
await Assert.ThrowsAsync<InvalidOperationException>(
() => f.Service.AddSubtask(task.Id, "x", null, CancellationToken.None));
}
}
```
> If the test project has no reusable `ExternalMcpServiceFixture`, mirror the construction already used by the nearest existing `ExternalMcpService` test (same ctor args, real SQLite via `IDbContextFactory`, a no-op/recording broadcaster). Do not invent a new pattern.
- [ ] **Step 2: Run the test to verify it fails** (compile error / method missing)
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
Expected: FAIL — `AddSubtask` not defined.
- [ ] **Step 3: Implement `add_subtask`**
Add to `ExternalMcpService` (near `UpdateTask`):
```csharp
[McpServerTool, Description(
"Append a subtask (step) to a task. orderNum defaults to the end. " +
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
public async Task<TaskDto> AddSubtask(
string taskId,
string title,
int? orderNum,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var tasks = new TaskRepository(ctx);
var subtasks = new SubtaskRepository(ctx);
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
await subtasks.AddAsync(new SubtaskEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = taskId,
Title = title.Trim(),
Completed = false,
OrderNum = order,
CreatedAt = DateTime.UtcNow,
}, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToDto(task);
}
```
Add `using ClaudeDo.Data.Repositories;` if not present (it is). `SubtaskEntity` is in `ClaudeDo.Data.Models` (already imported).
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
git commit -m "feat(mcp): add add_subtask tool to claudedo MCP"
```
---
## Task 2: Refine prompt (`PromptKind.Refine`)
**Files:**
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
- [ ] **Step 1: Add the enum value**
Change the enum line in `PromptFiles.cs`:
```csharp
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
```
- [ ] **Step 2: Add the path mapping**
In `PathFor`, add before the `_ => throw`:
```csharp
PromptKind.Refine => Path.Combine(Root, "refine.md"),
```
- [ ] **Step 3: Add the default mapping**
In `DefaultFor`, add:
```csharp
PromptKind.Refine => RefineDefault,
```
- [ ] **Step 4: Add the default prompt constant**
Add near the other `private const string ...Default` blocks:
```csharp
private const string RefineDefault = """
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
You are NOT executing the task — only improving its specification.
The task you are refining:
- id: {taskId}
- title: {title}
- description: {description}
- current subtasks (steps):
{subtasks}
What to do:
1. If a repository is available, read the relevant code (read-only) to ground your
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
where, and what "done" looks like. Keep scope tight — do not invent adjacent work.
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
helps) and description.
4. If the work is clearer as discrete steps, add them as subtasks with
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
not already present in the current subtasks above.
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
task, stop.
""";
```
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Data/PromptFiles.cs
git commit -m "feat(prompts): add Refine prompt kind and default"
```
---
## Task 3: RefineRunner, interfaces, prompt/args helper
**Files:**
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs`
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs`
- Create: `src/ClaudeDo.Worker/Refine/RefinePrompt.cs`
- Create: `src/ClaudeDo.Worker/Refine/RefineRunner.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
- [ ] **Step 1: Create `IRefineRunner.cs`**
```csharp
namespace ClaudeDo.Worker.Refine;
public interface IRefineRunner
{
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
}
public sealed record RefineRunOutcome(bool Success, string Message);
```
- [ ] **Step 2: Create `IRefineBroadcaster.cs`**
```csharp
namespace ClaudeDo.Worker.Refine;
public interface IRefineBroadcaster
{
Task RefineStartedAsync(string taskId);
Task RefineFinishedAsync(string taskId, bool success, string? error);
}
```
- [ ] **Step 3: Create `RefinePrompt.cs`**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Refine;
public static class RefinePrompt
{
public const string GetTaskTool = "mcp__claudedo__get_task";
public const string UpdateTaskTool = "mcp__claudedo__update_task";
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
public static string LogPath(string taskId) =>
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
// canReadRepo=false drops the read-only filesystem tools (text-only fallback).
public static string BuildArgs(int maxTurns, bool canReadRepo)
{
var tools = canReadRepo
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
$"--max-turns {maxTurns} --allowedTools {tools}";
}
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
{
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
{
["taskId"] = task.Id,
["title"] = task.Title,
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
["subtasks"] = subText,
});
}
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
}
```
- [ ] **Step 4: Write `RefinePromptTests.cs`**
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Refine;
public class RefinePromptTests
{
[Fact]
public void BuildArgs_includes_read_tools_when_repo_available()
{
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
Assert.Contains("--permission-mode acceptEdits", args);
Assert.Contains("mcp__claudedo__add_subtask", args);
Assert.Contains(" Read Grep Glob", args);
}
[Fact]
public void BuildArgs_drops_read_tools_in_text_only_mode()
{
var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
Assert.DoesNotContain("Glob", args);
Assert.Contains("mcp__claudedo__update_task", args);
}
[Fact]
public void BuildPrompt_seeds_task_fields_and_open_subtasks()
{
var task = new TaskEntity { Id = "abc12345", ListId = "l", Title = "T", Description = "D",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow };
var subs = new[]
{
new SubtaskEntity { Id="1", TaskId="abc12345", Title="open one", Completed=false, OrderNum=0, CreatedAt=DateTime.UtcNow },
new SubtaskEntity { Id="2", TaskId="abc12345", Title="done one", Completed=true, OrderNum=1, CreatedAt=DateTime.UtcNow },
};
var prompt = RefinePrompt.BuildPrompt(task, subs);
Assert.Contains("abc12345", prompt);
Assert.Contains("open one", prompt);
Assert.DoesNotContain("done one", prompt);
}
}
```
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefinePromptTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Create `RefineRunner.cs`**
`IClaudeProcess.RunAsync(arguments, prompt, workingDirectory, onStdoutLine, ct)` returns a result with `.IsSuccess` and `.ExitCode` (same as used by `PrimeRunner`). Resolve the working dir from the task's list; fall back to a sandbox dir + text-only when missing/invalid. Per-task single-flight via a guarded `HashSet<string>`.
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Refine;
public sealed class RefineRunner : IRefineRunner
{
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
private const int MaxTurns = 25;
private readonly IClaudeProcess _claude;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<RefineRunner> _logger;
private readonly IRefineBroadcaster _broadcaster;
private readonly object _lock = new();
private readonly HashSet<string> _inFlight = new();
public RefineRunner(
IClaudeProcess claude,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ILogger<RefineRunner> logger,
IRefineBroadcaster broadcaster)
{
_claude = claude;
_dbFactory = dbFactory;
_logger = logger;
_broadcaster = broadcaster;
}
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
{
lock (_lock)
{
if (!_inFlight.Add(taskId))
return new RefineRunOutcome(false, "Already refining this task");
}
var success = false;
string? error = null;
try
{
ClaudeDo.Data.Models.TaskEntity task;
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
string? workingDir;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
{
var tasks = new TaskRepository(dbCtx);
task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Idle)
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
workingDir = list?.WorkingDir;
}
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
var logPath = RefinePrompt.LogPath(taskId);
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
await using var logWriter = new LogWriter(logPath);
await _broadcaster.RefineStartedAsync(taskId);
var prompt = RefinePrompt.BuildPrompt(task, subs);
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(RunTimeout);
var result = await _claude.RunAsync(
arguments: args,
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
ct: timeoutCts.Token);
success = result.IsSuccess;
if (!success) error = $"exit code {result.ExitCode}";
return success
? new RefineRunOutcome(true, "Refine complete")
: new RefineRunOutcome(false, error!);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
return new RefineRunOutcome(false, error);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
error = ex.Message;
return new RefineRunOutcome(false, ex.Message);
}
finally
{
await _broadcaster.RefineFinishedAsync(taskId, success, error);
lock (_lock) { _inFlight.Remove(taskId); }
}
}
}
```
- [ ] **Step 6: Write `RefineRunnerTests.cs` (guards, with a fake IClaudeProcess)**
The test project already has a fake/stub for `IClaudeProcess` used by Prime tests — reuse it (recording invocation + returning a configurable success result). Do NOT spawn the real CLI.
```csharp
public class RefineRunnerTests
{
[Fact]
public async Task Refuses_when_task_not_idle()
{
await using var f = new RefineRunnerFixture(); // mirror Prime test fixture wiring
var task = await f.SeedTaskAsync(status: TaskStatus.Queued);
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal(0, f.Claude.RunCount); // never invoked the CLI
}
[Fact]
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
{
await using var f = new RefineRunnerFixture();
var task = await f.SeedTaskAsync(status: TaskStatus.Idle);
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal(1, f.Claude.RunCount);
Assert.Equal(1, f.Broadcaster.Started);
Assert.Equal(1, f.Broadcaster.Finished);
}
}
```
> Build the `RefineRunnerFixture`/fakes by copying the Prime test's `IClaudeProcess` stub + real-SQLite `IDbContextFactory` setup and a recording `IRefineBroadcaster`. If a Prime fixture exists, mirror it; otherwise construct inline.
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefineRunnerTests`
Expected: PASS (2 tests).
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/Refine tests/ClaudeDo.Worker.Tests/Refine
git commit -m "feat(refine): add RefineRunner, prompt/args helper, and interfaces"
```
---
## Task 4: Worker wiring — broadcaster, hub, DI
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1: Implement events on `HubBroadcaster`**
Add `IRefineBroadcaster` to the class's interface list (`public sealed class HubBroadcaster : ..., IRefineBroadcaster`) and add (mirroring the `Prep*` block):
```csharp
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
public Task RefineFinished(string taskId, bool success, string? error) =>
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
RefineFinished(taskId, success, error);
```
Add `using ClaudeDo.Worker.Refine;`.
- [ ] **Step 2: Add `RefineTask` to `WorkerHub`**
`WorkerHub` injects services via its constructor. Add a `private readonly IRefineRunner _refineRunner;` field, add the parameter to the constructor and assign it. Add the method (fire-and-forget; the runner brackets with its own events):
```csharp
public Task RefineTask(string taskId)
{
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
return Task.CompletedTask;
}
```
Add `using ClaudeDo.Worker.Refine;`.
- [ ] **Step 3: Register DI in `Program.cs`**
Near the Prime registrations:
```csharp
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
```
Add `using ClaudeDo.Worker.Refine;` if needed. (`HubBroadcaster` is already registered as a singleton — confirm and reuse that registration; do not double-register it.)
- [ ] **Step 4: Build the worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs
git commit -m "feat(refine): wire RefineTask hub method, broadcaster events, and DI"
```
---
## Task 5: UI worker client — call + events + fakes
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: test fakes implementing `IWorkerClient`
- [ ] **Step 1: Extend the interface**
In `IWorkerClient.cs` add (near `RunDailyPrepNowAsync` and the `Prep*` events):
```csharp
Task RefineTaskAsync(string taskId);
event Action<string>? RefineStartedEvent;
event Action<string, bool, string?>? RefineFinishedEvent;
```
- [ ] **Step 2: Implement in `WorkerClient`**
Add the method (mirror `RunDailyPrepNowAsync`):
```csharp
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
```
Declare the events:
```csharp
public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;
```
Subscribe in the constructor (mirror the `Prep*` subscriptions block):
```csharp
_hub.On<string>("RefineStarted", id =>
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
```
- [ ] **Step 3: Update test fakes**
Find every hand-rolled `IWorkerClient` implementation (search the test projects) and add `RefineTaskAsync` (return `Task.CompletedTask`) plus the two events (`= delegate {}` or `add{}remove{}` no-ops as the fake convention dictates). Build each affected test project.
- [ ] **Step 4: Build UI + test projects**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Then build the UI test project(s). Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs <fake files>
git commit -m "feat(ui): add RefineTask client call and refine events"
```
---
## Task 6: UI — icon, button, view model, command
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Modify: `locales/en.json`, `locales/de.json`
- [ ] **Step 1: Add the `Icon.Refine` geometry**
In `IslandStyles.axaml`, near the other `Icon.*` `StreamGeometry` resources, add the supplied SVG converted to path data (line-art, rendered stroked via `plan-icon`):
```xml
<StreamGeometry x:Key="Icon.Refine">M3,5 L11,5 M3,9 L9,9 M3,13 L7,13 M19,1.8 L19.7,3.9 L21.7,4.6 L19.7,5.3 L19,7.4 L18.3,5.3 L16.3,4.6 L18.3,3.9 Z M18,10.5 L12.2,16.3 M16.6,9.1 L19.4,11.9 M12.2,16.3 L11,18.5 L13.2,17.5 Z</StreamGeometry>
```
- [ ] **Step 2: Add `IsRefining`/`CanRefine` to `TaskRowViewModel`**
Add the observable property (with the other `[ObservableProperty]` fields):
```csharp
[ObservableProperty] private bool _isRefining;
```
Add a computed gate (refine is only offered for Idle, non-parent tasks). Place near other `Can*` getters:
```csharp
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
```
If `Status`/`PlanningPhase`/`IsRefining` are `[ObservableProperty]`, raise `CanRefine` change notifications via partial `On<Prop>Changed` hooks:
```csharp
partial void OnStatusChanged(TaskStatus value) => OnPropertyChanged(nameof(CanRefine));
partial void OnPlanningPhaseChanged(PlanningPhase value) => OnPropertyChanged(nameof(CanRefine));
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
```
> If `On...Changed` partials already exist for `Status`/`PlanningPhase`, add the `OnPropertyChanged(nameof(CanRefine))` line inside them instead of redeclaring.
- [ ] **Step 3: Add `RefineTaskCommand` + event wiring to `TasksIslandViewModel`**
Add the command (mirror an existing per-row command like `ToggleStarCommand`, which takes a `TaskRowViewModel`):
```csharp
[RelayCommand]
private async Task RefineTask(TaskRowViewModel row)
{
if (row is null || !row.CanRefine) return;
row.IsRefining = true;
try { await _worker.RefineTaskAsync(row.Id); }
catch { row.IsRefining = false; }
}
```
> Use the same injected worker-client field name this VM already uses (e.g. `_worker`/`_client`). Match it.
Subscribe to the refine events where the VM wires other worker events (where `OnWorkerTaskUpdated` is subscribed). Add handlers that flip the row flag:
```csharp
private void OnRefineStarted(string taskId)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = true;
}
private void OnRefineFinished(string taskId, bool ok, string? error)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = false;
}
```
Wire them next to the existing subscriptions (and unsubscribe in the same place the VM unsubscribes others, if it does):
```csharp
_worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished;
```
(Content changes—new description/subtasks—arrive through the existing `TaskUpdated``OnWorkerTaskUpdated` path; no extra work needed.)
- [ ] **Step 4: Add the button to `TaskRowView.axaml`**
Mirror the star button (`Grid.Column="5"` area). Add a refine `icon-btn` (e.g. as a new column or beside the star) bound to the parent ItemsControl's command, passing the row as parameter. Use the `plan-icon` stroked `Path` inside a `Viewbox` (matching the Plan-day button), gate visibility on `CanRefine`, and disable/spin on `IsRefining`:
```xml
<Button Classes="icon-btn refine-btn"
IsVisible="{Binding CanRefine}"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{loc:Tr tasks.refineTip}">
<Viewbox Width="16" Height="16">
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
</Viewbox>
</Button>
```
> Match the column layout already in `TaskRowView.axaml`. If a new grid column is needed, widen `ColumnDefinitions` accordingly and place the refine button left of the star (`Grid.Column`). Keep the existing `vm:` / `loc:` xmlns aliases the file already declares.
Optionally show a spinning/dimmed state while `IsRefining` (e.g. a style `Selector="Button.refine-btn:disabled"` or bind opacity to `IsRefining`). Keep it simple; a disabled look is enough.
- [ ] **Step 5: Add localization keys**
Add to both `locales/en.json` and `locales/de.json` under the `tasks` group (keys must stay in parity):
- en: `"tasks.refineTip": "Refine this task with Claude"`
- de: `"tasks.refineTip": "Aufgabe mit Claude verfeinern"`
> Match the file's actual key structure (flat `"tasks.x"` vs nested `tasks: { x }`)—look at an existing `tasks.*` tooltip key (e.g. the plan-day tip) and follow it exactly.
- [ ] **Step 6: Build UI**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Then run the Localization parity tests: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: Build succeeded; locale parity passes.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs locales/en.json locales/de.json
git commit -m "feat(ui): add Refine button, icon, and command to task card"
```
---
## Task 7: Full build + test sweep, manual smoke
- [ ] **Step 1: Build all main projects**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
Expected: Build succeeded for both.
- [ ] **Step 2: Run the worker + UI test suites**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: all green.
- [ ] **Step 3: Manual smoke (visual + real CLI — flag to user)**
Cannot be automated (no real-Claude in tests). Verify by hand: start Worker + UI, on an Idle task click the refine icon → button shows busy → after the run the description improves and steps appear in the Steps card → task stays Idle. Confirm the refine icon is hidden for Queued/Running/Done tasks and for planning parents. **Report this as a visual-verification gap for the user to confirm.**
---
## Notes on parallelism / execution
- Tasks 14 are backend and largely sequential (4 depends on 3). Tasks 1 and 2 are independent and could be done first in either order.
- Tasks 56 (UI) depend on Task 4's hub/event contract.
- Per project convention: subagents use `sonnet`, stage files by explicit path, and do NOT run git/build inside parallel agents — the orchestrator builds, tests, and commits after each task.
@@ -0,0 +1,74 @@
# Review & Roadblock UX Implementation Plan
> **For agentic workers:** execute task-by-task (subagent-driven-development). Steps use `- [ ]`.
**Goal:** Move the task-row review actions into the Details panel, give the Details panel a real `WaitingForReview` state + a populated diff meter, and add a glanceable yellow roadblock indicator on the task card.
**Architecture:** Persist a `RoadblockCount` on `TaskEntity` (set by the runner when it folds in `CLAUDEDO_BLOCKED` markers). The row shows a warning badge when count > 0; review controls relocate to `DetailsIslandView`.
**Tech Stack:** .NET 8, Avalonia, EF Core (one migration), xUnit.
**Coordination:** A second session (`claudedo-childloop`) is building the child-tasks/improvement-loop in a worktree and will rebase onto main *after* these commits. It also touches `DetailsIslandViewModel`, `TaskRowView.axaml`, `TaskStateService`, `TaskStatus`. This plan deliberately stays OUT of `TaskStateService` and the `TaskStatus` enum (persisting `RoadblockCount` from the runner via the repository instead).
Build/test (per-project, .NET 8):
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
```
---
## Task A — Persist RoadblockCount (Data + Worker, no UI)
**Files:** `TaskEntity.cs`, `TaskEntityConfiguration.cs`, new migration, `TaskRepository.cs`, `TaskRunner.cs`; test in `tests/ClaudeDo.Data.Tests`.
- Add `public int RoadblockCount { get; set; }` to `TaskEntity` (default 0).
- Map it in `TaskEntityConfiguration` to column `roadblock_count` (default 0). Mirror the pattern used by an existing scalar column (e.g. how `DailyPrepMaxTasks`/other ints are configured).
- Create EF migration `AddRoadblockCount` (run `dotnet ef migrations add AddRoadblockCount` against `src/ClaudeDo.Data`; if EF tooling is unavailable, hand-author the migration + Designer + snapshot edit mirroring the most recent migration). One column, default 0, no backfill needed.
- Add `TaskRepository.SetRoadblockCountAsync(string taskId, int count, CancellationToken ct)` using `ExecuteUpdateAsync` on `RoadblockCount`.
- In `TaskRunner.HandleSuccess`, BEFORE the terminal state write (`SubmitForReviewAsync`/`CompleteAsync`), call `SetRoadblockCountAsync(task.Id, result.Blocks.Count, CancellationToken.None)` so the `TaskUpdated` broadcast reflects it. (Do NOT route this through `TaskStateService`.)
- Test: a `TaskRepository` test that sets a count and reads it back.
- Commit: `feat(roadblock): persist roadblock count on the task`.
**Acceptance:** a finished run with N roadblocks leaves `tasks.roadblock_count = N`; a clean run leaves 0.
---
## Task B — Detail panel: host review actions + real WaitingForReview state + diff meter
**Files:** `DetailsIslandViewModel.cs`, `DetailsIslandView.axaml` (+ `.axaml.cs` if needed), locales if new keys; reuse `IWorkerClient.ApproveReview/RejectReviewToQueue/RejectReviewToIdle/CancelReview` (already exist).
1. **WaitingForReview state:**
- In `StatusToStateKey` map `WaitingForReview => "review"` (was `"running"`); in `FinishedStatusToStateKey` map `"waiting_for_review" => "review"`.
- Add `public bool IsWaitingForReview => AgentState == "review";` and raise it in `OnAgentStateChanged`.
- Add a `vm.agentStatus.review` locale key (en + de, parity) for the status label.
- Confirm `IsAgentSectionEnabled => !IsRunning` still holds (review is no longer "running", so the agent settings section re-enables in review — correct).
2. **Review actions (moved from the row):** add commands to `DetailsIslandViewModel` that call the worker for the selected task: `ApproveReviewCommand`, `RejectReviewCommand` (takes feedback text → `RejectReviewToQueueAsync`), `ParkReviewCommand` (`RejectReviewToIdleAsync`), `CancelReviewCommand` (`CancelReviewAsync`). Add a `ReviewFeedback` string property for the rejection comment. Mirror how the row's code-behind currently invokes these (see `TaskRowView.axaml.cs`).
- In `DetailsIslandView.axaml`, add a review section (visible when `IsWaitingForReview` and `IsTaskDetailVisible`) with Approve / Reject(+feedback box) / Park / Cancel, reusing the existing `tasks.approve/reject/park/cancel` + `tasks.feedback*` locale keys.
3. **Diff meter:** in `RefreshWorktreeAsync`, after setting `row.DiffStat`, parse the `--stat` summary into additions/deletions and assign `DiffAdditions`/`DiffDeletions` (drives `DiffMeterRatio`). Add a small static parser `ParseDiffStat(string?) -> (int add, int del)` reading the "N insertions(+), M deletions(-)" tail; unit-test it.
- Commit: `feat(ui): host review actions in the details panel; show review state and diff meter`.
**Acceptance:** selecting a `WaitingForReview` task shows a "review" status (not "running"), the four review actions work from the detail panel, and the diff meter reflects real additions/deletions.
---
## Task C — Task row: remove review buttons, add roadblock badge
**Files:** `TaskRowView.axaml`, `TaskRowView.axaml.cs`, `TaskRowViewModel.cs`; warning icon resource if missing.
- Remove the review-actions `StackPanel` (lines ~142157) and the now-unused `RejectAnchor` flyout (~250279) from `TaskRowView.axaml`, and the corresponding click handlers (`OnApproveReviewClick`, `OnRejectReviewClick`, `OnParkReviewClick`, `OnCancelReviewClick`, reject-flyout handlers) from the code-behind. (Review now lives in the detail panel — Task B.)
- `TaskRowViewModel`: add `int RoadblockCount` + `bool HasRoadblock => RoadblockCount > 0` + `string RoadblockTooltip` (e.g. `"{n} roadblock(s) reported — see details"`); map `RoadblockCount` in `FromEntity`.
- `TaskRowView.axaml`: add a yellow warning `PathIcon` immediately left of the action area (in the chip row, before the status chip or before the star — pick the spot that reads as "left of the Done/action button"), `IsVisible="{Binding HasRoadblock}"`, `ToolTip.Tip="{Binding RoadblockTooltip}"`. Use a filled-geometry warning icon (PathIcon fills geometry — a stroke path renders invisible); if no `Icon.Warning` resource exists, add one (filled triangle + exclamation) to the icon resources, colored with a yellow/amber brush.
- Commit: `feat(ui): roadblock badge on the task card; relocate review actions`.
**Acceptance:** rows no longer show the four review buttons; a task with `RoadblockCount > 0` shows a yellow ⚠ left of the action button with a tooltip; review still fully works via the detail panel.
---
## Task D — Build + visual-check
- Full build (`App` + `Worker`) and run Data + Worker test suites; all green.
- **Manual (flag for user):** start the app, take a `WaitingForReview` task (the deploy roadblock task qualifies), confirm: row shows the ⚠ badge + no row review buttons; detail panel shows "review" state, working review actions, and a non-zero diff meter for the farewell/README tasks. The agent cannot verify GUI — ask the user.
- Then ping `claudedo-childloop` via mailbox with the exact shared-file diffs so it can rebase.
@@ -0,0 +1,33 @@
# Task Detail Redesign — Component Build Prompts
Three isolated build tasks (one per component). Each runs in its own worktree off
`main`, with the project CLAUDE.md auto-loaded. Full design context lives in
`docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md` — every task
must read it first.
Shared rules (all three):
- Build a **standalone** `UserControl` + dedicated `ViewModel` that renders fully
in the Avalonia previewer via **design-time sample data** (parameterless ctor
populating realistic values). Do **not** bind to `DetailsIslandViewModel`.
- New files under `src/ClaudeDo.Ui/Views/Islands/Detail/` and
`src/ClaudeDo.Ui/ViewModels/Islands/Detail/`.
- Use **only** tokens from `Design/Tokens.axaml` and classes from
`Design/IslandStyles.axaml`. No inline hex, no magic numbers where a token
exists. `PathIcon` fills geometry — stroke-only art is invisible.
- Compiled bindings (`x:DataType`). MVVM via CommunityToolkit
(`[ObservableProperty]`, `[RelayCommand]`); VM inherits `ViewModelBase`.
- **Do NOT modify** `DetailsIslandView.axaml`, `DetailsIslandViewModel.cs`,
`AgentStripView`, `SessionTerminalView`, or `TaskRunner.cs`.
- Verify: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release` is green.
Stage files explicitly by path (never `git add -A`). Commit with a conventional
message.
---
## TASK 1 — TaskHeaderBar
(prompt text = task description; see below)
## TASK 2 — DescriptionStepsCard
## TASK 3 — WorkConsole
@@ -0,0 +1,432 @@
# Git Merge/Review — Shared Foundation + Layer A Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the shared worker conflict contract (so parallel Layer B/C sessions branch from frozen interfaces) and rework the Git tab into a single Approve+merge cockpit.
**Architecture:** Phase 0 adds the conflict-resolution contract to `IWorkerClient`/`WorkerClient` (real `_hub.InvokeAsync` bodies — the worker hub methods are implemented later by Layer C; calls simply fail at runtime until then) plus client-side DTOs and test-fake updates, then commits + pushes so B and C branch from it. Phase A reworks `WorkConsole.axaml`'s Git tab and routes single-task merge/approve conflicts into a `RequestConflictResolution` seam (wired to Layer C's resolver by the integrator at merge time).
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. Build individual csproj with `-c Release` (`.slnx` needs .NET 9; a running Worker locks `Debug`).
**Reference spec:** `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
**Note on the canonical diff renderer:** the unified diff model/control already exists — `DiffFileViewModel`/`DiffLineViewModel`/`UnifiedDiffParser` (in `src/ClaudeDo.Ui/ViewModels/Modals/`) rendered by `DiffLinesView` (`src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml`). `DiffModalView` and `PlanningDiffView` already use it. So "consolidate diff renderers" for this scope is just verifying that (Task A.3); migrating `WorktreeModalView`'s bespoke diff onto `DiffLinesView` is Layer B's job.
---
## File Structure
**Phase 0 (foundation — pushed before B/C branch):**
- Modify `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — 5 new method signatures.
- Modify `src/ClaudeDo.Ui/Services/WorkerClient.cs` — 5 `InvokeAsync` bodies + 3 new DTO records.
- Modify `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` — 5 new `virtual` no-op methods.
- Modify `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — 5 new methods on `FakeWorkerClient`.
**Phase A (Layer A — this session, after foundation commit):**
- Modify `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``RequestConflictResolution` seam; route Approve/Merge conflicts into it.
- Modify `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — fuse REVIEW + MERGE sections into one cockpit block.
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (or a sibling test file in the same folder).
---
## Phase 0 — Shared Foundation
### Task 0.1: Add the conflict contract (interface + client + DTOs)
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add the 5 method signatures to `IWorkerClient`**
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, after the existing
`Task CancelReviewAsync(string taskId);` line (line 45), add:
```csharp
// ── Conflict resolution (worker hub side implemented by Layer C) ──
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueMergeAsync(string taskId);
Task AbortMergeAsync(string taskId);
```
- [ ] **Step 2: Add the 3 DTO records to `WorkerClient.cs`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, immediately after line 534
(`public record MergeTargetsDto(...)`), add:
```csharp
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
```
- [ ] **Step 3: Add the 5 client method bodies to `WorkerClient.cs`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, right after the `MergeTaskAsync`
method (ends at line 270), add:
```csharp
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
public Task AbortMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortMerge", taskId);
```
- [ ] **Step 4: Build the UI project**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`
Expected: build FAILS — the two test projects won't compile yet, but the UI project
itself should succeed. If the UI project reports "does not implement interface member"
it means a body is missing; fix before continuing. (Test projects are fixed in 0.2.)
### Task 0.2: Update the hand-rolled test fakes
**Files:**
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
- [ ] **Step 1: Add 5 virtual no-ops to `StubWorkerClient`**
In `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, after the `MergeTaskAsync` override
(line 57), add:
```csharp
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
public virtual Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public virtual Task AbortMergeAsync(string taskId) => Task.CompletedTask;
```
- [ ] **Step 2: Add 5 methods to `FakeWorkerClient`**
In `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`, after the
`MergeTaskAsync` method (line 47), add:
```csharp
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
public Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public Task AbortMergeAsync(string taskId) => Task.CompletedTask;
```
- [ ] **Step 3: Build both test projects**
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
Expected: both BUILD succeed.
- [ ] **Step 4: Run the UI test suite to confirm green baseline**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: PASS (no behavior changed yet).
### Task 0.3: Commit and push the foundation
- [ ] **Step 1: Commit**
```bash
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
git commit -m "feat(ui): add conflict-resolution worker contract (foundation for merge rework)"
```
- [ ] **Step 2: Push so Layer B/C can branch from this commit**
Run: `git push`
Expected: pushed to `main`. (First push to git.kuns.dev may fail auth — retry once.)
**This commit is the branch point for the Layer B and Layer C kickoff prompts.**
---
## Phase A — Layer A Review/Merge Cockpit
### Task A.1: Conflict-resolution seam + route Approve/Merge conflicts into it (TDD)
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs` (new)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs`. Mirror
the VM-construction harness used in
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (same folder) —
construct `DetailsIslandViewModel` exactly as that file does, including its
`StubWorkerClient` subclass pattern. The test:
```csharp
[Fact]
public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam()
{
string? resolvedTaskId = null;
string? resolvedTarget = null;
// Construct the VM as in DetailsIslandPlanningTests, with a worker stub whose
// ApproveReviewAsync returns a conflict result:
// public override Task<MergeResultDto?> ApproveReviewAsync(string id, string target)
// => Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[]{"a.cs"}, null));
var vm = CreateVm(/* worker stub above */);
vm.RequestConflictResolution = (taskId, target) =>
{
resolvedTaskId = taskId; resolvedTarget = target;
return System.Threading.Tasks.Task.CompletedTask;
};
// assign a task in WaitingForReview + a SelectedMergeTarget = "main" via the same
// helpers DetailsIslandPlanningTests uses.
await vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(/* the seeded task id */, resolvedTaskId);
Assert.Equal("main", resolvedTarget);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
Expected: FAIL — `RequestConflictResolution` property does not exist (compile error).
- [ ] **Step 3: Add the seam property**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, beside the other
view-wired delegates (`ShowDiffModal`, `ShowMergeModal` around line 387390), add:
```csharp
// Invoked when a single-task merge/approve hits a conflict. Wired by the
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
```
- [ ] **Step 4: Route the Approve conflict branch into the seam**
In `ApproveReviewAsync` (around line 1453), replace the conflict branch body so it
prefers the seam, falling back to the current preview-text behavior:
```csharp
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
if (result?.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
```
- [ ] **Step 5: Route the manual Merge conflict branch into the seam**
In `MergeAsync` (around line 1170), apply the same pattern to its conflict branch:
```csharp
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
if (result.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
else
{
await RefreshMergePreviewAsync();
}
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
Expected: PASS.
- [ ] **Step 7: Run the full UI suite (no regressions)**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
git commit -m "feat(ui): route single-task merge conflicts into a resolution seam"
```
### Task A.2: Fuse the Git tab into one Approve+merge cockpit
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
- [ ] **Step 1: Replace the two Git-tab sections with one cockpit block**
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, replace the entire Git
`ScrollViewer` body (lines 255313 — the `<!-- Git: ... -->` block containing the
separate `REVIEW` `StackPanel` and the `MERGE & WORKTREE` `StackPanel`) with a single
cockpit where Approve sits with the merge target/preview/actions. Keep the existing
control class names (`section-label`, `field-label`, `btn`, `btn accent`, `meta`) and
the existing bindings (`SelectedMergeTarget`, `MergeTargetBranches`, `MergePreviewText`,
`MergeIsClean`, `MergeIsConflict`, `ShowMergePreviewMuted`, `OpenDiffCommand`,
`ApproveReviewCommand`, `MergeCommand`, `ShowSingleMerge`, `OpenWorktreeCommand`,
`ReviewCombinedDiffCommand`, `MergeAllCommand`, `CanMergeAll`, `MergeAllDisabledReason`,
`MergeAllError`):
```xml
<!-- Git: one Approve + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Target branch" />
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
<!-- Primary action: Approve flows straight into the merge.
Approve is the review-gated path; the plain Merge button covers
already-reviewed / kept worktrees. -->
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}"
IsVisible="{Binding IsWaitingForReview}" />
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn" Margin="0,0,8,8"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
Command="{Binding MergeAllCommand}"
IsEnabled="{Binding CanMergeAll}"
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
</WrapPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError,
Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</ScrollViewer>
```
Note: the cockpit now shows whenever `ShowMergeSection` is true. `ShowMergeSection`
(DetailsIslandViewModel line 161) must be true while `IsWaitingForReview` so the
Approve button appears. Check its expression in Step 2.
- [ ] **Step 2: Verify `ShowMergeSection` covers the review state**
Read `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 161. If
`ShowMergeSection` is false while `IsWaitingForReview` (e.g. it requires a non-review
state), widen it to also be true when `IsWaitingForReview && WorktreePath != null`, and
ensure `OnPropertyChanged(nameof(ShowMergeSection))` already fires on the relevant state
transitions (it is notified via `NotifySessionSections`). Make the minimal change needed
so the Approve button is visible in review state. If it already covers review, change
nothing.
- [ ] **Step 3: Build the app project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: BUILD succeeds (pulls in Ui + Data).
- [ ] **Step 4: Visual verification (manual — flag for the user)**
This is an AXAML layout change with no automated coverage. Launch the app, open a task
in `WaitingForReview`, open the Git tab, and confirm: the single MERGE block shows the
target combo, the colored preview line, an "Approve & Merge" button (review state), and
the diff/worktree/combined/merge-all actions. **Explicitly tell the user this needs a
visual pass — do not claim it works without running it.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): fuse git tab into one approve+merge cockpit"
```
### Task A.3: Verify diff-renderer consolidation
**Files:** none modified (verification only).
- [ ] **Step 1: Confirm DiffModal + Planning already use the canonical renderer**
Run: `rg -l "DiffLinesView" src/ClaudeDo.Ui/Views`
Expected: matches in `Modals/DiffModalView.axaml` and `Planning/PlanningDiffView.axaml`.
If `PlanningDiffView.axaml` does NOT use `DiffLinesView`, change its diff `ItemsControl`
to a `<controls:DiffLinesView Lines="{Binding SelectedFile.Lines}" />` (matching
`DiffModalView.axaml`'s usage) and rebuild the App project. If both already use it, this
task is a no-op — record that and move on. (`WorktreeModalView`'s bespoke diff is
intentionally left for Layer B.)
---
## Self-Review
- **Spec coverage:** Foundation contract (spec §"Frozen worker conflict contract") →
Task 0.1. Test fakes (spec parallel-boundaries row) → Task 0.2. Branch point (spec
§"built & pushed this session") → Task 0.3. Layer A cockpit + Approve/merge flow
together (spec §"Layer A") → Task A.2. Single-task approve-on-conflict opens resolver
via seam (spec §"Layer A" + §"integration seams") → Task A.1. Diff consolidation
(spec §"One diff model") → Task A.3. Output-footer feedback unchanged → not touched
(correct). No spec requirement left unmapped for this session's scope.
- **Placeholder scan:** none — every code step has concrete code; the only "mirror the
existing harness" reference (Task A.1 Step 1) points at a real file with a working
pattern, not a TODO.
- **Type consistency:** `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` and the
5 method names match between `IWorkerClient` (0.1 Step 1), `WorkerClient` (0.1 Steps
23), and both fakes (0.2). The seam `RequestConflictResolution` is
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 35). DTO field names match the
spec.
---
## Integration notes (for the integrator merging A + B + C)
- Wire `DetailsIslandViewModel.RequestConflictResolution` and Layer B's equivalent
callback to Layer C's `ConflictResolverViewModel` factory + `ShowConflictResolver`
dialog delegate.
- Layer C implements the worker hub methods `StartConflictMerge`, `GetMergeConflicts`,
`WriteConflictResolution`, `ContinueMerge`, `AbortMerge`; the client side from Task
0.1 already calls them by name.
@@ -0,0 +1,139 @@
# Git Merge/Review Rework — Parallel Kickoff Prompts (Layer B & Layer C)
These are self-contained prompts to paste into two fresh ClaudeDo sessions, each in its
own git worktree, run **in parallel** with the main session's Layer A work.
**Prerequisite — branch point:** Both sessions must branch from `main` **at or after**
the foundation commit `feat(ui): add conflict-resolution worker contract (foundation for
merge rework)` (Phase 0, Task 0.3 of
`docs/superpowers/plans/2026-06-05-git-merge-review-foundation-layerA.md`). That commit
adds the frozen `IWorkerClient` conflict contract both layers rely on. Do not start B/C
until that commit is pushed.
**Integration:** Neither session pushes to `main` or merges. Each leaves its branch/
worktree for the orchestrator (the main session) to review and merge.
Design reference for both: `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
---
## Layer B — Multi-worktree merge cockpit
```
We're reworking ClaudeDo's merge/review UX. Your job is Layer B: a multi-worktree merge
cockpit. The overall design is in docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md
(read the "Layer B" section and "Parallel boundaries" table first). A shared foundation
commit ("add conflict-resolution worker contract") is already on main — branch from it.
First, create an isolated worktree for this work (use the superpowers:using-git-worktrees
skill). Then write a plan (superpowers:writing-plans) for just Layer B and implement it
with superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
Scope:
- Rework WorktreesOverviewModalView + WorktreesOverviewModalViewModel into a batch-merge
cockpit: list mergeable worktrees, multi-select N, pick ONE target branch, "Merge all".
- Skip-and-continue: loop the EXISTING IWorkerClient.MergeTaskAsync(taskId, target,
removeWorktree:false, msg) over the selected tasks. Clean ones merge; conflicting ones
(MergeTaskAsync returns Status=="conflict", auto-aborts leaving the tree clean) are
collected into a "needs resolution" list shown with live progress.
- Each conflict row gets a "Resolve" button that invokes a seam:
public Func<string, string, Task>? RequestConflictResolution { get; set; } // (taskId, targetBranch)
Define this callback property on the cockpit VM; leave it unwired (the orchestrator
wires it to Layer C's resolver at merge time). Do NOT reference any ConflictResolver
type.
- Migrate WorktreeModalView's bespoke inline diff onto the canonical DiffLinesView
control (src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml) using DiffFileViewModel/
DiffLineViewModel/UnifiedDiffParser (src/ClaudeDo.Ui/ViewModels/Modals/). This removes
the last duplicate diff renderer.
Reuse these existing IWorkerClient methods (already implemented): MergeTaskAsync,
GetMergeTargetsAsync, GetWorktreesOverviewAsync, SetWorktreeStateAsync,
CleanupFinishedWorktreesAsync, ForceRemoveWorktreeAsync.
Do NOT touch (other layers own them): any worker-side files (WorkerHub, TaskMergeService,
GitService), IWorkerClient.cs / WorkerClient.cs, WorkConsole.axaml,
DetailsIslandViewModel.cs, or create the ConflictResolver UI.
Build with: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running
Worker locks Debug — use Release). Keep locales/en.json and de.json keys in parity if you
add any. If you change IWorkerClient (you shouldn't need to), update the hand-rolled fakes
in tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs and
tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs. No tests that spawn
the real claude CLI.
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
your worktree/branch for the orchestrator. Flag any AXAML layout for visual verification
rather than claiming it works.
```
---
## Layer C — Inline conflict resolver
```
We're reworking ClaudeDo's merge/review UX. Your job is Layer C: an in-app, VSCode-style
inline conflict resolver, plus the worker plumbing it needs. The overall design is in
docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md (read the "Layer C",
"Frozen worker conflict contract", and "Parallel boundaries" sections first). A shared
foundation commit ("add conflict-resolution worker contract") is already on main — branch
from it. That commit already wired the CLIENT side (IWorkerClient + WorkerClient call
these hub methods by name); your job includes implementing the matching WORKER hub methods.
First, create an isolated worktree (superpowers:using-git-worktrees). Then write a plan
(superpowers:writing-plans) for Layer C and implement it with
superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
Worker side — implement these 5 hub methods in WorkerHub (names/params/returns MUST match
the client calls already shipped in the foundation):
- StartConflictMerge(string taskId, string targetBranch) -> MergeResultDto
Calls TaskMergeService.MergeAsync with leaveConflictsInTree:true (the overload/flag
already exists — used today by PlanningMergeOrchestrator). Leaves .git/MERGE_HEAD in
the list's WorkingDir, returns Status="conflict" + conflict file list.
- GetMergeConflicts(string taskId) -> MergeConflictsDto
For each conflicted file (git diff --name-only --diff-filter=U), read ours/theirs/base
via `git show :2:<path>` / `:3:<path>` / `:1:<path>`. Add GitService helpers as needed.
- WriteConflictResolution(string taskId, string path, string resolvedContent) -> void
Write resolvedContent to the file in WorkingDir and `git add` it.
- ContinueMerge(string taskId) -> MergeResultDto
Wrap the EXISTING TaskMergeService.ContinueMergeAsync (git add -A → re-check
diff --diff-filter=U → git commit). Currently service-level only; expose it on the hub.
- AbortMerge(string taskId) -> void
Wrap the EXISTING TaskMergeService.AbortMergeAsync (git merge --abort).
Define worker-side DTO records that serialize identically to the client records already in
WorkerClient.cs:
MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)
ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)
ConflictHunkDto(string Ours, string Theirs, string? Base)
(place beside the other hub DTOs in WorkerHub.cs). MergeResultDto already exists.
UI side — new files only:
- ConflictResolverViewModel + ConflictResolverView. On open: StartConflictMergeAsync then
GetMergeConflictsAsync(taskId). Per conflict hunk show ours vs theirs stacked with
buttons Accept Current / Accept Incoming / Accept Both / Edit manually, plus a free-text
box for the merged result of that hunk. Use the UI conflict model from the design
(ConflictFile { Path, Hunks[] }, ConflictHunk { Ours, Theirs, Base, Resolution }) —
shape it so a future 3-way pane needs no model change.
- When every file is resolved: WriteConflictResolutionAsync per file, then
ContinueMergeAsync(taskId) (Status "merged" closes; "conflict" means not fully resolved,
stay open). AbortMergeAsync(taskId) cancels.
- Expose a factory Func<string, ConflictResolverViewModel> and a
Func<ConflictResolverViewModel, Task> ShowConflictResolver dialog delegate for the
orchestrator to wire to Layer A/B's RequestConflictResolution(taskId, target) seams.
Do NOT touch (other layers own them): WorkerClient.cs, IWorkerClient.cs (already wired),
WorkConsole.axaml, DetailsIslandViewModel.cs, WorktreesOverviewModalView/VM. You WILL need
to add the 5 worker hub methods + GitService conflict reads.
Tests: add worker tests for the conflict reads / continue / abort using real SQLite + real
git (follow existing GitService/TaskMergeService test patterns). NEVER spawn the real
claude CLI. If you change IWorkerClient (you should NOT — client is frozen), update the
fakes in both test projects.
Build with: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release and
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running Worker locks
Debug). Keep locales/en.json and de.json in parity for any new UI strings.
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
your worktree/branch for the orchestrator. Flag the resolver UI for visual verification.
```
@@ -0,0 +1,920 @@
# Layer C — Inline Conflict Resolver Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework.
**Architecture:** The worker performs a real merge that leaves conflicts in the list's working tree (`leaveConflictsInTree:true`), exposes ours/theirs/base per conflicted file via `git show :2:/:3:/:1:`, accepts written resolutions, and finishes via the existing `ContinueMergeAsync`/`AbortMergeAsync`. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues.
**Tech Stack:** .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures.
**Frozen client contract (already shipped in foundation commit `2dfc455`, DO NOT edit):**
- `IWorkerClient` / `WorkerClient.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`.
- Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)`, `ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)`.
- Worker-side DTOs must serialize identically (same record shape) and live in `WorkerHub.cs`.
**Do NOT touch:** `WorkerClient.cs`, `Interfaces/IWorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, `WorktreesOverviewModalView/VM`, `WorktreeModalView`. Test fakes for `IWorkerClient` already implement the 5 methods as no-op stubs (`StubWorkerClient` is `virtual` in Ui.Tests) — subclass/override, never edit the interface.
**Build/test commands (.NET 8 — running Worker locks `Debug`, always `-c Release`):**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
---
## File Structure
**Worker / Data (create + modify):**
- Modify `src/ClaudeDo.Data/Git/GitService.cs` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`.
- Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`.
- Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` + 5 hub methods.
**UI (create new only):**
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs``ConflictFile`, `ConflictHunk`.
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`.
- Create `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` + `.axaml.cs`.
**Wiring (modify):**
- Modify `src/ClaudeDo.App/Program.cs` — register `ConflictResolverViewModel` + `Func<string, ConflictResolverViewModel>`.
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`).
- Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog delegate.
- Modify `src/ClaudeDo.Localization/locales/en.json` + `de.json``conflictResolver.*` keys (parity enforced by Localization.Tests).
**Tests (create + modify):**
- Modify `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — conflict-read / write-resolution / round-trip tests.
- Create `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`.
---
## Task 1: GitService conflict-blob reads
**Files:**
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` (GitService exercised here via real repo; add focused tests in Task 2 round-trip)
- [ ] **Step 1: Add `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes.
In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to:
```csharp
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
```
(All existing callers keep the default `true`.)
- [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`):
```csharp
/// <summary>
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
/// Output is NOT trimmed so file content round-trips exactly.
/// </summary>
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
return exitCode == 0 ? stdout : null;
}
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
}
```
- [ ] **Step 3: Build the Data + Worker projects to verify compilation.**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Data/Git/GitService.cs
git commit -m "feat(git): add conflict-stage blob reads and single-path staging"
```
---
## Task 2: TaskMergeService conflict reads + resolution writes
**Files:**
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
- [ ] **Step 1: Write failing tests** (append inside `TaskMergeServiceTests`, before `#region Test doubles`). Reuse the existing helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`.
```csharp
[Fact]
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
var (svc, _) = BuildService(db);
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
Assert.Equal(task.Id, conflicts.TaskId);
var file = Assert.Single(conflicts.Files);
Assert.Equal("README.md", file.Path);
Assert.Contains("main change", file.Ours); // ours = target (main) side after checkout
Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch
Assert.NotNull(file.Base);
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
}
[Fact]
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
var (svc, _) = BuildService(db);
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
```
- [ ] **Step 2: Run tests to verify they fail** (no such methods).
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
Expected: compile error / FAIL (methods don't exist).
- [ ] **Step 3: Add records + methods to `TaskMergeService.cs`.**
Add records beside `MergeResult` (top of file, after the existing record declarations):
```csharp
public sealed record MergeConflicts(
string TaskId,
IReadOnlyList<ConflictFileContent> Files);
public sealed record ConflictFileContent(
string Path,
string Ours,
string Theirs,
string? Base);
```
Add methods inside the class (after `AbortMergeAsync`):
```csharp
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var result = new List<ConflictFileContent>(files.Count);
foreach (var path in files)
{
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
result.Add(new ConflictFileContent(path, ours, theirs, @base));
}
return new MergeConflicts(taskId, result);
}
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
await File.WriteAllTextAsync(full, content, ct);
await _git.AddPathAsync(list.WorkingDir, path, ct);
}
```
(Note: `Path` is `System.IO.Path` — the file already uses it via other helpers; the record property `Path` does not shadow it inside these methods because it's accessed as a static type, not an instance member.)
- [ ] **Step 4: Run the tests to verify they pass.**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
Expected: PASS (2 tests). If git unavailable they no-op.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(merge): read conflict stages and write user resolutions"
```
---
## Task 3: WorkerHub conflict methods + DTOs
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- [ ] **Step 1: Add DTOs** beside the existing merge DTOs (after `public record MergeTargetsDto(...)`):
```csharp
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
```
- [ ] **Step 2: Add the 5 hub methods** (after `PreviewMerge`). Names/params/returns MUST match the frozen client calls.
```csharp
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var r = await _mergeService.MergeAsync(
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
leaveConflictsInTree: true, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "merge blocked");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
=> HubGuard(async () =>
{
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
return new MergeConflictsDto(
c.TaskId,
c.Files.Select(f => new ConflictFileDto(
f.Path,
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
});
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
=> HubGuard(() => _mergeService.WriteResolutionAsync(
taskId, path, resolvedContent ?? "", CancellationToken.None));
public Task<MergeResultDto> ContinueMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "continue failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task AbortMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "abort failed");
});
```
- [ ] **Step 3: Build the Worker project to verify compilation.**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(hub): expose conflict-resolution merge methods"
```
---
## Task 4: Conflict UI model
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` (model tests added here in Task 5; this task is build-verified)
- [ ] **Step 1: Create the model file.** Shaped so a 3-way pane needs no model change (`Base` retained per hunk).
```csharp
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject
{
public string Ours { get; }
public string Theirs { get; }
public string? Base { get; }
[ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null;
public ConflictHunk(string ours, string theirs, string? @base)
{
Ours = ours;
Theirs = theirs;
Base = @base;
}
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours;
}
public sealed class ConflictFile
{
public string Path { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
{
Path = path;
Hunks = hunks;
}
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
/// <summary>The merged file content: concatenation of each hunk's resolution
/// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later).</summary>
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
}
```
- [ ] **Step 2: Build the Ui project to verify compilation.**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)"
```
---
## Task 5: ConflictResolverViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`
- [ ] **Step 1: Write failing tests.** Subclass the existing `StubWorkerClient` (its conflict methods are `virtual`).
```csharp
using System.Collections.Generic;
using System.Threading.Tasks;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Conflicts;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class ConflictResolverViewModelTests
{
private sealed class FakeWorker : StubWorkerClient
{
public string? WrittenPath;
public string? WrittenContent;
public bool Continued;
public bool Aborted;
public string ContinueStatus = "merged";
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
{
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
}));
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
{
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
}
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
{
Continued = true;
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
}
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
}
[Fact]
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts);
var file = Assert.Single(vm.Files);
Assert.Equal("README.md", file.Path);
Assert.False(vm.CanContinue); // nothing resolved yet
file.Hunks[0].AcceptIncomingCommand.Execute(null);
Assert.True(vm.CanContinue); // every hunk resolved
}
[Fact]
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n"
await vm.ContinueCommand.ExecuteAsync(null);
Assert.Equal("README.md", worker.WrittenPath);
Assert.Equal("ours\n", worker.WrittenContent);
Assert.True(worker.Continued);
Assert.True(closed);
}
[Fact]
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
{
var worker = new FakeWorker { ContinueStatus = "conflict" };
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.False(closed);
Assert.NotNull(vm.Error);
}
[Fact]
public async Task Abort_CallsWorkerAndCloses()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.AbortCommand.ExecuteAsync(null);
Assert.True(worker.Aborted);
Assert.True(closed);
}
}
```
- [ ] **Step 2: Run tests to verify they fail** (VM not defined).
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
Expected: compile error / FAIL.
- [ ] **Step 3: Implement the ViewModel.**
```csharp
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictResolverViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _taskId;
public ObservableCollection<ConflictFile> Files { get; } = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _canContinue;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
}
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
public async Task<bool> OpenAsync(string targetBranch)
{
IsBusy = true;
Error = null;
try
{
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
{
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
Error = start.ErrorMessage;
return false;
}
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
Files.Clear();
foreach (var f in conflicts.Files)
{
var hunks = f.Hunks.Select(h =>
{
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
hk.PropertyChanged += OnHunkChanged;
return hk;
}).ToList();
Files.Add(new ConflictFile(f.Path, hunks));
}
RecomputeCanContinue();
return Files.Count > 0;
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
finally { IsBusy = false; }
}
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
RecomputeCanContinue();
}
private void RecomputeCanContinue()
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
[RelayCommand]
private async Task ContinueAsync()
{
if (!CanContinue) return;
IsBusy = true;
Error = null;
try
{
foreach (var file in Files)
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
var result = await _worker.ContinueMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
CloseRequested?.Invoke();
else
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
}
catch (Exception ex)
{
Error = ex.Message;
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task AbortAsync()
{
IsBusy = true;
try { await _worker.AbortMergeAsync(_taskId); }
catch (Exception ex) { Error = ex.Message; }
finally
{
IsBusy = false;
CloseRequested?.Invoke();
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass.**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
git commit -m "feat(ui): add inline conflict resolver view-model"
```
---
## Task 6: ConflictResolverView + localization
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs`
- Modify: `src/ClaudeDo.Localization/locales/en.json`
- Modify: `src/ClaudeDo.Localization/locales/de.json`
- [ ] **Step 1: Add localization keys** to `en.json` as a new top-level section (sibling of `"planning"`):
```json
"conflictResolver": {
"windowTitle": "Resolve merge conflicts",
"modalTitle": "RESOLVE CONFLICTS",
"loading": "Loading conflicts…",
"current": "Current (ours)",
"incoming": "Incoming (theirs)",
"mergedResult": "Merged result",
"acceptCurrent": "Accept Current",
"acceptIncoming": "Accept Incoming",
"acceptBoth": "Accept Both",
"editManually": "Edit manually",
"continue": "Resolve & continue",
"abort": "Abort merge"
},
```
- [ ] **Step 2: Add the SAME keys to `de.json`** (German values, identical key set — parity enforced by Localization.Tests):
```json
"conflictResolver": {
"windowTitle": "Merge-Konflikte lösen",
"modalTitle": "KONFLIKTE LÖSEN",
"loading": "Konflikte werden geladen…",
"current": "Aktuell (unsere)",
"incoming": "Eingehend (ihre)",
"mergedResult": "Zusammengeführtes Ergebnis",
"acceptCurrent": "Aktuelle übernehmen",
"acceptIncoming": "Eingehende übernehmen",
"acceptBoth": "Beide übernehmen",
"editManually": "Manuell bearbeiten",
"continue": "Lösen & fortfahren",
"abort": "Merge abbrechen"
},
```
- [ ] **Step 3: Create the View** (`ConflictResolverView.axaml`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk.
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}"
Width="760" Height="640" MinWidth="560" MinHeight="420"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
</StackPanel>
</ctl:ModalShell.Footer>
<Grid RowDefinitions="Auto,*" Margin="16,12">
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
Text="{loc:Tr conflictResolver.loading}"
IsVisible="{Binding IsBusy}"/>
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{Binding Error}" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictFile">
<StackPanel Spacing="8" Margin="0,0,0,16">
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
<ItemsControl ItemsSource="{Binding Hunks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictHunk">
<Border BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
CornerRadius="6" Padding="10" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
FontFamily="{DynamicResource MonoFont}"/>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
FontFamily="{DynamicResource MonoFont}"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
Command="{Binding AcceptCurrentCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
Command="{Binding AcceptIncomingCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
Command="{Binding AcceptBothCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
Command="{Binding EditManuallyCommand}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
TextWrapping="NoWrap" AcceptsReturn="True" MinHeight="80" MaxHeight="200"
FontFamily="{DynamicResource MonoFont}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</ctl:ModalShell>
</Window>
```
**Note for the implementer:** if `MonoFont` / `path-mono` / `heading` / `meta` / `btn` resource keys or style classes don't resolve at build, drop the `FontFamily` attribute and unknown `Classes` (keep `btn`) — match whatever the existing `ConflictResolutionView.axaml` and app styles actually expose. Verify against `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` and the app's style resources before finalizing.
- [ ] **Step 4: Create the code-behind** (`ConflictResolverView.axaml.cs`):
```csharp
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Conflicts;
namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window
{
public ConflictResolverView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(System.EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ConflictResolverViewModel vm)
vm.CloseRequested = Close;
}
}
```
- [ ] **Step 5: Build the App + run Localization tests.**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: Build succeeded; localization parity tests PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
git commit -m "feat(ui): add inline conflict resolver view and localization"
```
---
## Task 7: Wire factory + dialog seam for the integrator
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
These are additive seams only. The integrator connects Layer A/B's `RequestConflictResolution(taskId, target)` callback to `IslandsShellViewModel.RequestConflictResolutionAsync`.
- [ ] **Step 1: Register the factory in `Program.cs`** (in the ViewModels region, near the other `Func<>` factories). Only the `Func<>` factory is needed — the VM is never resolved directly:
```csharp
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<WorkerClient>(), taskId));
```
Then, after `IslandsShellViewModel` is registered, set the factory on it once resolved. Replace the existing `sc.AddSingleton<IslandsShellViewModel>();` registration with a factory that injects the conflict-resolver factory:
```csharp
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
shell.ConflictResolverFactory =
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
return shell;
});
```
(`ActivatorUtilities.CreateInstance` resolves the existing big constructor + its `Func<>` deps exactly as the default registration did.)
- [ ] **Step 2: Add the additive seam to `IslandsShellViewModel`** (near the other `Show*` delegate properties):
```csharp
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
{
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
var vm = ConflictResolverFactory(taskId);
var hasConflicts = await vm.OpenAsync(targetBranch);
if (hasConflicts)
await ShowConflictResolver(vm);
}
```
(Add `using ClaudeDo.Ui.ViewModels.Conflicts;` or use fully-qualified names as above.)
- [ ] **Step 3: Wire the dialog opener in `MainWindow.axaml.cs`** inside `OnDataContextChanged`, alongside the other `vm.Show*` assignments:
```csharp
vm.ShowConflictResolver = async (resolverVm) =>
{
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
await dlg.ShowDialog(this);
};
```
- [ ] **Step 4: Build the App to verify compilation.**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): expose conflict-resolver factory and dialog seam for integrator"
```
---
## Task 8: Full verification
- [ ] **Step 1: Build both head projects.**
Run:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
Expected: both Build succeeded, 0 errors/warnings.
- [ ] **Step 2: Run the full relevant test suites.**
Run:
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: all PASS.
- [ ] **Step 3: Flag visual verification.** The resolver dialog cannot be opened end-to-end until the integrator wires Layer A/B's `RequestConflictResolution(taskId, target)``IslandsShellViewModel.RequestConflictResolutionAsync`. Report this as a visual-verification gap for the user/integrator: open a real conflicting merge, confirm hunks render, Accept buttons populate the merged box, Resolve & continue closes on success, Abort restores the tree.
- [ ] **Step 4: Leave the branch for the orchestrator.** Do NOT push, do NOT merge to main.
@@ -0,0 +1,837 @@
# Layer B — Multi-Worktree Merge Cockpit Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`.
**Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`.
**Frozen contracts reused (do NOT modify):**
- `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task<MergeResultDto>`
- `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?>` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches)`)
- `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)``Status` is `"merged" | "conflict" | "blocked" | <other>`
- `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync`
- `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it)
- `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten`
**Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type.
---
## File Structure
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`**modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact.
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`**modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons.
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`**modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`.
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`**modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource.
- `src/ClaudeDo.Localization/locales/en.json` + `de.json`**modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity).
- `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`**create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam.
No `IWorkerClient` change → no test-fake updates needed.
---
## Task 1: Row-level batch state (outcome enum + row VM fields)
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Modals;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class WorktreesOverviewBatchMergeTests
{
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
{
TaskId = id,
TaskTitle = $"Task {id}",
TaskStatus = TaskStatus.WaitingForReview,
State = WorktreeState.Active,
};
[Fact]
public void Row_outcome_helpers_reflect_state()
{
var row = ActiveRow("a");
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
Assert.False(row.IsConflict);
row.MergeOutcome = BatchMergeOutcome.Conflict;
Assert.True(row.IsConflict);
row.MergeOutcome = BatchMergeOutcome.Merged;
Assert.False(row.IsConflict);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error).
- [ ] **Step 3: Add the enum and row fields**
In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`:
```csharp
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
```
Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field:
```csharp
[ObservableProperty] private bool _isChecked;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsConflict))]
[NotifyPropertyChangedFor(nameof(HasOutcome))]
private BatchMergeOutcome _mergeOutcome;
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: PASS (1 test).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM"
```
---
## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue)
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly:
```csharp
private static WorktreesOverviewModalViewModel NewVm() =>
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
[Fact]
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
vm.SelectedTarget = "main";
var seen = new System.Collections.Generic.List<string>();
await vm.MergeSelectedAsync((id, target, remove, msg) =>
{
seen.Add(id);
Assert.Equal("main", target);
Assert.False(remove); // removeWorktree must be false
return System.Threading.Tasks.Task.FromResult(Merged());
});
Assert.Equal(new[] { "a" }, seen);
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
Assert.False(a.IsChecked); // cleared after merge
}
[Fact]
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
var b = ActiveRow("b"); b.IsChecked = true;
var c = ActiveRow("c"); c.IsChecked = true;
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
vm.SelectedTarget = "main";
await vm.MergeSelectedAsync((id, target, remove, msg) =>
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict
Assert.Contains(b, vm.ConflictRows);
Assert.Single(vm.ConflictRows);
}
[Fact]
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
var b = ActiveRow("b"); b.IsChecked = true;
vm.Rows.Add(a); vm.Rows.Add(b);
vm.SelectedTarget = "main";
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
? System.Threading.Tasks.Task.FromResult(Blocked())
: throw new System.InvalidOperationException("boom"));
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
Assert.Empty(vm.ConflictRows);
Assert.False(vm.IsMerging);
}
[Fact]
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
vm.Rows.Add(a);
vm.SelectedTarget = null;
var called = false;
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
Assert.False(called);
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error).
- [ ] **Step 3: Implement the orchestration + cockpit fields**
In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field):
```csharp
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
[ObservableProperty] private string? _batchProgress;
public ObservableCollection<string> MergeTargets { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
public Func<string, string, Task>? RequestConflictResolution { get; set; }
```
Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:
```csharp
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
public async Task MergeSelectedAsync(
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
CancellationToken ct = default)
{
var target = SelectedTarget;
if (string.IsNullOrWhiteSpace(target)) return;
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
if (selected.Count == 0) return;
IsMerging = true;
ConflictRows.Clear();
var done = 0;
try
{
foreach (var row in selected)
{
ct.ThrowIfCancellationRequested();
row.MergeOutcome = BatchMergeOutcome.Merging;
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
MergeResultDto result;
try
{
result = await mergeFn(row.TaskId, target!, false,
Loc.T("vm.merge.commitMessage", row.TaskTitle));
}
catch
{
row.MergeOutcome = BatchMergeOutcome.Failed;
continue;
}
switch (result.Status)
{
case "merged":
row.MergeOutcome = BatchMergeOutcome.Merged;
row.State = WorktreeState.Merged;
row.IsChecked = false;
break;
case "conflict":
row.MergeOutcome = BatchMergeOutcome.Conflict;
ConflictRows.Add(row);
break;
case "blocked":
row.MergeOutcome = BatchMergeOutcome.Blocked;
break;
default:
row.MergeOutcome = BatchMergeOutcome.Failed;
break;
}
}
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
}
finally
{
IsMerging = false;
}
}
```
> Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now.
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
git commit -m "feat(ui): add skip-and-continue batch merge orchestration"
```
---
## Task 3: Selection tracking, target loading, commands + resolve seam
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `WorktreesOverviewBatchMergeTests.cs`:
```csharp
[Fact]
public void SelectedCount_tracks_checked_active_rows()
{
var vm = NewVm();
var a = ActiveRow("a");
var b = ActiveRow("b");
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
Assert.Equal(0, vm.SelectedCount);
a.IsChecked = true;
Assert.Equal(1, vm.SelectedCount);
b.IsChecked = true;
merged.IsChecked = true; // not active -> not counted
Assert.Equal(2, vm.SelectedCount);
a.IsChecked = false;
Assert.Equal(1, vm.SelectedCount);
}
[Fact]
public void ResolveConflict_invokes_seam_with_task_and_target()
{
var vm = NewVm();
vm.SelectedTarget = "release";
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
(string Task, string Target)? captured = null;
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
vm.ResolveConflictCommand.Execute(row);
Assert.Equal(("x", "release"), captured);
}
[Fact]
public void MergeAll_canExecute_requires_target_selection_and_idle()
{
var vm = NewVm();
var a = ActiveRow("a");
vm.AddRowForTest(a);
Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target
a.IsChecked = true;
Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target
vm.SelectedTarget = "main";
Assert.True(vm.MergeAllCommand.CanExecute(null));
vm.IsMerging = true;
Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error).
- [ ] **Step 3: Implement subscription, commands, target loading**
In `WorktreesOverviewModalViewModel.cs`:
(a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class:
```csharp
private void HookRow(WorktreeOverviewRowViewModel row)
{
row.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
or nameof(WorktreeOverviewRowViewModel.State))
RecomputeSelected();
};
}
private void RecomputeSelected() =>
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
// Test seam: adds a row to the flat list with selection tracking wired up.
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
{
HookRow(row);
Rows.Add(row);
}
```
(b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites:
In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to:
```csharp
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
```
In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to:
```csharp
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
```
Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`:
After `Rows.Clear(); Groups.Clear();` add:
```csharp
ConflictRows.Clear();
SelectedCount = 0;
BatchProgress = null;
```
At the very end of the `try` block (after the if/else that fills rows/groups) add:
```csharp
await LoadMergeTargetsAsync();
```
(c) Add target loading. The branch list is repo-level, so query it from the first active row:
```csharp
private async Task LoadMergeTargetsAsync()
{
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
try
{
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
MergeTargets.Clear();
if (targets is null) { SelectedTarget = null; return; }
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
? targets.DefaultBranch
: MergeTargets.FirstOrDefault();
}
catch { MergeTargets.Clear(); SelectedTarget = null; }
}
```
(d) Add the commands:
```csharp
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
[RelayCommand]
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
{
if (row is null) return;
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
}
[RelayCommand]
private void ToggleSelectAll()
{
var actives = AllRows.Where(r => r.IsActive).ToList();
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
foreach (var r in actives) r.IsChecked = !allChecked;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: PASS (8 tests total in this file).
- [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
git commit -m "feat(ui): wire batch selection, target loading and resolve seam"
```
---
## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
This task is AXAML only (no logic) → no new unit test; flag for visual verification.
- [ ] **Step 1: Add the batch toolbar controls**
In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `<StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel>` of the toolbar `Border` with:
```xml
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
<ComboBox MinWidth="160"
ItemsSource="{Binding MergeTargets}"
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
<Button Classes="btn accent"
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
Command="{Binding MergeAllCommand}"/>
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
```
- [ ] **Step 2: Add a checkbox + outcome badge to the row template**
In the `WorktreeRowTemplate` `DataTemplate`, change the row `Grid` to add a leading checkbox column and a trailing outcome column. Replace the `<Grid ColumnDefinitions="*,90,80,80">...</Grid>` (the whole grid, lines for Task/State/Diff/Age) with:
```xml
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding IsActive}"
IsVisible="{Binding IsActive}"/>
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
<TextBlock Classes="meta" Text="•"
IsVisible="{Binding !PathExistsOnDisk}"/>
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding !PathExistsOnDisk}"
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
Text="{Binding MergeOutcome}"
IsVisible="{Binding HasOutcome}"/>
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
HorizontalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
</Grid>
```
Then update the column-header `Grid` (the one with `ColumnDefinitions="*,90,80,80"` near the ScrollViewer top) to match the new column layout:
```xml
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
</Grid>
```
- [ ] **Step 3: Add the "Needs resolution" panel**
Inside the content `ScrollViewer`'s root `StackPanel`, at the very top (before the column-header `Grid`), add a conflicts panel that only shows when there are conflicts:
```xml
<Border IsVisible="{Binding ConflictRows.Count}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource StatusErrorBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
<StackPanel Spacing="6">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
<ItemsControl ItemsSource="{Binding ConflictRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Text="{Binding TaskTitle}"/>
<Button Grid.Column="1" Classes="btn"
Content="{loc:Tr modals.worktreesOverview.resolve}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
```
> `IsVisible="{Binding ConflictRows.Count}"` uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.
- [ ] **Step 4: Build the app to verify the AXAML compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded (compiled bindings resolve against the new VM members).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"
```
---
## Task 5: Localization keys (en + de parity)
**Files:**
- Modify: `src/ClaudeDo.Localization/locales/en.json`
- Modify: `src/ClaudeDo.Localization/locales/de.json`
- [ ] **Step 1: Add the new keys to `en.json`**
Under `modals.worktreesOverview`, add:
```json
"columnOutcome": "RESULT",
"selectAll": "Select all",
"targetLabel": "Target",
"mergeAll": "Merge all",
"needsResolution": "NEEDS RESOLUTION",
"resolve": "Resolve"
```
Under `vm.worktreesOverview`, add:
```json
"batchProgress": "Merging {0}/{1}…",
"batchDone": "Merged {0}, {1} need resolution."
```
- [ ] **Step 2: Add the matching keys to `de.json`**
Under `modals.worktreesOverview`:
```json
"columnOutcome": "ERGEBNIS",
"selectAll": "Alle auswählen",
"targetLabel": "Ziel",
"mergeAll": "Alle mergen",
"needsResolution": "ZU LÖSEN",
"resolve": "Lösen"
```
Under `vm.worktreesOverview`:
```json
"batchProgress": "Merge {0}/{1}…",
"batchDone": "{0} gemergt, {1} zu lösen."
```
- [ ] **Step 3: Run the localization parity test**
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: PASS (en/de key parity holds).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"
```
---
## Task 6: Migrate `WorktreeModalView` diff onto `DiffLinesView`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`
- [ ] **Step 1: Switch the VM to the canonical diff model**
In `WorktreeModalViewModel.cs`:
(a) Delete the now-dead types at the top of the file:
```csharp
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
{
public required string Text { get; init; }
public required WorktreeDiffLineKind Kind { get; init; }
}
```
(b) Change the collection declaration from:
```csharp
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
```
to:
```csharp
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
```
(c) Replace the body of `LoadFileDiffAsync` (the `foreach (var line in diff.Split('\n'))` block) so it parses via `UnifiedDiffParser`. The method becomes:
```csharp
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
{
SelectedFileDiffLines.Clear();
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
return;
string diff;
try
{
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
}
catch
{
return;
}
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
SelectedFileDiffLines.Add(line);
}
```
(`DiffLineViewModel`, `DiffFileViewModel`, and `UnifiedDiffParser` are all in the same `ClaudeDo.Ui.ViewModels.Modals` namespace, so no new `using` is required.)
- [ ] **Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded. (If a compile error names `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind` outside this file or the view, that reference must be migrated too — there should be none besides `WorktreeModalView.axaml`, handled next.)
- [ ] **Step 3: Swap the view's inline diff for `DiffLinesView`**
In `WorktreeModalView.axaml`:
(a) Remove the now-unused converter resource. Delete:
```xml
<Window.Resources>
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
</Window.Resources>
```
(b) Replace the right-pane `ScrollViewer`'s `ItemsControl` (the `SelectableTextBlock` template bound to `SelectedFileDiffLines`) with the canonical control. Replace:
```xml
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
<SelectableTextBlock Text="{Binding Text}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
TextWrapping="NoWrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
```
with:
```xml
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
```
(The `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` namespace is already declared at the top of this file.)
- [ ] **Step 4: Build the app to verify the AXAML compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"
```
---
## Task 7: Full build + test sweep
**Files:** none (verification only).
- [ ] **Step 1: Build the whole app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 2: Run the UI + localization test projects**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Then: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: PASS (all green, including the 8 new batch-merge tests).
- [ ] **Step 3: Flag visual-verification gaps**
The cockpit toolbar/checkbox/conflicts-panel layout and the migrated `WorktreeModalView` diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).
---
## Self-Review Notes
- **Spec coverage:** batch-merge cockpit (Tasks 14), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 34), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 34), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔
@@ -0,0 +1,522 @@
# Terminal-style Review Controls Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move review feedback into the Output (terminal) tab as a prompt-style input with `[Retry]`/`[Reset]` actions, and relocate Approve + all merge/worktree controls to a new **Git** tab.
**Architecture:** Pure UI-layer change in `ClaudeDo.Ui`. Add an `IsGitTab` computed flag to `DetailsIslandViewModel`, re-home existing XAML blocks across three tabs (Output · Git · Session) in `WorkConsole.axaml`, add a bottom-docked review footer to the Output tab, and intercept Enter in `WorkConsole.axaml.cs`. No worker-side or `IWorkerClient` changes; no ViewModel command renames.
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, xUnit (ClaudeDo.Ui.Tests).
**Reference spec:** `docs/superpowers/specs/2026-06-05-terminal-review-design.md`
**Build/test note (from CLAUDE.md):** A running Worker locks `Debug` output — build UI in `-c Release`:
`dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
`dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
---
## File Structure
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add `IsGitTab`, wire notifications.
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — add Git tab button; split tab bodies; add Output-tab review footer; update Session empty-state text.
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs` — Enter-to-Retry key handling.
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create) — `IsGitTab` behavior.
---
### Task 1: Add `IsGitTab` tab flag to the ViewModel
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:139-147`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs`. Mirror the
construction pattern from `DetailsIslandPrepModeTests.cs` (temp SQLite db,
`TestDbFactory`, `StubWorkerClient`, `NullServiceProvider`, `StubNotesApi`).
```csharp
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DetailsIslandTabsTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandTabsTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tabs_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 sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
// StubWorkerClient is abstract — use a concrete no-op subclass (same pattern as DetailsIslandPrepModeTests).
private sealed class DefaultStub : StubWorkerClient { }
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi());
}
[Fact]
public void SelectTab_git_sets_IsGitTab_and_clears_others()
{
var vm = NewVm();
vm.SelectTabCommand.Execute("git");
Assert.True(vm.IsGitTab);
Assert.False(vm.IsOutputTab);
Assert.False(vm.IsSessionTab);
}
[Fact]
public void Default_tab_is_output_not_git()
{
var vm = NewVm();
Assert.True(vm.IsOutputTab);
Assert.False(vm.IsGitTab);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
Expected: FAIL — compile error, `DetailsIslandViewModel` has no `IsGitTab`.
- [ ] **Step 3: Add `IsGitTab` to the ViewModel**
In `DetailsIslandViewModel.cs`, find the `SelectedTab` property notifications and the
tab getters (around lines 139-147). Add the `IsGitTab` notification and getter:
```csharp
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
[NotifyPropertyChangedFor(nameof(IsGitTab))]
```
```csharp
public bool IsOutputTab => SelectedTab == "output";
public bool IsGitTab => SelectedTab == "git";
public bool IsSessionTab => SelectedTab == "session";
```
(Leave `SelectTab` unchanged — it already accepts any string and defaults to `"output"`.)
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs
git commit -m "feat(ui): add IsGitTab flag to work console view model"
```
---
### Task 2: Add the Git tab button and move the merge/worktree block onto it
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:124-135` (tab strip)
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:164-273` (tab body)
- [ ] **Step 1: Add the Git tab button**
In the tab strip `StackPanel` (lines 124-135), insert a Git button between the Output
and Session buttons:
```xml
<StackPanel Orientation="Horizontal">
<Button Classes="tab-btn"
Classes.active="{Binding IsOutputTab}"
Content="Output"
Command="{Binding SelectTabCommand}"
CommandParameter="output" />
<Button Classes="tab-btn"
Classes.active="{Binding IsGitTab}"
Content="Git"
Command="{Binding SelectTabCommand}"
CommandParameter="git" />
<Button Classes="tab-btn"
Classes.active="{Binding IsSessionTab}"
Content="Session"
Command="{Binding SelectTabCommand}"
CommandParameter="session" />
</StackPanel>
```
- [ ] **Step 2: Move the "Merge & worktree" block to a new Git-tab ScrollViewer**
In the tab body `Grid` (starts line 139), the body currently holds the Output
`ScrollViewer` (`IsVisible="{Binding IsOutputTab}"`, lines 142-162) and the Session
`ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`, lines 165-273).
Cut the **entire "Merge & worktree management" `StackPanel`** — the block currently at
lines 195-241, beginning with the comment `<!-- Merge & worktree management -->` and the
`<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">` and ending at its
matching `</StackPanel>` after the `MergeAllError` `TextBlock` (line 241).
Add a new Git-tab `ScrollViewer` between the Output and Session `ScrollViewer`s, and
paste the cut block inside it:
```xml
<!-- Git: merge target, approve, diff, worktree -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="14">
<!-- Approve (review-gated) -->
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
<TextBlock Classes="section-label" Text="REVIEW" />
<Button Classes="btn accent" Content="Approve"
Command="{Binding ApproveReviewCommand}" />
</StackPanel>
<!-- Merge & worktree management (moved from Session tab) -->
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE &amp; WORKTREE" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Merge target" />
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
<Button Classes="btn" Margin="0,0,8,8"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
Command="{Binding MergeAllCommand}"
IsEnabled="{Binding CanMergeAll}"
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
</WrapPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError,
Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
```
- [ ] **Step 3: Remove the old review block from the Session tab**
In the Session `ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`), delete the
**"Review controls" `StackPanel`** currently at lines 168-193 (the
`<!-- Review controls -->` comment, the `<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">`,
the REVIEW label, Feedback label, the `ReviewFeedback` TextBox, and the four buttons).
After this and Step 2, the Session tab's `StackPanel` should contain only the Child
outcomes block (lines 244-263) and the empty-state `TextBlock` (lines 266-270).
- [ ] **Step 4: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "feat(ui): add Git tab and move merge/approve controls onto it"
```
---
### Task 3: Add the prompt-style review footer to the Output tab
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (Output-tab area + the `Grid` body)
- [ ] **Step 1: Restructure the Output tab body to dock a footer below the log**
The body `Grid` (line 139) overlays all three tab `ScrollViewer`s. Replace the Output
`ScrollViewer` (lines 142-162) with a `DockPanel` that keeps the log filling and docks
the review footer at the bottom. Keep `Name="LogScroll"` on the `ScrollViewer` (the
code-behind references it). Use this exact markup:
```xml
<!-- Output: log + review footer, both gated on IsOutputTab -->
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
<!-- Review footer (terminal prompt) — only while awaiting review -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsWaitingForReview}"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="10,6">
<DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Bottom" Margin="8,0,0,0">
<Button Classes="btn accent" Content="Retry"
Command="{Binding RejectReviewCommand}" />
<Button Classes="btn" Content="Reset"
Command="{Binding ParkReviewCommand}" />
</StackPanel>
<TextBlock DockPanel.Dock="Left" Text="&#x276F;"
FontFamily="{StaticResource MonoFont}"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Top" Margin="0,4,8,0" />
<TextBox Name="ReviewInput"
Text="{Binding ReviewFeedback, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MaxHeight="160"
PlaceholderText="Feedback for the next run…"
Background="Transparent"
BorderThickness="0"
Padding="0,2"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" />
</DockPanel>
</Border>
<ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
AllowAutoHide="False"
Padding="12,8,12,4">
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,*" Margin="0,1">
<TextBlock Grid.Column="0"
Classes="log-ts"
Text="{Binding TimestampFormatted}" />
<SelectableTextBlock Grid.Column="1"
Text="{Binding Text}" Tag="{Binding ClassName}"
Foreground="{DynamicResource TextDimBrush}"
TextWrapping="Wrap" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
```
- [ ] **Step 2: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "feat(ui): add terminal review footer with Retry/Reset to Output tab"
```
---
### Task 4: Enter-to-Retry key handling
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs`
- [ ] **Step 1: Add the KeyDown handler**
In `WorkConsole.axaml.cs`, add `using Avalonia.Input;` at the top. Add a handler that
runs `RejectReviewCommand` on Enter (without Shift) and lets Shift+Enter insert a
newline. Wire it from the `ReviewInput` TextBox. Full file:
```csharp
using System;
using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands.Detail;
public partial class WorkConsole : UserControl
{
private INotifyCollectionChanged? _log;
public WorkConsole()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_log is not null)
_log.CollectionChanged -= OnLogChanged;
_log = (DataContext as DetailsIslandViewModel)?.Log;
if (_log is not null)
_log.CollectionChanged += OnLogChanged;
}
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action != NotifyCollectionChangedAction.Add) return;
EventHandler? handler = null;
handler = (_, _) =>
{
LogScroll.LayoutUpdated -= handler;
LogScroll.ScrollToEnd();
};
LogScroll.LayoutUpdated += handler;
}
private void OnReviewInputKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift))
return;
if (DataContext is DetailsIslandViewModel vm &&
vm.RejectReviewCommand.CanExecute(null))
{
vm.RejectReviewCommand.Execute(null);
}
e.Handled = true;
}
}
```
- [ ] **Step 2: Wire the handler in XAML**
On the `ReviewInput` TextBox added in Task 3, add the event hookup attribute:
```xml
<TextBox Name="ReviewInput"
KeyDown="OnReviewInputKeyDown"
Text="{Binding ReviewFeedback, Mode=TwoWay}"
```
- [ ] **Step 3: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs
git commit -m "feat(ui): send Retry on Enter in the review prompt"
```
---
### Task 5: Update the Session empty-state copy
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (empty-state `TextBlock`, was line 266-270)
- [ ] **Step 1: Reword the empty-state text**
The Session empty-state still says review/merge controls appear there. Replace its
`Text` so it reflects that those moved:
```xml
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
Classes="meta"
Foreground="{DynamicResource TextMuteBrush}"
TextWrapping="Wrap"
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
```
- [ ] **Step 2: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "docs(ui): reword Session empty-state for relocated review/merge controls"
```
---
### Task 6: Final verification
- [ ] **Step 1: Run the full UI test project**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: all tests PASS.
- [ ] **Step 2: Manual visual verification (cannot be auto-verified — flag to user)**
Launch the app with a task in `WaitingForReview` and confirm:
- Output tab shows the prompt footer (`` + input + `[Retry]` `[Reset]`) only while awaiting review; it is hidden otherwise.
- Typing + **Enter** sends Retry (requeues with feedback); **Shift+Enter** inserts a newline; **Enter on empty input** does nothing.
- `[Reset]` parks the task to Idle.
- Git tab shows **Approve** + merge target + Open Diff / Merge / Worktree / Review Combined Diff / Merge All Subtasks.
- Session tab shows only subtask outcomes / the reworded empty state.
- Tab switching highlights the active tab correctly (Output ↔ Git ↔ Session).
@@ -0,0 +1,55 @@
# Plan: Per-task model override via MCP + cheapest-model prompt guidance
Spec: `docs/superpowers/specs/2026-06-09-per-task-model-override-design.md`
TDD, one focused commit per task. Build with `-c Release` per project; run
`ClaudeDo.Worker.Tests` (and `Data.Tests` if touched).
## Task 1 — ModelRegistry: cost ordering + alias validation
- Add `ByCostAscending = ["haiku","sonnet","opus"]`.
- Add `string? NormalizeAlias(string? model)`: trim; null/blank → null;
case-insensitive match against `Aliases` → canonical lowercase; else throw
`ArgumentException($"Unknown model '{model}'. Allowed: {join(Aliases)}.")`.
- Tests (Data.Tests): "sonnet"/"OPUS"/" haiku " → normalized; ""/null/" " →
null; "gpt4" → throws.
## Task 2 — CreateChildAsync accepts model
- `TaskRepository.CreateChildAsync`: add `string? model = null` (before the
trailing `CancellationToken ct = default`); set
`child.Model = ModelRegistry.NormalizeAlias(model)`.
- Update the two existing callers to compile (named pass-through added in
Tasks 34; keep default null here).
## Task 3 — Planning + improvement MCP tools forward model
- `PlanningMcpService.CreateChildTask`: add `string? model` param after
`commitType`; pass to `CreateChildAsync`. Extend `[Description]` to document
the model arg (haiku/sonnet/opus; cheapest capable).
- `TaskRunMcpService.SuggestImprovement`: add `string? model` param after
`description`; pass to `CreateChildAsync`. Extend `[Description]`.
- Tests: each tool persists the model; invalid value throws.
## Task 4 — External AddTask forwards model
- `ExternalMcpService.AddTask`: add `string? model = null` param (before the
trailing `CancellationToken`); `entity.Model = ModelRegistry.NormalizeAlias(model)`.
Extend `[Description]`.
- Test: AddTask persists model; invalid value rejected.
## Task 5 — Prompt guidance
- `PromptFiles.PlanningSystemDefault`: add a short paragraph — assign each
subtask the cheapest model that does it well, with ordering haiku < sonnet <
opus and the heuristic; pass it as `CreateChildTask(model=...)`.
- `PromptFiles.SystemDefault` Out-of-scope section: when filing via
`SuggestImprovement`, pass the cheapest capable `model`.
- `PromptFiles.ImprovementChildDefault`: one-line minimality reminder.
- No test (static prompt text); verify build only.
## Task 6 — Verify
- Build App + Worker `-c Release`; run Worker.Tests + Data.Tests.
- Update `ClaudeDo.Worker/CLAUDE.md` (ConfigMcpTools/creation-tool notes) and
`ClaudeDo.Data/CLAUDE.md` (ModelRegistry) if needed.
@@ -0,0 +1,90 @@
# Plan — Unify the parent-task model
Spec: `docs/superpowers/specs/2026-06-09-unify-parent-task-model-design.md`
Subagents: `sonnet`. Stage files explicitly by path (never `git add -A`). TDD.
Build with `-c Release` per project. Commit per task (Conventional Commits).
## Task 1 — Single parent-advance path
- Rename `TaskStateService.TryAdvanceImprovementParentAsync``TryAdvanceParentAsync`.
- Make it advance **any** `WaitingForChildren` parent → `WaitingForReview` when all
children are terminal, and advance a parent with **zero** children straight to
`WaitingForReview`.
- In `OnChildTerminalAsync`: drop the `TryCompleteParentAsync` call; keep
`_chain.OnChildFinishedAsync`; call the renamed advance method for all parents.
- Tests: extend `WaitingForChildrenLifecycleTests` — (a) improvement parent still
advances; (b) a `WaitingForChildren` parent whose children are a *sequential chain*
advances only after the last one is terminal; (c) zero-children parent advances.
## Task 2 — Delete `TryCompleteParentAsync`
- Remove `TaskRepository.TryCompleteParentAsync` (`TaskRepository.cs:477-502`) and
any remaining references.
- Update `src/ClaudeDo.Data/CLAUDE.md` (drop it from the TaskRepository helper list).
- Build Data + Worker; fix references.
## Task 3 — Planning finalize enters `WaitingForChildren`
- `TaskStateService.FinalizePlanningAsync`: in the same `ExecuteUpdateAsync`, set
`Status = WaitingForChildren` alongside `PlanningPhase = Finalized` /
`PlanningFinalizedAt`.
- Verify `PlanningSessionManager.FinalizeAsync` ordering: finalize (→ WaitingForChildren)
**before** `SetupChainAsync` enqueues child[0]. Adjust only if ordering is wrong.
- Tests: finalizing a planning parent with N children leaves it `WaitingForChildren`;
after the chain completes it is `WaitingForReview` (not `Done`); a planning parent
with zero finalized children lands in `WaitingForReview`.
## Task 4 — Approve merges the whole unit
**Decision: full UX consolidation.** Approve becomes the single entry for reviewing
*and* merging any task; the separate planning-merge views are folded into the review
panel. The `PlanningMergeOrchestrator` (which already merges the unit + sets the
parent `Done` for both planning and improvement, with conflict continue/abort) is
reused as the engine; only its *entry/UI* moves.
Backend:
- `WorkerHub.ApproveReview`: for a parent that **has children**, drive
`PlanningMergeOrchestrator.StartAsync` (event-based: `PlanningMergeStarted` /
`PlanningSubtaskMerged` / `PlanningMergeConflict` / `PlanningMergeAborted` /
`PlanningCompleted`) instead of the one-shot `ApproveAndMergeAsync`. Childless tasks
keep `ApproveAndMergeAsync`. Conflict resolution still goes through
`ContinuePlanningMerge` / `AbortPlanningMerge`.
- Keep the orchestrator, `ContinuePlanningMerge`, `AbortPlanningMerge`,
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`. Remove the now-redundant
standalone `MergeAllPlanning` hub method (approve is the entry).
- (Optional cleanup) route the orchestrator's `FinalizeParentDoneAsync` through
`TaskStateService` so `Status` writes stay centralized; low priority.
UI (Avalonia, MVVM — visual-verification gaps, flag for user):
- The review panel (`DetailsIslandViewModel` / its view) is the single approve+merge
surface. For a child-bearing parent in `WaitingForReview`, approve shows the
unit-merge progress + per-subtask state, the aggregate/integration diff preview, and
conflict continue/abort — all inline in the review panel.
- Remove the separate planning-merge view(s)/commands and the standalone "Merge all"
button; re-wire their `PlanningMerge*` event handlers into the review panel VM.
- Sync `IWorkerClient` + hand-rolled test fakes in both UI/Worker test projects.
Tests: approving a parent with two `Done` children merges both then sets `Done`; a
conflicting second child surfaces the conflict and pauses (continue/abort) without
losing the parent's `WaitingForReview`/merge state.
## Task 5 — Cancellable `WaitingForChildren` parent
- Add `TaskStatus.WaitingForChildren` to the `CancelAsync` guard.
- Test: a parent in `WaitingForChildren` can be cancelled.
## Task 6 — Docs
- `src/ClaudeDo.Worker/CLAUDE.md`: add `WaitingForChildren` to the Status table +
transition diagram; document the unified parent flow and approve-merges-unit;
remove `MergeAllPlanning` from the Hub method list.
- `src/ClaudeDo.Data/CLAUDE.md`: add `WaitingForChildren` to the TaskEntity status list.
- Root `CLAUDE.md`: update the "Task status flow" convention line.
## Verify
- `dotnet test` for Worker.Tests + Data.Tests (`-c Release`).
- UI flows (planning finalize → review → approve-merge; improvement parent;
retired MergeAllPlanning button) are **visual-verification gaps** — flag for the
user to run the app; do not claim they work from tests alone.
@@ -0,0 +1,72 @@
# Online Inbox — implementation plan
Date: 2026-06-10
Spec: `docs/superpowers/specs/2026-06-10-online-inbox-design.md`
Contract: `docs/online-inbox-api-contract.md`
TDD, one commit per task, Conventional Commits. Build with `-c Release` per CLAUDE.md.
## Phase 1 — Worker sync engine (buildable now, no Zitadel package needed)
### Task 1 — Config
- Add `OnlineInboxConfig` + nested `ZitadelClientConfig` records.
- Add `online_inbox` (`OnlineInbox`) property to `WorkerConfig`; default `enabled=false`.
- `Load` leaves it untouched when absent (defaults = disabled).
- Test: missing section → disabled defaults; populated section round-trips.
### Task 2 — DTOs + Idle-backlog helper
- `Online/Dtos.cs`: `RemoteList(Id, Name)`, `RemoteTask(Id, ListId, Title, Description, CreatedAt)`,
`MirrorTask(Id, ListId, Title, Description)`.
- `Online/OnlineBacklog.cs`: `static Task<List<MirrorTask>> CurrentAsync(TaskRepository/ctx)` +
the filter predicate (Idle, no parent, PlanningPhase None, BlockedBy null).
- Test the filter against real SQLite seeded with mixed tasks.
### Task 3 — Auth abstraction + token store
- `Online/Interfaces/IOnlineAuthProvider.cs`.
- `Online/OnlineTokenStore.cs`: DPAPI CurrentUser persistence at `~/.todo-app/online-inbox.token`;
`Save(refreshToken)`, `Read()`, `Clear()`. (Windows-only encryption; thin + guarded.)
- A trivial `StaticTokenAuthProvider` (returns a configured token or null) for tests + as the
temporary default until Zitadel is wired.
- Test: token store round-trip (Windows); static provider returns/omits token.
### Task 4 — API client
- `Online/IOnlineInboxApi.cs` + `Online/OnlineInboxApiClient.cs` (typed `HttpClient`).
- Attaches `Authorization: Bearer` from `IOnlineAuthProvider`; refuses non-HTTPS non-loopback
base URLs; throws a typed `OnlineInboxException` on non-2xx.
- Test with a stubbed `HttpMessageHandler`: each method hits the right path/verb/body; 401
surfaces; bearer attached.
### Task 5 — Sync service
- `Online/OnlineSyncService.cs` (`BackgroundService`) implementing the §5 reconcile loop.
- DI: register only when `enabled`; resolve repos per-cycle via a scope.
- Per-cycle try/catch + structured logging; skip when no token; unknown-list skip.
- Test against a **fake `IOnlineInboxApi`** + real SQLite: pull→import→flag creates local Idle
tasks; mirror payload == Idle backlog; lists pushed; unknown list skipped & not flagged;
disabled/no-token = no api calls.
### Task 6 — Wire-up + docs
- Register the stack in `Program.cs` behind the enabled flag.
- Update `src/ClaudeDo.Worker/CLAUDE.md` (new `Online/` area) and `src/ClaudeDo.Worker/Config`
notes. Add `online_inbox` to the config section.
## Phase 2 — UI + real auth (AFTER the VPS reports client config)
### Task 7 — Hub + config plumbing
- Hub: `GetOnlineInboxConfig` / `SetOnlineInboxConfig` / `SetOnlineInboxAuth(refreshToken)` /
`ClearOnlineInboxAuth`. Update `IWorkerClient` + `WorkerClient` + test fakes (both test
projects — see the IWorkerClient-fakes memory).
### Task 8 — Settings UI
- "Online Inbox" section in `SettingsModalViewModel`: enable toggle, base URL, Sign in/out,
status. Localized keys in en.json + de.json (parity).
- Visual verification = manual (flag it).
### Task 9 — ZitadelAuthProvider
- Add the Zitadel package reference; implement `ZitadelAuthProvider` (refresh-token → access
token, cached to expiry) using the reported authority/client-id/flow.
- Swap it in for `StaticTokenAuthProvider` in DI when enabled.
- Manual smoke against the live VPS API (tracked, not an automated test).
## Notes
- No real network / no real Zitadel / no real Claude in any automated test.
- Stage files by explicit path in subagents; sonnet model; build+test+commit by the orchestrator.
@@ -0,0 +1,104 @@
# Feature unification — phased plan
Date: 2026-06-19
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md`
Six slices, sequenced cheapest/lowest-risk first. Each ends green
(`dotnet build -c Release` + the touched test project) and is independently
committable. Phases 01 are detailed here; 25 are scoped, and each gets its own
`docs/superpowers/plans/2026-06-19-unify-<slice>.md` when picked up (per the
2026-06-05 layer-A/B/C convention). Build per-csproj (`-c Release`) — `.slnx` needs
.NET 9 and a running Worker locks `Debug`.
---
## Phase 0 — Groundwork (Bucket C). No UX change.
**0a. Delete the dead hunks conflict API (C1).**
- Remove `TaskMergeService.GetConflictsAsync` + the `MergeConflicts`/`ConflictFileContent` records it returns (`src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs:250`) if unused elsewhere.
- Remove `WorkerHub.GetMergeConflicts` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:378`) + `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` if unused.
- Remove `WorkerClient`'s `"GetMergeConflicts"` invoke (`src/ClaudeDo.Ui/Services/WorkerClient.cs:276`) + the `IWorkerClient` member + every fake override (`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `TasksIslandViewModelPlanningTests.cs`, others — grep `GetMergeConflicts`).
- Delete `TaskMergeServiceTests.cs:672` `GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs`.
- Verify with grep first: `GetConflictsAsync` and `GetMergeConflicts` have **no** callers outside this chain + tests.
- Acceptance: Worker + Ui build; Worker.Tests + Ui.Tests green; `GetMergeConflictDocuments` path untouched.
**0b. Single task-creation path (C2).**
- Identify the path MCP `ExternalMcpService.AddTask` uses; expose a thin creation method (repository or a small `TaskCreationService`) that applies the same defaults (ListId, SortOrder, CreatedAt).
- Re-point `TasksIslandViewModel.AddAsync` at it instead of `db.Tasks.Add` direct EF.
- Acceptance: quick-add still works; one creation path; Ui.Tests + Worker.Tests green.
**0c. Prune stale worktrees (C3).**
- `git worktree list`; remove the orphaned `.claude/worktrees/*` entries (confirm each is unwanted with Mika before `git worktree remove`).
- Acceptance: only intended worktrees remain; no tracked files change.
> C4 (naming alignment) intentionally NOT in this phase — see design.
---
## Phase 1 — DialogService (B3B5). Lowmedium.
**Goal:** one `IDialogService` replaces the scattered `Show*` Func seams and the
duplicate open-commands.
- New `IDialogService` (Ui/Services) with typed methods: `OpenListSettings(ListNavItemViewModel)`, `OpenRepoImport()`, `OpenWorktreesOverview(string? listId)`, `OpenWeeklyReport()`, `OpenAbout()`, `OpenWorkerConnectionHelp()`. Implementation owns the factories + `ModalShell`/TCS wiring currently in `MainWindow.axaml.cs` + `IslandsShellViewModel.cs:59-71`.
- Inject it into `ListsIslandViewModel`, `TasksIslandViewModel`, `IslandsShellViewModel`. Collapse the three List-Settings doors (Lists context menu, Tasks header, shell bridge `IslandsShellViewModel.cs:190-194`) to one `dialogs.OpenListSettings(row)` call; same for Repo Import (2→1) and Worktrees Overview (2→1, keep the `listId?` param for global-vs-per-list).
- Keep `ModalShell`/TCS dialog pattern; this only centralizes *opening*.
- Update fakes/ctors per the IWorkerClient-fakes hazard (ctor changes ripple to Ui.Tests).
- Acceptance: every dialog opens via one method; no duplicate open-commands; Ui.Tests green; visual gap flagged (open each dialog from each former door).
---
## Phase 2 — MergeCoordinator (B1). Medium.
**Goal:** delete the five `RequestConflictResolution` seams; one coordinator.
- New `IMergeCoordinator` (Ui) `MergeAsync(taskId, targetBranch)` = the body of `IslandsShellViewModel.RequestConflictResolutionAsync` (`:49`) plus the "open MergeModal → on conflict open resolver" flow currently split across `MergeModalViewModel:108` and `DiffModalViewModel:103`.
- Remove the `Func<string,string,Task>? RequestConflictResolution` from `WorktreesOverviewModalViewModel:83`, `DiffModalViewModel:75`, `MergeModalViewModel:33`, `MergeSectionViewModel:51`, and the `DetailsIslandViewModel:347` delegate; inject the coordinator instead.
- Re-point doors: review Approve, Diff Merge button, WorktreesOverview single + batch (`:331`), Details merge section.
- Update seam tests (`WorktreesOverviewBatchMergeTests.cs:145`, `DetailsIslandConflictSeamTests.cs:84`) to assert via the coordinator.
- Acceptance: one merge entry API; resolver still opens for single-task AND planning conflict; Ui.Tests green; visual gap flagged (force a conflict from Approve and from the Diff Merge button).
---
## Phase 3 — WorktreeActions (A3). Medium.
**Goal:** one per-task worktree-actions VM reused by overview rows + Details.
- New `WorktreeActionsViewModel(taskId)` with Merge/Diff/Discard/Keep/ForceRemove over `IWorkerClient` (uses the Phase-2 coordinator for Merge, the Phase-5 viewer for Diff — until then, current calls).
- `WorktreesOverviewModalViewModel` rows compose one each; `MergeSectionViewModel` hosts one for the active task. Remove the duplicated commands.
- Acceptance: both surfaces drive the same VM; Ui.Tests green; visual gap flagged.
---
## Phase 4 — AgentConfigEditor (A2). Medium.
**Goal:** one config editor for Global | List | Task scope.
- New `AgentConfigEditorViewModel(scope)` over `InheritanceResolver` exposing Model/SystemPrompt/AgentPath/MaxTurns + reset commands + `InheritedBadge` state; persists via the scope's hub method (`UpdateListConfig` / `UpdateTaskAgentSettings` / app settings).
- Embed in `SettingsModalViewModel`, `ListSettingsModalViewModel`, and the Details `AgentSettingsSectionViewModel` host; delete the duplicated field/reset logic.
- Acceptance: identical editor in all three scopes; Localization parity; Ui.Tests green; visual gap flagged.
---
## Phase 5 — DiffViewer (A1 + B2). High; last.
**Goal:** one diff component replaces DiffModal + WorktreeModal + PlanningDiff.
- New `DiffViewerViewModel` with `DiffSource` enum/abstraction (`DirtyWorktree | BranchVsBase | CommitRange | PlanningAggregate | IntegrationBranch`) and an optional file-tree pane (port `WorktreeModal`'s tree + Avalonia-12 selection workaround); reuse `UnifiedDiffParser` + `DiffLinesView`; keep PlanningDiff's combined-mode toggle as a source switch.
- Re-point all B2 doors to open it with the right source. Remove the three old VMs/views.
- Update `DiffModalViewModelTests`, `PlanningDiffViewModelTests`.
- Acceptance: every diff door opens the one viewer; whole-unified AND file-tree layouts work; Ui.Tests green; visual gap flagged (worktree-dirty, post-merge commit-range, planning per-subtask + integration).
---
## Sequencing rationale
0 (delete/no-UX) → 1 (isolated, unblocks nothing but cheap) → 2 (coordinator; 3 & 5
lean on it for Merge/Diff) → 3 → 4 (independent) → 5 (biggest, most UX-sensitive,
benefits from 2's coordinator). Stop after any phase and the app is shippable.
## Per-phase commits
Conventional Commits, one per phase (or per sub-step in Phase 0): e.g.
`refactor(merge): single MergeCoordinator replaces 5 conflict seams`. Stage by path
(never `git add -A` — concurrent sessions). Commit the spec + this plan first.
@@ -0,0 +1,92 @@
# Plan: Rider-style 3-pane merge editor
Spec: `docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md`
TDD, one focused commit per task (Conventional Commits, `feat(merge): …`).
Build with `-c Release` per project (a running Worker locks `Debug`).
Run `ClaudeDo.Ui.Tests` (and `Localization.Tests` for Task 6). No real `claude` CLI in tests.
Stage ONLY the files each task touches, by explicit path (parallel sessions leave WIP).
Backend + seam stay unchanged. Implementer/reviewer subagents use **sonnet**.
## Task 1 — VM: active-file model + 3-pane reconstruction + readout
`ConflictResolverViewModel` / `ConflictModels.cs`, additive (seam untouched).
- Add `ActiveFile` (`MergeFile?`), `SelectFileCommand(MergeFile)`, default to first file
after load. Keep `Files`, `Current`/`CurrentIndex`/`Next`/`Previous` (focused conflict
for the header arrows), `CanContinue`, binary guard, planning routing — all unchanged.
- Add computed, per `ActiveFile`:
- `ActiveOursText` = concat(stable.Text | conflict.Ours)
- `ActiveTheirsText` = concat(stable.Text | conflict.Theirs)
- `ActiveResultText` = concat(stable.Text | conflict.Resolution ?? conflict.Ours)
- `ActiveConflicts` = ordered descriptors (block + segment index) for the view.
- `PositionText``"{conflicts} conflicts · {resolved} resolved"` for the active file;
keep `CanContinue` = every file resolved AND no binary.
- Switching files raises a change event the view listens to (reuse/extend
`CurrentChanged` → e.g. `ActiveFileChanged`).
- Tests (Ui.Tests): reconstruction text for ours/theirs/result (result seeds unresolved
with Ours); resolving a block updates `ActiveResultText` + readout; switching files
preserves each block's `Resolution`; `CanContinue` blocks until all files resolved;
binary file still blocks. Keep all existing tests green.
## Task 2 — View: 3-pane AXAML shell + document assembly + synced scroll
`Views/Conflicts/ConflictResolverView.axaml(.cs)`. Visual — verified by running.
- Replace AXAML: ModalShell host kept; header row (◀/▶ focus arrows bound to
Previous/Next, file switcher `ItemsControl`/`ComboBox` over `Files` bound to
`SelectFileCommand`, right-aligned `PositionText`); `Grid ColumnDefinitions="*,*,*"`
of three bordered panes with headers **Ours · current (merge target)** /
**Result** / **Theirs · incoming (task)** (drop Base); footer Continue
(`IsEnabled=CanContinue`) / Abort; binary banner (kept); `Escape`→Abort (kept).
- Code-behind: build three `TextDocument`s from `ActiveFile` segments, recording each
conflict's start line + line count per document; install TextMate per pane by file
extension; rebuild on `ActiveFileChanged`; Ours/Theirs `IsReadOnly=true`.
- Proportional synced vertical scroll across the three panes (re-entrancy guard).
- Push Result edits back to the active block `Resolution` (refined in Task 4).
## Task 3 — Result pane: read-only stable, editable conflicts
`ConflictResolverView.axaml.cs` + a small `IReadOnlySectionProvider` helper.
- Track each conflict's result span in a `TextSegmentCollection<…>` over the Result
document (anchors auto-adjust on edit).
- `IReadOnlySectionProvider`: `CanInsert` only strictly inside a conflict span;
`GetDeletableSegments` intersects with conflict spans only. Stable text becomes
immutable; conflict regions stay editable.
- Editing inside a conflict span writes the span text back to the block `Resolution`
and flips it resolved (updates readout + `CanContinue`).
## Task 4 — Color blocks (IBackgroundRenderer) + accept overlay
`ConflictResolverView.axaml.cs` + renderer/overlay helpers.
- `IBackgroundRenderer` per pane: unresolved conflict = red (Blood tint), resolved =
green/muted, Ours side = Moss tint, Theirs side = Accent tint — driven by recorded
spans + block `IsResolved`.
- Between-pane overlay Canvas (Ours|Result and Result|Theirs): `` accept-ours / ``
accept-theirs + `✕` dismiss per conflict, positioned at the block's `TextView` visual
top, recomputed on scroll/resize. Click → `block.AcceptOurs/AcceptTheirs` and replace
the tracked Result span; resolved blocks recolor.
## Task 5 — Polish: readout, focus arrows scroll-to-conflict, resolved styling
- ◀/▶ arrows move `Current` and scroll all three panes to that conflict.
- `M conflicts · K resolved` live readout; Continue tooltip/hint when blocked.
- Resolved conflict recolors and drops its accept overlay; unresolved stays red.
(Fold into Task 4 if small.)
## Task 6 — Localization + tokens
- Add `conflictResolver.*` keys (pane headers, readout, accept tooltips, hints) to
`locales/en.json` AND `locales/de.json` (keep key parity).
- Add Tokens.axaml color tokens only if a needed conflict/resolved shade is missing.
- Run Localization.Tests (parity) + a quick scan for hard-coded strings in the view.
## Task 7 — Verify
- Build `ClaudeDo.App` + `ClaudeDo.Ui` `-c Release`; run `Ui.Tests` + `Localization.Tests`.
- Update `src/ClaudeDo.Ui/CLAUDE.md` (Planning/Conflicts paragraph → new 3-pane editor).
- **Visual verification gap (flag to Mika):** run the app, trigger a real conflict
(single-task approve + planning unit-merge) and confirm panes/colors/accept/scroll/
gating/binary render correctly — cannot be asserted in tests.
@@ -0,0 +1,131 @@
# Phase 4 — AgentConfigEditor (A2)
Date: 2026-06-23 (picked up after reordering Phase 3 ↔ 4)
Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md`
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A2)
## Reordering note
Phase 3 (WorktreeActions) was deferred. Its premise — overview rows and the Details
merge section each owning duplicate worktree commands — only half-holds: Details has
no Discard/Keep/ForceRemove, and the two Diff doors open different VMs (`WorktreeModal`
vs `DiffModal`) that only Phase 5 unifies. So Phase 3's clean form depends on Phase 5
(Diff) and a fuller MergeCoordinator (Merge); doing it now would build throwaway
per-surface delegates. **Phase 3 is folded into Phase 5.** Phase 4 (independent, clean
dedup) runs now.
## Scope decision: List + Task only (global left as-is)
The design names three scopes (Global | List | Task). Verified against the tree on
2026-06-23, only **List and Task genuinely duplicate**:
- **List** (`ListSettingsModalViewModel`, "AGENT" section): Model / MaxTurns /
SystemPrompt / AgentFile, each with `InheritedBadge` + `↺` reset; 2-tier
(list→global) badges computed with inline logic (does **not** use the existing
`InheritanceResolver.ResolveList` — which is currently dead code); explicit Save.
- **Task** (`AgentSettingsSectionViewModel`, TaskHeaderBar gear flyout): same four
fields; 3-tier (task→list→global) badges via `InheritanceResolver.Resolve`;
`EffectiveMaxTurns` + `EffectiveSystemPromptHint`; `IsRunning` gate; debounced
auto-save.
**Global** (`GeneralSettingsTabViewModel`, Settings → General) is the root: no
inheritance, no badges, no agent file, no reset — three plain controls (model combo,
max-turns numeric, instructions textbox) plus a global-only PermissionMode, interleaved
with unrelated settings (Language, parallelism, report paths, standup weekday) and
saved batched into one `AppSettingsDto` via the modal Save. Embedding the shared editor
there buys ~3 plain fields at the cost of a degenerate no-badges/no-agent/no-reset mode
plus surgery on the settings save path and a relayout of the most settings-dense view.
**Not worth it — global stays as-is.** (Confirmed with Mika 2026-06-23.)
The real maintenance hazard is the **VM logic** (two copies of badge/reset/inheritance
that already drifted), and the **view** (3 of 4 field blocks are pixel-identical). Both
collapse cleanly for List+Task.
## Target
One `AgentConfigEditorViewModel` + one `AgentConfigEditor` UserControl, instantiated
per surface with a scope. The two host VMs keep only their non-agent concerns and host
the editor as a child.
### `ViewModels/Agent/AgentConfigEditorViewModel.cs` (new)
- `enum AgentConfigScope { List, Task }`
- ctor `(IWorkerClient worker, AgentConfigScope scope)`
- Unified bindable surface (single names both views bind to):
`Model` (string?), `MaxTurns` (decimal?), `SystemPrompt` (string),
`SelectedAgent` (AgentInfo?); `ModelOptions`, `Agents`;
`ModelBadge`/`TurnsBadge`/`AgentBadge`, `ModelInheritedHint`/`TurnsInheritedHint`,
`EffectiveSystemPromptHint`; `EffectiveMaxTurns` (int), `IsRunning`/`IsEnabled`.
- Reset commands: `ResetModel`, `ResetTurns`, `ResetAgent`, `ResetAll`.
- Badges via `InheritanceResolver`: scope==Task → `Resolve(own, list, global)`;
scope==List → `ResolveList(own, global)` (adopts the dead method). One `BadgeFor`
helper covers both (List scope never yields the `List` source).
- Load: `LoadForListAsync(listId)` and `LoadForTaskAsync(TaskEntity entity)` — both
pull agents + app-settings (global defaults); Task also pulls the list tier +
`EffectiveSystemPromptHint`. Localizer-change re-badges (port the `Loc.LanguageChanged`
handler + `IDisposable`).
- Save: `SaveAsync()` is scope-aware — List builds `UpdateListConfigDto`
`UpdateListConfigAsync`; Task builds `UpdateTaskAgentSettingsDto`
`UpdateTaskAgentSettingsAsync`. Task scope also auto-saves debounced (300ms) on field
changes; List does not (the modal Save button calls `SaveAsync`). `SaveAsync` is
directly callable (tests bypass the debounce).
- Task-only `Clear()` + `TaskId`.
### `Views/Controls/AgentConfigEditor.axaml` (+ .axaml.cs) (new)
- `x:DataType` = `AgentConfigEditorViewModel`; host sets `DataContext="{Binding Agent}"`.
- The four field blocks (model/turns/systemprompt/agent) with `InheritedBadge` + `↺`
reset, lifted verbatim from the existing two views (they already match). Agent combo
shows Name + Description (both scopes; harmless for task). `EffectiveSystemPromptHint`
line gated on non-empty (hides for List).
- `StyledProperty<bool> ShowAgentBrowse` (default false). True → render the Browse
button + path line; the browse file-picker code-behind lives here (moved from
`ListSettingsModalView`).
- Shared localization namespace `settings.agentEditor.*` (model/maxTurns/systemPrompt/
agentFile/promptPrepended). Reset tooltip reuses `settings.inherit.resetToInherited`.
### Re-point hosts
- `ListSettingsModalViewModel`: drop the agent fields/badges/resets/option-lists; add
`public AgentConfigEditorViewModel Agent { get; }` (scope=List). `LoadAsync`
`Agent.LoadForListAsync(listId)`. `SaveAsync` keeps `UpdateListAsync` (name/dir) and
adds `await Agent.SaveAsync()`. Keep working-dir browse (`BrowseClicked`).
- `ListSettingsModalView.axaml`: replace the AGENT section body with
`<ctl:AgentConfigEditor DataContext="{Binding Agent}" ShowAgentBrowse="True"/>`; the
section-header "Reset agent settings" button binds `Agent.ResetAllCommand`. Remove the
agent browse code-behind (moved into the control).
- `DetailsIslandViewModel`: `AgentSettings` becomes `AgentConfigEditorViewModel`
(scope=Task). Preserve the call sites: ctor, `EffectiveMaxTurns``TurnsText`
PropertyChanged hook, `IsRunning` push, `Dispose`, `Clear`, `TaskId`,
`LoadForTaskAsync(entity, ct)`.
- `TaskHeaderBar.axaml`: replace the flyout field blocks with
`<ctl:AgentConfigEditor DataContext="{Binding AgentSettings}"/>` (ShowAgentBrowse=false).
Keep the gear button + heading.
- Delete `AgentSettingsSectionViewModel.cs`.
## Tests
- New `tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs`:
- List scope: badges resolve override-vs-global; resets clear; `SaveAsync` builds the
right `UpdateListConfigDto` (via `StubWorkerClient`).
- Task scope: badges resolve override/list/global; `EffectiveMaxTurns`/
`EffectiveSystemPromptHint` from list tier; resets clear; `SaveAsync` builds the right
`UpdateTaskAgentSettingsDto`.
- `InheritanceResolverTests` unchanged (resolver untouched).
- Existing DetailsIsland* tests must stay green (they construct the VM but don't name the
moved members).
## Acceptance
- `dotnet build -c Release` clean for Ui (+ App).
- `Ui.Tests` + `Localization.Tests` green.
- One editor VM + one control drive both List and Task; duplicated field/badge/reset
logic deleted; `ResolveList` now has a real caller.
- Visual gap flagged: open List Settings → Agent, and a task's gear flyout — verify
badges, ↺ resets, reset-all, agent browse (list only), system-prompt hint (task), and
that list Save persists + task auto-saves.
## Commit
`refactor(agent-config): single AgentConfigEditor for list + task scopes`. Stage by
path. Commit this plan with it.
@@ -0,0 +1,111 @@
# Phase 5 — DiffViewer (A1 + B2)
Date: 2026-06-23
Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md`
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A1, B2)
## Goal
One diff component replaces the three parallel read-only diff windows:
`DiffModalViewModel`/View, `WorktreeModalViewModel`/View, `PlanningDiffViewModel`/View.
**Merge editor (`ConflictResolverViewModel`) is untouched** — per the design's hard
decision; the viewer only *opens* it on conflict via the existing Merge flow.
All three are already master-detail: **left nav pane + right `DiffLinesView`**. They
differ only in left-pane content, chrome, and data source — so they collapse into one
shell with a source mode.
## Decisions (Mika, 2026-06-23)
- **File nav = file-tree** (folder-grouped), not a flat list. Port `WorktreeModal`'s tree
+ the Avalonia-12 `TreeView.SelectionChanged` workaround. Carry per-file status + +adds/
dels into the tree rows (from the parsed `DiffFileViewModel`).
- Planning keeps its **subtask-list + combined-mode toggle**; the branch source keeps its
**Merge** button.
## Target
### Shared types → `ViewModels/Modals/DiffModels.cs` (new, same namespace)
Move out of the to-be-deleted VMs so `UnifiedDiffParser`/`DiffLinesView` keep compiling:
`DiffLineKind`, `DiffFileStatus`, `DiffLineViewModel`, `DiffFileViewModel` (from
`DiffModalViewModel.cs`), `SubtaskDiffRow` (from `PlanningDiffViewModel.cs`). Add new
`DiffTreeNodeViewModel` (dir/file node; file leaves hold their `DiffFileViewModel`).
### `DiffViewerViewModel` (`ViewModels/Modals/DiffViewerViewModel.cs`, new)
ctor `(GitService git, IWorkerClient worker)`. A `DiffViewerMode { Files, Planning }`.
- **File sources** (replaces DiffModal + WorktreeModal): config props `WorktreePath`,
`BaseRef`, `HeadCommit`, `FromCommitRange`, `TaskId`, `TaskTitle` + `ShowMergeModal`/
`ResolveMergeVm` delegates. `LoadAsync` pulls the whole diff via GitService
(`GetCommitRangeDiffAsync` | `GetBranchDiffAsync` | `GetDiffAsync`), parses with
`UnifiedDiffParser.Parse`, builds `FileTree`. `SelectedNode` (leaf) → `SelectedFile`
(header + binary/empty placeholders + `Lines`). Commit-range null-guard → "no longer
available" (preserve DiffModal behavior). `MergeCommand` (CanMerge = TaskId +
delegates) opens the MergeModal, closes on merged/routed (verbatim from DiffModal).
- **Planning source** (replaces PlanningDiff): config `PlanningTaskId`, `TargetBranch`.
`LoadAsync` pulls `GetPlanningAggregateAsync``Subtasks`; `SelectedSubtask`
`DisplayedDiff`; `IsCombinedMode` toggle → `BuildPlanningIntegrationBranchAsync`
(success → combined diff; conflict → `CombinedWarning` with subtask + file count;
null → hub-error warning). `DisplayedDiff` → flattened `DiffLines` (right pane).
- Shared: `StatusMessage`, `CloseAction`, `CloseCommand`.
### `DiffViewerView` (`Views/Modals/DiffViewerView.axaml` + `.cs`, new)
`ModalShell`-based window. Left pane: `TreeView` (Files mode) or subtask `ListBox`
(Planning mode), toggled by mode. Right pane: the DiffModal file pane (header + binary/
empty/no-changes placeholders + `DiffLinesView Lines="SelectedFile.Lines"`) in Files mode,
or `DiffLinesView Lines="DiffLines"` in Planning mode. Toolbar: combined toggle + warning
+ loading (Planning). Footer: Merge button (Files mode, CanMerge). Code-behind: `CloseAction`,
the `TreeView.SelectionChanged``SelectedNode` workaround, dir-row tap-to-expand.
### Re-point the 3 doors → one viewer
- **`MergeSectionViewModel`**: `OpenDiffAsync` builds a Files-mode `DiffViewerViewModel`
(+ ShowMergeModal/ResolveMergeVm) and calls a single `ShowDiffViewer` delegate;
`ReviewCombinedDiffAsync` builds a Planning-mode one and calls the *same* delegate.
Replaces `ShowDiffModal` + `ShowPlanningDiffModal` with one `Func<DiffViewerViewModel,Task>
ShowDiffViewer`; keeps `ShowMergeModal`. (Resolve the VM via `_services`.)
- **`DetailsIslandView.axaml.cs`**: replace the two `ShowDiffModal`/`ShowPlanningDiffModal`
wirings (→ `DiffModalView`/`PlanningDiffView`) with one `ShowDiffViewer` (→ `DiffViewerView`).
Keep `ShowMergeModal`.
- **`WorktreesOverviewModalViewModel`**: `ShowDiff` builds a Files-mode viewer (worktree path
+ base). Change `_diffVmFactory` from `Func<WorktreeModalViewModel>` to
`Func<DiffViewerViewModel>`; `ShowDiffAction` stays `Action<DiffViewerViewModel>`.
- **`WindowDialogService.cs`**: `ShowDiffAction``new DiffViewerView` + `LoadAsync` + show.
- **`Program.cs`**: register `DiffViewerViewModel` (transient) + `Func<DiffViewerViewModel>`;
drop the `WorktreeModalViewModel` registration.
### Delete
`DiffModalViewModel.cs`, `WorktreeModalViewModel.cs`, `PlanningDiffViewModel.cs`,
`DiffModalView.axaml(.cs)`, `WorktreeModalView.axaml(.cs)`, `PlanningDiffView.axaml(.cs)`.
### Localization
Reuse existing keys in the merged view (`modals.diff.*` for the file pane, `planning.diff.*`
for the planning toolbar). Prune clearly-orphaned `modals.worktree.*` if trivial; keep en/de
parity.
## Tests
Replace `DiffModalViewModelTests` + `PlanningDiffViewModelTests` with
`DiffViewerViewModelTests` preserving the behaviors: commit-range null-guard → unavailable;
planning init populates + selects first; subtask select → DisplayedDiff; combined toggle
success/conflict/null. `WorktreesOverviewBatchMergeTests` compiles unchanged (`() => null!`
satisfies the new Func type). `UnifiedDiffParserTests` unchanged.
## Acceptance
- `dotnet build -c Release` clean (App); `Ui.Tests` + `Localization.Tests` green.
- One viewer reached from all 3 doors; old VMs/views deleted; merge editor untouched.
- Visual gap flagged: Details "Open Diff" (dirty + post-merge commit-range), Worktrees-
Overview "Show Diff" (tree), Details "Review Combined Diff" (subtasks + combined toggle),
and the Merge button still opens the merge form / resolver on conflict.
## Commit
`refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff`.
Stage by path (exclude concurrent peers' files). Then Phase 3 (WorktreeActions) follows as
its own slice, reusing this viewer.
@@ -0,0 +1,32 @@
# Plan — Worker log → footer + Log Visualizer overlay
Design: `docs/superpowers/specs/2026-06-23-worker-log-footer-overlay-design.md`. Build on `main`, TDD, commit per task (Conventional Commits, explicit paths — shared worktree). Build `-c Release`.
## Task 1 — `LogRingBuffer` (Worker) + tests
- `src/ClaudeDo.Worker/Logging/WorkerLogRecord.cs``record WorkerLogRecord(string Message, WorkerLogLevel Level, DateTime TimestampUtc)`.
- `src/ClaudeDo.Worker/Logging/LogRingBuffer.cs` — thread-safe, `TimeSpan window` + int cap; `Append(record)`, `Snapshot()`. Uses an injected clock func (`Func<DateTime>`) for testability (default `() => DateTime.UtcNow`).
- Tests: age eviction, cap eviction, snapshot order. **No `DateTime.UtcNow` in tests — drive the clock.**
## Task 2 — `BroadcastLogSink` (Worker) + tests
- `src/ClaudeDo.Worker/Logging/BroadcastLogSink.cs : ILogEventSink` — level map, render (+exception first line), append-all-levels, broadcast Warn/Err via deferred `HubBroadcaster` (`Attach`), dedupe window (const 120s), loop-guard (skip SignalR `SourceContext` for broadcast; swallow broadcast exceptions). Inject clock func.
- Broadcaster is an abstraction the test can fake: depend on a tiny `Func<string,WorkerLogLevel,DateTime,Task>?` set by `Attach`, OR on `HubBroadcaster` directly (it's a sealed class — prefer a delegate to keep the test pure). Use a delegate.
- Tests: all levels buffered; only Warn/Err invoke the broadcast delegate; dedupe suppresses 2nd identical within window but still buffers; exception rendering; SignalR-source event buffered but not broadcast.
## Task 3 — wire into `Program.cs` + `WorkerHub.GetRecentLogs`
- `Program.cs`: create `LogRingBuffer` + `BroadcastLogSink` locals before build; `.WriteTo.Sink(broadcastSink)`; `AddSingleton(logBuffer)`; after build `broadcastSink.Attach((m,l,t) => broadcaster.WorkerLog(m,l,t))` using resolved `HubBroadcaster`.
- `WorkerHub`: inject `LogRingBuffer`; `public IReadOnlyList<WorkerLogRecordDto> GetRecentLogs()` → snapshot mapped to DTO. Add `WorkerLogRecordDto` (Hub or shared). Update `WorkerHub` ctor → check hub-construction call sites/tests.
- Build Worker `-c Release`; run Worker.Tests (filtered to new + hub).
## Task 4 — `IWorkerClient.GetRecentLogsAsync` + WorkerClient + fakes
- `IWorkerClient` + `WorkerClient` impl (`_hub.InvokeAsync<List<WorkerLogEntry>>("GetRecentLogs", ct)`).
- Update fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, Worker.Tests UiVm fake(s) → return `Array.Empty<WorkerLogEntry>()`.
- Build Ui + Worker.Tests.
## Task 5 — `LogVisualizerViewModel` + View + dialog wiring + tests
- VM (Modals/), View (Modals/, ModalShell), `IDialogService.ShowLogVisualizerAsync` + `WindowDialogService` impl.
- `IslandsShellViewModel.OpenLogVisualizerCommand` (resolves VM, loads, shows). Make footer worker-log line a clickable Button → command.
- Localization `vm.logVisualizer` en+de.
- Tests: VM load/populate/filter. Build App `-c Release`; Ui.Tests + Localization.Tests.
## Task 6 — verify + docs
- Full relevant test pass. Update `src/ClaudeDo.Ui/CLAUDE.md` (overlay VM/view, footer click) + `src/ClaudeDo.Worker/CLAUDE.md` (Logging/ folder, sink, GetRecentLogs, WorkerLog now carries Serilog Warn/Err). Note visual-verification gap (overlay render) for the user.
@@ -0,0 +1,56 @@
# Plan — Interactive "Answer Claude's Questions"
Spec: `docs/superpowers/specs/2026-06-25-interactive-ask-user-design.md`
Implement on the shared main tree. Commit explicit paths per task (never `git add -A`).
Build with `-c Release` (running Worker locks Debug). No real-Claude tests.
## Task 1 — PendingQuestionRegistry (worker, new file)
- `src/ClaudeDo.Worker/Runner/PendingQuestionRegistry.cs`: singleton; `record PendingQuestion(TaskId, QuestionId, Question)`.
- `(string QuestionId, Task<string> Answer) Register(taskId, question)` — overwrites any stale entry, `RunContinuationsAsynchronously`.
- `bool TryAnswer(taskId, questionId, answer)`; `PendingQuestion? Get(taskId)`; `void Remove(taskId, questionId)`.
- Test: `tests/ClaudeDo.Worker.Tests/Runner/PendingQuestionRegistryTests.cs` — register→answer resolves the task; wrong questionId no-ops; Get reflects state; second Register overwrites.
## Task 2 — AskUser MCP tool (worker)
- `TaskRunMcpService.cs`: inject `PendingQuestionRegistry`; add
`[McpServerTool] async Task<string> AskUser(string question, CancellationToken ct)`:
- caller id from `_ctx.Current.CallerTaskId`; register; broadcast `TaskQuestionAsked`.
- await answer via `Task<string>.WaitAsync` with a 3-min linked-CTS; on timeout return the fallback string; on request-cancel rethrow.
- `finally`: `Remove` + broadcast `TaskQuestionResolved`.
- `[Description]`: when to use (only when a wrong guess is costly/irreversible; otherwise proceed).
- Test: `tests/ClaudeDo.Worker.Tests/Runner/AskUserToolTests.cs` — answer path returns the answer; timeout path returns fallback (inject a short timeout or a seam) with a fake broadcaster + stub context accessor.
## Task 3 — Wire MCP for all runs + timeout env (worker)
- `TaskRunner.RunAsync`: move MCP-identity setup out of the `standalone` gate so every run gets `claudedo_run`; `AllowedTools` = `mcp__claudedo_run__AskUser` always, append `,mcp__claudedo_run__SuggestImprovement` when standalone. Keep token cleanup in `finally`.
- `ClaudeProcess.cs`: `psi.Environment["MCP_TOOL_TIMEOUT"] = "200000";`.
- System prompt file (PromptKind.System default): add one guidance line about `AskUser`.
## Task 4 — Hub + Broadcaster (worker)
- `HubBroadcaster.cs`: `TaskQuestionAsked(taskId, questionId, question)`, `TaskQuestionResolved(taskId, questionId)`.
- `WorkerHub.cs`: inject registry; `bool AnswerTaskQuestion(taskId, questionId, answer)`; `PendingQuestionDto? GetPendingQuestion(taskId)`; `record PendingQuestionDto(...)`.
- `Program.cs`: register `PendingQuestionRegistry` as singleton.
## Task 5 — UI client (IWorkerClient/WorkerClient + fakes)
- `IWorkerClient`: `Task AnswerTaskQuestionAsync(taskId, questionId, answer)`, `Task<PendingQuestionDto?> GetPendingQuestionAsync(taskId)`, events `Action<string,string,string>? TaskQuestionAskedEvent`, `Action<string,string>? TaskQuestionResolvedEvent`; UI DTO record.
- `WorkerClient`: implement invokes + `On<...>` handlers raising the events.
- Update hand-rolled `IWorkerClient` fakes in Ui.Tests (and Worker.Tests if present).
## Task 6 — TaskMonitorViewModel (hot file)
- Subscribe both events (filter by `_subscribedTaskId`); dispose handlers.
- Props: `PendingQuestionId`, `PendingQuestion`, `HasPendingQuestion`, `AnswerDraft`, `IsWaitingForInput`.
- `SubmitAnswerCommand` (CanExecute: non-empty draft + HasPendingQuestion) → `AnswerTaskQuestionAsync`; clear draft.
- Clear pending on `TaskFinished` for this task and in `Reset()`.
- Test: `TaskMonitorViewModelTests` — asked event surfaces question; submit invokes client + clears; resolved/finished clears.
## Task 7 — Hydrate on attach (MissionControlViewModel)
- In `HydrateAsync`, after `ApplyState`, call `GetPendingQuestionAsync(taskId)`; if present, set the monitor's pending question (re-attach case).
## Task 8 — View banner (hot file, additive)
- `MonitorPaneView.axaml`: a `Border DockPanel.Dock="Top"` above `SessionTerminalView`, `IsVisible="{Binding HasPendingQuestion}"`, showing the question text, a `TextBox` bound to `AnswerDraft` (Enter submits), and a Send `Button``SubmitAnswerCommand`. Mirror the roadblock-banner styling.
## Task 9 — Localization
- `en.json` + `de.json`: `missionControl.question.title`, `.placeholder`, `.send`. Keep parity (Localization.Tests).
## Task 10 — Build + test + verify
- `dotnet build` App + Worker `-c Release`; run Worker.Tests, Ui.Tests, Localization.Tests.
- Self-review diffs. Flag the two manual verification gaps to Mika. Do not push.
@@ -0,0 +1,98 @@
# Plan — Mission Control (multi-task live monitoring)
Spec: `docs/superpowers/specs/2026-06-25-mission-control-design.md`
Execution: subagent-driven, **sonnet** model, TDD where a test is meaningful, build + test before
each commit, one Conventional Commit per task. Stage files explicitly by path (never `git add -A`).
**No duplication** — every task reuses the assets named in the spec's reuse map.
---
## Phase 1 — Extract the reusable monitor core (no behavior change)
### Task 1.1 — Move `LogLineViewModel` + `LogKind` to their own file
- Cut `LogKind` enum and `LogLineViewModel` from `DetailsIslandViewModel.cs` into
`ViewModels/Islands/LogLineViewModel.cs` (same namespace). No logic change.
- Build `ClaudeDo.App`; run Ui.Tests. Commit: `refactor(ui): split LogLineViewModel into own file`.
### Task 1.2 — Create `TaskMonitorViewModel` owning the streaming/status/outcome core
- New `ViewModels/Islands/TaskMonitorViewModel.cs`. Move from `DetailsIslandViewModel`:
`Log`, `_subscribedTaskId`, `_formatter`, `_claudeBuf`, `OnTaskMessage`, `AppendStdoutLine`,
`FlushClaudeBuffer`, `ReplayLogFileAsync`, `ExpandUserPath`; `AgentState` + all `Is*` flags +
`OnAgentStateChanged`; `StatusToStateKey` / `FinishedStatusToStateKey`; `SessionOutcome` /
`Roadblocks` + `ApplyOutcome` + `RoadblockMarker`; the worker `TaskMessage/Started/Finished/Updated`
subscriptions for the streaming concern; `Title`/`TaskIdBadge`/`Model`/`TurnsText`/`TokensFormatted`/
diff text/elapsed; `BlockingReason` (+visible flag) from `BlockedByTaskId`/review/children/roadblocks.
- Ctor takes `IDbContextFactory<ClaudeDoDbContext>`, `IWorkerClient`. `Attach(taskId)` /
`AttachAsync(entity)` to (re)bind + replay; `IDisposable` unsubscribes (mirror existing Dispose).
- Unit test (Ui.Tests): feed `[stdout]`/`[claude]`/`[tool]` lines via the worker fake → `Log`
accumulates correctly; `TaskFinished` flips `AgentState`; `ApplyOutcome` splits the roadblock marker.
Reuse the existing IWorkerClient fake (see `iworkerclient_fakes_sync`).
- Build + test. Commit: `feat(ui): extract TaskMonitorViewModel streaming core`.
### Task 1.3 — `DetailsIslandViewModel` delegates to `Monitor`
- Add `public TaskMonitorViewModel Monitor { get; }`; construct it; route `Bind`/`BindAsync` to
`Monitor.Attach`. Remove the moved members; keep subtasks/attachments/editing/merge/review/child
outcomes/notes/prep intact. Dispose `Monitor`.
- Repoint `WorkConsole.axaml` Output-tab bindings (`Log`, `IsRunning/IsDone/IsFailed`,
`SessionOutcome`, `TurnsText`, `DiffAddText`/`DiffDelText`, `Model`) to `Monitor.*`. Leave
review/merge/session bindings unchanged.
- Build + test. **Manual visual pass: Details pane behaves exactly as before** (flag for Mika).
Commit: `refactor(ui): route DetailsIsland streaming through Monitor`.
---
## Phase 2 — Mission Control window
### Task 2.1 — `MissionControlViewModel`
- New `ViewModels/MissionControlViewModel.cs`: `ObservableCollection<TaskMonitorViewModel> Monitors`
keyed by id; seed from `GetActive()`; add on `TaskStarted`, flip-state-and-keep on `TaskFinished`;
`ClearFinished` command; `ColumnCount`/layout signal from `Monitors.Count`; least-active collapse.
`IDisposable` disposes all monitors. Inject `IDbContextFactory`, `IWorkerClient`, `IServiceProvider`.
- Register `AddSingleton<MissionControlViewModel>` in `App/Program.cs`.
- Unit test: simulate two `TaskStarted` → two monitors; `TaskFinished` keeps the pane; `ColumnCount`
matches count. Commit: `feat(ui): add MissionControlViewModel`.
### Task 2.2 — `RevealTaskAsync` navigation on the shell
- Add `IslandsShellViewModel.RevealTaskAsync(taskId)` (resolve list → select → await load → select row).
- Wire `TaskMonitorViewModel.OpenInApp` to it (via an `Action<string>?` set by the shell, like the
existing `CloseDetail`/`DeleteFromList` hooks — no new DI cycle).
- Unit test for the select-by-id path. Commit: `feat(ui): reveal a task by id from anywhere`.
### Task 2.3 — `MonitorPaneView` (reuses `SessionTerminalView`)
- New `Views/MissionControl/MonitorPaneView.axaml(.cs)`: header (title/chip/tok/turn/elapsed),
blocking banner (`live-chip`/`terminal`/error-tint classes from IslandStyles — reuse), body =
`<SessionTerminalView Entries="{Binding Log}" ... />`, footer (Open in app / Detach / Cancel).
`x:DataType=TaskMonitorViewModel`. No new console control. Add `missionControl.*` en+de keys.
- Build + Localization.Tests. Commit: `feat(ui): add MonitorPaneView`.
### Task 2.4 — `MissionControlView` grid + `MissionControlWindow`
- `MissionControlView.axaml`: `ItemsControl`/`UniformGrid` of `MonitorPaneView` driven by `ColumnCount`,
horizontal scroll fallback, header with `ClearFinished` (+ optional QuickAdd, deferrable).
- `MissionControlWindow.axaml(.cs)`: hosts the view; lazy-create + hide-on-close.
- Build. Commit: `feat(ui): add MissionControl window + grid`.
### Task 2.5 — Launch button + lifetime
- Title-bar toggle button in `MainWindow.axaml` → shell command that shows/focuses the window
(created lazily, owns the singleton VM).
- Set `desktop.ShutdownMode = OnMainWindowClose` in `App.OnFrameworkInitializationCompleted`.
- Build. **Manual visual pass** (flag for Mika): open with 2+ running tasks; main window still adds
tasks; blocking banner; Open-in-app. Commit: `feat(ui): open Mission Control from the title bar`.
---
## Phase 3 — Per-pane detach (lowest priority)
### Task 3.1 — `TaskMonitorWindow` + detach/re-dock
- `Views/MissionControl/TaskMonitorWindow.axaml(.cs)` hosting `MonitorPaneView`; `Detach` removes the
monitor from the grid and shows it in the window (optional always-on-top); close re-docks.
- Build. Manual visual pass. Commit: `feat(ui): detach a monitor into its own window`.
---
## Cross-cutting checklist (every task)
- Stage by explicit path; sonnet subagents; reuse per the spec's map — no new console/streaming/insert path.
- en.json + de.json parity for any new string (Localization.Tests).
- If `IWorkerClient`/ctor signatures change, update the hand-rolled fakes in **both** test projects.
- Build `ClaudeDo.App` (`-c Release` if Worker is running) before marking a task done.
- Never push without asking.
@@ -0,0 +1,101 @@
# Plan — In-App Interactive Sessions
Spec: `docs/superpowers/specs/2026-06-26-in-app-interactive-sessions-design.md`
Implement on the shared main tree. Commit explicit paths per task (never `git add -A`).
Build with `-c Release` (running Worker locks Debug). No real-Claude tests — fake the
process stream. Sonnet subagents. Autonomous `TaskRunner`/`ClaudeProcess` path stays untouched.
## Task 1 — StreamingClaudeSession (worker, new file)
- `Runner/StreamingClaudeSession.cs`: persistent `claude` process. Ctor takes resolved args,
working dir, seeded first prompt, a line callback, `WorkerConfig`. Reuse the
`ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT="200000"` from `ClaudeProcess`.
- Keeps stdin open; sends the first prompt as a user-message JSON line (escape via
`JsonSerializer`).
- stdout/stderr read tasks → line callback; parse `result` events to track `IsTurnInFlight`.
- `SendUserMessageAsync(text, ct)` — enqueue/write a user-message JSON line; if
`IsTurnInFlight`, also `InterruptAsync`.
- `InterruptAsync(ct)` — write the control-protocol interrupt line; best-effort (swallow +
log on failure → queue fallback applies).
- `StopAsync` / `DisposeAsync` — close stdin, kill the tree, await exit.
- Injectable stream seam so a fake can drive it without a real `claude` binary.
- Test: `StreamingClaudeSessionTests` (fake stream) — first message emitted; `result` flips
`IsTurnInFlight` off; a sent message produces a second turn; mid-turn send calls interrupt
then delivers; interrupt throw → delivered at natural turn end; stop kills.
## Task 2 — LiveSessionRegistry (worker, new file)
- `Runner/LiveSessionRegistry.cs`: singleton; `Register(taskId, StreamingClaudeSession)`,
`bool TryGet(taskId, out session)`, `Unregister(taskId)`, `Task StopAsync(taskId)`.
- Test: register→get; unregister; second register stops+replaces; missing get returns false.
## Task 3 — InteractiveSessionService (worker, new file)
- `Planning/InteractiveSessionService.cs`: inject `IDbContextFactory`, `WorkerConfig`,
`ClaudeArgsBuilder` (or build args inline), `HubBroadcaster`, `LiveSessionRegistry`.
- `StartAsync(taskId, ct)`: resolve list working dir + seeded prompt (reuse the body of
`PlanningSessionManager.OpenInteractiveAsync` + `BuildInteractivePrompt`); build interactive
args (`--model PlanningAlias --permission-mode auto` + streaming flags); spawn the session
with a callback that does `HubBroadcaster.TaskMessage(taskId, "[stdout] " + line)`;
register; broadcast `InteractiveSessionStarted`. Reject if one is already live for the task.
- `SendAsync(taskId, text, ct)` → registry `TryGet``SendUserMessageAsync`.
- `StopAsync(taskId, ct)` → registry stop + `InteractiveSessionEnded`.
- Move `OpenInteractiveAsync`/`BuildInteractivePrompt` out of `PlanningSessionManager` if it
reads cleaner (or call into it). Remove the `InteractiveLaunchContext` terminal coupling.
- Test: `InteractiveSessionServiceTests` (fake session factory + fake broadcaster) — start
resolves dir, seeds prompt, registers, broadcasts started; missing working dir throws;
send routes; stop broadcasts ended.
## Task 4 — Remove terminal interactive path (worker)
- `Planning/Interfaces/ITerminalLauncher.cs` + `WindowsTerminalLauncher.cs`: delete
`LaunchInteractiveAsync`; remove `InteractiveLaunchContext` from `PlanningSessionContext.cs`.
Keep planning start/resume launches.
- Fix any references; ensure the planning launcher tests still build.
## Task 5 — Hub + Broadcaster + DI (worker)
- `Hub/WorkerHub.cs`: re-point `OpenInteractiveTerminalAsync` to
`InteractiveSessionService.StartAsync` (drop `_launcher.LaunchInteractiveAsync`); add
`Task SendInteractiveMessage(taskId, text)`, `Task StopInteractiveSession(taskId)`
(+ optional `InterruptInteractiveSession`).
- `Hub/HubBroadcaster.cs`: `InteractiveSessionStarted(taskId)`, `InteractiveSessionEnded(taskId)`.
- `Program.cs`: register `LiveSessionRegistry` + `InteractiveSessionService` singletons.
- Test: `WorkerHub` send routes to a fake service; start invokes the service.
## Task 6 — UI client + fakes (ui)
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs`: `SendInteractiveMessageAsync(
taskId, text)`, `StopInteractiveSessionAsync(taskId)` (+ optional interrupt); events
`Action<string>? InteractiveSessionStartedEvent`, `InteractiveSessionEndedEvent` with
`On<...>` handlers. `OpenInteractiveTerminalAsync` keeps name/signature.
- Update hand-rolled `IWorkerClient` fakes in **both** Ui.Tests and Worker.Tests.
## Task 7 — StreamLineFormatter user bubble (ui)
- Render `type:"user"` NDJSON events as `LogKind.User` (add the kind if missing).
- Test: a `user` event yields a `LogKind.User` `LogLineViewModel` with the text.
## Task 8 — Shared composer state on the session VMs (ui, hot files)
- Add to `TaskMonitorViewModel` and `DetailsIslandViewModel` (factor a shared helper —
`InteractiveComposer` — to avoid duplication): `ComposerDraft`, `IsInteractiveLive`
(toggled by `InteractiveSessionStarted/Ended` for the subscribed task),
`SubmitComposerCommand` (CanExecute: non-empty draft && (`HasPendingQuestion` ||
`IsInteractiveLive`)). Route: pending question → existing `AnswerTaskQuestionAsync`; else →
`SendInteractiveMessageAsync`. Clear draft on submit; clear `IsInteractiveLive` on ended.
- `MissionControlViewModel`: `EnsureMonitor(taskId)` on `InteractiveSessionStarted`.
- Test: composer enabled while interactive-live; submit routes (chat vs answer) + clears;
ended clears live state.
## Task 9 — SessionTerminalView composer (ui)
- `Views/Islands/SessionTerminalView.axaml(.cs)`: optional composer docked bottom (styled
props `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`); TextBox
(Enter submits) + Send button. Reuse existing tokens (no inline values).
- Bind it in `MonitorPaneView.axaml` and `DetailsIslandView.axaml` to each VM's composer
state. Fold the existing AskUser banner into the composer's "answering" state if it reads
cleaner; otherwise leave the banner and add the composer below.
## Task 10 — Localization
- `en.json` + `de.json`: `interactive.composer.placeholder`, `.send`, `.stop`, plus any
"session ended" notice. Keep parity (Localization.Tests).
## Task 11 — Build + test + verify
- Build App + Worker `-c Release`; run Worker.Tests, Ui.Tests, Localization.Tests.
- Self-review diffs. **Manual smoke (real CLI) — flag to Mika:** (a) Run interactively opens
an in-app chat (no terminal) and streams; (b) sending a message mid-turn interrupts +
redirects; (c) stop kills the process; (d) session shows in both task detail and Mission
Control. Do not push.
@@ -0,0 +1,94 @@
# Session Skills — Implementation Plan
Spec: `docs/superpowers/specs/2026-07-03-session-skills-design.md`
Approach: subagent-driven TDD (sonnet), build + test + commit per task, stage files by
path (never `git add -A`).
**Pre-flight (do first, before building anything):** manual smoke test — drop a skill
into a scratch worktree's `.claude/skills/` and run `claude -p` to confirm cwd skills are
discovered in headless mode. The whole feature rests on this. If it fails, stop and
redesign around `CLAUDE_CONFIG_DIR`.
---
## Task 1 — Data layer: columns + registry table + migration
- Add nullable `SessionSkills` (string, JSON array) to `TaskEntity`, `ListConfigEntity`,
`AppSettingsEntity`; map `session_skills` columns in their `*Configuration.cs`.
- New `SessionSkillEntity` (`name` PK, `source_url`, `pinned_ref`, `subpath`,
`description`, `added_at`) — one row per skill; a multi-skill repo writes N rows sharing
`source_url`/`pinned_ref` — + configuration + `session_skills` table.
- New `SessionSkillRepository` (async, CancellationToken): `ListAsync`, `GetAsync(name)`,
`UpsertAsync`, `DeleteAsync(name)`, `DeleteBySourceAsync(url)`, `ListBySourceAsync(url)`.
- EF migration `AddSessionSkills` (columns + table).
- **Tests (Data.Tests):** repository CRUD on real SQLite; JSON column round-trips a
name list.
## Task 2 — Registry service (install / update / remove)
- `Skills/SessionSkillRegistry` + `Skills/Interfaces/ISessionSkillRegistry`,
`IRepoCloner` (clone abstraction so tests inject a local source dir).
- `GitRepoCloner` (production) does `git clone` + resolves HEAD SHA.
- Install: clone → **detect layout** (`skills/*/SKILL.md` bundle → each subskill; else
root `SKILL.md` → single; else reject) → per skill parse YAML frontmatter (`name`,
`description`), copy its dir **flat** to `~/.todo-app/session-skills/<name>/`, upsert a
row with `subpath`. Reject collision with a skill from a different source; reinstalling
the same source refreshes.
- Update(sourceUrl) / Remove(sourceUrl) per spec (act on all of a source's skills).
- **Tests (Worker.Tests):** install a **multi-skill** fixture (fake cloner, mirrors
ponytail's `skills/*/SKILL.md`) → N rows + N flat dirs; install a root-`SKILL.md`
fixture → 1 row; neither → rejected; cross-source name collision rejected;
remove-by-source deletes all its dirs + rows. **No real network / no real claude CLI.**
## Task 3 — Resolution: union into ClaudeRunConfig
- Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig` (default empty).
- In `TaskRunner.ResolveConfigAsync`: parse each level's `session_skills`, union + dedup,
filter to registry-existing names (drop + log missing).
- **Tests (Worker.Tests):** union across the three levels; dedup; unknown name dropped.
## Task 4 — Seeder
- `Skills/SessionSkillSeeder` + interface. `SeedAsync(cwd, skillNames, isWorktree, ct)`:
copy each installed skill dir → `<cwd>/.claude/skills/<name>/`; if worktree, append
`/.claude/skills/<name>/` to `git rev-parse --git-path info/exclude` target if absent.
- Wire into `TaskRunner` after run-dir resolution, before `ClaudeProcess.RunAsync`
(both worktree and sandbox paths).
- **Tests (Worker.Tests):** seeds into real temp dir; idempotent re-seed; worktree
exclude line written once and not duplicated; seeded path is git-ignored (real git
temp repo → `git status` clean for the seeded dir).
## Task 5 — Hub + DTOs + client
- `WorkerHub`: `GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(name)`,
`RemoveSessionSkill(name)`.
- New `SessionSkillDto`; extend `AppSettingsDto`, `ListConfigDto`, `UpdateListConfigDto`,
`UpdateTaskAgentSettingsDto` with skill-name lists; map in the update handlers.
- `IWorkerClient` + `WorkerClient` additions.
- **Update hand-rolled fakes** in Worker.Tests + Ui.Tests (memory
`iworkerclient_fakes_sync`).
- **Tests:** hub method round-trip via existing hub test harness where present.
## Task 6 — UI: registry tab + selectors
- `SessionSkillsSettingsTabViewModel` + a **Skills** tab in `SettingsModalView.axaml`:
installed list, Add (URL), Update, Remove, status line. Mirror
`FilesSettingsTabViewModel`.
- Global multi-select in General settings tab → `AppSettings.SessionSkills`.
- Skills multi-select in shared `AgentConfigEditor` (covers List + Task) with inheritance
badge, wired through `AgentConfigEditorViewModel`.
- Localization: add EN + DE keys in parity (Localization.Tests enforces).
- **Tests (Ui.Tests / Localization.Tests):** VM load/save of selections; locale parity.
- **Visual verification is Mika's** — flag the gaps.
## Task 7 — Wiring, build, end-to-end smoke
- DI registration (registry, cloner, seeder) in `Program.cs`.
- Build all touched projects `-c Release`; run Worker/Data/Ui/Localization test projects.
- Manual E2E: install ponytail via the UI, enable per-task, run a task, confirm the skill
is available to the agent and **not** committed and **not** in interactive sessions.
---
Commit per task with Conventional Commits (`feat(worker|ui|data): …`). Commit the
spec + plan docs first.
@@ -0,0 +1,88 @@
# Plan — ConPTY Interactive Sessions
Spec: `docs/superpowers/specs/2026-07-23-conpty-interactive-sessions-design.md`
Date: 2026-07-23
Execution: subagent-driven-development, sonnet model, TDD where meaningful,
build + test + commit per task. Stage files explicitly by path (never
`git add -A`). Terminal rendering is visual — flagged for the user's visual pass.
## Task 0 — Spike: embed a ConPTY terminal running `claude`
Not TDD; a throwaway proof. Add a temporary window/view that embeds each
candidate control and launches `claude` in a known worktree.
- Evaluate **SvcSystems.UI.Terminal** and **Iciclecreek.Avalonia.Terminal**.
- Acceptance: the real `claude` TUI renders correctly — colors, resize/reflow,
and a live permission prompt is usable; input reaches the CLI.
- Output: pick one library; note the control API (start with cwd/exe/args/env,
process-exited event, dispose/kill). Record the decision in the spec.
- Remove the throwaway harness before Task 1 (or keep as a manual dev sample,
not wired into the app).
**Stop for the user's visual verification of the spike before continuing.**
## Task 1 — Worker: interactive launch-spec endpoint
- Add a Worker service/hub method that, given a taskId, prepares the worktree
(session-skills seeding, agent files, MCP config, env — reuse the autonomous
run prep path) and returns a `LaunchSpec { cwd, exe, args, env }`.
- Reuse `WindowsTerminalLauncher.BuildResumeCommand` for exe/args.
- Guards mirror `ResumeTaskInTerminal` (not Running/Queued, persisted SessionId,
worktree Active/Kept). Never-run task → spec without `--resume` (fresh start).
- Tests (Worker.Tests, real SQLite/git): guard cases, spec contents for a
resumable task, fresh-start case. No real `claude` in tests.
## Task 2 — Worker: ad-hoc launch-spec
- Method to build a `LaunchSpec` for a free session in a given directory:
MCP config + env set up, no task/session-skills seeding.
- Tests: env/MCP presence, arbitrary cwd.
## Task 3 — UI: terminal host control + view model
- Wrap the chosen library in an app control/view (e.g. `InteractiveTerminalView`
+ `InteractiveTerminalViewModel`) that starts from a `LaunchSpec` and exposes
running/exited state.
- `IWorkerClient`: add methods to fetch the task and ad-hoc launch specs; wire
the SignalR client + hub method.
- Update hand-rolled `IWorkerClient`/hub fakes in BOTH test projects.
- Tests: view model starts/stops lifecycle with a fake terminal backend;
fake worker returns a spec.
## Task 4 — Command Center: host interactive panes + entry points
- `MonitorPaneView`: autonomous panes keep the streamed log; interactive panes
host the terminal control.
- Entry points: "Open interactive session" from a task (task-based) and a
"New session" action (ad-hoc, pick directory).
- Layout toggle: focus (tabs) ↔ overview (grid); reuse/extend the existing
`UniformGrid` column logic for the grid mode.
- Tests: view-model level (pane kind selection, layout toggle state). Rendering
is a visual-pass item.
## Task 5 — Remove the streaming interactive stack
Only after Tasks 14 land and the terminal path works.
- Worker: delete `StreamingClaudeSession`, `InteractiveSessionService`,
interactive `WorkerHub` methods + broadcast events, DI registrations.
Verify `LiveSessionRegistry` / `IdleSessionReaper` usage first; remove only if
unreferenced.
- UI: remove composer bits on `TaskMonitorViewModel`, the composer/queued portion
of `SessionTerminalView`, `IWorkerClient` interactive methods.
- Update fakes and delete now-dead tests. Full build + all test projects green.
## Task 6 — Docs
- Update `docs/open.md` with visual-verification items (spike render, terminal
resize/focus, grid vs tabs).
- Update affected per-project `CLAUDE.md` (Worker interactive removal, UI new
terminal host).
## Verification gates
- After Task 0: user visual pass on the spike.
- After Task 4: user visual pass on Command Center (task + ad-hoc, tabs + grid,
permission prompt round-trip).
- Never claim the terminal UI works without the user running it.
@@ -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,938 @@
# Per-List Task Handler Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make "Let Claude handle it" list-scoped only, and turn its prompt into a five-phase run — read all tasks, dedupe, enhance, queue, review+merge.
**Architecture:** Four independent commits. Two touch only leaf code (the MCP status tool, the prompt templates). One strips the global UI entry point. The last is an atomic sweep that makes `listId` non-nullable end to end and collapses the launch spec to a single repo — atomic because a half-flipped signature chain leaves nullable warnings scattered across a commit boundary.
**Tech Stack:** .NET 8, xUnit, Avalonia 12, EF Core + SQLite, CommunityToolkit.Mvvm.
**Spec:** `docs/superpowers/specs/2026-07-27-list-handler-design.md`
**Build note:** `dotnet build ClaudeDo.slnx` needs .NET 9 — build individual csproj with `-c Release` (a running Worker locks `Debug` output).
**Staging note:** the checkout is shared with parallel sessions. Always `git add -- <exact paths>` and `git commit -- <exact paths>`. Never `git add -A`, never a bare `git commit`.
---
### Task 1: `update_task_status` accepts `Cancelled`
Dedupe needs to retire an **Idle** duplicate. Today nothing can: `UpdateTaskStatus` allows only
`Idle`/`Queued`, `cancel_task` only cancels a *running* task, and `review_task(decision="cancel")`
requires WaitingForReview/Running/Queued. `TaskStateService.CancelAsync` already owns the
transition and its side effects.
`BatchMcpTools.BatchUpdateTaskStatus` delegates to this same method, so batch cancel comes free.
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs:264-300`
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append inside the `ExternalMcpServiceTests` class. `SeedTaskAsync` does not exist in this class —
seed inline the way the existing tests do, via `_lists` / `_tasks`.
```csharp
private async Task<TaskEntity> SeedPlainTaskAsync(TaskStatus status)
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
{
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
var queue = CreateQueue();
var sut = BuildSut(queue);
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
Assert.Equal("Cancelled", dto.Status);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
}
[Fact]
public async Task UpdateTaskStatus_Done_StillRejected()
{
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
var queue = CreateQueue();
var sut = BuildSut(queue);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
Assert.Contains("not settable externally", ex.Message);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~ExternalMcpServiceTests.UpdateTaskStatus"
```
Expected: `UpdateTaskStatus_Cancelled_CancelsAnIdleTask` FAILS with
`Status 'Cancelled' is not settable externally.`; `UpdateTaskStatus_Done_StillRejected` passes.
- [ ] **Step 3: Add the `Cancelled` branch**
In `ExternalMcpService.UpdateTaskStatus`, insert between the `Queued` case and `default`:
```csharp
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
```
Then update the `[McpServerTool, Description(...)]` text directly above the method — it currently
claims only Idle and Queued are permitted. Replace the whole attribute with:
```csharp
[McpServerTool, Description(
"Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' are permitted externally — " +
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
"Cancelled (retire the task without deleting it; it can be reset to Idle later). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
```
Also fix the `default` branch message, which still points at `cancel_task`:
```csharp
default:
throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use run_task_now or review_task.");
```
- [ ] **Step 4: Run the tests to verify they pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~ExternalMcpServiceTests"
```
Expected: all pass.
- [ ] **Step 5: Run the MCP schema test**
`ExternalMcpToolSchemaTests` asserts over tool descriptions and may pin the old text.
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~ExternalMcpToolSchemaTests"
```
Expected: PASS. If it fails on the changed description, update the assertion to match the new
text — do not revert the description.
- [ ] **Step 6: Commit**
```bash
git add -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(worker): allow update_task_status to set Cancelled" -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
```
(If Step 5 required a schema-test edit, add that path to both commands too.)
---
### Task 2: Five-phase helper prompt
**Files:**
- Modify: `src/ClaudeDo.Data/PromptFiles.cs:231-276` (`MergeHelperDefault`, `MergeHelperInitialDefault`)
- Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs:54-80`
- [ ] **Step 1: Write the failing tests**
Replace the existing `DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools` test with
the two below, and keep the other merge-helper tests as they are.
```csharp
[Fact]
public void DefaultFor_merge_helper_covers_all_five_phases()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.False(string.IsNullOrWhiteSpace(d));
Assert.Contains("Phase 0", d);
Assert.Contains("Phase 1", d);
Assert.Contains("Phase 2", d);
Assert.Contains("Phase 3", d);
Assert.Contains("Phase 4", d);
Assert.Contains("Phase 5", d);
}
[Fact]
public void DefaultFor_merge_helper_names_the_tools_each_phase_needs()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.Contains("batch_get_tasks", d); // phase 0
Assert.Contains("update_task", d); // phase 1 + 2
Assert.Contains("get_app_settings", d); // phase 3
Assert.Contains("update_task_status", d); // phase 3
Assert.Contains("review_task", d); // phase 4
Assert.Contains("continue_merge", d); // phase 4
Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start
}
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
--filter "FullyQualifiedName~PromptFilesTests"
```
Expected: both new tests FAIL (no "Phase 0", no `batch_get_tasks`).
- [ ] **Step 3: Replace `MergeHelperDefault`**
Replace the whole `private const string MergeHelperDefault = """ … """;` block with:
```csharp
private const string MergeHelperDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure — that is the point of this session.
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work — reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
Work the five phases in order. Do not start a phase before the previous one is finished.
## Phase 0 — Read everything
Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all — Phase 1 needs the whole set in view.
## Phase 1 — Dedupe
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
- merge → fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this.
- keep both → note why and move on.
Cancel nothing without an explicit answer. If there are no duplicates, say so and go on.
## Phase 2 — Enhance for execution
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
- concrete acceptance criteria — what must be true when it is done,
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
- what is explicitly out of scope.
Write it back with update_task (title, description and commitType are the settable fields).
Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task.
## Phase 3 — Run
Do NOT use run_task_now for a batch — there is a single override slot and the second call fails with "override slot busy".
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
Then, for each surviving task:
- Idle or Failed → update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
- Queued → leave it; it is already waiting for a slot.
- Running or WaitingForChildren → leave it; only poll.
- WaitingForReview → leave it; it goes straight to Phase 4.
Poll get_task until every task has left Queued and Running — WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
## Phase 4 — Review and merge
One task at a time, in the order the brief lists them.
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description.
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
- Clean merge → the task is Done; move on.
- Conflict (markers left in the working tree, repoPath returned) → resolve it.
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
Resolving a conflict:
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
Rules for the whole session:
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
- Ask the user for anything ambiguous, risky, or destructive.
## Phase 5 — Summary
Print one line per task from the original brief:
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no) — final status — merge commit (if any) — conflicts resolved (if any).
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
""";
```
- [ ] **Step 4: Replace `MergeHelperInitialDefault`**
The scope is now always one list with one repo, so the header states it once and the task lines
drop the constant `list:` / `repo:` fields.
```csharp
private const string MergeHelperInitialDefault = """
# List handler brief
Scope: {scope}
Repo: {repo}
Handle the following tasks. Work Phases 05 as your instructions describe, asking me whenever you are unsure.
{tasks}
When every task is handled, print the summary.
""";
```
- [ ] **Step 5: Add the `{repo}` token test**
`{repo}` is a new token — Task 4 will pass it. Add to `PromptFilesTests`:
```csharp
[Fact]
public void DefaultFor_merge_helper_initial_has_repo_token()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
Assert.Contains("{repo}", d);
}
```
The existing `RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks` test passes only
`scope` and `tasks`. `RenderTemplate` leaves unknown tokens alone, so its two `Assert.Contains`
still hold and its `Assert.DoesNotContain("{scope}", outp)` still holds. Leave it unchanged.
- [ ] **Step 6: Run the tests to verify they pass**
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
--filter "FullyQualifiedName~PromptFilesTests"
```
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
git add -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
git commit -m "feat(data): five-phase list-handler prompt with dedupe and enhance" -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
```
---
### Task 3: Drop the global entry point and the LIST column
This removes every caller that passes a null `listId`, clearing the way for Task 4's signature
sweep. Types stay nullable here; only callers and UI go.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:103-113`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml:184,206-210`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs:14-53`
- Modify: `src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml:41-72`
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `src/ClaudeDo.Localization/locales/de.json`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs`
- [ ] **Step 1: Update the dialog tests to the list-only API**
`Configure` becomes `Configure(string listId, string listName)` and `IsGlobal` and `ListName` are
gone. Rewrite the affected tests. `Load_ExcludesTerminalStatuses_AndTicksActionableByDefault`,
`CanConfirm_FollowsRowSelection` and `Confirm_ReturnsSelectedIds_InRowOrder` all used
`Configure(null, null)` to see every seeded task — point them at `"L1"` instead, which holds all
eight seeded statuses (`t-other-list` lives in `L2` and drops out).
Replace the four tests below; leave `Load_NoCandidates_HasTasksFalse_CannotConfirm` untouched.
```csharp
[Fact]
public async Task Load_ExcludesTerminalStatuses_AndTicksActionableByDefault()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list");
Assert.Equal(6, vm.Tasks.Count);
Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-review").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-failed").IsSelected);
Assert.False(vm.Tasks.Single(t => t.Id == "t-running").IsSelected);
Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected);
}
[Fact]
public async Task Load_PerListScope_FiltersToThatList()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L2", "Home");
await vm.LoadAsync();
Assert.Single(vm.Tasks);
Assert.Equal("t-other-list", vm.Tasks[0].Id);
Assert.Contains("Home", vm.ScopeLabel);
}
[Fact]
public async Task CanConfirm_FollowsRowSelection()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
Assert.True(vm.CanConfirm);
vm.SelectNoneCommand.Execute(null);
Assert.False(vm.CanConfirm);
Assert.All(vm.Tasks, t => Assert.False(t.IsSelected));
vm.Tasks[0].IsSelected = true; // single row re-enables via PropertyChanged hook
Assert.True(vm.CanConfirm);
vm.SelectAllCommand.Execute(null);
Assert.All(vm.Tasks, t => Assert.True(t.IsSelected));
}
[Fact]
public async Task Confirm_ReturnsSelectedIds_InRowOrder()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
vm.SelectNoneCommand.Execute(null);
vm.Tasks.Single(t => t.Id == "t-review").IsSelected = true;
vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = true;
var closed = false;
vm.CloseAction = () => closed = true;
vm.ConfirmCommand.Execute(null);
var result = await vm.Result.Task;
Assert.NotNull(result);
// Row order (SortOrder): t-idle was seeded before t-review.
Assert.Equal(new[] { "t-idle", "t-review" }, result);
Assert.True(closed);
}
```
Also change `Cancel_ReturnsNull`'s `vm.Configure(null, null);` to `vm.Configure("L1", "Work");`.
- [ ] **Step 2: Run the tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release \
--filter "FullyQualifiedName~MergeHelperSelectionModalViewModelTests"
```
Expected: FAIL — the project does not compile, because `Configure(string, string)` does not exist
yet and `IsGlobal` was removed from an assertion that still compiles against it. Compilation
failure is the expected "red" here.
- [ ] **Step 3: Make the dialog VM list-only**
In `MergeHelperSelectionModalViewModel.cs`:
Remove the `ListName` property from `MergeHelperTaskRowViewModel`:
```csharp
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
public required string Title { get; init; }
public required string StatusText { get; init; }
[ObservableProperty] private bool _isSelected;
}
```
Change the field to non-nullable, drop `IsGlobal`, and make `Configure` list-only:
```csharp
private string _listId = "";
```
```csharp
[ObservableProperty] private string _scopeLabel = "";
public bool HasTasks => Tasks.Count > 0;
```
```csharp
public void Configure(string listId, string listName)
{
_listId = listId;
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
}
```
In `LoadAsync`, the list filter is now unconditional and `ListName` is no longer selected:
```csharp
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var candidates = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
.Where(t => t.ListId == _listId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.Status })
.ToListAsync(ct);
foreach (var c in candidates)
{
var row = new MergeHelperTaskRowViewModel
{
Id = c.Id,
Title = c.Title,
StatusText = c.Status.ToString(),
IsSelected = IsTickedByDefault(c.Status),
};
row.PropertyChanged += OnRowChanged;
Tasks.Add(row);
}
```
- [ ] **Step 4: Drop the LIST column from the dialog view**
In `MergeHelperSelectionModal.axaml`, change both `ColumnDefinitions="32,*,120,120"` (lines 41 and
57) to `ColumnDefinitions="32,*,120"`, and delete the two `Grid.Column="3"` elements — the header
`TextBlock` bound to `modals.mergeHelper.columnList` (lines 45-46) and the row `TextBlock` bound to
`ListName` (lines 68-71).
- [ ] **Step 5: Remove the global command and the Broom button**
In `ListsIslandViewModel.cs`, delete the whole `LetClaudeHandleAllAsync` method including its
`[RelayCommand]` attribute (lines 103-113). Leave `LetClaudeHandleListAsync` and the
`MergeHelperRequest` record as they are — Task 4 changes those.
In `ListsIslandView.axaml`, revert the button row to two columns:
```xml
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
```
and delete the whole `<Button Grid.Column="2" … LetClaudeHandleAllCommand … />` element
(lines 206-210) including its `<PathIcon>` child.
- [ ] **Step 6: Remove the three dead localization keys**
Delete from **both** `src/ClaudeDo.Localization/locales/en.json` and
`src/ClaudeDo.Localization/locales/de.json`:
- `lists.letClaudeAllTip` (and the trailing comma on the preceding key, so the object stays valid JSON)
- `modals.mergeHelper.scopeAll`
- `modals.mergeHelper.columnList` (and the trailing comma on the preceding key)
Keep `lists.contextLetClaude` and `modals.mergeHelper.scopeList`.
- [ ] **Step 7: Build and run the tests**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: build succeeds, all tests pass. The localization parity test is the one that catches a
key removed from only one of the two JSON files.
- [ ] **Step 8: Commit**
```bash
git add -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
git commit -m "refactor(ui): scope \"Let Claude handle it\" to a single list" -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
```
---
### Task 4: Non-nullable `listId` and a single-repo launch spec
One atomic commit across Worker and Ui. Splitting it would leave one side passing `string?` into a
`string` parameter — nullable warnings strewn across a commit boundary, and a launch spec that
still carries a dead multi-repo path.
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:166-253`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:682-687`
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:525-526`
- Modify: `src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-357`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103`
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs:363-472`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs:409-463`
- [ ] **Step 1: Rewrite the launch-spec tests**
In `InteractiveLaunchSpecServiceTests.cs`, replace the four merge-helper `[Fact]`s (from
`BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation` through
`BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope`) with these five. Keep
the `_mergeHelperSessionDirs` field and `TrackSessionDir` helper above them exactly as they are.
```csharp
[Fact]
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
Assert.Contains("working directory", ex.Message);
}
[Fact]
public async Task BuildForMergeHelperAsync_UnknownList_Throws()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
{
var repo = Path.Combine(_tempDir, "repoOnly");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
var args = spec.Args.ToList();
var pmIdx = args.IndexOf("--permission-mode");
Assert.True(pmIdx >= 0);
Assert.Equal("default", args[pmIdx + 1]);
var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
// --add-dir: session dir + the list's single repo dir
var addIdx = args.IndexOf("--add-dir");
var appendIdx = args.IndexOf("--append-system-prompt-file");
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
Assert.Equal(new[] { sessionDir, repo }, addDirs);
var systemPromptPath = args[appendIdx + 1];
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
Assert.True(File.Exists(systemPromptPath));
// kickoff is the LAST arg (positional), single line, points at brief.md
var kickoff = args[^1];
var briefPath = Path.Combine(sessionDir, "brief.md");
Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
{
var repo = Path.Combine(_tempDir, "repoBrief");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
Assert.Contains("Scope: List: Alpha", brief);
Assert.Contains($"Repo: {repo}", brief);
Assert.Contains("First task", brief);
Assert.Contains("Second task", brief);
Assert.Contains(t1, brief);
Assert.Contains(t2, brief);
}
```
- [ ] **Step 2: Run the launch-spec tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests.BuildForMergeHelper"
```
Expected: FAIL. `BuildForMergeHelperAsync_UnknownList_Throws` fails because the current code
resolves the repo from the tasks and never validates the list; the brief test fails on the missing
`Repo:` line.
- [ ] **Step 3: Rewrite `BuildForMergeHelperAsync`**
Replace the method body (`InteractiveLaunchSpecService.cs:166-253`) with:
```csharp
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
{
if (taskIds.Count == 0)
throw new InvalidOperationException("No tasks selected for the list handler.");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var taskRepo = new TaskRepository(ctx);
var listRepo = new ListRepository(ctx);
var list = await listRepo.GetByIdAsync(listId, ct)
?? throw new KeyNotFoundException($"List not found: {listId}");
var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
var briefLines = new List<string>();
foreach (var id in taskIds)
{
var task = await taskRepo.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"Task not found: {id}");
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
}
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);
var briefPath = Path.Combine(sessionDir, "brief.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
new Dictionary<string, string>
{
["scope"] = $"List: {list.Name}",
["repo"] = repoDir,
["tasks"] = string.Join("\n", briefLines),
}), ct);
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
// Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
// positional kickoff LAST — a multi-line positional prompt truncates at the first
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
var args = new List<string>
{
"--permission-mode", "default",
"--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath,
$"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
"After reading it, begin the session as your instructions describe.",
};
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = "200000",
};
return new LaunchSpec(cwd: repoDir, resolvedClaude, args, env);
}
```
- [ ] **Step 4: Flip the interface and hub signatures**
`src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`:
```csharp
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
```
`src/ClaudeDo.Worker/Hub/WorkerHub.cs:682`:
```csharp
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
```
(leave the method body as it is).
- [ ] **Step 5: Run the Worker tests**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests"
```
Expected: build succeeds, all pass. `TasksIslandViewModelPlanningTests.cs:78` holds a fake
implementing `IWorkerClient` — its signature is flipped in Step 7; if the Worker.Tests build fails
there, do Step 7 first and re-run.
- [ ] **Step 6: Update the Mission Control tests**
In `MissionControlViewModelTests.cs`, the four `OpenMergeHelperConPtySessionAsync` calls pass
`null` as the list id. Replace `null` with `"L1"` on lines 421, 435, 436 and 461, and change the
`ThrowingMergeHelperLaunchSpecWorker` override signature at line 411 to:
```csharp
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
```
The list-title lookup in `OpenMergeHelperConPtySessionAsync` is wrapped in a `try/catch` and falls
back to the plain title, so an unseeded `"L1"` is harmless.
- [ ] **Step 7: Flip the UI signatures**
`src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`:
```csharp
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default);
```
`src/ClaudeDo.Ui/Services/WorkerClient.cs:525`:
```csharp
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
```
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103` and
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78` — same parameter change
(`string? listId``string listId`), bodies unchanged.
`src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`:
```csharp
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
```
`src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-341` — the `listId is not null` guard is
now dead:
```csharp
// List-handler session over a hand-picked set of tasks ("Let Claude handle it").
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
{
if (taskIds is not { Count: > 0 }) return;
var title = Loc.T("missionControl.mergeHelperTitle");
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
}
catch { /* best-effort title lookup */ }
```
Leave the rest of the method (the `try` block that fetches the spec and adds the pane) unchanged.
- [ ] **Step 8: Build everything and run the full suite**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: both builds succeed with no `CS8600`/`CS8604` nullability warnings on the touched files,
and every test passes.
- [ ] **Step 9: Commit**
```bash
git add -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
git commit -m "refactor(worker): make the list-handler launch spec single-list and single-repo" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
```
---
### Task 5: Update the project docs
**Files:**
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
- Modify: `docs/open.md`
- [ ] **Step 1: Check what the CLAUDE.md files claim**
```bash
grep -n "merge.helper\|Let Claude handle\|MergeHelper" src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
```
- [ ] **Step 2: Correct any stale claim**
Where those files describe the merge helper as having a global scope, or describe the prompt as
run-and-merge only, update them to: list-scoped only, single repo, five phases (read, dedupe,
enhance, queue, review+merge). Note in `src/ClaudeDo.Worker/CLAUDE.md` that
`update_task_status` now also accepts `Cancelled`. Do not restructure the files beyond that.
- [ ] **Step 3: Add the open verification items**
Append to the open-items section of `docs/open.md`:
```markdown
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
landing in task descriptions, queued execution, merges).
```
- [ ] **Step 4: Commit**
```bash
git add -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
git commit -m "docs: describe the list-scoped five-phase handler" -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
```
---
## Verification left to the user
None of this can be confirmed from tests alone:
- The lists footer no longer shows the Broom button, and the row context menu still offers
"Let Claude handle it" for lists with a working dir.
- The selection dialog shows TASK and STATUS only, and the scope line reads `List: <name>`.
- A real ConPTY run: Phase 1 asks about duplicates, Phase 2's enhancements are visible in the task
descriptions afterwards, Phase 3 reports the slot count and the tasks execute, Phase 4 merges or
hands off to conflict resolution.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,939 @@
# Handler-Run Links Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL.
**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path.
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md`
---
## File Structure
**Modified:**
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs``SetHandlerTaskIdAsync`
- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs``HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``HandlerBadgeBrush` + `Border.badge.handler`
- `src/ClaudeDo.Localization/locales/en.json` + `de.json``tasks.badgeHandler`, `tasks.handlerTip`
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``HandledTasks` collection, loader, clear, refresh
- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs
**Created:**
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`
---
## Task 1: Data — `HandlerTaskId` column and migration
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131`
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (generated)
- [ ] **Step 1: Add the property**
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add:
```csharp
// Id of the "list handler" run task that processed this task ("Let Claude handle it").
// 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately
// NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering.
// No FK: a deleted handler task must not cascade into the tasks it merely touched.
public string? HandlerTaskId { get; set; }
```
- [ ] **Step 2: Map the column and index it**
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line
`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add:
```csharp
builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id");
```
At the end of `Configure`, after the line
`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add:
```csharp
builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id");
```
Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less.
- [ ] **Step 3: Generate the migration**
Run from the repo root:
```bash
dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn<string>(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry.
If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring
`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add
`Property<string>("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");`
plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`.
- [ ] **Step 4: Build**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
Expected: `Build succeeded`, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations
git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run"
```
⚠️ Always commit with explicit paths (`git commit -- <paths>`), never a bare `git commit` — the
main checkout is shared with concurrent sessions.
---
## Task 2: Data — `SetHandlerTaskIdAsync` repository method
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`)
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Repositories;
/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it"
/// run processed, so the handler task's detail pane can list them after the run.
public sealed class TaskRepositoryHandlerLinkTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public TaskRepositoryHandlerLinkTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> CreateListAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test List",
CreatedAt = DateTime.UtcNow,
});
return listId;
}
private async Task<string> AddTaskAsync(string listId)
{
var id = Guid.NewGuid().ToString();
await _tasks.AddAsync(new TaskEntity
{
Id = id,
ListId = listId,
Title = "T",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
});
return id;
}
[Fact]
public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var b = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId);
Assert.Equal(2, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId);
Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(
new[] { a, "does-not-exist" }, handlerId);
Assert.Equal(1, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_SecondRunOverwrites()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var firstHandler = await AddTaskAsync(listId);
var secondHandler = await AddTaskAsync(listId);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler);
Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp()
{
var listId = await CreateListAsync();
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty<string>(), handlerId);
Assert.Equal(0, affected);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`.
- [ ] **Step 3: Implement the method**
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add:
```csharp
// Links the tasks a "list handler" run processed back to the handler's own task, so the
// handler's detail pane can list them after the run. Stamped from the user's selection at
// creation time -- that way tasks the handler later cancels as duplicates stay visible.
// Unknown ids are silently skipped. Returns the number of rows actually stamped.
public async Task<int> SetHandlerTaskIdAsync(
IReadOnlyList<string> taskIds,
string handlerTaskId,
CancellationToken ct = default)
{
if (taskIds.Count == 0) return 0;
var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList();
if (ids.Count == 0) return 0;
return await _context.Tasks
.Where(t => ids.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.HandlerTaskId, handlerTaskId), ct);
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: `Passed! - Failed: 0, Passed: 4`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs
git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks"
```
---
## Task 3: Worker — stamp the selection when the handler task is created
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`)
Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList<string> taskIds` — the UI →
`IWorkerClient``WorkerHub` chain needs **no** change.
- [ ] **Step 1: Write the failing test**
Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test:
```csharp
[Fact]
public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var handlerId = await svc.CreateMergeHelperTaskAsync(
new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
using var readCtx = _db.CreateContext();
var tasks = new TaskRepository(readCtx);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId);
// The handler never links to itself.
Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"`
Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`.
- [ ] **Step 3: Stamp the selection**
In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
return handlerTask.Id;
```
with:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
// Link the selection back to this run BEFORE the session starts: the handler cancels
// duplicates in phase 1, and those still belong in the "what was this run supposed to do"
// list. Stamping later (e.g. at handoff) would lose them.
await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct);
return handlerTask.Id;
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"`
Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task"
```
---
## Task 4: Ui — HANDLER badge instead of MANUAL
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144`
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990`
- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on
/// it -- the HANDLER badge must win and MANUAL must disappear.
public class TaskRowViewModelHandlerBadgeTests
{
[Fact]
public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge()
{
var row = new TaskRowViewModel { Id = "t1" };
row.IsManual = true;
row.HandlerBaseCommit = "base123";
Assert.True(row.IsHandlerRun);
Assert.NotNull(row.HandlerBadge);
Assert.Null(row.ManualBadge);
}
[Fact]
public void PlainManualTask_StillShowsManualBadge()
{
var row = new TaskRowViewModel { Id = "t2" };
row.IsManual = true;
Assert.False(row.IsHandlerRun);
Assert.Null(row.HandlerBadge);
Assert.NotNull(row.ManualBadge);
}
[Fact]
public void UpdateFromEntity_MirrorsHandlerBaseCommit()
{
var row = new TaskRowViewModel { Id = "t3" };
row.UpdateFromEntity(new TaskEntity
{
Id = "t3",
ListId = "l1",
Title = "List handler: Alpha",
Status = TaskStatus.Idle,
IsManual = true,
HandlerBaseCommit = "base123",
CreatedAt = DateTime.UtcNow,
});
Assert.Equal("base123", row.HandlerBaseCommit);
Assert.True(row.IsHandlerRun);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`.
- [ ] **Step 3: Add the properties**
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field
declaration (`[ObservableProperty] private bool _isManual;`), add:
```csharp
// Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run
// host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL.
[ObservableProperty] private string? _handlerBaseCommit;
```
Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with:
```csharp
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
// HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and
// "MANUAL" would read as a hand-written reminder. The two badges never show together.
public bool ShowManualBadge => IsManual && !IsHandlerRun;
public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null;
```
Add a change hook next to the existing `OnIsManualChanged` partial method:
```csharp
partial void OnHandlerBaseCommitChanged(string? value)
{
OnPropertyChanged(nameof(IsHandlerRun));
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ShowManualBadge));
OnPropertyChanged(nameof(ManualBadge));
}
```
Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add:
```csharp
OnPropertyChanged(nameof(ShowManualBadge));
```
In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add:
```csharp
HandlerBaseCommit = t.HandlerBaseCommit;
```
Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line:
```csharp
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ManualBadge));
```
- [ ] **Step 4: Add the locale keys**
In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler run — see the tasks it processed in the detail pane",
```
In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich",
```
- [ ] **Step 5: Add the badge style and brush**
In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line
`<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>` add:
```xml
<SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>
```
After the existing `Border.badge.manual` style block add:
```xml
<!-- handler → peat: a "Let Claude handle it" run host, not a hand-written reminder -->
<Style Selector="Border.badge.handler">
<Setter Property="Background" Value="{DynamicResource HandlerBadgeBrush}"/>
</Style>
```
- [ ] **Step 6: Render the badge**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144):
```xml
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
```
with:
```xml
<Border Classes="badge manual" IsVisible="{Binding ShowManualBadge}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
<Border Classes="badge handler" IsVisible="{Binding IsHandlerRun}"
ToolTip.Tip="{loc:Tr tasks.handlerTip}">
<TextBlock Text="{Binding HandlerBadge}"/>
</Border>
```
Only the `IsVisible` binding changed on the manual border (`IsManual``ShowManualBadge`); the
handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`.
- [ ] **Step 7: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: `Passed! - Failed: 0, Passed: 3`.
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: `Passed! - Failed: 0` (en/de key parity).
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded` — this compiles the AXAML.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks"
```
---
## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833`
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with
/// its id, including ones the handler cancelled as duplicates.
public class DetailsIslandHandledTasksTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandHandledTasksTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_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 sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class FakeWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
}
private DetailsIslandViewModel BuildVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
}
[Fact]
public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus()
{
const string listId = "list-1";
const string handlerId = "handler-task-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = handlerId, ListId = listId, Title = "List handler: L",
Status = TaskStatus.WaitingForReview, IsManual = true,
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "done-1", ListId = listId, Title = "Merged task",
Status = TaskStatus.Done, HandlerTaskId = handlerId,
SortOrder = 0, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled",
Status = TaskStatus.Cancelled, HandlerTaskId = handlerId,
SortOrder = 1, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "unrelated-1", ListId = listId, Title = "Not part of the run",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview });
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0)
await Task.Delay(20);
Assert.Equal(2, vm.HandledTasks.Count);
Assert.True(vm.HasHandledTasks);
Assert.Equal("Merged task", vm.HandledTasks[0].Title);
Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status);
Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status);
Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1");
}
[Fact]
public async Task Bind_PlainTask_HasNoHandledTasks()
{
const string listId = "list-1";
const string taskId = "plain-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "Plain",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle });
await Task.Delay(300);
Assert.Empty(vm.HandledTasks);
Assert.False(vm.HasHandledTasks);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`.
- [ ] **Step 3: Add the collection**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line
`public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();` add:
```csharp
// Tasks a "list handler" run processed ("Let Claude handle it"), linked via
// TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the
// planning/improvement parent's children and feeds the merge card's combined diff, which a
// handler run must not touch (it commits straight to the list's working dir).
public ObservableCollection<ChildOutcomeRowViewModel> HandledTasks { get; } = new();
```
After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add:
```csharp
public bool HasHandledTasks => HandledTasks.Count > 0;
```
- [ ] **Step 4: Clear it on rebind**
In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add:
```csharp
HandledTasks.Clear();
```
and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add:
```csharp
OnPropertyChanged(nameof(HasHandledTasks));
```
- [ ] **Step 5: Load it on bind**
In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add:
```csharp
await LoadHandledTasksAsync(row.Id, ct);
```
Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`:
```csharp
// Tasks stamped with this handler run's id. Ordered like the task list itself so the panel
// reads in the same order the user picked them.
private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var handled = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Where(t => t.HandlerTaskId == handlerTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
if (handled.Count == 0) return;
HandledTasks.Clear();
foreach (var h in handled)
HandledTasks.Add(new ChildOutcomeRowViewModel
{
Id = h.Id,
Title = h.Title,
Status = h.Status,
RoadblockCount = h.RoadblockCount,
WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
});
OnPropertyChanged(nameof(HasHandledTasks));
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
}
```
- [ ] **Step 6: Keep the rows live**
In the same file, in `RefreshChildOutcomeAsync`, replace:
```csharp
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
with:
```csharp
// The same refresh serves both lists: a planning parent's children and a handler run's
// handled tasks. Only one of them can hold a given id.
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId)
?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
- [ ] **Step 7: Render the panel**
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing
`</StackPanel>` of the existing `<!-- Child outcomes -->` block, add:
```xml
<!-- Handled tasks (list handler run) -->
<StackPanel Spacing="6" IsVisible="{Binding HasHandledTasks}">
<TextBlock Classes="section-label" Text="HANDLED TASKS" />
<ItemsControl ItemsSource="{Binding HandledTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
IsVisible="{Binding HasRoadblock}"
Foreground="#E0A030"
Margin="8,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
Opacity="0.75" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
```
- [ ] **Step 8: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: `Passed! - Failed: 0, Passed: 2`.
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded`.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane"
```
---
## Task 6: Full verification and docs
**Files:**
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list)
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets)
- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section)
- [ ] **Step 1: Run every affected test project**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile,
it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test.
- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`**
In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after
`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing
`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet:
```markdown
- `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree.
```
- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`**
In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add
`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`.
In the `TaskRowViewModel` sentence, after the `IsManual` clause, add
`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`.
- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`**
In the "The host task and its commit range" section, add after the existing description:
```markdown
`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task
(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail
pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates.
The handler never links to itself.
```
Bump that note's "verified against" commit line to the current HEAD.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md
git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link"
```
- [ ] **Step 6: Report the visual-verification gap**
The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika:
1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder.
2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll).
3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,515 @@
# Feedback für langlaufende Operationen — Vorgaben für die Umsetzung
> **Dieses Dokument ist absichtlich kein Task-Skript.** Es enthält die Vorgaben pro Gruppe.
> Die konkreten Tasks schreibt der **Merge-Helper je Gruppe** beim Ausführen — er kennt dann
> den tatsächlichen Codestand. Was hier steht, ist bindend; was hier nicht steht, entscheidet
> er. Checkboxen stehen auf Paketebene, damit der Fortschritt sichtbar bleibt.
**Design:** `docs/superpowers/specs/2026-08-11-operation-feedback-design.md` — dort stehen die
Belege (Datei:Zeile) und die Begründungen. Dieses Dokument wiederholt sie nicht.
**Ziel:** Jede Operation, die länger als ~300 ms dauern kann, zeigt an, dass sie läuft, was sie
tut und wie lange sie schon läuft — über **einen** Mechanismus statt pro Fall neu gebaut.
---
## Ausführungsmodell
```
P0 (Fundament) ── muss ALLEIN und ZUERST landen
├── Gruppe A UI-Stille Merge-Helper 1
├── Gruppe B UI-Freeze Merge-Helper 2
└── Gruppe C Worker-Stille Merge-Helper 3
Gruppe D MCP-Stille Merge-Helper 4 ── unabhängig von P0, kann sofort starten
```
**Ein Merge-Helper pro Gruppe.** Nach P0 laufen A, B, C und D parallel. Jede Gruppe hält sich
strikt an ihr Datei-Eigentum — das ist die einzige Absicherung gegen gegenseitiges
Überschreiben, weil alle im gemeinsamen `main`-Checkout arbeiten.
### Datei-Eigentum (bindend)
| Gruppe | Besitzt exklusiv | Darf **nicht** anfassen |
|---|---|---|
| **P0** | `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), **beide `locales/*.json`**, `Ui/Services/WorkerClient.cs` (nur Timing-Hook) | alles andere |
| **A** | Island-VMs, Modal-VMs, deren AXAML, `Ui/Services/UpdateCheckService.cs` | `locales/*`, `WorkerClient`, `IWorkerClient`, Worker-Projekt |
| **B** | `Ui/ViewModels/Modals/DiffViewerViewModel.cs`, `Ui/Views/Controls/DiffTextView.axaml.cs` | `locales/*`, Island-VMs, Worker-Projekt |
| **C** | `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `StubWorkerClient.cs`, `Worker/Lifecycle/*Recovery.cs`, `Worker/Runner/WorktreeManager.cs`, `Worker/Worktrees/WorktreeMaintenanceService.cs` | `locales/*`, Island-VMs, `Worker/External/*` |
| **D** | `Worker/External/*McpTools.cs`, `Worker/External/ExternalMcpService.cs`, `Worker/Lifecycle/TaskMergeService.cs` (nur die Progress-Schleife) | `locales/*`, alles im Ui-Projekt |
**Locale-Regel:** Nur P0 schreibt in `en.json`/`de.json`. Braucht eine Gruppe doch einen Key,
den P0 nicht vorgesehen hat: als **letzte** Änderung des Pakets anhängen und im Merge-Helper
als bekannte Konfliktstelle behandeln. Nie mitten in der Gruppe.
---
## Vorgaben für den Merge-Helper selbst
Gilt für alle vier Gruppen:
- **Agent-Modell:** `sonnet` für Implementierer und Reviewer. Nie haiku, nie opus, nie Fable.
- **`maxTurns` explizit auf 200 setzen.** `model_presets` ist NULL, sonst bekommt ein
sonnet-Task 30 Turns und stirbt mit „exited with code 1 and no result".
- **`serializeOnFileOverlap` auf der Gruppenliste einschalten.** Innerhalb einer Gruppe
fassen mehrere Pakete dieselben Dateien an.
- **Bauen:** `dotnet build ClaudeDo.slnx` schlägt auf .NET 8 fehl. Einzelprojekte mit
`-c Release` bauen (ein laufender Worker sperrt `Debug`):
```
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
- **Testen — pro Gruppe die relevanten Projekte:**
```
A, B dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/... -c Release (nur wenn Keys berührt)
C Ui.Tests + ClaudeDo.Worker.Tests (Worker.Tests enthält auch UiVm-Tests)
D dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
P0 Ui.Tests + Localization.Tests
```
Fällt ein Test innerhalb der Verify-Kette, erst die Suite **allein** laufen lassen — die
`Ui.Tests` haben eine bekannte reihenfolgen-abhängige Flakiness, die nichts mit dem Merge zu
tun hat.
- **Nie `git add -A`.** Immer explizit nach Pfad stagen und `git commit -- <pfade>` — der
`main`-Checkout ist von parallelen Sessions geteilt, ein blankes Commit fegt fremde WIP mit.
- **Nach dem Batch-Merge `main` selbst prüfen.** Approve baut und testet nicht. Neun grüne
Branches haben `main` schon zweimal zerlegt, davon einmal durch eine Test-Kollision, die ein
reiner src-Build nicht sieht. Also: Build **und** die betroffenen Testprojekte.
- **Vor Approve prüfen, ob der Branch überhaupt etwas enthält** (`changedFileCount`). Ein
blockierter Task wird sonst `Done` mit leerem Branch.
- **Keine EF-Migration** in diesem Vorhaben. Parallele Migrationen aus Geschwister-Branches
löschen sich beim SQLite-Table-Rebuild gegenseitig die Spalten, und die Tests bemerken es
nicht (`EnsureCreated`).
- **Keine Tests, die die echte `claude`-CLI starten.**
- **Visuelle Prüfung ist nicht durch Tests ersetzbar.** Jedes Paket in A und B endet mit einer
offenen visuellen Prüfung für den Nutzer. Nie behaupten, die UI funktioniere.
---
## P0 — Fundament
- [x] **P0-1 — `OperationStatus` + `OperationIndicator` + Locale-Keys**
- [x] **P0-2 — Timing-Hook für die Messung**
- [x] **P0-3 — Regel in den CLAUDE.md-Dateien**
> **Stand 2026-08-17.** P0, A (außer A5), B, C1 und D sind gemergt; der visuelle Pass über A und B
> ist durch, ohne Fund. Nachtrag zu P0-2: der Timing-Sink liegt seit dem Release-Vorlauf hinter
> `CLAUDEDO_OP_TIMING=1` und ist **standardmäßig aus** — die 63 `InvokeTimedAsync`-Call-Sites
> bleiben bewusst stehen. Offen: C2C5.
**Vorbedingung:** keine. Muss allein landen, bevor A/B/C starten.
### Der Vertrag (bindend — 19 Pakete hängen daran)
`src/ClaudeDo.Ui/Services/OperationStatus.cs`, eine `ObservableObject`-Klasse:
| Member | Verhalten |
|---|---|
| `bool IsRunning` | **sofort** `true` bei `Begin`. Treibt `CanExecute`, verhindert Doppelklick |
| `bool ShowIndicator` | erst nach **300 ms** `true`. Kein Flackern bei schnellen Calls |
| `string? Label` | lokalisierter Text |
| `string Elapsed` | `mm:ss`, **lokal getickt** |
| `bool IsStalled` | `true` nach **60 s ohne `Report`** |
| `IDisposable Begin(string label)` | startet; `Dispose` beendet **auch im Exception-Fall** |
| `void Report(string label)` | überschreibt `Label` mid-flight, setzt die Stall-Uhr zurück |
**Nachbesserung 1 (bindend):** `IsStalled` bemisst sich an der Zeit **seit dem letzten
`Report`**, nicht an der Gesamtdauer. Ein regulär mehrminütiges Verify-Gate darf sich nicht
selbst als hängend melden. Eine Operation ohne jeden `Report` gilt nach 60 s als stalled — das
ist genau der Fall, der heute wie ein Absturz aussieht.
Benutzung im ViewModel:
```csharp
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
var result = await _worker.ApproveReviewAsync(...);
```
### Weitere Vorgaben
- **Zeitquelle injizierbar** über `TimeProvider` (in .NET 8 vorhanden). **Kein statischer
`DispatcherTimer`** — geteilter statischer State ist die Ursache der reihenfolgen-abhängigen
Flakiness in den `Ui.Tests`. Timer-Callbacks kommen vom Threadpool und müssen auf den
Dispatcher gepostet werden.
- **Mehrere `OperationStatus` pro VM sind erwünscht.** `WorktreesOverviewModalViewModel`
braucht getrennte für Refresh, Cleanup und Merge — sonst blockiert ein laufender Refresh die
Cleanup-Anzeige.
- **`OperationStatus` transportiert keine Fehler.** Fehlerbehandlung bleibt unverändert über
`ShowErrorAsync` / `ErrorReported` / `FlashFooterError`.
- **`OperationIndicator`** nutzt `Ellipse.spinner` aus `Design/IslandStyles.axaml` (14×14,
Accent, 0.9 s Rotation) plus Label und Elapsed. Ohne dieses Control wird die
Spinner-StackPanel aus `MergeModalView.axaml` acht Mal von Hand nachgebaut. Werte aus
`Tokens.axaml` verwenden, keine Inline-Zahlen.
- **Locale-Keys:** neuer Top-Level-Namespace `ops` in `en.json` und `de.json`, Parität wird von
`Localization.Tests` erzwungen. P0 legt die Keys für **A und C** vorab an — abgeleitet aus den
Paketlisten unten.
**Nachbesserung 3 (bindend):** **Gruppe D braucht keine Locale-Keys.**
MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische
Klartext-Strings.
### P0-2: Messung an genau einer Stelle
Zwei Hooks, nicht zwanzig:
1. In `WorkerClient` jeden Hub-Invoke mit Dauer loggen.
2. Im DB-Pfad der Islands dasselbe.
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. **A5 und C werten sie aus**,
statt zu raten. Bild 2 und 4 brauchen keine Messung.
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen
Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
### Definition of Done für P0
`OperationStatus` hat Unit-Tests mit einem Fake-`TimeProvider` für: 300-ms-Grace, 60-s-Stall
**ab letztem Report**, Elapsed-Formatierung, `Dispose` nach Exception, `Report` überschreibt
Label und setzt die Stall-Uhr zurück. `Localization.Tests` grün. Kein VM ist umgestellt — P0
liefert nur das Werkzeug.
### Task-Entwürfe (grob)
**P0-1 · `OperationStatus` + `OperationIndicator` + `ops`-Locale-Keys**
Baue das Primitive nach dem Vertrag oben, dazu das Anzeige-Control und den neuen
Locale-Namespace `ops` in beiden Sprachdateien. Keys für Gruppe A und C vorab anlegen, für D
keine. Zeitquelle über `TimeProvider`, kein statischer Timer.
*Dateien:* `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), `Localization/locales/{en,de}.json`, `tests/ClaudeDo.Ui.Tests/`
*Fertig wenn:* Unit-Tests für Grace, Stall-ab-Report, Elapsed, Dispose-nach-Exception, Report-Reset. Localization.Tests grün. Kein ViewModel angefasst.
**P0-2 · Timing-Hook für die Messung**
Zwei Hooks: Dauer jedes Hub-Invokes in `WorkerClient`, Dauer des DB-Pfads der Islands. Ziel
sind auswertbare Zahlen, keine Anzeige. Ausgabe so, dass sie nach einem Tag Nutzung sortierbar
ist.
*Dateien:* `Ui/Services/WorkerClient.cs`, der gemeinsame DB-Zugriffspfad der Island-VMs
*Fertig wenn:* Beide Hooks aktiv, Overhead vernachlässigbar, das Format ist dokumentiert.
**P0-3 · Feedback-Regel in den CLAUDE.md-Dateien**
`src/ClaudeDo.Ui/CLAUDE.md`: jeder `IWorkerClient`-Call in einem `[RelayCommand]` läuft durch
eine `OperationStatus`, Anzeige über `OperationIndicator`. `src/ClaudeDo.Worker/CLAUDE.md`: ein
MCP-Tool, das über ~5 s laufen kann, reportet Progress.
*Fertig wenn:* Beide Regeln stehen, jeweils mit einem Satz Begründung.
---
## Gruppe A — Bild 1: UI-Stille
- [x] **A1 — Detail-Pane** ⚠️ *der gemeldete Fall*
- [x] **A2 — WorktreesOverview**
- [x] **A3 — Settings-Tabs** — ohne OnlineInbox-SignIn, siehe Nachzügler unten
- [x] **A4 — Reports und Planning-UI** — ohne die beiden Planning-Calls im `DiffViewerViewModel`
- [x] ~~**A5 — Island-DB-Pfade nach Messdaten**~~**abgesagt** (2026-08-13): kein DB-Pfad über
500 ms (höchster p95 372 ms), die Ausreißer saßen alle auf der Hub-Seite. Wie vorgesehen
geschlossen, ohne Code.
**Nachzügler (kein Task):** drei Locale-Keys aus P0 sind ungenutzt geblieben —
`ops.onlineInbox.signingIn` (A3), `ops.planning.buildingIntegrationBranch` und
`ops.planning.loadingAggregate` (A4). Die beiden Planning-Calls liegen in
`DiffViewerViewModel.cs`, einer Datei aus Gruppe B, die A4 nicht anfassen durfte.
**Vorbedingung:** P0 gemergt.
### Vorgaben
- **A1 zuerst.** `DetailsIslandViewModel.ApproveReviewAsync` ist der gemeldete Schmerz. Dazu im
selben Paket: `SubmitForReviewAsync`, `RejectReviewAsync`, `ParkReviewAsync` und
`MergeSectionViewModel.PreviewMergeAsync`.
- **A1 abonniert `IWorkerClient.MergeProgressEvent`**, um das Label mid-flight von „merging" auf
„verifying" zu schärfen. Das Event existiert seit `cad0582`.
**Nachbesserung 2 (bindend):** A verlässt sich darauf, dass dieses Event **bestehen bleibt**.
C1 baut den generischen Kanal darunter, muss `MergeProgressEvent` aber als dünnen Forwarder
erhalten. Andernfalls müsste C `DetailsIslandViewModel` ändern — eine Datei aus Gruppe A —
und die Parallelität wäre zerstört. **A darf keinen anderen Kanal verwenden.**
- **Abo nur für die Dauer des Calls.** Der Worker broadcastet an alle Clients; ein dauerhaft
registriertes VM bekommt Events für fremde Tasks. `MergeModalViewModel` macht es richtig
vor: `+=` im `try`, `-=` im `finally`, plus `if (taskId != TaskId) return;`.
- **A2:** `IsBusy` existiert dort schon, schaltet aber nur `IsEnabled`. Die Anzeige fehlt
komplett. Betrifft Refresh, Cleanup, Reset, ForceRemove und Batch-Merge — Batch-Merge
zusätzlich mit Zeilen-Status, weil dort N Tasks sequenziell durchlaufen.
- **A3:** SessionSkill-Install ist ein `git clone` — der offensichtlichste Kandidat. Dazu
Update/Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check.
- **A4:** GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch,
GetPlanningAggregate, Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined`
existiert bereits und bleibt — nicht doppeln.
- **A5 erst nach Auswertung von P0-2.** Nur Stellen umstellen, die die Messung als Ausreißer
zeigt. Kein Umstellen auf Verdacht.
- **`DetailsIslandViewModel` ist groß.** Nicht umstrukturieren, nur die Commands anfassen.
- **Bereits saubere Flächen nicht anfassen:** `MergeModalViewModel`,
`ConflictResolverViewModel`, `DiffViewerViewModel.IsLoadingCombined`,
`UsageMonitorModalViewModel`.
### Definition of Done pro Paket
Jeder umgestellte Command: `IsRunning` während des Laufs gesetzt, `CanExecute` gesperrt,
Zustand nach einer Exception zurückgesetzt — als Test in `ClaudeDo.Ui.Tests`. Kein
handgebauter Spinner, immer `OperationIndicator`. **Visuelle Prüfung offen und explizit
benannt** (Grace-Periode und Layout kann kein Test bestätigen).
### Task-Entwürfe (grob)
**A1 · Detail-Pane: Approve, Submit, Reject, Park, Preview** ⚠️ *der gemeldete Fall*
Fünf Commands im Detail-Pane bekommen je eine `OperationStatus` und einen `OperationIndicator`
neben dem auslösenden Button. Approve abonniert zusätzlich `MergeProgressEvent` **nur für die
Dauer des Calls** und filtert auf die eigene TaskId, um das Label von „merging" auf „verifying"
zu schärfen. `DetailsIslandViewModel` nicht umstrukturieren.
*Dateien:* `Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, `MergeSectionViewModel.cs`, `Ui/Views/Islands/Detail/*`
*Fertig wenn:* Approve zeigt während des Verify-Gates Phase und Elapsed, Button ist gesperrt, Zustand nach Fehler zurückgesetzt. Visuelle Prüfung offen.
**A2 · WorktreesOverview: Anzeige für alle fünf Aktionen**
`IsBusy` existiert, schaltet aber nur `IsEnabled`. Getrennte `OperationStatus` für Refresh,
Cleanup, Reset, ForceRemove und Batch-Merge — der Batch-Merge zusätzlich mit Zeilen-Status, weil
er N Tasks sequenziell durchläuft.
*Dateien:* `Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`, `Ui/Views/Modals/WorktreesOverviewModalView.axaml`
*Fertig wenn:* Jede der fünf Aktionen zeigt sichtbar, dass sie läuft; ein laufender Refresh blockiert die Cleanup-Anzeige nicht. Visuelle Prüfung offen.
**A3 · Settings-Tabs: Skill-Install, Restore-Defaults, OnlineInbox, RepoImport, Update-Check**
Fünf Flächen, bei denen `IsBusy` heute nur den Button sperrt. Der Skill-Install ist ein
`git clone` und der offensichtlichste Kandidat.
*Dateien:* `Ui/ViewModels/Modals/Settings/*`, `Ui/ViewModels/Modals/RepoImportModalViewModel.cs`, `Ui/Services/UpdateCheckService.cs`, `Ui/Views/Modals/SettingsModalView.axaml`
*Fertig wenn:* Alle fünf zeigen Aktivität; der Skill-Install zeigt zusätzlich, dass geklont wird. Visuelle Prüfung offen.
**A4 · Reports und Planning-UI**
GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate,
Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined` existiert bereits und
bleibt unangetastet.
*Dateien:* `Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs`, Planning-Pfade in `DetailsIslandViewModel`/`TasksIslandViewModel`, zugehörige AXAML
*Fertig wenn:* Report-Generierung und Planning-Integration zeigen Aktivität; kein Doppel-Indikator im Combined-Diff. Visuelle Prüfung offen.
**A5 · Island-DB-Pfade nach Messdaten**
**Erst die Zahlen aus P0-2 auswerten**, dann nur die Ausreißer umstellen. Kandidaten:
`ClearCompleted`, Drag-Reorder über viele Zeilen, Laden großer Listen. Nichts auf Verdacht.
*Dateien:* nach Messergebnis
*Fertig wenn:* Die Auswertung ist im Task dokumentiert, und jede umgestellte Stelle ist durch eine Messung begründet. Wenn nichts über der Schwelle liegt: Task mit Begründung schließen, nichts bauen.
---
## Gruppe B — Bild 2: UI-Freeze
- [x] **B1 — `UnifiedDiffParser.Parse` auslagern**
- [x] **B2 — `DiffAlignment.Build` Grenze**
- [x] **B3 — Guard-Test gegen Dispatcher-Blockade**
**Vorbedingung:** P0 gemergt (für den Indikator in B1).
### Vorgaben
- **Reihenfolge ist zwingend: erst auslagern, dann anzeigen.** Ein Spinner auf einem
blockierten UI-Thread wird nicht gezeichnet. B1 verschiebt `UnifiedDiffParser.Parse` nach
`Task.Run` und setzt danach den Indikator.
- **`UnifiedDiffParser` ist statisch und rein** — thread-safe, `Task.Run` ist unkritisch. Das
Zurückschreiben der Ergebnisse in Observable-Collections muss auf dem UI-Thread passieren.
- **B2 braucht eine Entscheidung, die der Merge-Helper trifft:** `DiffAlignment.Build` läuft in
`DiffTextView.axaml.cs` — in einem Control, nicht in einem VM. Entweder Grenze („ab N Zeilen
auslagern") oder inkrementeller Aufbau. Die Wahl gehört ins Paket, nicht hierher; die
Vorgabe ist nur: **eine sichtbare Grenze definieren, keine unbegrenzte Synchron-Arbeit.**
- **AvaloniaEdit-Fallen** (belegt, nicht neu ausprobieren): `this.TryGetResource` in einem
Control findet Brushes aus `Tokens.axaml` **nie** und scheitert lautlos; Brushes und Typeface
nicht im Konstruktor auflösen. `ScrollToVerticalOffset` ist in v12 ein No-op — schreiben über
`ScrollViewer.Offset`, lesen über `TextView.ScrollOffsetChanged`.
- Gemeinsames Editor-Boilerplate gehört in `Views/Controls/DiffEditorSetup.cs`, nicht ein
drittes Mal kopiert.
### Definition of Done
B3 ist der einzige Test im Vorhaben, der Blockade prüft: großes Diff-Fixture, Nachweis, dass
der Dispatcher weiter Nachrichten verarbeitet. Visuelle Prüfung offen (Verhalten bei großem
Diff, Indikator während des Parsens).
### Task-Entwürfe (grob)
**B1 · `UnifiedDiffParser.Parse` vom UI-Thread nehmen**
Beide Aufrufstellen im `DiffViewerViewModel` nach `Task.Run` verschieben, danach den
`OperationIndicator` setzen. Reihenfolge ist zwingend: erst auslagern, dann anzeigen — ein
Spinner auf einem blockierten Dispatcher wird nicht gezeichnet. Das Zurückschreiben in
Observable-Collections muss auf dem UI-Thread passieren.
*Dateien:* `Ui/ViewModels/Modals/DiffViewerViewModel.cs`
*Fertig wenn:* Kein synchroner Parse mehr im VM, Indikator während des Parsens sichtbar. Visuelle Prüfung mit einem großen Diff offen.
**B2 · `DiffAlignment.Build` — Grenze gegen unbegrenzte Synchron-Arbeit**
`Build` läuft in einem Control, nicht in einem VM. Der ausführende Agent entscheidet zwischen
Auslagern ab einer Zeilenzahl und inkrementellem Aufbau — die Vorgabe ist nur: **eine
sichtbare, begründete Grenze**. AvaloniaEdit-Fallen aus dem Vorgaben-Abschnitt beachten
(`TryGetResource` scheitert lautlos, `ScrollToVerticalOffset` ist ein No-op).
*Dateien:* `Ui/Views/Controls/DiffTextView.axaml.cs`, gemeinsames Boilerplate nach `DiffEditorSetup.cs`
*Fertig wenn:* Die gewählte Grenze ist im Code kommentiert und begründet; große Diffs blockieren nicht mehr unbegrenzt. Visuelle Prüfung offen.
**B3 · Guard-Test: großer Diff blockiert den Dispatcher nicht**
Headless-Test mit großem Diff-Fixture, der nachweist, dass der Dispatcher während Parse und
Build weiter Nachrichten verarbeitet. Der einzige Blockade-Test im Vorhaben — soll auch
zukünftige Regressionen fangen.
*Dateien:* `tests/ClaudeDo.Ui.Tests/`, Fixture im Testprojekt
*Fertig wenn:* Der Test fällt gegen den Stand **vor** B1/B2 und ist danach grün.
---
## Gruppe C — Bild 3: Worker-Stille
- [x] **C1 — generischer `OperationProgress`-Kanal**
- [ ] **C2 — Startup-Recovery sichtbar**
- [ ] **C3 — Worktree-Anlage sichtbar**
- [ ] **C4 — Rebase-after-Merge und WorktreeMaintenance**
- [ ] **C5 — periodische Dienste**
> **Einzige offene Gruppe.** C2C5 sind unblockiert (C1 ist gemergt). Woran man den Reststand
> erkennt: die vier Keys `ops.worker.startupRecovery`, `.creatingWorktree`, `.rebasingAfterMerge`
> und `.maintainingWorktrees` liegen ungenutzt in beiden Sprachdateien.
**Vorbedingung:** P0 gemergt. Die Abhängigkeit zur Parallelsession ist mit `cad0582`
aufgelöst.
### Vorgaben
- **Der Kanal:**
```
OperationProgress(string opKey, string phase, int current, int total)
```
`opKey` = TaskId bei task-gebundenen Operationen, sonst ein stabiler String
(`"worktree-cleanup"`, `"startup-recovery"`, `"planning-integration:<taskId>"`).
- **Kein Elapsed auf der Leitung.** Das UI tickt lokal. Die heutige Implementierung lässt den
Worker alle 30 s ticken — damit zeigt sie die ersten 30 Sekunden nur „merging". C1 stellt das
um.
- **`MergeProgressEvent` bleibt als Forwarder** (siehe Nachbesserung 2 unter Gruppe A). Vier
Zeilen. Entfernen erst, wenn A und C beide gemergt sind — nicht in dieser Gruppe.
- **`current`/`total` wird als Text gezeigt, nie als Prozentbalken.** Die meisten Operationen
haben kein sinnvolles Total.
- **C3 zuerst prüfen, nicht bauen:**
`docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md` führt
`WorktreeManager.cs:103` als DB-Write-ohne-Broadcast. Ob das inzwischen geschlossen ist,
gehört geprüft, bevor es doppelt behoben wird.
- **C5 ist der wackeligste Punkt im Design.** Periodische Dienste (Usage, OnlineSync, Prime,
Queue) dürfen **nur Aktivität und Fehler** melden, keinen Tick-Strom. Wenn der Merge-Helper
beim Umsetzen zum Schluss kommt, dass C5 nur Rauschen erzeugt: **weglassen und begründen**,
statt es durchzuziehen.
- **Test-Fakes wachsen mit.** Ein neues Event auf `IWorkerClient`/`WorkerHub` bricht
handgeschriebene Fakes in **beiden** Testprojekten — `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
und die UiVm-Tests unter `tests/ClaudeDo.Worker.Tests/`.
- **Broadcast-Sparsamkeit:** vor einem „fehlenden Broadcast" immer den Aufrufer prüfen. Im
08-07-Spec war ein gemeldetes Loch in Wahrheit schon vom Aufrufer abgedeckt, und der Fix
wäre ein Duplikat gewesen.
### Definition of Done
Progress-Callbacks in `ClaudeDo.Worker.Tests` mit einem Fake gezählt — keine echten Timeouts,
keine echte CLI. UI-Seite: die Anzeige beim Worker-Start ersetzt „reconnecting" durch die
laufende Recovery-Phase. Visuelle Prüfung offen.
### Task-Entwürfe (grob)
**C1 · Generischer `OperationProgress`-Kanal, `MergeProgress` darauf umstellen**
Neues Hub-Event `OperationProgress(opKey, phase, current, total)`, ohne Elapsed auf der Leitung.
Der Merge-Pfad sendet darüber. **`MergeProgressEvent` bleibt als vierzeiliger Forwarder** —
Gruppe A hängt daran und darf nicht angefasst werden. Fakes in beiden Testprojekten mitziehen.
*Dateien:* `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, UiVm-Fakes in `Worker.Tests`
*Fertig wenn:* Merge-Phasen laufen über den neuen Kanal, das UI tickt Elapsed lokal, `MergeProgressEvent` funktioniert unverändert weiter, beide Testprojekte grün.
**C2 · Worker-Startup-Recovery sichtbar machen**
Die sechs Recovery-Services beim Worker-Start melden Phase und `i/n` über den Kanal
(`opKey = "startup-recovery"`). Das UI ersetzt „reconnecting" durch die laufende Phase.
*Dateien:* `Worker/Lifecycle/*Recovery.cs`, Anzeige im `IslandsShellViewModel`-Umfeld
*Fertig wenn:* Beim Worker-Start ist sichtbar, welche Recovery läuft. Visuelle Prüfung offen.
**C3 · Worktree-Anlage beim Task-Start sichtbar machen**
**Zuerst prüfen**, ob der im 08-07-Spec gemeldete Broadcast-Gap in `WorktreeManager` inzwischen
geschlossen ist — nicht doppelt beheben. Danach die stille Lücke zwischen `Queued` und erster
Ausgabe an der Task-Zeile sichtbar machen.
*Dateien:* `Worker/Runner/WorktreeManager.cs`, Task-Row-Anzeige
*Fertig wenn:* Das Prüfergebnis steht im Task; die Anlage-Phase ist an der Zeile sichtbar. Visuelle Prüfung offen.
**C4 · Rebase-after-Merge und WorktreeMaintenance in den Kanal**
`RebaseOthersAfterMergeAsync` läuft heute innerhalb des Merge-Calls **nach** dem
Phasen-Broadcast und ist damit vollständig unsichtbar. Dazu der Hintergrunddienst über alle
Worktrees, mit `i/n`.
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs` (Rebase-Abschnitt), `Worker/Worktrees/WorktreeMaintenanceService.cs`
*Fertig wenn:* Beide melden Fortschritt; ein Merge zeigt nach dem Verify-Gate die Rebase-Phase statt Stille.
**C5 · Periodische Dienste — optional, Abbruch erlaubt**
Usage, OnlineSync, Prime, Queue sollen **nur Aktivität und Fehler** melden, keinen Tick-Strom.
Der wackeligste Punkt im Design: kommt der ausführende Agent zum Schluss, dass das nur
Anzeige-Rauschen erzeugt, **weglassen und begründen** statt durchziehen.
*Dateien:* `Worker/Usage/`, `Worker/Online/`, `Worker/Prime/`, `Worker/Queue/`
*Fertig wenn:* Entweder sparsame Meldungen ohne Rauschen — oder eine begründete Absage im Task.
---
## Gruppe D — Bild 4: MCP-Stille
- [x] **D1 — `ProgressReporter` extrahieren**
- [x] **D2 — die `batch_*`-Tools** — es sind **sieben**, nicht acht (die Achterzahl unten war ein
Zählfehler beim Schreiben des Plans)
- [x] **D3 — Worktree- und Diff-Tools** — drei davon; `batch_cleanup_task_worktrees` lief über D2
- [x] **D4 — Rest und Doku-Regel** — fand zwei echte Lücken, die D1D3 offen gelassen hatten:
`continue_merge` reichte sein Progress-Token nicht ins Post-Merge-Verify-Gate, und der
Unit-Merge-Drain im `PlanningMergeOrchestrator` verlor es nach dem ersten Tick. Dazu
`list_worktrees`.
**Vorbedingung:** keine. Kann sofort starten, parallel zu P0.
### Vorgaben
- **Warum das zählt:** ohne Progress bricht der MCP-Client bei Stille nach 300 s ab, während
der Worker weiterarbeitet. Das ist die dokumentierte Ursache dafür, dass ein Merge zwar
durchläuft, den Task aber nie auf `Done` bringt und Abhängige dauerhaft blockiert.
- **D1:** `TaskMergeService.RunReportingProgressAsync` ist die einzige existierende
Progress-Schleife. Als eigenständige Klasse extrahieren, plus ein Overload für
Element-Fortschritt (`i/n`). Nicht kopieren — extrahieren, damit es eine Implementierung
bleibt.
- **`TaskMergeService` ist die einzige Datei, die D mit C teilt.** D fasst dort ausschließlich
die Progress-Schleife an. Wenn beide Gruppen gleichzeitig laufen, ist das die Stelle, die der
Merge-Helper im Auge behalten muss.
- **Keine Locale-Keys** (Nachbesserung 3). Englische Klartext-Strings.
- **Zielumfang:** die 7 `batch_*`-Tools mit `i/n`, dann `cleanup_task_worktree`,
`batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`. Bereits versorgt und
nicht anzufassen: die 5 Tools in `ExternalMcpService` mit `IProgress`, `TaskWaitMcpTools`,
`merge_task`/`review_task`.
- **D4 schreibt die Regel ins Worker-`CLAUDE.md`:** ein MCP-Tool, das über ~5 s laufen kann,
reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten Tool.
### Definition of Done
Für jedes umgestellte Tool ein Test in `ClaudeDo.Worker.Tests`, der mit einem Fake-`IProgress`
zählt, dass Meldungen kommen — bei `batch_*` mindestens eine pro Element. Keine visuelle
Prüfung nötig, diese Gruppe hat keine UI.
### Task-Entwürfe (grob)
**D1 · `ProgressReporter` extrahieren**
`TaskMergeService.RunReportingProgressAsync` als eigenständige Klasse herauslösen, plus einen
Overload für Element-Fortschritt (`i/n`). **Extrahieren, nicht kopieren** — es soll eine
Implementierung bleiben. Dies ist die einzige Datei, die D mit C teilt; nur die Progress-Schleife
anfassen.
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs`, neue Klasse im Worker-Projekt
*Fertig wenn:* Der Merge-Pfad nutzt die extrahierte Klasse, Verhalten unverändert, bestehende Merge-Tests grün.
**D2 · Die sieben `batch_*`-Tools mit Element-Fortschritt**
Jedes `batch_*`-Tool reportet pro verarbeitetem Element. Grund: ohne Meldung bricht der
MCP-Client nach 300 s Stille ab, während der Worker weiterläuft — genau der Mechanismus, der
einen Merge durchlaufen lässt, den Task aber nie auf `Done` bringt.
*Dateien:* `Worker/External/BatchMcpTools.cs`
*Fertig wenn:* Pro Tool ein Test mit Fake-`IProgress`, der mindestens eine Meldung je Element zählt. Englische Klartext-Strings, keine Locale-Keys.
**D3 · Worktree- und Diff-Tools mit Progress**
`cleanup_task_worktree`, `batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`.
Bereits versorgt und **nicht** anzufassen: die fünf Tools mit `IProgress` in
`ExternalMcpService`, `TaskWaitMcpTools`, `merge_task`, `review_task`.
*Dateien:* die betroffenen `Worker/External/*McpTools.cs`, `ExternalMcpService.cs`
*Fertig wenn:* Jedes der vier Tools meldet Fortschritt, mit Test.
**D4 · Restliche Long-Runner und die Regel im Worker-`CLAUDE.md`**
Verbleibende Tools durchgehen, die über ~5 s laufen können, und die Regel festschreiben: ein
MCP-Tool über ~5 s reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten
Tool.
*Dateien:* restliche `Worker/External/*McpTools.cs`, `src/ClaudeDo.Worker/CLAUDE.md`
*Fertig wenn:* Die Regel steht mit Begründung; die durchgegangenen Tools sind im Task aufgelistet — auch die, die bewusst nichts bekommen.
---
## Was nach allen vier Gruppen offen bleibt
- ~~**Visuelle Prüfung** für alle Pakete in A und B~~**am 2026-08-17 durch**, ohne Fund
(Worktrees-Übersicht, Settings/Skill-Install, WeeklyReport, großer Diff, Detail-Pane-Approve).
- ~~**Auswertung von P0-2**~~ — am 2026-08-13 ausgewertet, siehe A5.
- **`MergeProgressEvent`-Forwarder entfernen**, sobald A und C beide gemergt sind. Ein
Aufräum-Task, kein Paket.
- **Bewusst nicht gebaut:** Cancel, Footer-Anzeige für laufende Operationen, Prozentbalken,
Änderungen am Installer (der hat bereits eine vollständige Progress-Pipeline).
@@ -0,0 +1,123 @@
# Waiting for Review — Task State — Design
**Date:** 2026-06-01
**Status:** Approved (brainstorming)
**Scope:** `ClaudeDo.Data` (TaskEntity, EF config + migration), `ClaudeDo.Worker` (TaskStateService, TaskRunner, QueueService, WorkerHub, ExternalMcpService), `ClaudeDo.Ui` (StatusColorConverter, TaskRowViewModel, views), CLAUDE.md docs
## Problem
A successful task run currently transitions straight to `Done` and is considered complete. There is no gate for a human (or another agent) to review the result before it is accepted. We want review to be a mandatory step: after a successful run a task waits for an explicit approval, and a reviewer can send it back with feedback for another turn.
## Goals
- Add a `WaitingForReview` lifecycle state that a task enters automatically after a **successful** run.
- Reviewer can **approve** (→ `Done`), **reject-and-re-run** (→ `Queued`, resuming the same Claude session with required feedback), **reject-and-park** (→ `Idle`), or **cancel** (→ `Cancelled`).
- Reject-and-re-run reuses the existing session-resume mechanism so the agent continues with full context.
- Both the desktop UI and the external MCP surface can perform review actions.
## Non-Goals
- No change to the failure path: a **failed** run still goes straight to `Failed`, never to `WaitingForReview`.
- No change to planning-phase finalization. A planning parent that generates child tasks keeps its current behavior and does **not** route through review. Only ordinary executable runs (`Running` → success) are affected.
- No change to worktree state flow (`Active | Merged | Discarded | Kept`).
- No change to the in-run auto-retry-on-failure behavior; only the *final* successful completion routes to review.
## Design
### 1. State machine
Changed/added transitions in **bold**:
| From | To | Trigger |
|---|---|---|
| Idle | Queued | enqueue (unchanged) |
| Queued | Running | queue picker claim (unchanged) |
| Running | **WaitingForReview** | **successful run (was → Done)** |
| Running | Failed | failed run (unchanged) |
| Running | Cancelled | cancel during run (unchanged) |
| **WaitingForReview** | **Done** | **approve** |
| **WaitingForReview** | **Queued** | **reject + required feedback → resume re-run** |
| **WaitingForReview** | **Idle** | **reject → park for manual edit** |
| **WaitingForReview** | **Cancelled** | **abandon an almost-done task** |
| Done \| Failed \| Cancelled | Idle | reset (unchanged) |
### 2. Data model
`ClaudeDo.Data`:
- `TaskStatus` enum (`Models/TaskEntity.cs`): add `WaitingForReview` after `Running`.
- EF string converter (`Configuration/TaskEntityConfiguration.cs`): map `WaitingForReview``"waiting_for_review"` (TEXT column, no schema constraint to change).
- New nullable column **`ReviewFeedback : string?`** on `TaskEntity`. Holds the reviewer's rejection comment until the re-run consumes it, then it is cleared. Persisted so it survives a worker restart and is visible to the UI.
- One EF migration: add the `review_feedback` column. No backfill — the new status value and column are only written going forward.
### 3. Worker — status transitions (`State/TaskStateService.cs`)
`TaskStateService` remains the sole owner of status writes. New/changed methods:
- `SubmitForReviewAsync(taskId)``Running``WaitingForReview`. Sets `FinishedAt` and `Result` exactly as `CompleteAsync` does today. Called by `TaskRunner` on success **instead of** `CompleteAsync`. (`CompleteAsync` is retained for the approve path.)
- `ApproveReviewAsync(taskId)``WaitingForReview``Done`.
- `RejectToQueueAsync(taskId, feedback)``WaitingForReview``Queued`. Rejects empty/whitespace feedback with a failed `TransitionResult`. Stores `feedback` in `ReviewFeedback`. Wakes the queue.
- `RejectToIdleAsync(taskId)``WaitingForReview``Idle`. Parks for manual editing; leaves `Result` intact, clears `ReviewFeedback`.
- `CancelAsync` — extend the allowed source states to include `WaitingForReview`.
Each transition broadcasts `TaskUpdated` as today. Invalid source states return a failed `TransitionResult` (no throw), matching existing convention.
### 4. Resume-aware re-run (`Queue/QueueService.cs`)
The queue picker still atomically claims a `Queued`, unblocked task (`UPDATE … SET status='running' … RETURNING *`). The `RETURNING` row already carries `ReviewFeedback`. After a successful claim, `QueueService` branches:
1. **`ReviewFeedback` set + latest run has a `SessionId`** → `TaskRunner.ContinueAsync(task, feedback)``--resume {sessionId}` with `feedback` as the next-turn prompt.
2. **`ReviewFeedback` set, no prior `SessionId`** (edge case) → `TaskRunner.RunAsync` with the feedback appended to the task prompt, so the comment is not lost.
3. **No `ReviewFeedback`** → normal `TaskRunner.RunAsync` (fresh session).
`ReviewFeedback` is cleared once consumed (single UPDATE), so a later re-run does not re-apply stale feedback.
### 5. External MCP surface (`External/ExternalMcpService.cs`)
- New tool **`review_task(taskId, decision, feedback?)`**, `decision ∈ {approve, reject_rerun, reject_park, cancel}`. `feedback` is required when `decision = reject_rerun` (validation error otherwise). Maps onto the `TaskStateService` methods in §3. This lets automation / other agents act as reviewers.
- `get_task_status_values` — add `WaitingForReview` with a description covering the four exit actions.
- `list_tasks` status-filter parsing and validation message — include `WaitingForReview`.
- `get_task` lifecycle description text — update to `Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled`.
- `update_task_status` stays restricted to `Idle` and `Queued`; all review decisions go through `review_task` (keeps the "set status freely" affordance and the review affordance distinct).
### 6. Worker hub (`Hub/WorkerHub.cs` + `Hub/HubBroadcaster.cs`)
New hub methods called by the UI, each delegating to `TaskStateService`:
- `ApproveReview(taskId)`
- `RejectReviewToQueue(taskId, feedback)`
- `RejectReviewToIdle(taskId)`
Cancel already exists. No new broadcast events — `TaskUpdated` covers it.
### 7. UI (`ClaudeDo.Ui`)
- `Converters/StatusColorConverter.cs`: add a `waiting_for_review` case. Snap to an existing color token from the scale; final visual pass is left to the user (per project convention — centralize/tokenize, user does the visual pass).
- `ViewModels/Islands/TaskRowViewModel.cs`: add `IsWaitingForReview` computed property and commands **Approve**, **RejectRerun**, **RejectPark**, **Cancel** (the last reuses the existing cancel command). Commands are enabled only when `Status == WaitingForReview`.
- Reject-Rerun opens a small flyout/dialog with a required multi-line feedback text box; on confirm it calls `RejectReviewToQueue(taskId, feedback)`.
- Wire the commands to the new SignalR client methods.
### 8. Docs
Update the status flow in:
- root `CLAUDE.md` — "Task status flow" line.
- `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity status list.
- `src/ClaudeDo.Worker/CLAUDE.md` — status-model transition table.
## Testing
`ClaudeDo.Worker.Tests` (real SQLite + real git, existing harness):
- `SubmitForReviewAsync`: a successful run lands in `WaitingForReview`, not `Done`.
- `ApproveReviewAsync`: `WaitingForReview``Done`.
- `RejectToQueueAsync`: empty feedback rejected; valid feedback stored in `ReviewFeedback` and status → `Queued`.
- `RejectToIdleAsync`: → `Idle`, `Result` preserved, `ReviewFeedback` cleared.
- `CancelAsync` from `WaitingForReview``Cancelled`.
- Invalid source states (e.g. approve from `Idle`) return a failed `TransitionResult`.
- Resume-aware re-run: a task with `ReviewFeedback` + a prior `SessionId`, when claimed, resumes the session with the feedback as the prompt and clears `ReviewFeedback`.
- `review_task` MCP tool: each decision maps to the correct transition; `reject_rerun` without feedback errors.
## Open questions
None outstanding. Planning-task exclusion (Non-Goals) is the one assumption to verify against the planning-finalization code path during implementation; if planning finalization shares `CompleteAsync`, route only the executable-run success site through `SubmitForReviewAsync`.
@@ -0,0 +1,116 @@
# Prime: recurring weekday schedule
**Date:** 2026-06-02
**Status:** Approved
## Problem
The Prime feature fires a single non-interactive "ping" prompt to warm up the
Claude usage window. Today a schedule is defined by a **date range**
(`StartDate`/`EndDate`) plus a `TimeOfDay` and a single `WorkdaysOnly` toggle.
This is awkward for the real use case: the user wants a *recurring* morning ping
on specific weekdays, not a bounded calendar window.
Desired behavior: pick the **days of the week** (e.g. MonFri) and a **time**.
The schedule recurs forever. Whenever the worker is running and it is one of the
selected days, the ping fires at (or shortly after) the chosen time. Concretely:
the worker autostarts on login, detects it is an eligible day around the target
time, and fires the ping.
## Decisions
- **Catch-up window:** unchanged. Keep the existing 30-minute catch-up — if the
worker boots within 30 min after the target time, the ping fires immediately;
otherwise it waits for the next eligible day. (User chose "keep current 30 min".)
- **Day picker UI:** seven compact **toggle buttons** in one row (Mo Tu We Th Fr
Sa Su), highlighted when selected — not labeled checkboxes.
## Design
### 1. Data model
`PrimeScheduleEntity` (`ClaudeDo.Data/Models`):
- **Remove:** `StartDate`, `EndDate`, `WorkdaysOnly`
- **Add:** `Days` — a `[Flags] enum PrimeDays` (`Monday=1, Tuesday=2, Wednesday=4,
Thursday=8, Friday=16, Saturday=32, Sunday=64`), stored as a single
`days_of_week INTEGER` column.
- **Keep:** `TimeOfDay`, `Enabled`, `LastRunAt`, `PromptOverride`, `CreatedAt`.
Rationale for a bitmask over a CSV string or 7 bool columns: one column, trivial
EF mapping (int), and a clean eligibility check.
`PrimeScheduleEntityConfiguration`: drop the `start_date`/`end_date`/
`workdays_only` property mappings; map `Days` to `days_of_week` (int, required,
default 31 = MonFri).
### 2. Scheduling logic — `NextDueCalculator`
- Drop all `StartDate`/`EndDate` gating (the `EndDate < today` early-out, the
`StartDate > today` clamps, and the bounds check in `IsEligibleDay`).
- `IsEligibleDay(s, d)` becomes: does `s.Days` contain the flag for
`d.DayOfWeek`? (Map `System.DayOfWeek``PrimeDays`.)
- The existing forward search (loops up to 8 days ahead) now simply walks to the
next selected weekday.
- `alreadyFiredToday` (compares `LastRunAt`'s local date to today) is unchanged.
- The 30-min catch-up (`FireImmediately`) is unchanged.
- A schedule with `Days == 0` (none selected) is never eligible. UI validation
prevents saving that state.
### 3. UI — `SettingsModalView.axaml` + `PrimeScheduleRowViewModel`
Row template changes:
- **Remove** the `ThemedDatePicker` (range) and the single "MonFri" checkbox.
- **Add** a horizontal row of 7 `ToggleButton`s (Mo Tu We Th Fr Sa Su), styled
to highlight when checked, bound to seven bool properties on the row VM.
- Keep the enabled checkbox, the time `TextBox`, the last-run label, and the
remove button.
`PrimeScheduleRowViewModel`:
- Replace `StartDate`/`EndDate`/`WorkdaysOnly` with seven `[ObservableProperty]`
bools: `Monday``Sunday`.
- Constructor decomposes `dto.Days` into the seven bools.
- `ToDto()` composes the seven bools back into the `Days` int.
`PrimeClaudeTabViewModel`:
- `AddSchedule` default: MonFri selected, time 07:00, enabled.
- `Validate`: replace the `StartDate > EndDate` check with "at least one day must
be selected"; keep the time-range (00:0023:59) check.
Update the explainer `TextBlock` text to describe weekday recurrence (keep the
"fires immediately if started within 30 minutes of the target time" note).
### 4. Migration
New EF Core migration in `ClaudeDo.Data/Migrations`:
- Add `days_of_week INTEGER NOT NULL DEFAULT 31`.
- Backfill from existing rows: `workdays_only = 1``31` (MonFri),
`workdays_only = 0``127` (all 7 days).
- Drop `start_date`, `end_date`, `workdays_only`.
- Update the model snapshot.
### 5. DTOs
Both copies of `PrimeScheduleDto` (Worker `ClaudeDo.Worker.Prime` and UI
`ClaudeDo.Ui.Services`) are passed over SignalR and must stay structurally
compatible. In both: remove `StartDate`, `EndDate`, `WorkdaysOnly`; add a single
`int Days` field (serializes cleanly as JSON; avoids sharing the enum across
projects). `PrimeScheduler.ToDto` maps `entity.Days``(int)`.
`PrimeScheduleRepository`: update `UpsertAsync` (copy `Days` instead of the three
removed fields) and `ListAsync` ordering (order by `TimeOfDay` instead of
`StartDate`).
### 6. Tests
- `NextDueCalculatorTests` — rewrite cases around weekday sets (e.g. MonFri
skips weekend; single-day schedule; catch-up still fires; already-fired-today
skips to next eligible day).
- `PrimeSchedulerTests` — update fixture DTOs to the new shape.
- `PrimeScheduleRepositoryTests` — update entity construction and assertions.
- `PrimeClaudeTabViewModelTests` — update for the day-bool VM and new validation.
## Out of scope
- Per-schedule catch-up tuning (rejected; fixed 30 min).
- Multiple times per day, timezones, or holiday calendars.
@@ -0,0 +1,182 @@
# Daily Prep ("Prime Claude") — Design
Date: 2026-06-03
## Overview
Turn the existing Prime Time warm-up into a **daily preparation** ("Tagesvorbereitung").
At a scheduled time (or on demand), Claude reads the open tasks, estimates effort,
and selects a focused subset into the MyDay list — capped so it never moves
everything in. Claude does the reasoning itself (agentic), via the already-registered
ClaudeDo MCP. This replaces the current `"ping"` behavior entirely.
A later phase will feed external tickets (Jira, possibly a second system) into the
same candidate pool; that is out of scope for this spec.
## Goals
- Scheduled and manual ("Tag vorbereiten" button) daily prep.
- Claude picks a subset of open tasks into MyDay, ordered so related tasks sit together.
- Effort-aware selection, hard-capped at `X` open MyDay tasks.
- Keep existing MyDay tasks across re-runs; only top up to `X`.
- Candidates limited to tasks in repos that are **not** excluded from the weekly report.
## Non-Goals
- External ticket integration (Jira etc.) — future phase.
- Group labels/headers in the MyDay view — grouping is ordering-only via `SortOrder`.
- A user-editable prep prompt — the prompt is fixed, parameterized.
## Key Decisions
| Topic | Decision |
| --- | --- |
| Who reasons | Agentic — Claude decides via MCP tools. |
| MyDay model | `TaskEntity.IsMyDay` flag (smart list `smart:my-day`). |
| Grouping | Ordering only via existing `SortOrder` (no new field, no migration for grouping). |
| Selection | Effort estimate, hard cap `X` tasks/day. |
| Candidates | `Status == Idle`, `BlockedByTaskId == null`, list `WorkingDir` not under `ReportExcludedPaths`. |
| Re-run | Keep existing MyDay tasks; top up to `X`. |
| Trigger | Existing Prime schedule **and** a manual button. |
| Ping | Removed — daily prep replaces it. |
| Prompt | Fixed, with injected parameters (`X`, today's date). |
| Tool access | Reuse the globally registered `claudedo` MCP — **no** separate `--mcp-config`. |
## Architecture
### 1. MCP tools (extend `ExternalMcpService`, port 47822)
The worker already exposes `ExternalMcpService` as the `claudedo` MCP server. Add two tools;
they automatically surface as `mcp__claudedo__get_daily_prep_candidates` and
`mcp__claudedo__set_my_day`.
- **`get_daily_prep_candidates()`** → JSON containing:
- `candidates[]`: open, non-blocked tasks in non-excluded repos, each with
`id, title, description, listName, isStarred, scheduledFor, age` (age derived from `CreatedAt`).
- `currentMyDay[]`: currently-`IsMyDay` open tasks (so Claude sees remaining capacity).
- Filter: `Status == Idle` AND `BlockedByTaskId == null` AND the task's list `WorkingDir`
does not start with any prefix in `AppSettings.ReportExcludedPaths`
(default `["C:\\Private"]`; case-insensitive prefix match, same semantics as the weekly report).
- **`set_my_day(taskId, isMyDay, sortOrder?)`** →
- Sets `IsMyDay` and (optionally) `SortOrder` on the task via `TaskRepository`.
- Broadcasts `TaskUpdated` via `HubBroadcaster` so the UI updates live.
- **Cap-guard:** when `isMyDay == true`, count current open (`Idle`) tasks with
`IsMyDay == true`. If `count >= X`, reject with an error message
("MyDay limit {X} reached"). `isMyDay == false` is always allowed.
`X = AppSettings.DailyPrepMaxTasks`. This guarantees the "never move everything in"
invariant server-side, independent of Claude's behavior.
### 2. `DailyPrepRunner` (replaces ping logic)
Rename `IPrimeRunner`/`PrimeRunner``IDailyPrepRunner`/`DailyPrepRunner` (the `"ping"`
concept is gone). It:
- Loads `AppSettings` (`X = DailyPrepMaxTasks`).
- Builds the fixed prompt with injected parameters (`X`, today's date).
- Invokes `claude -p --output-format stream-json --verbose` with:
- `--permission-mode` set so the headless run won't block on permission prompts,
- `--allowedTools mcp__claudedo__get_daily_prep_candidates mcp__claudedo__set_my_day`,
- `--max-turns 30` (constant), timeout 5 min (constant; larger than the old 60s ping).
- **No `--mcp-config`** — relies on the globally registered `claudedo` MCP (the worker runs
as the user via the per-user logon Scheduled Task, so the headless run inherits the
user-scope registration and its auth).
- Returns an outcome (e.g. number of tasks added) for broadcasting.
### 3. Scheduler
`PrimeScheduler` is unchanged in structure — it now calls `IDailyPrepRunner` instead of the
ping runner. `NextDueCalculator` and the schedule model are untouched.
### 4. Manual trigger
- Worker hub method `RunDailyPrepNow()` invokes the same `DailyPrepRunner`.
- UI button **"Tag vorbereiten"** in the MyDay list header.
- **Single-flight guard:** if a prep run is already in progress, the trigger reports
"already running" and does not start a parallel run (applies to both schedule and button).
### 5. Parameter config
- New field **`DailyPrepMaxTasks`** (int, default `5`) on `AppSettingsEntity`.
- Plumbing: EF config + migration, `AppSettingsRepository`, `WorkerHub` AppSettings DTO,
UI DTO mirror + `WorkerClient`, and a numeric editor in the Prime Claude settings tab.
- `ReportExcludedPaths` is reused as-is (already on `AppSettings`).
## Data Flow
1. Trigger (schedule due **or** button) → `DailyPrepRunner.RunAsync`.
2. Runner loads `AppSettings` (`X`), builds prompt, launches Claude.
3. Claude → `get_daily_prep_candidates` → DB query returns filtered candidates + current MyDay.
4. Claude estimates effort, tops up to **X total**, calls `set_my_day(id, true, sortOrder)`
for each chosen task (consecutive `sortOrder` for related tasks).
5. `ExternalMcpService` writes `IsMyDay`/`SortOrder`, broadcasts `TaskUpdated` → MyDay list
updates live.
6. Runner updates `LastRunAt`, broadcasts "prep done" (count added).
## Fixed Prompt (parameterized)
Content (parameters in `{}`):
> Du bereitest meinen Arbeitstag für **{today}** vor.
> 1. Rufe `get_daily_prep_candidates` auf.
> 2. Behalte bereits als MyDay markierte offene Tasks.
> 3. Fülle bis **maximal {X} offene Tasks gesamt** in MyDay auf — niemals mehr.
> 4. Schätze pro Task grob den Aufwand; wähle eine machbare Mischung (nicht nur Großbrocken).
> Priorisiere `isStarred`, fällige (`scheduledFor`) und ältere Tasks.
> 5. Lege thematisch verwandte Tasks durch aufeinanderfolgende `sortOrder`-Werte nebeneinander.
> 6. Setze die Auswahl via `set_my_day(id, true, sortOrder)`. Markiere nichts außerhalb der
> Kandidatenliste.
Injected parameters: `{today}` (date) and `{X}` (= `DailyPrepMaxTasks`).
## Error Handling
- No candidates → Claude marks nothing; runner reports "0 added".
- Claude run fails / times out → log + failure broadcast (existing scheduler event channel);
`LastRunAt` is set on attempt, as today, to avoid tight retry loops.
- `set_my_day` on an invalid/ineligible id → tool returns an error string; Claude adapts.
- Cap exceeded → tool returns an error; Claude stops adding.
- Concurrent trigger → single-flight guard reports "already running".
## Testing
Real SQLite + real git (project convention).
- `get_daily_prep_candidates`: only `Idle`; blocked excluded; tasks in excluded repos
(`ReportExcludedPaths`) excluded; current MyDay tasks included.
- `set_my_day`: sets flag + `SortOrder`; broadcasts `TaskUpdated`; cap-guard rejects at limit;
unset always allowed.
- `DailyPrepRunner`: prompt contains `{X}` + date; args contain `--allowedTools` +
permission-mode + `--max-turns`; success/failure outcomes via an `IClaudeProcess` fake.
- Rename `IPrimeRunner``IDailyPrepRunner` requires syncing `PrimeScheduler` tests/fakes.
## Files to Create / Modify (high level)
**Data**
- `Models/AppSettingsEntity.cs` — add `DailyPrepMaxTasks`.
- `Configuration/AppSettingsEntityConfiguration.cs` — map new column.
- `Migrations/` — new migration for `daily_prep_max_tasks`.
- `Repositories/AppSettingsRepository.cs` — persist new field.
**Worker**
- `External/ExternalMcpService.cs` — add `get_daily_prep_candidates`, `set_my_day` (+ cap-guard).
- `Prime/PrimeRunner.cs``DailyPrepRunner.cs`; `Prime/Interfaces/IPrimeRunner.cs`
`IDailyPrepRunner.cs`; prompt builder + arg builder.
- `Prime/PrimeScheduler.cs` — depend on `IDailyPrepRunner`.
- `Hub/WorkerHub.cs` — AppSettings DTO field; `RunDailyPrepNow()`.
- `Program.cs` — DI registration update.
**UI**
- `Services/WorkerClient.cs` + AppSettings DTO mirror — new field; `RunDailyPrepNow` call.
- Prime Claude settings tab VM/view — numeric editor for `DailyPrepMaxTasks`.
- MyDay list header — "Tag vorbereiten" button + command (Lists/IslandsShell VM).
**Tests**
- `ClaudeDo.Worker.Tests` — MCP tools, runner, scheduler fakes.
- `ClaudeDo.Data.Tests` — AppSettings persistence (if covered there).
- `ClaudeDo.Ui.Tests` — settings VM / button wiring as applicable.
## Future Phase (out of scope)
External ticket sources (Jira, possibly a second system) feed into the candidate pool used by
`get_daily_prep_candidates`, behind a task-source abstraction. Designed separately.
@@ -0,0 +1,151 @@
# Daily Prep — Live Output View + Clear Day — Design
Date: 2026-06-03
## Overview
Two follow-ups to the daily-prep ("Prime Claude") feature:
1. **Live output view.** While Claude prepares the day, there is no feedback. Add a
live, human-readable view of the prep run's output, shown as a new content mode in
the existing right-hand **Details island** (mirroring how Daily Notes works — a mode
swap, not a separate window/column).
2. **Clear Day button.** A MyDay-header button that clears the MyDay selection
immediately.
## Goals
- See the prep run's progress live, rendered with the same friendly terminal renderer
used for task runs (assistant text + tool calls like `set_my_day …`, not raw NDJSON).
- Both manual (button) and scheduled prep runs stream into the log.
- The manual button opens the prep view; a scheduled run fills the log silently and is
opened via a dedicated "Vorbereitungs-Log" button (the existing `PrimeStatus` footer
remains the hint that a run happened).
- A "Tag leeren" button clears all MyDay tasks (any status) with no confirmation.
## Non-Goals
- No new island/column and no popup/overlay — reuse the Details island as a mode swap.
- No persistence of prep output across app restarts (in-memory log only).
- No undo for Clear Day (re-runnable via "Tag vorbereiten").
## Key Decisions
| Topic | Decision |
| --- | --- |
| Rendering | Reuse the existing `SessionTerminalView` / `StreamLineFormatter` renderer. |
| Location | New `IsPrepMode` content panel inside the Details island (like `IsNotesMode`). |
| Lifecycle | Manual click opens the view (UI-local); `PrepStarted/PrepLine/PrepFinished` events fill the log regardless of current mode; scheduled runs do not auto-open. |
| Open after schedule | Dedicated "Vorbereitungs-Log" header button + existing `PrimeStatus` footer hint. |
| Clear Day scope | All MyDay tasks regardless of status. |
| Clear Day confirm | None — clear directly. |
## Architecture
### Feature A — Live prep output
**Worker**
- Extend `IPrimeBroadcaster` (`src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`)
with `PrepStartedAsync()`, `PrepLineAsync(string line)`, `PrepFinishedAsync(bool success)`.
- Implement in `HubBroadcaster` (`src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`) sending
SignalR events `PrepStarted`, `PrepLine` (string), `PrepFinished` (bool).
- `PrimeRunner` (`src/ClaudeDo.Worker/Prime/PrimeRunner.cs`): inject `IPrimeBroadcaster`.
In `FireAsync`, after the single-flight gate is entered and a run will actually happen:
call `PrepStartedAsync()` before `RunAsync`; replace the discard lambda with
`async line => await _broadcaster.PrepLineAsync(line)`; call
`PrepFinishedAsync(result.IsSuccess)` after. The "already running" early-return path
emits nothing (no run occurs). Both scheduled and manual runs go through `FireAsync`,
so both stream.
**UI**
- `WorkerClient` (`src/ClaudeDo.Ui/Services/WorkerClient.cs`): register
`_hub.On<…>("PrepStarted"/"PrepLine"/"PrepFinished", …)` each via
`Dispatcher.UIThread.Post`, raising `PrepStartedEvent` / `PrepLineEvent(string)` /
`PrepFinishedEvent(bool)`. Declare these on `IWorkerClient`.
- `DetailsIslandViewModel`: add `IsPrepMode` (bool), `IsPrepRunning` (bool), a dedicated
`PrepLog` (`ObservableCollection<LogLineViewModel>`), and `ShowPrep()` (calls
`Bind(null)`, sets `IsNotesMode=false`, `IsPrepMode=true`). Subscribe to the three prep
events in the ctor (always active, independent of mode):
- `PrepStarted` → clear `PrepLog`, `IsPrepRunning=true`.
- `PrepLine` → format the line with the same `StreamLineFormatter` path used by the
stdout branch of `OnTaskMessage`, append a `LogLineViewModel` to `PrepLog`.
- `PrepFinished``IsPrepRunning=false` (optionally append a status line).
Mode exclusivity: the normal task-details panel becomes visible on
`!IsNotesMode && !IsPrepMode`; `ShowNotes()` also sets `IsPrepMode=false`; `Bind(task)`
resets both flags.
- `DetailsIslandView.axaml`: add a third `<Panel IsVisible="{Binding IsPrepMode}">` in the
body grid alongside the existing details/notes panels, rendering `PrepLog` in the
terminal style (reuse the `LogLineViewModel` item template used by `SessionTerminalView`).
**Wiring**
- `TasksIslandViewModel`: add a `PrepRequested` event (mirror `NotesRequested`).
`PrepareDayCommand` raises `PrepRequested` in addition to calling
`RunDailyPrepNowAsync()`. Add `ShowPrepLogCommand` that raises `PrepRequested`. Add the
"Vorbereitungs-Log" button to the MyDay header (`IsVisible="{Binding IsMyDayList}"`).
- `IslandsShellViewModel`: wire `Tasks.PrepRequested += () => Details.ShowPrep()`.
### Feature B — Clear Day
**Worker**
- `WorkerHub.ClearMyDay()` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs`): query ids where
`IsMyDay == true`; `ExecuteUpdateAsync` setting `is_my_day = false`; broadcast
`TaskUpdated(id)` for each affected id (the UI reloads the current list on `TaskUpdated`).
**UI**
- `IWorkerClient.ClearMyDayAsync()` + `WorkerClient` impl invoking `"ClearMyDay"`.
- `TasksIslandViewModel.ClearDayCommand` calls `_worker.ClearMyDayAsync()` (no confirm).
Add the "Tag leeren" button to the MyDay header next to "Tag vorbereiten".
## Data Flow (live view)
1. Trigger (schedule or button) → `PrimeRunner.FireAsync`.
2. `PrepStartedAsync()` → SignalR `PrepStarted``WorkerClient.PrepStartedEvent`
`DetailsIslandViewModel` clears `PrepLog`, sets `IsPrepRunning`.
3. Each Claude stdout line → `PrepLineAsync(line)``PrepLine` → formatted, appended to
`PrepLog` (visible if the user is in prep mode; filled silently otherwise).
4. Run ends → `PrepFinishedAsync(success)``PrepFinished``IsPrepRunning=false`.
5. Manual button click also raised `PrepRequested``Details.ShowPrep()` (view open).
After a scheduled run, the user clicks "Vorbereitungs-Log" to open it.
## Error Handling
- Prep run fails/times out → `PrepFinished(false)`; the existing `PrimeFired` footer
status still reports failure.
- "Already running" → no prep events emitted (no run happened); existing behavior intact.
- `ClearMyDay` with zero MyDay tasks → no-op, no broadcasts.
## Testing
- Worker: `PrimeRunner` streams `PrepStarted` → N×`PrepLine``PrepFinished` (fake
`IClaudeProcess` invokes `onStdoutLine` with sample lines; fake `IPrimeBroadcaster`
records calls). `WorkerHub.ClearMyDay` clears all IsMyDay rows and broadcasts per id
(real SQLite, mirror existing hub tests).
- UI: `DetailsIslandViewModel` appends to `PrepLog` on `PrepLineEvent` and `ShowPrep()`
sets the mode flags (mutual exclusivity with notes); `TasksIslandViewModel.ClearDayCommand`
calls `ClearMyDayAsync` (stub worker client).
## Files (high level)
**Modify**
- `src/ClaudeDo.Worker/Prime/Interfaces/IPrimeBroadcaster.cs`
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- `src/ClaudeDo.Worker/Prime/PrimeRunner.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` (ClearMyDay)
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- `src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml`
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- `src/ClaudeDo.Localization/locales/en.json`, `de.json` (button labels)
**Test**
- `tests/ClaudeDo.Worker.Tests/Prime/PrimeRunnerTests.cs`
- `tests/ClaudeDo.Worker.Tests/Hub/…` (ClearMyDay)
- `tests/ClaudeDo.Ui.Tests/…` (DetailsIslandViewModel prep events; TasksIslandViewModel ClearDay) + `StubWorkerClient`
## Known fragility
Changing `IWorkerClient` / `WorkerClient` / VM constructors breaks hand-rolled fakes
(`StubWorkerClient`, `FakeWorkerClient`) in both test projects — update all of them.
@@ -0,0 +1,114 @@
# Localization (i18n) Support — Design
**Date:** 2026-06-03
**Status:** Approved (pending spec review)
## Goal
Add translation support to ClaudeDo. The user picks a language in the Settings modal and **all** UI text reflects it instantly (no restart). The WPF installer is localized the same way and gets its own language picker. Ship **English only** now, but the system is fully data-driven: adding a new language means dropping one JSON file into a folder — **no code changes, no rebuild**.
## Decisions (from brainstorming)
- **Languages:** English only at launch; extensible via translation files.
- **Switching:** Live / instant — all bound UI text updates the moment the language changes.
- **Storage:** Selected language stored in `~/.todo-app/ui.config.json` (the local UI config that also holds `DbPath`/`SignalRUrl`). Purely a UI concern — does **not** go through the worker/SignalR settings path.
- **Installer:** Defaults to existing config language (upgrade) → OS culture → English. Shows a language picker in the wizard, live-switches its own UI, and writes the chosen language into `ui.config.json` so the app launches matching the installer.
- **Locale files:** Loose `*.json` files in a `locales/` folder next to the running exe, scanned at startup to discover available languages.
- **Code sharing:** A shared `ClaudeDo.Localization` project holds the loading/lookup/language-list logic, referenced by `ClaudeDo.Ui`, `ClaudeDo.App`, and `ClaudeDo.Installer`. Each UI framework keeps its own thin markup-extension binding layer (Avalonia ≠ WPF).
## Architecture & Components
### New shared project: `ClaudeDo.Localization`
- **`LocaleStore`** — discovers and loads `*.json` files from the `locales/` folder next to the running exe. Parses each file's nested JSON, **flattens it into an internal `Dictionary<string,string>`** keyed by dot-path for O(1) lookup, and captures `metadata.code` / `metadata.name`. Exposes the list of available languages for the dropdowns.
- **`ILocalizer` / `Localizer`** — singleton holding the *active* language dictionary. Members:
- indexer `this[string key]` → translated string (with fallback),
- `string Get(string key, params object[] args)``string.Format` for parameterized strings,
- `void SetLanguage(string code)` → swaps the active dictionary and raises `PropertyChanged` for the indexer so **all live bindings refresh** (this is what enables instant switching),
- `AvailableLanguages` (list of `{ code, name }`), `CurrentCode`.
- **Fallback chain:** requested key in active language → same key in English → the key path string itself (a missing translation is visible, never a crash).
- **OS-culture resolution:** helper that maps the current OS UI culture to an available locale code, falling back to English.
### Per-framework binding layer (not shared)
- **Avalonia:** a `{loc:Tr Some.Key}` markup extension that binds to `Localizer[key]` (Source = the singleton `Localizer`, Path = `[key]`). Language change raises the indexer `PropertyChanged`, refreshing every binding.
- **WPF installer:** an equivalent markup extension doing the same against the installer's own `Localizer` instance.
Both consume the **same JSON files and the same `LocaleStore`/`Localizer` logic** from the shared project.
## Translation File Format
`locales/en.json` (and future `de.json`, `fr.json`, …) — nested, human-friendly hierarchy:
```json
{
"metadata": { "code": "en", "name": "English" },
"settings": {
"save": "Save",
"cancel": "Cancel",
"general": { "model": "Model", "maxParallel": "Max parallel executions" }
},
"tasks": {
"addPlaceholder": "Add a task…",
"overdue": "OVERDUE"
},
"worktrees": { "autoCleanupDays": "{0} days" }
}
```
- `metadata.code` is the language id stored in `ui.config.json` and matched to OS culture; `metadata.name` is the dropdown label.
- **Lookup by dot-path key** (`"settings.general.model"`). On-disk file stays grouped/nested; the runtime flattens it for fast lookup. Authors edit a clean hierarchy.
- **Parameters:** `{0}`, `{1}` placeholders resolved via `Get(key, args)`.
- **Encoding:** UTF-8 — non-ASCII languages work out of the box.
## Data Flow & Wiring
### App config
- Add `Language` (string, e.g. `"en"`) to `AppSettings` (`ClaudeDo.Ui/AppSettings.cs`) and to the installer mirror `InstallerAppSettings` (`ClaudeDo.Installer/Core/ConfigModels.cs`).
- Add a `Save()` method to `AppSettings` (today the UI only reads it).
### App startup (`ClaudeDo.App/Program.cs`)
1. `AppSettings.Load()` reads `Language` (missing/empty → resolve from OS culture, else `"en"`).
2. `LocaleStore` scans `locales/` next to the exe; `Localizer` is registered as a singleton and set to the configured language.
3. UI renders; every `{loc:Tr ...}` binding pulls from the active dictionary.
### Changing language in Settings (General tab)
- New "Language" dropdown bound to `Localizer.AvailableLanguages`; selection bound to current code.
- On change → `Localizer.SetLanguage(code)` (instant UI refresh) **and** `AppSettings.Language = code; AppSettings.Save()`. Local UI state only — not routed through worker/SignalR.
### Installer (`ClaudeDo.Installer`)
- On launch: default language = existing `ui.config.json` `Language` if present (upgrade), else OS culture, else English.
- Wizard gets a language dropdown (same `LocaleStore`, installer's own markup extension) → live-switches the installer UI.
- When writing `ui.config.json`, persists the chosen `Language` so the app launches matching the installer.
### Build wiring
- `locales/*.json` copied to output (`CopyToOutputDirectory`) for both App and Installer.
- Installer packages the `locales/` folder so it lands beside the installed exe.
## String-Extraction Scope
Mechanical but large; done screen-by-screen so each commit is reviewable, building one `en.json` as the single source of truth.
- **22 Avalonia `.axaml` views** — replace inline `Text="..."`, `Content="..."`, `PlaceholderText="..."`, and inline `ComboBoxItem` text with `{loc:Tr key}`.
- **ViewModel strings** — user-facing literals built in C# (e.g. `HeaderTitle`, `StatusPill`, status text, parameterized messages) resolve via injected `ILocalizer` (`localizer.Get(...)`). Log messages and non-user-facing strings stay as-is. **Live-switch note:** a VM string resolved once will not refresh on language change. For VM-built user-facing text, either (a) prefer resolving in XAML via `{loc:Tr}` where possible, or (b) have the VM subscribe to the `Localizer` change event and re-raise `PropertyChanged` (or re-resolve) for its localized properties. Decide per-property during extraction.
- **10 WPF installer files** — same treatment with the installer's markup extension; VM-driven headings (`Heading`, `NextButtonText`, etc.) go through `ILocalizer`.
- **Enum-ish display values** (model names, permission modes, weekday names) — translate the *display* text while keeping the underlying value/binding intact.
## Testing
- `ClaudeDo.Localization` unit tests: load/flatten nested JSON, dot-path lookup, fallback chain (active→en→key), `{0}` formatting, OS-culture resolution.
- `LocaleStore` discovery test (folder scan → available languages).
- **Key-coverage test:** every locale file's flattened key set matches `en.json`; fails the build if `en.json` drifts from other locale files.
- Settings round-trip test: `SetLanguage` updates `Localizer` **and** persists to `ui.config.json`.
- Manual UI pass (user's visual review): confirm instant switching with a throwaway `de.json` stub during dev, then remove it.
## Out of Scope (YAGNI)
- Pluralization rules, RTL layout, per-string gender.
- Translating the German weekly-report **body** (generated content — stays as-is).
- Localizing log output and non-user-facing strings.
@@ -0,0 +1,226 @@
# Weekly Report — Design
**Date:** 2026-06-03
**Status:** Approved (pending spec review)
## Goal
Generate a short, standup-focused report of what the user did over the past week,
for the Wednesday standup. The report is built from the user's Claude Code session
history across all repos, distilled and summarized by Claude. Personal repos under a
configurable excluded path (default `C:\Private`) are left out. The user can author
per-day bullet notes inside ClaudeDo (via the My Day list) that are folded into the
report.
## Decisions (from brainstorming)
- **Data source:** all Claude Code history in `~/.claude/projects/*/*.jsonl`, both manual
sessions and ClaudeDo-run tasks, grouped by repo.
- **Exclusion:** a configurable list of path prefixes (default `["C:\\Private"]`). Any
session whose `cwd` starts with an excluded prefix is dropped.
- **Summarization:** Claude CLI summarizes. The Worker distills the logs, then runs a
single one-shot `claude -p` call via the existing `ClaudeProcess` and returns the
result markdown. No worktree, no task row, no queue.
- **Period:** default "since last Wednesday → today", computed from a configurable
standup weekday. The range is adjustable in the modal.
- **Signal fed to Claude:** user prompts (intent), assistant closing summaries, and the
user's daily notes. No git-commit scanning.
- **Report shape:** German, grouped by day, first-person past-tense bullets, ~3-5
bullets/day with trivia merged/dropped, notes blended into one deduplicated list per
day. See the Report Prompt section.
- **Placement:** a "Weekly Report" overlay modal opened from the toolbar, rendering via
the existing `MarkdownView`.
- **Output:** view-only in-app (no export).
- **Notes UI:** authored in the My Day list via a pinned non-task "Notes" pseudo-row that
repurposes the Details island into a bullet-notes editor. Per-day bullets with a day
navigator (prev/next arrows + date picker + Today).
- **Report persistence:** generated reports are stored, keyed by exact date range, and
reused. Generation is button-driven (never automatic); a Regenerate button overwrites.
## Architecture Overview
```
UI (WeeklyReportModal, Details-island notes mode)
│ SignalR
WorkerHub ── GetWeekReport / GenerateWeekReport / daily-notes CRUD
├── WeekReportService ──► ClaudeHistoryReader (scan ~/.claude/projects)
│ │ (distilled activity)
│ ├── DailyNoteRepository (notes in window)
│ ├── ClaudeProcess (one-shot summarize)
│ └── WeekReportRepository (store/reuse)
└── DailyNoteRepository (CRUD)
Data: DailyNoteEntity, WeekReportEntity + repositories + EF migration
AppSettingsEntity: ReportExcludedPaths, StandupWeekday
```
## Components
### 1. Data layer (`ClaudeDo.Data`)
**`DailyNoteEntity`** (table `daily_notes`)
- `Id` (GUID string, init-only PK)
- `Date` (date-only; the day the bullet belongs to)
- `Text` (string, the bullet content)
- `SortOrder` (int; ordering within a day)
- `CreatedAt` (DateTime)
**`DailyNoteRepository`** (async, CancellationToken, follows existing repo pattern)
- `ListByDayAsync(DateOnly day)` — bullets for one day, ordered by `SortOrder`.
- `ListBetweenAsync(DateOnly start, DateOnly end)` — bullets in a window (used by the report).
- `AddAsync(DateOnly day, string text)` — appends a bullet (assigns next `SortOrder`).
- `UpdateAsync(string id, string text)`
- `DeleteAsync(string id)`
**`WeekReportEntity`** (table `week_reports`)
- `Id` (GUID string, init-only PK)
- `StartDate`, `EndDate` (date-only; the report window — unique together)
- `Markdown` (string; the generated report)
- `GeneratedAt` (DateTime)
**`WeekReportRepository`**
- `GetByRangeAsync(DateOnly start, DateOnly end)` — stored report for an exact range, or null.
- `UpsertAsync(DateOnly start, DateOnly end, string markdown)` — insert or overwrite by range.
**`AppSettingsEntity`** — two new columns:
- `ReportExcludedPaths` (string, JSON array of path prefixes; default `["C:\\Private"]`)
- `StandupWeekday` (int, `DayOfWeek`; default `Wednesday` = 3)
**Migration** — one EF migration adds `daily_notes`, `week_reports`, and the two
`app_settings` columns. Entity configs in `Configuration/` (date-only and enum/JSON
conversion via `ValueConverter`, per existing convention).
### 2. Worker (`ClaudeDo.Worker`) — new `Report/` folder
**`ClaudeHistoryReader`** (raw → distilled)
- Input: date window + excluded path prefixes.
- Enumerates `~/.claude/projects/*/*.jsonl`.
- Parses each line as JSON; tolerant of malformed lines (skip, never throw).
- Drops a session entirely if its `cwd` starts with any excluded prefix
(case-insensitive, normalized separators).
- Keeps messages whose `timestamp` falls in `[start, end]`.
- Extracts, per repo (`cwd`) → per day:
- **user prompts**: `type == "user"` text content (string or `content[].text`).
Skip tool-result-only user turns and queue/attachment/hook noise.
- **assistant closing summaries**: the final assistant text block of each turn/session.
- Output: a structured model, e.g.
`IReadOnlyList<RepoActivity>` where `RepoActivity { RepoPath, Days: List<DayActivity{ Date, Prompts[], Summaries[] }> }`.
**`WeekReportService`** (distilled → stored summary)
- `GenerateAsync(start, end, ct)`:
1. Read settings (excluded paths, standup weekday).
2. `ClaudeHistoryReader` → distilled activity.
3. `DailyNoteRepository.ListBetweenAsync` → notes grouped by day.
4. Pivot the distilled activity (repo→day from the reader) into **day-major**
(day→repo) to match the day-grouped report, and build the prompt from the
template in the Report Prompt section. Empty window → produce a "no activity"
report without calling Claude.
5. Run `ClaudeProcess` once (`claude -p`, no worktree/agents; working dir = a neutral
dir). Read `RunResult.ResultMarkdown`.
6. `WeekReportRepository.UpsertAsync(start, end, markdown)`; return markdown.
7. On Claude failure, surface `RunResult.ErrorMarkdown` to the caller (do not store).
- `GetStoredAsync(start, end)``WeekReportRepository.GetByRangeAsync`.
Interfaces live in `Report/Interfaces/` per the area convention.
#### Report Prompt
`WeekReportService` assembles this prompt. Instructions are in English (more reliable
steering); the output is forced to German. `{...}` are filled at build time.
```
You are generating a concise weekly standup report for a software developer.
Summarize what they accomplished between {start:dd.MM.yyyy} and {end:dd.MM.yyyy}.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
activity (German weekday names). Omit days with no activity entirely.
- Within each day: 35 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The developer's notes are authoritative — never omit or
contradict their substance.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
== Activity (from session history) ==
{day-major: for each day → for each repo → its prompts + closing summaries}
== Developer notes ==
{day-major: for each day → the bullets}
```
### 3. IPC (Hub + WorkerClient)
**`WorkerHub`** new methods:
- `GetWeekReport(string startIso, string endIso)` → stored markdown or null.
- `GenerateWeekReport(string startIso, string endIso)` → generates, stores, returns markdown.
- `GetDailyNotes(string dayIso)` → bullets for a day.
- `AddDailyNote(string dayIso, string text)` → created bullet.
- `UpdateDailyNote(string id, string text)`.
- `DeleteDailyNote(string id)`.
**`WorkerClient`** (UI) mirrors these, following the existing
`WorkerPrimeScheduleApi`/AppSettings method pattern.
### 4. UI (`ClaudeDo.Ui`)
**Weekly Report modal** (`WeeklyReportModalView` + `WeeklyReportModalViewModel`)
- Overlay modal in the `Modals/` pattern (like `WorktreesOverviewModalView`),
registered in `IslandsShellViewModel`, opened from a new toolbar button.
- Date range: two `ThemedDatePicker`s, default "since last Wednesday → today" computed
from `StandupWeekday`.
- On open and on range change: call `GetWeekReport`.
- Stored report exists → render markdown via `MarkdownView`, show `GeneratedAt`, show
a **Regenerate** button.
- None → empty state ("Not generated yet") + a **Generate** button.
- **Generate**/**Regenerate**: call `GenerateWeekReport` with a busy/spinner state;
render the returned markdown. Generation only ever runs from these buttons.
- View-only; no export.
**Notes in My Day**
- The My Day smart list (`smart:my-day`) pins a fixed, non-task "Notes" pseudo-row at
the top, recognized by the list/selection code (not a `TaskEntity`).
- Selecting it puts the **Details island** into **notes mode** (task fields hidden,
notes editor shown). The island hosts a dedicated `NotesEditorViewModel` + small view
rather than swelling `DetailsIslandViewModel` (already ~978 lines); the bullet logic
stays isolated and testable.
- **Day navigator** in the editor header: `<` / `>` arrows to step days, a
`ThemedDatePicker` to jump to any date, and a "Today" button. Defaults to today; the
pinned row's default day rolls over at midnight (no data lost — past days remain
reachable via the navigator).
- **Bullet editing** for the selected day: list of bullets with add / inline-edit /
delete / reorder (`SortOrder`). Each operation goes through the daily-notes hub CRUD.
### 5. Settings
- Add the excluded-path list and the standup weekday to the existing Settings modal,
persisted via the new `app_settings` columns and the existing
`GetAppSettings`/`UpdateAppSettings` path.
## Error Handling
- Malformed/unreadable JSONL lines are skipped, never fatal.
- Empty window → a "no activity" report, no Claude call.
- Claude call failure → error surfaced in the modal; nothing stored.
- Date ranges normalized to date-only; the stored report key is the exact (start, end).
## Testing
- **`ClaudeHistoryReader`** (Worker tests, fixture `.jsonl`): date-window filtering,
excluded-prefix dropping (case/separator normalization), prompt/summary extraction,
malformed-line tolerance, repo/day grouping.
- **`WeekReportService`**: prompt-building from distilled activity + notes; empty-window
short-circuit; storage upsert; with a faked `ClaudeProcess`.
- **`DailyNoteRepository`** and **`WeekReportRepository`**: CRUD / upsert / range lookup
against real SQLite (matches existing test style).
## Out of Scope
- Report export (clipboard/file) — view-only for now.
- Git-commit scanning.
- Editing or summarizing full transcripts; only prompts + closing summaries are used.
@@ -0,0 +1,173 @@
# Approve = Merge → Done, plus Conflict Preview — Design
**Date:** 2026-06-04
**Status:** Approved (autonomous — user on break, authorized to continue)
**Author:** brainstormed from issue "Make merge/diff real"
## Problem
Approving a `WaitingForReview` task flips it straight to `Done`
(`TaskStateService.ApproveReviewAsync`) and **never merges** its worktree — the
worktree stays `Active`. The user approved three component tasks expecting them
to merge; none did. Separately, there is **no way to see whether a task's
worktree merges cleanly** before acting, and a standalone task has no direct
**Merge** button (single-task merge is only reachable from inside the Diff
modal).
What is already real (verified): `WorkerHub.MergeTask → TaskMergeService.MergeAsync`
performs a real `git merge --no-ff`, aborts on conflict, and marks the worktree
`Merged`. **Open Diff** opens a real in-app diff. **Merge All Subtasks**
(planning) is real. So the gaps are narrow.
## Scope decisions (autonomous)
- **Tab location:** keep the **single "Session" tab** that the recent commit
`ac9bae9` deliberately consolidated. All new controls go in its existing
`MERGE & WORKTREE` block (`WorkConsole.axaml:196`). Do **not** re-introduce a
separate "Actions" tab.
- **Approve target:** Approve merges into the UI-selected merge target
(`SelectedMergeTarget`); when blank, the worker resolves to the repo's current
branch.
- **On conflict:** task stays in `WaitingForReview` (no new status). The conflict
is surfaced inline. No automatic state change to a "blocked" status.
- **Worktree removal on approve:** do **not** remove — merge marks the worktree
`Merged` and existing auto-cleanup handles disposal (matches the single-task
merge default `removeWorktree:false`).
- **Applies to:** standalone leaf tasks with an active worktree. A
`WaitingForReview` task with **no** active worktree (e.g. ran in a sandbox, or
an improvement parent whose children own the worktrees) is just marked `Done`
— current behavior preserved. Planning parents keep "Merge All Subtasks".
## Acceptance (restated)
1. Approve a clean-merging task → worktree merged into target, worktree `Merged`,
task `Done`.
2. Approve a conflicting task → task **not** `Done`, conflict surfaced.
3. Opening a Done/WaitingForReview task shows clean/conflict status **without
mutating** the tree (use `git merge-tree`, not a real merge).
## Architecture
Three layers, each single-purpose; the only new cross-dependency is
`TaskMergeService → ITaskStateService` (one-way; verify no DI cycle).
### 1. GitService — non-destructive conflict probe (`ClaudeDo.Data`)
New method:
```csharp
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
public async Task<MergePreview> PreviewMergeAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
```
- Runs `git merge-tree --write-tree --name-only <target> <source>` from `repoDir`.
`merge-tree` computes the merge base itself and writes only loose objects — it
does **not** touch the working tree, index, or refs.
- Exit code `0``Clean = true`, no conflict files.
- Exit code `1``Clean = false`; conflicted paths are the lines after the
first (tree-OID) line, up to the first blank line.
- Any other outcome (e.g. git too old → "unknown option") → `Supported = false`
(UI shows "mergeability unknown").
New helper for the "· N files" count (clean case):
`git diff --name-only <target>...<source>` (three-dot = changes on source since
the merge base); count non-empty lines. May reuse/extend existing diff helpers.
### 2. TaskMergeService — preview + approve orchestration (`ClaudeDo.Worker`)
Inject `ITaskStateService` (verify `PlanningChainCoordinator` has no back-edge to
`TaskMergeService`; if a cycle exists, fall back to orchestrating in the hub).
```csharp
public sealed record MergePreviewResult(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
// Status: "clean" | "conflict" | "unavailable"
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct);
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct);
```
**PreviewAsync:** load context. If no active worktree → `"unavailable"`. Resolve
`targetBranch` (blank → current branch). Call `GitService.PreviewMergeAsync`; map
`Supported=false``"unavailable"`, else clean/conflict (+ ChangedFileCount on
clean).
**ApproveAndMergeAsync:** load context; require `task.Status == WaitingForReview`
(else `Blocked`). Resolve target (blank → current branch).
- **No active worktree**`_state.ApproveReviewAsync(taskId)` → return
`MergeResult(StatusMerged, [], null)` ("approved, nothing to merge").
- **Active worktree** → `MergeAsync(taskId, target, removeWorktree:false,
"Merge {branch}", ct)`. On `StatusMerged` → `_state.ApproveReviewAsync(taskId)`
then return the merged result. On `StatusConflict`/`StatusBlocked` → return as-is;
**do not** flip status (task stays `WaitingForReview`).
`TaskStateService.ApproveReviewAsync` is unchanged (still the sole Status writer;
still runs `OnChildTerminalAsync`).
### 3. WorkerHub — signatures (`ClaudeDo.Worker`)
```csharp
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch); // new
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch); // CHANGED: was void(taskId)
```
`ApproveReview` returns the orchestration result so the UI can react to conflicts.
`MergeTask` / `GetMergeTargets` unchanged.
### 4. UI (`ClaudeDo.Ui`)
`IWorkerClient` (+ `WorkerClient` + **both test-project fakes** — see memory:
changing `IWorkerClient` breaks hand-rolled fakes):
- Change `Task ApproveReviewAsync(string)``Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)`.
- Add `Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)`.
- Add `Task<MergeResultDto> MergeTaskAsync(...)` to the **interface** (already on
the concrete client) so the single-task Merge button can use `_worker`.
`DetailsIslandViewModel`:
- **Load merge targets whenever a worktree exists.** In `BindAsync`, when
`entity.Worktree != null` and the task is not a planning parent, call
`GetMergeTargetsAsync(taskId)` and set `SelectedMergeTarget = DefaultBranch`
(fixes the standalone-task gap where targets were never loaded).
- **Mergeability indicator** properties: `MergePreviewText` (string),
`MergeIsClean` / `MergeIsConflict` (bool, for color). Compute via
`PreviewMergeAsync` when the merge section is shown for an **Active** worktree;
recompute on `SelectedMergeTarget` change. If worktree state is
`Merged/Discarded/Kept`, show that label instead of probing. Text examples:
"Merges cleanly · 7 files" / "Conflicts in a.cs, b.cs" / "Mergeability unknown".
- **Approve** (`ApproveReviewAsync`): pass `SelectedMergeTarget ?? ""`; inspect
result — on `"conflict"` set the conflict indicator + a short notice
("Approve blocked — resolve conflicts first"); success path relies on the
existing `TaskUpdated` broadcast to refresh.
- **Single-task Merge** (`MergeCommand`): `MergeTaskAsync(taskId,
SelectedMergeTarget ?? "", removeWorktree:false, "Merge task")`; on `"conflict"`
show the conflict indicator. Shown for non-planning tasks with an active
worktree (planning parents keep "Merge All Subtasks").
`WorkConsole.axaml` (Session tab, `MERGE & WORKTREE` block):
- Add a status line above the button row bound to `MergePreviewText`, colored
green (`MossBrush`) when `MergeIsClean`, red (`BloodBrush`) when
`MergeIsConflict`, muted otherwise. Use existing tokens/classes only.
- Add a **Merge** button (`MergeCommand`) beside **Open Diff** for the
single-task path.
## Testing (git-backed, no real Claude)
In `ClaudeDo.Worker.Tests` (real temp git repos + real SQLite), and/or
`ClaudeDo.Data.Tests` for the pure git probe:
- `GitService.PreviewMergeAsync`: clean branches → `Clean=true`; a real
edit-conflict on the same lines → `Clean=false` with the expected file in
`ConflictFiles`.
- `ApproveAndMergeAsync`: clean worktree → returns `merged`, task is `Done`,
worktree state `Merged`. Conflicting worktree → returns `conflict`, task still
`WaitingForReview`, worktree still `Active`, target branch unmodified
(HEAD unchanged, no `MERGE_HEAD`).
- No-worktree `WaitingForReview` task → returns `merged`, task `Done`.
## Out of scope
External difftools, new task statuses, auto-removing worktrees on approve,
re-splitting the console into separate tabs, conflict resolution UI (the existing
`ContinueMerge`/`AbortMerge` paths remain as-is for mid-merge cases).
@@ -0,0 +1,236 @@
# Bundled Prompts Overhaul — Design
Date: 2026-06-04
## Goal
Replace ClaudeDo's bundled prompts with a clean, professional baseline and make
every prose prompt a user-editable file with a bundled default. Add a roadblock
protocol so an autonomous run can flag problems mid-task without aborting.
The execution-side defaults (`system.md`) ship as a moderate, **project-agnostic**
engineering baseline — ClaudeDo users run tasks against their *own* repos, so no
ClaudeDo-specific rules belong there. Everything is in English (tighter
tokenization, more reliable instruction-following); the only German output is the
weekly report, which a human reads.
## File layout
All prompts live under `~/.todo-app/prompts/` as editable files with bundled
defaults seeded by `PromptFiles.EnsureExists` (which never overwrites a file the
user already has). The `system` + `agent` prompts collapse into one `system.md`;
the old `agent`/manual distinction was removed when tags were retired.
| File | Replaces | Placeholders |
|---|---|---|
| `system.md` | system + agent (merged) | — |
| `planning-system.md` | planning system prompt | — |
| `planning-initial.md` | "analyze & break down" kickoff | `{title}`, `{description}` |
| `retry.md` | "try again and fix" prompt | — |
| `daily-prep.md` | daily-prep prompt | `{date}`, `{maxTasks}` |
| `weekly-report.md` | weekly-report instructions | `{start}`, `{end}` |
The task-execution prompt (title + description + `## Sub-Tasks` checkboxes) stays
assembled in code — it is data-shaped, not prose.
### Templating
`PromptFiles` gains `Render(PromptKind kind, IReadOnlyDictionary<string,string> values)`
that replaces **only** the known named tokens for that kind. Any other `{...}` in
the file (e.g. the literal `{Wochentag}` / `{dd.MM.yyyy}` in the German report
rules) passes through untouched. Daily-prep tool names are inlined as literals —
`--allowedTools` already carries the real names, and inlining keeps the file from
silently breaking if a user edits a placeholder.
### Migration
`EnsureExists` keeps its current semantics: it seeds a default only when the file
is missing, never overwriting user edits. The old `planning.md` and `agent.md`
become inert — `TaskRunner` stops reading `agent.md`, and the planning system
prompt now reads `planning-system.md`. Old files are harmless to leave or delete.
`PromptKind` changes: `Agent` is removed; `Planning` maps to `planning-system.md`;
new kinds `PlanningInitial`, `Retry`, `DailyPrep`, `WeeklyReport` are added.
## Roadblock protocol
An autonomous run has no human watching, so it must not silently stop or block on
a question. Instead the agent emits an inline marker whenever it hits a true
blocker, **any number of times**, and keeps working on whatever it still can.
- **Prompt side** (`system.md`): instruct the agent to write
`CLAUDEDO_BLOCKED: <one short sentence>` on its own line whenever something
genuinely prevents progress (missing credentials, contradictory requirements, a
destructive action it won't take unasked) — then continue with the rest of the
task. Reserved for true blockers, not routine decisions it can make itself.
- **Detection** (`StreamAnalyzer`): as `assistant` messages stream, scan their
text content for lines matching `^CLAUDEDO_BLOCKED:` and collect each reason
into an ordered list (`Blocks`). This is live and cumulative — multiple problems
across one run are all captured, not just the last.
- **Result wiring** (`StreamResult``RunResult` → run record): carry the
collected `Blocks`. Strip the marker lines from the displayed result text.
- **Routing**: a run that finishes with blocks still goes to `WaitingForReview`
(standalone tasks) — it is "done as far as the agent could get". The review card
shows a ⚠ roadblock hint listing the collected problems. The user answers them
via the existing reject-rerun feedback path, which resumes the session with the
answers as the next-turn prompt — so the agent continues with the problems
resolved rather than restarting.
## The prompts
### `system.md`
```markdown
# Working Agreement
You are completing one well-defined task autonomously in a git repository.
## Scope
- Do exactly what the task asks — no unrequested refactors, renames, dependency
changes, or "while I'm here" cleanup.
- If intent is ambiguous, state the assumption you're making and proceed with the
most reasonable reading. Stop only if you genuinely cannot move forward.
- Prefer three similar lines over a premature abstraction. Don't build for
hypothetical future needs.
## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase —
they override generic defaults.
- Prefer editing existing files to creating new ones. Don't write comments that
just restate the code.
- Validate only at real boundaries (user input, external APIs).
## Finishing
- Before claiming done, verify: run the build and relevant tests, confirm they
pass, and report what you ran. If you couldn't verify something, say so plainly.
- Make focused commits using the repository's existing commit-message convention.
## Safety
- Never force-push, hard-reset, or delete branches/files beyond the task's scope
without being asked.
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
## You are running unattended
You run autonomously with no human watching. There is no one to answer mid-task
questions, so never stop to ask — make the most reasonable decision, note the
assumption, and continue.
## When you are blocked
If something genuinely prevents you from completing part of the task (missing
credentials, contradictory requirements, a destructive action you won't take
unasked), do NOT silently give up. Write this marker on its own line, then keep
working on whatever else you can:
CLAUDEDO_BLOCKED: <one short sentence describing what blocked you>
Emit it as many times as needed — once per distinct blocker. Use it only for true
blockers, not for routine decisions you can make yourself.
```
> `system.md` also gains an **"Out-of-scope improvements"** section that tells the
> agent to file follow-up work via the `SuggestImprovement` tool. That section is
> defined in `2026-06-04-child-tasks-and-improvement-loop-design.md` and lands with
> that feature.
### `planning-system.md`
```markdown
You are the planning assistant for ClaudeDo. Your job is to break a task into
smaller, independently executable subtasks — the session ends by creating those
subtasks.
Start every session by invoking the `superpowers:brainstorming` skill (Skill
tool) and follow it end to end: clarifying questions one at a time, then 23
approaches with a recommendation, then a short design. Do not create any subtasks
until the user has approved the design.
You can ONLY shape this task's plan — you cannot edit files or touch other tasks.
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear
done-state, ordered so dependencies come first.
```
### `planning-initial.md`
```markdown
# Task to plan: {title}
{description}
```
### `retry.md`
```markdown
The task did not complete on the previous attempt — you may have run out of
turns, hit an error, or stopped before finishing.
Review the work already done in this session and the current state of the
repository, identify what is still incomplete or broken, and finish the task.
Don't restart from scratch or repeat a failed approach. Verify the result
(build + tests) before you stop.
```
Self-contained — no error injection. The runner appends the captured process
output **only when it is a genuine error** (i.e. not the generic
`"Claude exited with code N and no result."` fallback), since real session errors
are already in the resumed context.
### `daily-prep.md`
```markdown
You are preparing my workday for {date}.
1. Call mcp__claudedo__get_daily_prep_candidates.
2. Keep tasks already marked MyDay (currentMyDay) — never remove them.
3. Fill MyDay to at most {maxTasks} open tasks TOTAL (currentMyDay counts). Never exceed it.
4. Estimate each candidate's effort and pick a feasible mix — not only big items.
Prioritize isStarred, due (scheduledFor), and older tasks.
5. Place related tasks next to each other using consecutive sortOrder values.
6. Apply via mcp__claudedo__set_my_day(taskId, true, sortOrder). Never mark anything
outside the candidate list.
If there are no candidates, do nothing.
```
### `weekly-report.md`
```markdown
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## {Wochentag}, {dd.MM.yyyy}" section per day that has
activity (German weekday names). Omit days with no activity.
- Within each day: 35 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The notes are authoritative — never omit or contradict them.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
Two sections follow below: an activity log derived from Claude session history,
and the developer's own notes. Base the report on both; the notes are
authoritative where they conflict with the derived activity.
```
## Touch points
- `src/ClaudeDo.Data/PromptFiles.cs` — new `PromptKind` members, new defaults,
`Render` helper.
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — stop reading `agent.md`; use
`retry.md`; conditional stderr append on retry; carry/route `Blocks`.
- `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs` — scan assistant text for
`CLAUDEDO_BLOCKED:` markers, collect `Blocks`, strip from result.
- `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs` / `RunResult` — carry `Blocks`.
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — read
`planning-system.md` and `planning-initial.md` via `PromptFiles.Render`.
- `src/ClaudeDo.Worker/Prime/DailyPrepPrompt.cs` — read `daily-prep.md`.
- `src/ClaudeDo.Worker/Report/WeekReportPromptBuilder.cs` — read `weekly-report.md`.
- UI — review card shows the ⚠ roadblock hint with collected problems.
- `src/ClaudeDo.Ui/.../FilesSettingsTabViewModel.cs` — expose the new prompt files.
- Tests — `PromptFiles` render/seed; `StreamAnalyzer` marker collection; planning/
prep/report builders read from files.
## Out of scope
- The in-code task-execution assembly (title/description/subtasks) is unchanged.
- `ResultSchema` / `--output-schema` remains untouched.
- No change to commit-message templating.
```
@@ -0,0 +1,186 @@
# Reusable Child Tasks + Agent Improvement Loop — Design
Date: 2026-06-04
## Goal
Let an executing task agent offload out-of-scope improvements it spots into
**child tasks** that run automatically, so ClaudeDo can drive a self-improvement
loop. Generalize the parent/child machinery that planning uses today into a
reusable subsystem not bound to planning.
Example: while implementing task X, Claude notices "this module should really be
refactored, but that's out of scope" — instead of scope-creeping, it calls a tool
that files the refactor as a child of X. The child runs on its own; once all of
X's children finish, X surfaces for review with its whole tree visible.
This builds on the bundled-prompts overhaul (`system.md` gains one instruction to
use the offload tool). It is otherwise independent.
## Lifecycle
A new task status `WaitingForChildren` is added.
```
Running → WaitingForReview standalone success, no children (existing)
Running → WaitingForChildren standalone success, ≥1 child (new)
Running → Done planning child success (existing)
WaitingForChildren → WaitingForReview all children terminal (new)
WaitingForChildren → Cancelled cancel (new)
```
- Improvement-children are created `Idle` **during** the parent's run and stay
unqueued until the parent's own run finishes — this avoids the parent and a
child working the same repo concurrently.
- When the parent's run succeeds and it has ≥1 non-terminal child, the parent goes
to `WaitingForChildren` and its children are enqueued (they then run under the
normal queue, governed by max-parallel — they are independent, not a forced
sequential chain like planning).
- Children run automatically and reach `Done` on success without their own review
gate (a per-child review would stall the loop). Each child still produces its
own worktree/commit; those worktrees are surfaced under the parent for merge.
- Children emit `CLAUDEDO_BLOCKED:` markers like any run (see the prompt-overhaul
spec). Each child's collected problems roll up onto the **parent's** review card,
so a parent in `WaitingForReview` shows "child N reported a problem" alongside
its own roadblocks.
## Worktree topology & merge
The correctness rule that makes this work:
- **Children base off the parent's worktree HEAD, not the list's base branch.**
The parent's code work lives only on `claudedo/{parentId}` until merged, so a
child refactoring code the parent just wrote must branch from the parent's HEAD
to see it. (Planning children base off the target branch because a planning
parent writes no code — improvement parents do, hence the difference.) The
per-run worktree setup takes the base commit from the parent task's recorded
worktree HEAD when `ParentTaskId` is set and the parent is a non-planning task.
- **Fan-out:** all children branch off the same parent HEAD and run independently
(parallel allowed). Parent-dependency is always satisfied; sibling overlaps
surface later as merge conflicts.
- **Merge reuses the planning orchestrator,** generalized into a shared
"tree merge": build an integration branch off the target, then sequentially
`merge --no-ff` the **parent's own branch** followed by each child branch,
pausing on conflict (continue / abort), exactly as `PlanningMergeOrchestrator`
/`PlanningAggregator` do today. Approving the parent triggers this one guided
flow, merging parent + all children in as few steps as possible. Because
children descend from the parent HEAD, the parent's commits are shared ancestors
and merge cleanly ahead of the children.
- The parent advances to `WaitingForReview` once **all** children are terminal —
counting `Done`, `Failed`, and `Cancelled`, so a failed child can't wedge the
parent forever. Failed/cancelled children are flagged on the review card.
Planning parents keep their existing behavior (parent → `Done` when its chain
finishes); they do not use `WaitingForChildren`.
## Consolidating the child subsystem
Today child handling is planning-coupled. Generalize:
- **`TaskRepository.CreateChildAsync`** — drop the `parent.PlanningPhase != None`
guard. A child can attach to any existing parent. (Planning callers are
unaffected; their parents have a planning phase.) The child sets
`ParentTaskId = parentId`; the caller decides `CreatedBy`.
- **Child-completion coordinator** — generalize planning's
`OnChildFinishedAsync` / `TryCompleteParentAsync` into a single component that,
on any child reaching a terminal state, checks the parent and applies a
**completion policy**:
- *planning parent* → finalize/Done (existing chain advancement stays in the
planning layer: unblock the next chained child).
- *improvement parent* (in `WaitingForChildren`, all children terminal) →
`WaitingForReview`.
- `TaskStateService` remains the sole writer of `Status` and owns the new
transitions (`SubmitForChildrenAsync`, the `WaitingForChildren → WaitingForReview`
advance).
## The offload tool
A narrow MCP tool exposed only to task runs (not the general external surface):
```
SuggestImprovement(title, description) → { childTaskId }
```
- The **server** stamps everything — the agent cannot choose the parent, the
status, or queue anything directly:
- `ParentTaskId = <calling task id>`
- `CreatedBy = <calling task id>` (unambiguous "agent-suggested improvement"
marker — distinct from `null` user/planning tasks and `"mcp"` external tasks)
- `Status = Idle`, same `ListId` as the parent.
- **One layer deep:** the tool rejects the call if the calling task already has a
`ParentTaskId` (a child cannot spawn children).
### Knowing the caller's identity
The always-on external `claudedo` MCP is shared and can't tell which task is
calling. So task runs get a **per-run MCP identity**, mirroring planning's
per-session token:
- `TaskRunner` mints a per-run token and writes a run-scoped `.mcp.json` (or
reuses the global server with a token header) so the offload tool resolves
token → calling task id server-side. A `TaskRunMcpContextAccessor` exposes the
current task id to the tool, the same way `PlanningMcpContextAccessor` does.
- This is the reliable path for both correct provenance and the one-layer-deep
guard — the id is never supplied by the model.
`system.md` gains a short instruction (from the prompt-overhaul spec):
```markdown
## Out-of-scope improvements
If you notice worthwhile work that is genuinely outside this task's scope
(a refactor, a follow-up, tech debt), do NOT do it here. File it with
SuggestImprovement(title, description) and stay focused on the task at hand.
```
## UI
- **Collapsible tree:** children group under their parent (by `ParentTaskId`).
Improvement-children are visually marked as agent-suggested (via
`CreatedBy == parentId`).
- **New status chip** for `WaitingForChildren` (e.g. amber "waiting on N
improvements") with its own color in `StatusColorConverter`.
- **Review card** for a parent in `WaitingForReview` lists child outcomes
(done/failed) and their rolled-up `CLAUDEDO_BLOCKED` problems, and drives the
shared tree-merge (parent + children) via the planning-style sequential flow
with conflict pause/continue/abort.
## Data / migration
- Add `WaitingForChildren` to the `TaskStatus` enum and its EF `ValueConverter`.
No new columns — `ParentTaskId` and `CreatedBy` already exist. No backfill
needed (no existing rows use the new value).
## Touch points
- `src/ClaudeDo.Data/Models/TaskStatus` (enum) + `TaskEntityConfiguration` — new value.
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — generalize `CreateChildAsync`.
- `src/ClaudeDo.Worker/State/TaskStateService.cs``WaitingForChildren` transitions.
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — route to `WaitingForChildren` when
children exist; enqueue children on parent finish; mint per-run MCP token.
- New: child-completion coordinator (generalized from planning) + the offload tool
(e.g. `TaskRunMcpService.SuggestImprovement`) + `TaskRunMcpContextAccessor` +
token auth (mirrors `PlanningTokenAuth`).
- `src/ClaudeDo.Worker/Planning/*` — refactor planning to consume the shared
child-completion coordinator and the shared tree-merge; keep chain-specific
advancement local. Generalize `PlanningMergeOrchestrator` / `PlanningAggregator`
into a reusable tree-merge that also folds in the parent's own branch.
- Worktree setup (`TaskRunner` / `WorktreeManager`) — base an improvement-child's
worktree on the parent task's recorded worktree HEAD instead of the list base.
- UI — tree grouping, `WaitingForChildren` chip/color, parent review card with
child outcomes + rolled-up roadblocks + the merge flow.
- Tests — offload tool stamps parent/createdBy + rejects nested calls;
parent → `WaitingForChildren``WaitingForReview` lifecycle; child worktree
bases off parent HEAD; tree-merge folds parent + children; planning regression
(still reaches Done).
## Open questions for review
1. **Failed child:** parent still advances to `WaitingForReview` with the failure
flagged (default), vs. parent → `Failed` if any child failed.
## Out of scope
- Multi-level nesting (only one layer deep by design).
- Per-list "disable improvement offload" toggle (could come later; the tool is
always available to top-level runs for now).
- Changes to how planning sets up its sequential chain.
@@ -0,0 +1,90 @@
# Debug Logging & Frontend↔Backend Traceability — Design
**Date:** 2026-06-04
**Status:** Approved (pending spec review)
## Goal
Make debug logging rich enough to diagnose problems across the UI↔Worker boundary, while keeping the installed (production) build near-silent. Verbosity is decided by **build configuration, detected at runtime** — no runtime knob, no config field, no `#if DEBUG`:
- **Debug build** (Rider run button) → verbose, console + file.
- **Release build** (installed app) → minimal, file only.
## Decisions (from brainstorming)
1. **Mechanism:** runtime build-config detection via the entry assembly's `DebuggableAttribute` (JIT optimizer disabled ⇒ Debug build). A single `BuildConfig.IsDebug` helper drives ordinary `if` branching — no `#if DEBUG` directives. Rider's run button builds `Debug`; the installer ships `-c Release`.
2. **Scope:** Worker **and** App/Ui. The desktop side currently has no log sink at all — UI/IPC failures vanish today.
3. **Release behavior:** all three log `Warning`+ to file (not silent — capture crashes). Worker drops from its current `Information` to `Warning`.
4. **One shared log file** across both processes, unified timeline.
5. **Correlation:** TaskId-based (option A). Enrich log lines with `TaskId` when one is in scope. No changes to the SignalR contract (`IWorkerClient`/`WorkerHub` untouched → test fakes untouched).
## Verbosity matrix
| Process | Debug build | Release build |
|---|---|---|
| Worker | `Debug` level, console + shared file | `Warning` level, shared file |
| App/Ui | `Debug` level, console + shared file | `Warning` level, shared file |
## Shared log file
- Single daily-rolling file: `~/.todo-app/logs/claudedo-.log` (Serilog appends the date).
- `shared: true` on both processes' file sinks → Serilog coordinates multi-process writes via a global mutex.
- `retainedFileCountLimit: 2`.
- Each line is tagged with a `Process` property (`"worker"` / `"app"`) so the two sides are distinguishable in the interleaved timeline.
> The existing `worker-.log` is replaced by `claudedo-.log`. Task-run NDJSON (`{taskId}_run{n}.ndjson`) and `daily-prep.log` are **out of scope** — they are data streams, not diagnostic logs, and stay exactly as they are.
## Output template
```
[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}
```
- `{Process}``worker` or `app`.
- `{SourceContext}` — the `ILogger<T>` category (the logging class), so you see *which* component spoke.
- `{TaskId}` — the correlation key, defaulted to `-` when no task is in scope (see enricher below).
## Traceability (TaskId correlation)
Use Serilog's `LogContext` (`.Enrich.FromLogContext()` on both processes) plus a small default enricher so `TaskId` is always present (renders `-` when absent — avoids the raw `{TaskId}` token leaking into output).
Push the property at the entry points where a task is in scope; all nested `ILogger<T>` calls inherit it automatically:
- **Worker:** wrap per-task execution in `TaskRunner` (the run/continue entry) with `using (LogContext.PushProperty("TaskId", task.Id))`. This covers the bulk of backend activity (runner, state transitions, worktree, planning) for free.
- **App/Ui:** push `TaskId` in `WorkerClient` task-targeted calls (e.g. RunNow / Cancel / Continue / review actions) so the UI side of a task action carries the same key.
Result: grep one `TaskId` in `claudedo-.log` and read the full UI→Worker→UI story in timestamp order.
This adds **no parameters** to the SignalR surface — correlation rides on the existing `taskId` arguments already present in those calls.
## Implementation surface
A single shared helper keeps the two processes' Serilog setup from drifting.
- **New project:** `ClaudeDo.Logging` — a small library both `ClaudeDo.App` and `ClaudeDo.Worker` reference (keeps `ClaudeDo.Data` free of any Serilog dependency). Contains:
- `BuildConfig.IsDebug` — checks the entry assembly's `DebuggableAttribute` (`IsJITOptimizerDisabled` ⇒ Debug build). Cached static.
- The output template and the default-TaskId enricher.
- `ConfigureLogger(LoggerConfiguration, processTag, logRoot)` — applies level/sink choices by branching on `BuildConfig.IsDebug` (Debug ⇒ `Debug` level + console + file; Release ⇒ `Warning` level + file only). Both processes call it so level/template/retention stay in sync.
- **Worker `Program.cs:34`:** replace the inline `UseSerilog` body with a call into the shared helper (`processTag = "worker"`).
- **App `Program.cs`:** add Serilog packages; build a logger via the shared helper (`Process = "app"`) and register it with `sc.AddLogging(b => b.AddSerilog(logger, dispose: true))`. App currently registers **no** logging at all, so this also makes `ILogger<T>` injection actually work UI-side. Remove/keep `.LogToTrace()` as appropriate (Avalonia internal trace, separate concern — leave it).
- **App shutdown:** flush/close the logger (`Log.CloseAndFlush()` or dispose via the container's existing `finally`).
### Packages to add (App project)
- `Serilog.Extensions.Logging` (bridge `ILogger` → Serilog)
- `Serilog.Sinks.File`
- `Serilog.Sinks.Console`
- (Worker already has Serilog + File sink; add `Serilog.Sinks.Console` for the Debug console output.)
## Testing
- This is logging wiring; per project policy, no tests that spawn the real Claude CLI and no heavy test scaffolding for log output.
- Light verification: a unit-level check that the default enricher yields `-` when no `TaskId` is pushed, and (if practical) that `ConfigureLogger` wires the expected sinks. `BuildConfig.IsDebug` reflects the test assembly's own build config, so it can't be flipped within one run — assert each branch by passing the level/flag explicitly rather than relying on the ambient value, or verify the Release path and smoke-test Debug manually from Rider.
- Manual smoke test (documented, not automated): run from Rider, confirm console + `claudedo-.log` show `Debug` lines with `Process`/`SourceContext`; run a task and confirm both `app` and `worker` lines share the same `[TaskId]`.
## Out of scope
- Runtime/config log-level knob.
- Per-call correlation IDs for non-task flows (connect, config edits, prep) — TaskId-only for now; revisit if a non-task flow proves to be a black hole.
- Changes to task-run NDJSON capture or `daily-prep.log`.
- Any change to `IWorkerClient` / `WorkerHub` signatures.
@@ -0,0 +1,154 @@
# Inherited settings display, per-task overrides, and Turns
**Date:** 2026-06-04
**Status:** Approved (design)
## Problem
Config inheritance is three-tier (Task → List → Global app settings). Today the UI
only signals inheritance with a placeholder sentinel (`(inherit)` for tasks,
`(default)` for lists) and, for tasks, a faint "Effective if inherited: {value}"
hint under Model and Agent. Two gaps:
1. You can't see the *actual resolved value* an inherited field will use, nor where
it comes from (List vs Global).
2. **Max turns** is global-only (`AppSettingsEntity.DefaultMaxTurns` = 100). It is not
overridable per list or per task, unlike Model / SystemPrompt / AgentPath.
## Goals
- Show the real inherited value in-place, muted, with a **source-aware marker**
(`inherited · List` vs `inherited · Global`). Picking a value turns it into an
override; a reset affordance clears it back to inherited.
- Add **Turns** (max turns) as an overridable field at both List and Task levels,
inheriting from the global default. Numeric box; empty = inherit.
- Keep SystemPrompt as-is (it is additive, not override) but show what gets prepended.
## Non-goals
- No change to SystemPrompt merge semantics (stays additive/concatenated).
- No new global settings; `DefaultMaxTurns` already exists.
- No change to PermissionMode handling.
## Inheritance semantics (reference)
Resolved in `TaskRunner.BuildRunConfig` (~line 388):
| Field | Semantics | Resolution |
|--------------|------------|--------------------------------------------------------|
| Model | override | `task.Model ?? listConfig?.Model ?? global.DefaultModel` |
| AgentPath | override | `task.AgentPath ?? listConfig?.AgentPath` (no global) |
| MaxTurns | override | **new:** `task.MaxTurns ?? listConfig?.MaxTurns ?? global.DefaultMaxTurns` |
| SystemPrompt | additive | merged: global + list + task + agent (unchanged) |
Lists inherit only from Global (no tier above them), so a list's inherited marker is
always `inherited · Global`.
## Design
### 1. Data layer
- `ListConfigEntity`: add `int? MaxTurns`.
- `TaskEntity`: add `int? MaxTurns` (nullable override).
- EF Core migration adding `max_turns` column to `list_config` and `tasks`
(nullable, no default — null = inherit).
- `TaskRunner` BuildRunConfig: `MaxTurns: task.MaxTurns ?? listConfig?.MaxTurns ?? global.DefaultMaxTurns`.
`ClaudeRunConfig.MaxTurns` and `ClaudeArgsBuilder` already accept/emit `--max-turns`
when `> 0` — no change needed there.
- `ListRepository.SetConfigAsync` (upsert) and `TaskRepository.UpdateAgentSettingsAsync`
extend to carry `maxTurns`.
### 2. DTOs / transport
Add `int? MaxTurns` to (Worker + Ui copies kept in sync):
- `UpdateListConfigDto`, `ListConfigDto` (WorkerHub.cs + WorkerClient.cs)
- `UpdateTaskAgentSettingsDto` (WorkerHub.cs + WorkerClient.cs)
- `TaskConfigDto` (ConfigMcpTools.cs)
`WorkerHub.UpdateListConfig` / `UpdateTaskAgentSettings` persist the new field via the
repositories above. MCP `SetListConfig` / `SetTaskConfig` gain an optional `maxTurns`
parameter to keep the agent-facing API at parity with the UI.
### 3. Resolution helper (Ui)
A small helper that, given `(taskValue, listValue, globalValue)`, returns
`(effectiveValue, source)` where `source ∈ { Override, List, Global }`. Drives the
marker text and muted/normal styling for Model, Agent, and Turns so the logic isn't
duplicated per field or per editor. Lives in the Ui layer beside its consumers.
### 4. UI rendering — inherited marker (source-aware)
For **Model**, **Agent**, **Turns** in both `ListSettingsModalView` and the
DetailsIsland "Agent settings (overrides)" expander:
- Remove the `(inherit)` / `(default)` sentinel *row* from the control's item source.
- When no override is set: control shows the **resolved value muted/greyed** (dropdown
shows e.g. "sonnet" dimmed; Turns box shows e.g. "100" as a muted placeholder), and a
small badge beside the field label reads `inherited · List` or `inherited · Global`.
- On picking a value / typing a number: it becomes an override — text returns to normal
color, the badge flips to `override` (or hides), and a small **"↺ reset to inherited"**
affordance appears that clears the value back to null.
- List modal: source is always Global → badge reads `inherited · Global`; reset clears
to the global default.
- Turns: numeric box, empty = inherit (muted resolved number as placeholder); a typed
number is the override.
**Rendering approach:** a small reusable `InheritedFieldHeader` control (label + badge +
reset button), fed by the resolution helper's `source`, wraps each field. Keeps the three
fields consistent and avoids per-field XAML duplication. Badge / muted styling uses
existing design tokens. Visual polish pass is the user's.
### 5. SystemPrompt (stays plain)
SystemPrompt keeps its plain multi-line text box (additive, not override). Below it, a
small **read-only, collapsed-by-default** hint shows the inherited prompts that will be
prepended (global + list), labeled e.g. "Prepended automatically:". No marker, no reset —
it never replaces, only appends.
### 6. Localization
New keys in `locales/en.json` + `locales/de.json` (parity enforced by Localization.Tests):
marker text (`inherited · List`, `inherited · Global`, `override`), reset affordance
label, Turns field label, and the SystemPrompt "prepended automatically" hint. Retire the
now-unused `vm.details.effectiveIfInherited` key (and its German counterpart) if nothing
else references it.
## Affected files (indicative)
- `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `TaskEntity.cs`
- `src/ClaudeDo.Data/Migrations/` (new migration)
- `src/ClaudeDo.Data/Repositories/ListRepository.cs`, `TaskRepository.cs`
- `src/ClaudeDo.Data/Configuration/` (column mapping for `max_turns`)
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` (+ `Interfaces/IWorkerClient.cs`)
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` + view
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` + `DetailsIslandView.axaml`
- `src/ClaudeDo.Ui/Views/Controls/` (new `InheritedFieldHeader`)
- `src/ClaudeDo.Ui/` resolution helper
- `locales/en.json`, `locales/de.json`
## Testing
- Data: migration applies; `MaxTurns` round-trips through `ListRepository.SetConfigAsync`
and `TaskRepository.UpdateAgentSettingsAsync`.
- Worker: `BuildRunConfig` resolves MaxTurns via task → list → global precedence
(unit test on the resolution). Existing `ClaudeArgsBuilder` `--max-turns` behavior
unchanged.
- Ui: resolution helper returns correct `(value, source)` for each of the
override / list / global cases across Model, Agent, Turns.
- Localization: en/de key parity (existing Localization.Tests).
- Test fakes: update hand-rolled `IWorkerClient` fakes in both test projects for the new
DTO fields (per known gotcha).
- Visual verification of the marker / muted styling: flagged for the user (cannot be
asserted programmatically).
## Open risks
- DTO/ctor changes ripple into hand-rolled test fakes in Worker.Tests and Ui.Tests —
must be updated in the same change.
- Removing the sentinel row from dropdowns changes selection binding; ensure null/empty
override state is represented without a sentinel item (e.g. dropdown `SelectedItem`
null when inherited).
@@ -0,0 +1,200 @@
# ClaudeDo distribution website — design
**Date:** 2026-06-04
**Status:** Approved (design), ready for implementation planning
**Repo:** new standalone repo `claudedo-web` (not part of the ClaudeDo app solution)
**Domain:** `claudedo.kuns.dev` (Coolify on the user's VPS)
## Purpose
Give friends a public place to download ClaudeDo and learn what it does, without
sending them to the Gitea repo — so the source repo can be made more private. The
site also fronts the app's self-updater so the Gitea URL is never exposed in the
app or on the page.
## Goals / non-goals
**Goals**
- Public, no-auth landing page at `claudedo.kuns.dev` that matches the app's visual identity.
- A primary download (installer `.exe`) plus the portable `.zip` and checksums.
- A release proxy that (a) feeds the page the current version and (b) serves the
app's self-updater the same JSON shape Gitea returns, with download URLs rewritten
to route through `claudedo.kuns.dev` — hiding Gitea entirely.
**Non-goals**
- No docs site / getting-started page (the app ships an installer that handles setup).
- No changelog page (release notes already live on Gitea releases).
- No auth, accounts, analytics, or CMS.
- No CI/PR tooling for this repo beyond what Coolify needs to deploy.
## Access & distribution decisions
- **Access:** fully public. No password/login. Relies on the unadvertised URL.
- **Download source:** build-time fetch of the latest Gitea release for the displayed
version; actual download links route through the proxy and resolve the latest asset
at request time (so a stale page still downloads the current build).
- **Self-updater proxy:** in scope for v1 (not deferred).
## Tech stack
- **Nuxt 3** (Vue 3) — single framework, single repo, single Coolify deploy.
- **Nitro** server routes for the release proxy + asset streaming.
- **No DB, no auth, no secrets** (the `releases/ClaudeDo` repo is public).
- Fonts: **Inter Tight** (display/body) + **JetBrains Mono** (mono), self-hosted or via
Google Fonts. Design tokens ported from `docs/UI Rewrite/design_handoff_claudedo/Tokens.axaml`
and `styles.css` (moss/sage/peat palette, dark-first, 14px island radius, grain texture).
## Concept: "the page IS the app"
The landing page is a faithful, in-browser rendering of the ClaudeDo desktop — the
window chrome + the three islands — rather than a conventional marketing page. This
is the chosen direction (over a cinematic single-window scroll and a worklog feed).
### Layout — three islands on the app "desktop"
Desktop background = the app's layered moss gradients + 3px grain overlay. A centered
app `window` (titlebar + body) holds a 3-column island grid:
1. **Lists island (left)** — repurposed as page nav.
- Header "Lists" + a decorative search box (`Ctrl K` kbd chip).
- "Pages" group: Overview · Features (6) · How it works · Screenshots (3) · Download,
each with a colored swatch dot; active item gets the accent left-bar.
- Footer styled like the app's user footer: avatar, "For friends", `claudedo.kuns.dev`.
2. **Tasks island (middle)** — features rendered as **task cards**.
- Header: date eyebrow, big title (the hero line "Queue the work. Claude does it."),
a `running · review` badge, eye/gear icon buttons, and a subtitle.
- A decorative "Add a task…" row.
- Six **feature cards** (circle check — done cards filled; title; a status chip
`done`/`running`/`waiting for review`; a star). The features:
1. Isolated worktrees
2. The task queue
3. Review & merge
4. Live session log
5. Per-list & per-task config
6. Self-updating
- A "Ready" group with the final **"↓ Download ClaudeDo"** card.
3. **Detail island (right)** — faithful to the reworked Task-Detail island.
- **Source of truth for the detail visuals:** `docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md`
(the app's in-progress rework). The website's detail pane must track that design.
- Three-zone stack:
- **Task header** — mono id (`#F01…`), title, trash/gear action icons.
- **DETAILS bar**`DETAILS` eyebrow + `Edit` / copy / `⋯`, then a **markdown body**
(headings, paragraphs, inline `code`, ordered/unordered lists) describing the feature.
- **WorkConsole** docked at the bottom — traffic-light dots, `· N turns · +x y`,
tabs **Output / Actions / Session**, and a `Created …` footer.
- Per-feature mapping:
- Feature panels: markdown writeup in DETAILS + a short relevant **Output** log.
- "Review & merge": opens on the **Actions** tab with `Merge target` + `Open Diff` /
`Approve & merge`.
- **Download**: DETAILS shows requirements (`.NET 8 Desktop Runtime`, `Claude CLI`,
`Git`); the **Actions** tab holds the install controls, with `Merge target`
repurposed as a **Build** selector and buttons `↓ Download installer` /
`Portable .zip` / `checksums.txt`.
4. **Statusbar (bottom)**`● Online · claudedo.kuns.dev · private build`.
### Interaction
- Clicking a task card selects it (accent bar + card highlight) and swaps the active
detail panel; the WorkConsole tabs are clickable within a panel.
- **All panels are server-rendered and present in the DOM**, toggled by class — the
page is fully readable and downloadable **without JavaScript** (progressive
enhancement). Vue handles the selection state on the client.
### Responsive
- ≤ ~1100px: drop the Detail island; show Lists + Tasks.
- ≤ ~780px: single column — the Tasks list; tapping a feature pushes to a full-screen
Detail view (mirrors the app's narrow-window behavior) with a back affordance.
## Server: release proxy (Nitro)
The app's `ReleaseClient` (`src/ClaudeDo.Releases/ReleaseClient.cs`) calls
`{apiBase}/releases/latest` and reads `tag_name`, `name`, and
`assets[].browser_download_url`; `DownloadAsync` GETs an asset URL directly.
- **`GET /api/releases/latest`** — fetches `https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest`,
returns the **same JSON shape**, but every `assets[].browser_download_url` is rewritten
from the Gitea URL to `https://claudedo.kuns.dev/api/download/<encoded-asset-path>`.
Cached briefly (e.g. 5 min) server-side.
- **`GET /api/download/[...path]`** — reconstructs the Gitea asset URL from the path and
**streams** the binary back (no redirect to Gitea, so the URL stays hidden). Sets
appropriate `Content-Type`/`Content-Disposition`.
- **`server/utils/gitea.ts`** — shared base URL (`GITEA_API`, `REPO` from env), fetch
helper, and the URL-rewrite/asset-path round-trip.
- The page's download buttons point at the same `/api/download/...` routes (with a
stable "latest installer" path), so links never go stale between deploys.
### App-side coordinating change (separate, in the ClaudeDo repo)
Point `ReleaseClient`'s `apiBase` at `https://claudedo.kuns.dev/api` instead of the
Gitea default (one-line DI change where `ReleaseClient`/`UpdateCheckService` are
constructed). Tracked as a follow-up; not part of the `claudedo-web` repo. The proxy
path (`/api/releases/latest`) is chosen to match the existing
`{apiBase}/releases/latest` call so the parser is untouched.
## Content / assets
- **Screenshots (provided):** main 3-column view (hero), diff review modal, worktrees
panel. Stored in `public/screenshots/`. Placeholders sized for them until dropped in.
- Copy: hero "Queue the work. Claude does it."; six feature writeups as above (final
wording during implementation).
## Error handling
- **Build-time release fetch fails:** render the page with a last-known/placeholder
version label; download buttons still work because they resolve via the runtime
proxy route.
- **Proxy `/api/releases/latest` upstream failure:** return a 502/`null`-equivalent the
way Gitea would on miss; the app's `UpdateCheckService` already treats null/exception
as `CheckFailed` and degrades gracefully.
- **`/api/download` upstream failure:** surface a 502; the button shows an error state.
- No retries beyond a single upstream attempt for v1 (low traffic, friends-only).
## Testing
- **Vitest** unit tests for `server/utils/gitea.ts`: URL rewrite (Gitea → proxy) and the
asset-path round-trip (proxy path → Gitea URL), and release-JSON shape preservation.
- A light component smoke test that the page renders the islands and the download
controls without JS errors.
- No real-network/Gitea calls in tests — mock the upstream fetch.
## Deployment (Coolify)
- **Dockerfile**: `node:20-alpine` build → `nuxt build` → run `.output/server/index.mjs`.
- Coolify app bound to `claudedo.kuns.dev` with TLS via its reverse proxy.
- Env: `GITEA_API` (default `https://git.kuns.dev/api/v1`), `REPO` (`releases/ClaudeDo`),
`PUBLIC_BASE_URL` (`https://claudedo.kuns.dev`) for URL rewriting.
- Deploy on push to `main`; re-deploy (or a periodic rebuild) refreshes the displayed
version. No PR/CI tooling beyond Coolify's build.
## Open risk
- The reworked Detail island in the app is still in flux. The website's detail pane
must be kept in sync with `2026-06-04-task-detail-redesign-design.md`; expect a
visual-polish pass once that rework lands.
## Repo layout
```
claudedo-web/
├── nuxt.config.ts
├── app.vue
├── pages/index.vue # the one landing page
├── components/
│ ├── AppWindow.vue # window chrome + statusbar
│ ├── ListsIsland.vue # page nav
│ ├── TasksIsland.vue # feature cards + download card
│ ├── DetailIsland.vue # three-zone detail (header / DETAILS md / WorkConsole)
│ ├── WorkConsole.vue # tabs: Output / Actions / Session
│ └── content/ # per-feature markdown/blurbs + download panel
├── server/
│ ├── api/releases/latest.get.ts
│ ├── api/download/[...path].get.ts
│ └── utils/gitea.ts
├── assets/css/tokens.css # palette + type ported from Tokens.axaml/styles.css
├── public/screenshots/ # 3 PNGs
└── Dockerfile
```
@@ -0,0 +1,127 @@
# Refine Task — Design
**Date:** 2026-06-04
**Status:** Approved (pending spec review)
## Goal
Add a one-click **Refine Task** action to a task card. Clicking it spawns a
headless Claude session that reads the task (and the repo), rewrites the task's
description to be clearer and runnable autonomously, and — where it helps —
breaks the work into subtasks. The user then reviews/hand-edits the result and
queues the task manually.
This is **not** an interactive terminal session. It is a fire-and-forget
headless run, structurally similar to the existing daily-prep ("Prime Claude")
flow (`PrimeRunner`), not the interactive planning flow.
## Non-goals / scope
- No new task status. The task stays `Idle` throughout; refine only mutates the
task's `Title`/`Description` and its subtasks.
- No worktree, no interactive terminal, no auto-queue.
- No per-task refine config (model, turns) — uses the worker's defaults.
- Refine does not edit repository files; repo access is read-only.
## User flow
1. User clicks the refine icon on an `Idle` task's card.
2. UI calls `WorkerHub.RefineTask(taskId)``RefineRunner`.
3. `RefineRunner` spawns `claude -p` headless in the list's working directory,
seeded with a fixed refine prompt + the task's title/description/current
subtasks + the task id.
4. Claude reads the repo (read-only), then calls:
- `mcp__claudedo__update_task` to improve title/description, and
- `mcp__claudedo__add_subtask` to add steps where useful.
Each MCP call broadcasts `TaskUpdated`, so the description and Steps card
update live in the UI.
5. Run finishes; the card's refine button returns to its idle state. User
reviews, optionally hand-edits the description/steps, then queues manually.
## Architecture
### Worker — `RefineRunner`
- New `Worker/Refine/RefineRunner.cs` implementing `IRefineRunner`
(`Worker/Refine/Interfaces/IRefineRunner.cs`). Modeled on `PrimeRunner`.
- **Concurrency / single-flight:** an in-flight `HashSet<string>` of task ids
guarded by a lock (or `SemaphoreSlim`), so the *same* task cannot refine
twice concurrently, but different tasks may refine in parallel. A second
click on an already-refining task is a no-op.
- **Guards:** only runs when `task.Status == Idle`. Resolves the list's working
directory. If the list has **no valid working dir**, fall back to a sandbox
directory and run text-only (drop `Read`/`Grep`/`Glob` from the allowlist).
- **CLI invocation** (relies on the globally-registered `claudedo` MCP, like
daily-prep — no `--mcp-config`):
```
claude -p --output-format stream-json --verbose
--permission-mode acceptEdits
--max-turns <N>
--allowedTools mcp__claudedo__get_task,mcp__claudedo__update_task,mcp__claudedo__add_subtask,Read,Grep,Glob
```
`Edit`/`Write`/`Bash` are deliberately **not** whitelisted, so the run is
read-only on the repo even under `acceptEdits`. (Chosen over `plan` mode to
avoid the headless "exit plan mode to act" friction; the allowlist is the
real read-only gate.)
- **Logging:** stream stdout to a per-run log at
`logs/refine-<taskId[:8]>.log`, truncated at the start of each run.
### Prompt — `PromptKind.Refine`
- Add `Refine` to the `PromptKind` enum in `PromptFiles.cs`, file
`prompts/refine.md`, with a bundled default.
- Default prompt instructs: refine one ClaudeDo task so it is ready to run
autonomously; ground the description in the actual code (read-only); keep
scope tight (no scope creep into adjacent work); add steps as subtasks only
when they genuinely help; use only `get_task`, `update_task`, `add_subtask`
and the read-only tools; never edit files.
- Rendered via `PromptFiles.Render` with `{taskId}`, `{title}`,
`{description}`, and the current subtask list seeded into the prompt so the
agent knows which steps already exist.
### MCP tool — `add_subtask`
- New `[McpServerTool]` on `ExternalMcpService` (part of the global `claudedo`
MCP), signature `add_subtask(taskId, title, orderNum?)`.
- Creates a `SubtaskEntity` via `SubtaskRepository`; `orderNum` defaults to
append-at-end (max existing + 1). Refuses if the task is `Running`.
Broadcasts `TaskUpdated`.
- **Append semantics, not replace:** the current subtasks are already in the
prompt, so the agent only adds missing steps; re-running refine will not
silently wipe steps the user hand-edited.
- `update_task` already exists (title/description/commitType) and is reused
unchanged.
### UI — button, icon, feedback
- **Icon:** add the supplied SVG as an `Icon.Refine` `StreamGeometry` in
`IslandStyles.axaml`, rendered as a **stroked `Path`** (`plan-icon` style,
fill none) — it is line art, so per the PathIcon-fills-geometry gotcha it
must be stroked, not filled.
- **Button:** a new `icon-btn` in `TaskRowView.axaml` near the star button,
visible only when the task is `Idle`. Bound to a new `RefineTaskCommand` on
`TasksIslandViewModel`.
- **Feedback:** new broadcaster events `RefineStarted(taskId)` /
`RefineFinished(taskId, ok, error?)` drive an `IsRefining` flag on
`TaskRowViewModel`; the button shows a busy/disabled state while running. The
description and Steps card update live via the existing `TaskUpdated` events
fired by the MCP calls.
- Wire `RefineTask` through `IWorkerClient` / `WorkerClient`, the `WorkerHub`
method, and update the hand-rolled test fakes in both test projects.
## Testing
- `add_subtask`: creates the row, appends order correctly, refuses when
`Running`, broadcasts `TaskUpdated`.
- Refine prompt builder and CLI-args builder produce the expected prompt/flags
(including the text-only fallback when no working dir).
- `RefineRunner` guards: `Idle`-only, per-task single-flight no-op on a second
concurrent call.
- **No test spawns the real `claude` CLI** (project rule). The end-to-end run
is a manual smoke step.
## Open implementation calls (decided)
- **Permission mode:** `acceptEdits` + restricted allowlist for read-only
(rather than `plan` mode).
- **`add_subtask`:** append-only (rather than replace-all).
@@ -0,0 +1,200 @@
# Task Detail Island Redesign — Design
**Date:** 2026-06-04
**Status:** Approved (design), pending implementation
**Author:** brainstormed with user via visual companion
## Problem
The Detail island (`DetailsIslandView`, 413 lines) grew into one long scrolling
column as features piled on. The user has to scroll constantly. Specific pains
(confirmed by the user):
- **Everything is always stacked** — Steps, Description, Terminal, and several
conditional sections share one scroll column with no way to hide/fold.
- **Duplicated info**`model` shows in the gear flyout *and* the agent strip;
the branch line shows in the agent strip *and* as the terminal label.
- **Agent strip is a heavy 5-row block** pinned near the bottom even when idle.
- **Steps + Description take a lot of room** before the action controls.
The terminal staying prominent is *fine* — not a pain point.
## Solution overview
Replace the linear body with a **fixed-region layout** built from **3 new
self-contained components**, plus a roadblock band. Top region (header + details
card) stays put; the work console is pinned to the lower third.
```
┌─────────────────────────────────────┐
│ TaskHeaderBar (separated title) │ #T42 · title · 🗑/💀 · ⚙
├─────────────────────────────────────┤
│ DescriptionStepsCard │ card; text ⇄ steps toggle icon
│ (Preview = what Claude gets) │ copy · preview/edit
├─────────────────────────────────────┤
│ Roadblock band (only when failed) │ ⚠ message · Continue · Reset&Retry
├─────────────────────────────────────┤
│ WorkConsole (pinned, terminal) │ ●●● · model·turns·diff
│ tabs: Output | Actions | Session │
└─────────────────────────────────────┘
```
## The 3 components
Each is a standalone `UserControl` + dedicated `ViewModel` with **design-time
sample data** so it renders fully in the Avalonia previewer in isolation. Built
in separate worktrees; **none touch `DetailsIslandView.axaml` or
`DetailsIslandViewModel.cs`** (that is the wiring session). All visuals use
**only** the existing design tokens (`Design/Tokens.axaml`) and style classes
(`Design/IslandStyles.axaml`) — no hardcoded colors/sizes.
New folder: `src/ClaudeDo.Ui/Views/Islands/Detail/`
New VMs: `src/ClaudeDo.Ui/ViewModels/Islands/Detail/`
### 1. TaskHeaderBar
- **Layout:** one row — `#T42` id badge (mono `meta`, copyable) · editable title
`TextBox` (transparent, `FontSizeTaskTitle`, wraps) · **trash/skull button** ·
⚙ gear button with the agent-settings flyout.
- **Trash → Skull:** when **not** running show `Icon.Trash` (delete task,
`BloodBrush`); when **running** show a **skull** glyph (kill session). One
button, swaps icon + command on running state. Skull is a *new* filled
geometry to add to `IslandStyles.axaml` resources (`Icon.Skull`).
- **No done circle. No star** (the star lives on the task card/row already).
- **Gear flyout:** keep the existing agent-settings content verbatim — Model
combo + `InheritedBadge` + reset; Max Turns `NumericUpDown` + badge + reset;
System Prompt `TextBox` + "prepended" hint; Agent File combo + badge + reset.
Disabled while running (`IsAgentSectionEnabled`).
- **Existing bindings reused:** `TaskIdBadge`, `EditableTitle`, `DeleteTaskCommand`,
`StopCommand`, `IsRunning`, `IsAgentSectionEnabled`, all the agent-settings
members (`TaskModelOptions`/`TaskModelSelection`/`ModelBadge`/
`ResetTaskModelCommand`/`TaskMaxTurns`/`TurnsBadge`/`ResetTaskTurnsCommand`/
`TaskSystemPrompt`/`EffectiveSystemPromptHint`/`TaskAgentOptions`/
`TaskSelectedAgent`/`AgentBadge`/`ResetTaskAgentCommand`).
### 2. DescriptionStepsCard
A `Border.island`-style card. The single explicitly-requested "separate
component." Top-right **toggle icon** switches the card between **Description**
and **Steps** views; the icon shows the *other* mode (in Description view → steps
icon `Icon.MoreHorizontal`/list glyph; in Steps view → text glyph).
- **Header row:** small `section-label` ("DETAILS" / "STEPS") · spacer · **Copy**
icon button (`Icon.Copy`) · **Preview/Edit** toggle button (Description view
only) · **toggle icon** (top-right).
- **Description view:**
- *Preview mode* = renders **what Claude gets** via `MarkdownView`: the
canonical composed text (Title + Description + open steps — see below).
- *Edit mode* = raw description `TextBox` (mono, `Surface2Brush`, multiline).
- **Steps view:** add-step input (Enter to add) + list of step rows (check
circle `Ellipse.task-check` + inline-editable title, `subtask-row` style).
- **Copy** copies the **formatted** version (Title + Description + open steps),
nothing else, to the clipboard.
- **Existing bindings reused (when wired):** `EditableDescription`,
`IsEditingDescription`/`ToggleEditDescriptionCommand`, `Subtasks`,
`NewSubtaskTitle`/`AddSubtaskCommand`, `ToggleSubtaskDoneCommand`,
`CommitSubtaskEditCommand`.
- **New members (defined on the component VM now, lifted into
`DetailsIslandViewModel` at wiring):** `IsStepsView` + `ToggleCardViewCommand`;
`ComposedPreview` (string, the canonical format); `CopyFormattedCommand`.
### 3. WorkConsole
Terminal-styled card (`Border.terminal`) pinned to the lower third.
- **Title bar:** three cosmetic traffic-light dots (`Ellipse.dot-red`,
`dot-yellow`, `dot-green`) on the left; centered/!right small **info header**:
`model · {turns} turns · +adds dels` (mono `meta`; `diff-add`/`diff-del`
classes for the numbers). **No branch line.** LIVE/DONE/FAILED chip
(`live-chip`) on the right.
- **Tab strip:** `Output` | `Actions` | `Session`.
- **Output** — the live log. Reuse `SessionTerminalView` (`Entries`, `Label`,
`IsRunning`, `IsDone`, `IsFailed`) for the body, *or* the same
timestamp+`SelectableTextBlock` row template.
- **Actions** — worktree management: merge-target `ComboBox`, **Open Diff**,
**Worktree**, **Merge** (+ planning **Merge All Subtasks** when planning
parent). Bindings: `MergeTargetBranches`/`SelectedMergeTarget`,
`OpenDiffCommand`, `OpenWorktreeCommand`, `MergeAllCommand`/`CanMergeAll`/
`MergeAllDisabledReason`/`MergeAllError`, `ReviewCombinedDiffCommand`.
- **Session** — review + outcomes: feedback `TextBox` + Approve/Reject/Park/
Cancel (`ReviewFeedback`, `ApproveReviewCommand`, `RejectReviewCommand`,
`ParkReviewCommand`, `CancelReviewCommand`, shown when `IsWaitingForReview`)
and the child-outcomes list (`ChildOutcomes`, `HasChildOutcomes`).
- **Roadblock band** (above the tabs, inside or just above the card): visible on
`IsFailed`/`IsCancelled`; shows a warning (`Icon.Warning`, `BloodBrush`) and
**Continue** (`ContinueCommand`, `ShowContinue`) + **Reset & Retry**
(`ResetAndRetryCommand`, `ShowResetAndRetry`).
- **Info-header bindings:** `Model`, `Turns`, `DiffAdditions`, `DiffDeletions`,
`IsRunning`/`IsDone`/`IsFailed`.
## Combined Description + Steps behavior
Steps are part of the description. When the task runs, the **effective prompt =
Title + Description + only the OPEN steps**. Resolved steps are dropped.
**Canonical composed format** (shared by the Worker prompt, the card's Preview,
and Copy):
```
<Title>
<Description>
## Sub-Tasks
- [ ] <open step 1>
- [ ] <open step 2>
```
- Omit the `## Sub-Tasks` section entirely when no open steps remain.
- Omit the description paragraph when description is empty.
**Worker change (wiring session, by Claude):** `TaskRunner.cs:104-113` currently
appends *all* subtasks with `[x]`/`[ ]`. Change to append **only incomplete**
subtasks as `- [ ]` lines (drop completed). Factor the format into a shared
`TaskPromptComposer` in `ClaudeDo.Data` (referenced by both Worker and UI) so the
card's Preview and the real prompt never diverge.
## Color / token guidelines (mandatory)
- Backgrounds: `IslandBackgroundBrush`, `Surface2Brush`, `Surface3Brush`,
`DeepBrush`, `VoidBrush` (terminal). Borders: `LineBrush`/`LineBrightBrush`,
`HairlineOverlayBrush`. Text: `TextBrush`/`TextDimBrush`/`TextMuteBrush`/
`TextFaintBrush`. Accent: `AccentBrush`/`AccentDimBrush`. Status: blood/peat/
moss/sage + the `*TintBrush` pairs.
- Radii: `IslandCornerRadius` (14), `ButtonCornerRadius` (6), `InputCornerRadius`
(8). Spacing: `SpaceXs..Space2Xl`. Fonts: `SansFont`, `MonoFont`; sizes
`FontSizeMono`/`FontSizeBody`/`FontSizeTaskTitle`.
- Reuse style classes: `island`, `island-header`, `chip`, `btn`/`btn accent`/
`primary`/`danger`, `icon-btn`, `flat`, `terminal`, `dot-red/yellow/green`,
`live-chip`, `task-check`, `subtask-row`, `section-label`, `field-label`,
`meta`, `diff-add`/`diff-del`, `diff-meter-*`.
- **No inline hex, no magic numbers** where a token exists. `PathIcon` fills
geometry — line-art must be filled or stroked via `Path`.
## Build / isolation strategy
1. Three ClaudeDo tasks (list "Claude do", repo `C:\Private\ClaudeDo`), one per
component, run sequentially in their own worktrees.
2. Each delivers: `Detail/<Name>.axaml` + `.axaml.cs` + `Detail/<Name>ViewModel.cs`
with design-time sample data; the `ClaudeDo.Ui` project **builds green**
(`dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`).
3. Components are visual-only against sample data. Real `DetailsIslandViewModel`
binding + the Worker steps→prompt change happen in the **wiring session**
(this Claude session, done while the build tasks run).
## Wiring plan (this session)
- Implement `TaskPromptComposer` + the `TaskRunner` open-steps change + a unit
test in `ClaudeDo.Worker.Tests`/`Data.Tests`.
- After the 3 components land: host them in `DetailsIslandView` (header top,
card below, roadblock band, work console pinned bottom), lift the new card VM
members into `DetailsIslandViewModel`, repoint `x:DataType`, delete the
superseded inline sections + `AgentStripView` usage. Update locale parity and
the test fakes.
## Monitoring loop (this session)
While the build tasks run: poll each via `get_task` / `get_task_log` /
`get_task_diff`, summarize progress and anything a session got stuck on, and if a
session is blocked on something missing, add a small follow-up task to the
"Claude do" list.
@@ -0,0 +1,197 @@
# Git Tab / Merge & Review Rework — Design
Date: 2026-06-05
Status: Approved
## Goal
Make handling merges and reviews as simple as possible in the Terminal component's
Git tab, and rework the diff viewers and worktree modals along the way. The work is
split into three layers built across separate sessions, with a shared foundation that
is built and pushed first so the parallel sessions branch from frozen contracts.
The user mostly trusts task output but wants the diff one click away for important
work, and wants to land several independently-queued worktrees without per-task
hopping or hand-resolving conflicts in an external editor.
## Layers
- **Layer A — Review/merge cockpit** (this session). Single-task review + merge UX in
the Git tab; consolidate the four diff renderers into one `DiffView`.
- **Layer B — Multi-worktree merge cockpit** (parallel session). Batch-merge N
worktrees into one target, skip-and-continue, conflicts collected for resolution.
- **Layer C — Inline conflict resolver** (parallel session). VSCode-style inline hunk
resolver plus the worker-side conflict plumbing it needs.
They stack: A defines the single-task flow, B reuses it for many tasks, both funnel
conflicts into C.
## Shared foundation (built & pushed this session, before B/C branch)
Everything B and C depend on lands first on `main`. B and C branch from that commit.
### 1. One diff model + one `DiffView` control
Today there are four diff renderers and two parallel diff models:
- `DiffLinesView.axaml` (used by `DiffModalView`)
- the inline diff `ItemsControl` in `WorktreeModalView.axaml`
- `PlanningDiffView.axaml`
- their backing models: `DiffFileViewModel`/`DiffLineViewModel` (+ `UnifiedDiffParser`)
vs `WorktreeNodeViewModel`/`WorktreeDiffLineViewModel`
Collapse into a single canonical diff model + parser + a `DiffView` UserControl. All
diff rendering across the app goes through `DiffView`.
- Model: `DiffFileViewModel { Path, AddCount, DelCount, Lines }`,
`DiffLineViewModel { OldNo, NewNo, Kind (Add|Del|Ctx|File|Hunk), Text }`.
- Parser: one static `UnifiedDiffParser.Parse(rawUnifiedDiff)` returning the model.
- `DiffView` exposes a `Files` styled property (file list + selected-file lines), or a
simpler `Lines` property for single-file use — Layer A decides the exact surface
while building it, but the type names above are frozen so B and C can bind to them.
### 2. Frozen worker conflict contract
Added to `IWorkerClient` (and `WorkerClient` with stub bodies that throw
`NotSupportedException`) plus new DTOs, so A and B compile against the interface while
C provides the real worker-side implementation.
```csharp
// IWorkerClient additions (signatures frozen this session)
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueMergeAsync(string taskId);
Task AbortMergeAsync(string taskId);
```
- `StartConflictMergeAsync` performs the merge with `leaveConflictsInTree: true` (the
worker already supports this flag — used today by the planning orchestrator) and
returns `MergeResultDto` with `Status="conflict"` and the conflict file list, leaving
`.git/MERGE_HEAD` in place in the list's `WorkingDir`.
- `GetMergeConflictsAsync` returns each conflicted file with ours/theirs/base content,
read via `git show :2:<path>` (ours), `:3:<path>` (theirs), `:1:<path>` (base).
- `WriteConflictResolutionAsync` writes resolved content to the file in `WorkingDir`
and `git add`s it.
- `ContinueMergeAsync` wraps the existing `TaskMergeService.ContinueMergeAsync`
(`git add -A` → re-check `git diff --name-only --diff-filter=U``git commit`).
- `AbortMergeAsync` wraps the existing `TaskMergeService.AbortMergeAsync`
(`git merge --abort`).
New DTOs (defined in the worker hub DTO file, mirrored client-side):
```csharp
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
```
Existing DTOs reused unchanged: `MergeResultDto(Status, ConflictFiles, ErrorMessage)`,
`MergePreviewDto`, `MergeTargetsDto`.
### 3. Conflict data model (UI)
`ConflictFile { Path, Hunks[] }`, `ConflictHunk { Ours, Theirs, Base, Resolution }`.
Shaped so a future 3-way merge pane needs no model change (Layer C is the inline
resolver now; the model leaves room for 3-way later).
### 4. Integration seams (delegates, wired by the integrator at merge)
A's and B's cockpits hold a `RequestConflictResolution(string taskId)` callback (an
`Action<string>` or `Func<string, Task>`). They never reference Layer C's resolver
types. The integrator connects these callbacks to C's `ConflictResolverViewModel`
factory when merging the three branches together.
## Parallel boundaries (verified disjoint)
| Area | A (this session) | B (parallel) | C (parallel) |
|---|---|---|---|
| `DiffView` + diff model/parser | builds | reuses | reuses |
| `WorkConsole.axaml` / `DetailsIslandViewModel` | owns | — | — |
| `DiffModalView` + `PlanningDiffView` | migrates to `DiffView` | — | — |
| `WorktreesOverviewModalView/VM` + `WorktreeModalView` | — | owns | — |
| `WorkerHub` / `TaskMergeService` / `GitService` | — | — | owns |
| New `ConflictResolverView/VM` + conflict UI model | — | — | owns |
| `IWorkerClient` / `WorkerClient` | adds frozen stubs + DTOs | reuses `MergeTaskAsync` | fills stub bodies |
| Test fakes (`IWorkerClient`) in both test projects | adds new no-op methods | — | makes them functional if needed |
The only file C and A both touch is `WorkerClient.cs` (C replaces the stub bodies A
wrote). Contained; reconciled at integration. Everything else is disjoint.
## Layer A — review/merge cockpit (this session)
- The Git tab becomes the single Approve + merge surface. `Approve` and the merge
target / preview / diff flow together as one block (no separate REVIEW vs
MERGE & WORKTREE sections).
- `Continue` (reject → requeue with feedback) and `Reset` (reject → idle) **stay** in
the Output tab footer — unchanged.
- The diff is shown via the unified `DiffView` opened as a modal from the cockpit. No
inline diff recap in the tab (the island is too small).
- On a single-task **Approve that conflicts**: instead of today's auto-abort, call
`StartConflictMergeAsync` and fire `RequestConflictResolution(taskId)`. This leaves
the main checkout mid-merge until the user resolves or aborts (behavior change,
intended). The callback is inert until Layer C is merged; the integrator wires it.
- Migrate `DiffModalView` and `PlanningDiffView` onto the new `DiffView`.
### Behavior change accepted
Today `MergeTask`/`ApproveReview` use `leaveConflictsInTree: false` (auto-abort on
conflict). Under this design, an Approve that conflicts leaves the merge in progress
and opens the resolver. The mid-merge guard (`IsMidMergeAsync`) still prevents a second
concurrent merge.
## Layer B — multi-worktree merge cockpit (parallel)
- Rework `WorktreesOverviewModalView`/`WorktreesOverviewModalViewModel` into a
batch-merge cockpit: list mergeable worktrees, select N, choose one target branch
(single target — 99% of the time everything goes to the same branch), "Merge all".
- **Skip-and-continue**: client-side loop calling the existing
`MergeTaskAsync(taskId, target, removeWorktree, msg)` per selected task. Clean merges
apply; conflicting ones are collected (existing `MergeTaskAsync` auto-aborts on
conflict, leaving the tree clean) into a "needs resolution" list with live progress.
- Each conflict row exposes a **Resolve** action → `RequestConflictResolution(taskId)`
(wired to Layer C at integration).
- Per-task diff via the shared `DiffView`; migrate `WorktreeModalView`'s inline diff
onto it.
- B touches no worker files — keeps it parallel-safe.
## Layer C — inline conflict resolver (parallel)
### Worker side
Implement the five frozen contract methods:
- Add hub methods `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`,
`ContinueMerge`, `AbortMerge` in `WorkerHub`.
- `StartConflictMerge` calls the existing `TaskMergeService.MergeAsync` overload with
`leaveConflictsInTree: true`.
- `ContinueMerge` / `AbortMerge` wrap the existing `TaskMergeService.ContinueMergeAsync`
/ `AbortMergeAsync` (currently service-level only, not hub-exposed).
- `GetMergeConflicts` reads ours/theirs/base per conflicted file via
`git show :2:/:3:/:1:`; add the `GitService` helpers needed.
- `WriteConflictResolution` writes the resolved content to `WorkingDir` and stages it.
- Fill the `WorkerClient` stub bodies (real SignalR `InvokeAsync` calls).
- Update the hand-rolled `IWorkerClient` fakes in both test projects.
### UI
- New `ConflictResolverView` + `ConflictResolverViewModel`. Per conflict hunk, show
ours vs theirs stacked, with buttons **Accept Current / Accept Incoming / Accept Both
/ Edit manually** plus a free-text box for the merged result of that hunk.
- When every file's hunks are resolved → `ContinueMergeAsync(taskId)``MergeResultDto`
(`merged` closes the resolver; `conflict` means not fully resolved, stay open).
- `AbortMergeAsync(taskId)` cancels and aborts the merge.
- Expose a factory (`Func<string, ConflictResolverViewModel>`) the integrator wires to
A's and B's `RequestConflictResolution` callbacks.
## Build / test
`.slnx` needs .NET 9; on .NET 8 build individual csproj with `-c Release` (a running
Worker locks `Debug`). Run the relevant test projects. No tests that spawn the real
`claude` CLI. Keep `en.json`/`de.json` localization keys in parity.
## Out of scope
- Full 3-way synchronized merge editor (model leaves room; not built now).
- Per-task differing merge targets in the batch (single target only).
- Any CI/PR tooling (direct push-to-main workflow).
@@ -0,0 +1,99 @@
# Terminal-style review controls
**Date:** 2026-06-05
**Status:** Approved (design)
## Problem
Review feedback today is a multi-line `TextBox` plus four buttons (Approve / Reject /
Park / Cancel) tucked into the WorkConsole **Session** tab
(`WorkConsole.axaml:169-193`). It feels disconnected from the live terminal. Entering
feedback should feel like typing into the terminal, with action buttons docked at the
bottom — and merge/approve actions should live in an obvious, dedicated place.
## Goal
- Type review feedback directly in the **Output (terminal)** tab, prompt-style.
- Bottom-docked action strip on the terminal: `[Retry]` `[Reset]`.
- Move all git/merge/worktree actions (including **Approve**) into a new **Git** tab so
it is obvious where each action lives.
## Tab structure
Three tabs in WorkConsole: **Output** · **Git** · **Session**.
| Tab | Contents |
| --- | --- |
| **Output** | Live `Log` (unchanged) + new review footer (below), footer gated on `IsWaitingForReview`. |
| **Git** | The current "Merge & worktree" block — merge-target dropdown, mergeability indicator, **Approve**, Open Diff, Merge, Worktree, Review Combined Diff, Merge All Subtasks. Visibility gated on `ShowMergeSection` / `IsWaitingForReview` as today. |
| **Session** | Child outcomes + empty-state only. |
### ViewModel changes (`DetailsIslandViewModel`)
- Add `public bool IsGitTab => SelectedTab == "git";`
- Add `[NotifyPropertyChangedFor(nameof(IsGitTab))]` alongside the existing
`IsOutputTab` / `IsSessionTab` notifications on `SelectedTab` (`:139-144`).
- `SelectTab` already accepts a string parameter — no change beyond the new `"git"`
value wired from XAML.
- No command renames (avoids breaking hand-rolled test fakes).
## Terminal footer (Output tab)
A `Border` docked `Bottom` inside the Output tab body, visible only when
`IsWaitingForReview`:
- Background `Surface2Brush`, top border `LineBrush` (`BorderThickness="0,1,0,0"`).
- A `` prompt-prefix `TextBlock` (mono, `TextMuteBrush`) + a borderless mono `TextBox`:
- Bound `Text="{Binding ReviewFeedback, Mode=TwoWay}"`.
- `AcceptsReturn="True"`, `TextWrapping="Wrap"`, transparent background, no border.
- Starts ~1 line tall; grows with content up to `MaxHeight≈160`, then scrolls.
- `PlaceholderText` e.g. "Feedback for the next run…".
- Right-aligned button strip:
- `[Retry]``Classes="btn accent"``RejectReviewCommand`.
- `[Reset]``Classes="btn"``ParkReviewCommand`.
`[Accept]` is **not** in the footer; approval happens on the Git tab via
`ApproveReviewCommand`. The old `Cancel` review button is dropped from this UI; cancel
remains reachable through the task's existing cancel control (`CancelReviewCommand`
stays on the ViewModel, just not surfaced here).
### Enter handling (`WorkConsole.axaml.cs`)
- Handle `KeyDown` on the input `TextBox`:
- **Enter** without Shift → execute `RejectReviewCommand` (if it can execute) and set
`e.Handled = true`.
- **Shift+Enter** → fall through to default behavior (inserts newline).
- `RejectReviewAsync` already returns early on whitespace-only feedback
(`DetailsIslandViewModel.cs:1464`), so pressing Enter with an empty prompt is a no-op
with no extra guard needed.
## Command mapping
| Button | Location | Command | Effect |
| --- | --- | --- | --- |
| `[Retry]` | Output footer | `RejectReviewCommand` | Reject-to-queue with feedback; resumes the session (Queued). |
| `[Reset]` | Output footer | `ParkReviewCommand` | Park back to Idle. |
| `[Approve]` | Git tab | `ApproveReviewCommand` | Merge `SelectedMergeTarget` → Done (conflict keeps it in review). |
## Copy / empty state
- Update the Session empty-state text (`WorkConsole.axaml:270`) — it currently says
"review and merge controls appear here once the run finishes", which is no longer
accurate. Reword to reflect that only outcomes live on Session.
- Button labels remain literal strings (`Retry`, `Reset`, `Approve`), matching the
existing review buttons (no new localization keys).
## Out of scope
- No changes to worker-side review/merge logic or `IWorkerClient` signatures.
- No merge-target selector duplicated into the terminal footer (Approve uses the Git
tab dropdown / default target).
- No command renames on the ViewModel.
## Testing / verification
- Build `ClaudeDo.App` and `ClaudeDo.Worker` in `-c Release`.
- Manual visual verification (must be flagged — cannot be auto-verified):
- Footer appears only in `WaitingForReview`, on the Output tab.
- Enter sends Retry; Shift+Enter inserts a newline; empty Enter does nothing.
- Git tab shows Approve + merge/worktree controls; Session shows only outcomes.
@@ -0,0 +1,80 @@
# Per-task model override via MCP + cheapest-model prompt guidance
Date: 2026-06-09
## Goal
Let Claude pick the model for each task it generates (planning subtasks,
improvement follow-ups, external task creation) directly at creation time via
MCP, and instruct Claude — in the relevant prompts — to choose the *cheapest*
model that can do the job well.
## Background
- `TaskEntity.Model` (nullable) already exists and is resolved
task → list-config → global default in `TaskRunner.ResolveConfigAsync`, then
passed to the CLI as `--model` by `ClaudeArgsBuilder`.
- Today the model can only be set *after* creation via `set_task_config`
(`ConfigMcpTools.SetTaskConfig`). The creation tools (`CreateChildTask`,
`SuggestImprovement`, `AddTask`) accept no model, so assigning one is a
two-call dance.
- `ModelRegistry.Aliases = ["sonnet","opus","haiku"]`; no cost ordering or
validation helper exists.
No schema change is required — only plumbing a `model` argument through the
creation paths plus prompt edits.
## Decisions
- **Validation:** strict alias-only. `model` must be one of haiku/sonnet/opus
(case-insensitive); blank/null means "inherit" (no override); anything else
throws an MCP error so Claude self-corrects immediately rather than the task
failing later at CLI runtime.
- **`AddSubtask` is out of scope:** it creates a `SubtaskEntity` (a checklist
step), which is never independently executed — a model there is a no-op.
- **Improvement-child prompt:** the child's model is fixed at filing time and
it cannot re-pick, so only a one-line "this is an intentionally small/cheap
unit — stay minimal" reminder is added. The real model-choice instruction
lives in the main system prompt's SuggestImprovement guidance.
## Cost ordering & heuristic (single source: `ModelRegistry.ByCostAscending`)
`haiku < sonnet < opus`
- **haiku** — trivial/mechanical: doc tweaks, simple renames, small localized edits.
- **sonnet** — normal coding work (default).
- **opus** — complex architecture, cross-cutting changes, hard debugging.
## Changes
1. **`ClaudeDo.Data/Models/ModelRegistry.cs`**
- `ByCostAscending = ["haiku","sonnet","opus"]`.
- `string? NormalizeAlias(string? model)` — trim; null/blank → null;
case-insensitive match → canonical lowercase alias; else throw
`ArgumentException` with the allowed list.
2. **`TaskRepository.CreateChildAsync`** — add optional `string? model = null`;
set `child.Model = ModelRegistry.NormalizeAlias(model)`. Single choke-point
for both child-creation MCP tools.
3. **MCP creation tools** (add `model` param, document in `[Description]`):
- `PlanningMcpService.CreateChildTask` → forward to `CreateChildAsync`.
- `TaskRunMcpService.SuggestImprovement` → forward to `CreateChildAsync`.
- `ExternalMcpService.AddTask``NormalizeAlias` then set `entity.Model`.
4. **Prompts (`PromptFiles.cs`)**
- `PlanningSystemDefault` — instruct the planner to pass each
`CreateChildTask` the cheapest capable model (with the ordering/heuristic).
- `SystemDefault` (Out-of-scope improvements) — when filing via
`SuggestImprovement`, pass the cheapest capable `model`.
- `ImprovementChildDefault` — one-line minimality reminder.
5. **Tests** (no real CLI):
- `NormalizeAlias`: valid aliases (any case), blank/null → null, unknown → throws.
- `CreateChildTask` / `SuggestImprovement` / `AddTask` persist the model;
invalid model is rejected.
## Out of scope
- No DB migration. No locale changes (prompts and MCP descriptions are not
localized). No UI changes (existing per-task model display already covers it).
@@ -0,0 +1,148 @@
# Unify the parent-task model (planning · improvement · normal)
**Date:** 2026-06-09
**Status:** Approved-pending-implementation
## Problem
ClaudeDo has three ways a task produces and waits on work, grown as separate
mechanisms that represent the *same shape* — "a task runs, may emit children,
and once it + its children are terminal it surfaces for review":
| | children authored | scheduling | parent flow today | merge of children |
|---|---|---|---|---|
| **Normal** | none | — | `Running → WaitingForReview → Done` | own worktree on approve |
| **Improvement** | autonomously *during* run (`suggest_improvement`) | parallel (no blockers) | `Running → WaitingForChildren → WaitingForReview → Done` | separate `MergeAllPlanning` |
| **Planning** | interactively *before* run (planning session) | sequential chain (`BlockedByTaskId`) | `Idle →(Active→Finalized)→ Done` (skips review) | separate `MergeAllPlanning` |
The incidental divergence we want to remove:
1. **Two "parent is waiting on children" representations** — improvement uses
`Status=WaitingForChildren`; planning uses `PlanningPhase=Finalized` with the
parent's `Status` jumping `Idle → Done`, never passing through the waiting/review
states at all.
2. **Two parent-advance methods** doing the same job —
`TaskRepository.TryCompleteParentAsync` (planning → `Done`, no review) vs
`TaskStateService.TryAdvanceImprovementParentAsync` (improvement → `WaitingForReview`).
3. **A separate merge action**`MergeAllPlanning` / `PlanningMergeOrchestrator`
merges children, decoupled from the parent's `approve`. Approving a parent and
merging its unit are two clicks.
What is **genuinely unique and kept**: `PlanningPhase.Active` — the interactive,
human-in-the-loop authoring gate where children are drafted and cannot run until
finalize. Improvement has no equivalent. The two *authoring* entry points
(`PlanningMcpService.CreateChildTask` vs `TaskRunMcpService.SuggestImprovement`)
also stay distinct — they already share `CreateChildAsync`; unifying the authoring
UX is explicitly out of scope.
## Decisions (locked)
- **All parents get review.** A planning parent now surfaces in `WaitingForReview`
after its children finish, instead of auto-completing to `Done`.
- **Approve merges the whole unit — full UX consolidation.** Approve is the single
entry for reviewing *and* merging any task. For a parent with children it drives the
existing `PlanningMergeOrchestrator` (unit merge + parent→`Done` + conflict
continue/abort, all already implemented); the standalone "Merge All" button is
removed and the orchestrator's conflict dialog + combined-diff preview are reused
in-place. Childless tasks keep `ApproveAndMergeAsync`.
- **Scope = state model + code paths.** Internal refactor; authoring UX and child
base-commit resolution are unchanged.
## Target model
**One parent-with-children lifecycle, used by every parent regardless of how its
children were authored:**
```
┌─ (no children) ──────────────┐
Idle → Queued → Running ──┤ ├→ WaitingForReview → Done
└─ (has/spawns children) ─┐ │ (approve =
│ │ merge unit)
WaitingForChildren ─┘ │
│ │
(all children terminal) ───────┘
```
Planning parent (never runs as an agent — it runs an interactive session):
```
Idle (PlanningPhase None)
→[StartPlanning] Idle (PlanningPhase Active) ← authoring gate (KEPT)
→[FinalizePlanning] WaitingForChildren (Finalized) ← children chain runs
→[all children terminal] WaitingForReview
→[approve] merge unit → Done
```
Children (planning **and** improvement) keep going straight to `Done` with no
individual review; they accumulate on their branches and merge as a unit when the
parent is approved.
### State machine after the change
- `WaitingForChildren` is the **single** "parent waiting on children" state, used by
both planning and improvement parents.
- `WaitingForReview` is reached by every parent before `Done`.
- `PlanningPhase`: `None | Active | Finalized` — unchanged; `Active` remains the
authoring gate, `Finalized` marks "was a planning parent" and is set together with
`Status=WaitingForChildren`.
## Code changes
1. **Single parent-advance path.** Rename
`TaskStateService.TryAdvanceImprovementParentAsync`
`TryAdvanceParentAsync`; it already only checks `Status==WaitingForChildren` +
"all children terminal" → `WaitingForReview` (with the failed/cancelled
annotation on `Result`). It becomes the only path for both systems.
- Handle **zero children**: a finalized planning parent with no children must go
straight to `WaitingForReview` (today `TryComplete`/`TryAdvance` both `return`
on `Count == 0`).
2. **Delete `TaskRepository.TryCompleteParentAsync`** (`TaskRepository.cs:477`) and
its invocation in `TaskStateService.OnChildTerminalAsync`. Planning parents now
advance via `TryAdvanceParentAsync` to `WaitingForReview` instead of `Done`.
- Keep `_chain.OnChildFinishedAsync` (inter-child unblock — planning-only effect).
3. **`FinalizePlanningAsync`** (`TaskStateService.cs:289`) sets the parent
`Status = WaitingForChildren` in the same update that sets
`PlanningPhase = Finalized`. This happens before `SetupChainAsync` enqueues
child[0], so the parent is in `WaitingForChildren` before any child can finish.
4. **Approve merges the unit.** `WorkerHub.ApproveReview` (and the MCP
`ReviewTask` approve path): when the approved task has children, run
`PlanningMergeOrchestrator` (parent worktree if `Active` + each `Done` child in
order), then transition the parent to `Done`. On a child merge conflict, the
parent stays in `WaitingForReview` (mirrors current single-task approve-conflict
behavior). Retire the `MergeAllPlanning` Hub method + UI button.
5. **Allow cancelling a `WaitingForChildren` parent.** Add `WaitingForChildren` to
the `CancelAsync` guard so a parent waiting on children can be cancelled (today it
cannot — minor gap).
6. **Docs.** Fix the `WaitingForChildren`-missing drift in
`src/ClaudeDo.Data/CLAUDE.md` and `src/ClaudeDo.Worker/CLAUDE.md`, and update the
transition diagram + the root `CLAUDE.md` status-flow line to the unified model.
## Out of scope (unchanged)
- Authoring UX: planning session vs `suggest_improvement` stay as two distinct
entry points (both already call `CreateChildAsync`).
- `WorktreeManager.ResolveBaseCommitAsync` base-commit divergence (planning children
branch from list HEAD; improvement children from parent head) — left as-is.
- Sequential-vs-parallel scheduling — already shared infrastructure
(`BlockedByTaskId`); planning chains, improvement doesn't. No change.
## Risks / edge cases
- **Ordering on finalize** — parent must be `WaitingForChildren` before the first
child can reach terminal. Guaranteed by setting it inside `FinalizePlanningAsync`,
which runs before `SetupChainAsync`.
- **Zero-children planning parent** — must advance to `WaitingForReview`, not stick
in `WaitingForChildren`. Explicit branch in `TryAdvanceParentAsync` /
`FinalizePlanningAsync`.
- **Failed/cancelled children** — parent still advances to `WaitingForReview` with
the existing `⚠ Children: N failed, M cancelled` annotation; no wedge.
- **Approve-merge conflict** — keep parent in `WaitingForReview`; surface the
conflicting child like the current merge-conflict path.
- **Existing rows** — planning parents currently sitting at `Idle`+`Finalized` with
live children: behavior change is forward-only (new finalizes use the new flow);
no migration needed since `Status`/`PlanningPhase` columns already exist.
@@ -0,0 +1,142 @@
# Online Inbox — desktop-side design
Date: 2026-06-10
Status: approved, implementing
Related: `docs/online-inbox-api-contract.md` (the API both ends share)
## Goal
Let the owner add task ideas and view their Idle backlog from a phone/browser. The desktop
ClaudeDo opts in to an online service, syncs its list catalog + Idle backlog up, and pulls
web-created tasks down as local `Idle` tasks. Execution stays 100% local.
This spec covers only the **desktop side** (this repo). The API + web client are built
VPS-side against the shared contract.
## Non-goals
- No remote execution; the Worker still runs everything locally.
- No syncing of any task state other than the `Idle` mirror.
- No multi-user. Single Zitadel user = the owner.
- Web client is create + read only.
## Opt-in & where things live
- **Off by default.** When disabled: zero network, zero auth — byte-for-byte today's
behaviour. Auth only matters once enabled.
- Sync runs in the **Worker** (it owns the DB and already hosts `BackgroundService`s). The
opt-in config and the stored refresh token live in `worker.config.json`-adjacent state.
- Interactive Zitadel login happens in the **UI** (browser flow), which hands the resulting
refresh token to the Worker over SignalR; the Worker persists it (DPAPI) and uses it for
headless token refresh during polling.
## Config (`WorkerConfig`, new `online_inbox` section)
```jsonc
"online_inbox": {
"enabled": false,
"api_base_url": "", // e.g. https://inbox.claudedo.kuns.dev
"poll_interval_seconds": 60,
"zitadel": {
"authority": "", // issuer URL (from VPS report)
"client_id": "",
"scopes": "openid offline_access" // offline_access → refresh token
}
}
```
The refresh token is NOT stored in this file. It lives encrypted via
`System.Security.Cryptography.ProtectedData` (DPAPI, CurrentUser) at
`~/.todo-app/online-inbox.token` and is read/written only by the Worker.
## Components (Worker, new `Online/` folder)
```
Worker/Online/
OnlineInboxConfig.cs — the config record (bound from WorkerConfig.OnlineInbox)
Dtos.cs — RemoteList, RemoteTask, MirrorTask DTOs (match the contract)
IOnlineInboxApi.cs — typed client surface (one method per endpoint)
OnlineInboxApiClient.cs — HttpClient impl; attaches bearer via IOnlineAuthProvider
Interfaces/IOnlineAuthProvider.cs — Task<string?> GetAccessTokenAsync(ct)
ZitadelAuthProvider.cs — concrete (PENDING: needs the Zitadel package + client config)
OnlineTokenStore.cs — DPAPI-backed refresh-token persistence
OnlineSyncService.cs — BackgroundService: the reconcile loop (§contract 5)
OnlineBacklog.cs — static helper: the Idle-backlog query/filter (§contract 2)
```
### `IOnlineInboxApi`
```
Task PutListsAsync(IReadOnlyList<RemoteList> lists, ct)
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(ct) // GET /tasks?imported=false
Task MarkImportedAsync(string id, ct) // POST /tasks/{id}/imported
Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, ct) // PUT /tasks/mirror
```
(The desktop never calls `POST /tasks`, `GET /lists`, or `GET /lists/{id}/tasks` — those are
web-only.)
### `IOnlineAuthProvider`
Single method `Task<string?> GetAccessTokenAsync(CancellationToken)` returning a bearer token
(refreshing transparently), or `null` if not logged in / refresh failed. Abstracting it lets
us:
- ship and test the sync engine now with a fake provider,
- wire the real `ZitadelAuthProvider` once the VPS reports authority/client-id and we add the
Zitadel package reference.
`ZitadelAuthProvider` reads the refresh token from `OnlineTokenStore`, exchanges it for an
access token, caches the access token until near expiry. **Marked with a
`// TODO(online-inbox)` until the flow is wired.**
> **Auth correction (2026-06-10):** the `KunsZitadel` nuget package is a *server-side*
> resource-server helper (`AddKunsZitadel``JwtBearer` token *validation*). It belongs on
> the VPS API, NOT the desktop. The desktop must *acquire* tokens, so `ZitadelAuthProvider`
> uses a client OIDC flow — `IdentityModel.OidcClient` (auth-code + PKCE, loopback redirect)
> or the device-authorization grant — against Zitadel's OIDC endpoints, then persists the
> refresh token via `OnlineTokenStore`.
### `OnlineSyncService` (the loop)
- Hosted only when `online_inbox.enabled == true` (guarded at registration).
- Every `poll_interval_seconds`: create a DI scope, resolve `TaskRepository` + `ListRepository`
(same pattern as the External MCP app), run the §5 reconcile loop.
- Skips a cycle (logs at debug) if `GetAccessTokenAsync` returns null (not logged in).
- All failures are caught per-cycle and logged; never crashes the Worker. Network errors back
off to the next interval.
- Import safety: a pulled task whose `listId` has no local list is skipped + logged (not
imported), and NOT marked imported, so it retries once the list exists. Imported tasks land
as `Status=Idle, CreatedBy="online"` — they never auto-run; the user queues them locally.
## UI (later increment, after VPS report)
- Settings modal → new "Online Inbox" section: enable toggle, API base URL, **Sign in /
Sign out** (Zitadel browser/device flow via the OIDC client lib), connection status.
- Login produces a refresh token; UI sends it to the Worker via a new hub method
`SetOnlineInboxAuth(refreshToken)` → Worker writes it through `OnlineTokenStore`.
- Config read/write via hub methods `GetOnlineInboxConfig` / `SetOnlineInboxConfig`
(mirrors the existing `GetAppSettings`/`UpdateAppSettings` pattern).
- Visual verification is a manual step (flagged — never claimed working without a run).
## Security
- Disabled → no network, no token read.
- Bearer attached only over HTTPS `api_base_url`; refuse `http://` non-loopback base URLs.
- Refresh token encrypted at rest (DPAPI CurrentUser). Never logged.
- Imported tasks are `Idle` only — no auto-execution path from the web.
## Testing
- `OnlineSyncService` reconcile logic tested against a **fake `IOnlineInboxApi`** + real
SQLite (Worker.Tests style): pull→import→flag, mirror set = Idle backlog, list catalog push,
unknown-list skip, disabled = no calls, not-logged-in = skipped cycle.
- `OnlineBacklog` filter tested directly (excludes children/planning/blocked/non-Idle).
- **No real network and no real Zitadel** in tests — fake the api + auth provider. (Consistent
with the no-real-Claude-in-tests rule.)
- DPAPI token store: round-trip test is Windows-only; guard or keep as a thin wrapper.
## Open items (need the VPS report)
- Exact Zitadel authority/issuer, client id, scopes, and **which grant the Zitadel app is
registered for** (auth-code+PKCE with which loopback redirect URI, or device-code). This
drives the desktop OIDC client implementation.
- Final API base URL.
- Desktop client OIDC library decision: `IdentityModel.OidcClient` (recommended) vs
hand-rolled device-code. (`KunsZitadel` is server-side only — see the auth correction
above; it's for the VPS API.)
@@ -0,0 +1,91 @@
# Feature unification — one component per feature
Date: 2026-06-19
## Goal
ClaudeDo grew organically; several features now exist as parallel implementations
or are reachable through many hand-wired entry points. This design maps the
duplication and defines a target where **each feature is one component**, reached
through one path, with dead code removed.
## Method
Mapped via five parallel exploration agents (merge/conflict, review→merge,
diff+worktree, task create/edit, UI entry-point inventory), then verified the
load-bearing claims by grep/read before writing this. Every file:line below was
confirmed against the working tree on 2026-06-19.
## Key finding: it is NOT three merge engines
There is **one** merge engine (`TaskMergeService`), wrapped **once** for multi-child
units (`PlanningMergeOrchestrator`), with **one** conflict resolver (the Rider
3-pane). `Worker/CLAUDE.md` already records "there is no separate 'Merge all' entry —
approve is the single review+merge action." What *looks* like 23 merge features is
**entry-point sprawl** in the UI plus **one dead hunks-API** left over from the
Layer-C rework. So unification is mostly UI plumbing + deletion, not re-architecting
the engine.
## Findings — three buckets
### Bucket A — genuine duplication (parallel implementations of one job)
| # | Feature | Duplicated components | Shared already |
|---|---|---|---|
| A1 | Diff viewing | `DiffModalViewModel` (worktree + commit-range), `WorktreeModalViewModel` (file-tree + per-file), `PlanningDiffViewModel` (per-subtask + integration) | `UnifiedDiffParser`, `DiffLinesView` (good) |
| A2 | Agent-config editing | `ListSettingsModalViewModel` (list scope), `AgentSettingsSectionViewModel` (task scope); global lives in `SettingsModalViewModel` | `InheritanceResolver`, `InheritedBadge` (good) |
| A3 | Worktree actions | `WorktreesOverviewModalViewModel` per-row cmds (Merge/Discard/Keep/ForceRemove/ShowDiff/Jump) vs `MergeSectionViewModel` (Merge/OpenDiff) | same `IWorkerClient` calls |
| A4 | Merge display | ~~`AgentStripView` re-displays `MergeSectionViewModel` state~~ — resolved 2026-08-06: `AgentStripView` was dead (never bound after the task-detail redesign); deleted instead of unified | — |
### Bucket B — entry-point sprawl (one backend, many hand-wired doors)
| # | Feature | Doors | Evidence |
|---|---|---|---|
| B1 | Conflict-resolution seam | 5 copies of `Func<string,string,Task>? RequestConflictResolution` | `WorktreesOverviewModalViewModel.cs:83`, `DiffModalViewModel.cs:75`, `MergeModalViewModel.cs:33`, `MergeSectionViewModel.cs:51`, `DetailsIslandViewModel.cs:347` (delegates). Threaded through `MainWindow.axaml.cs:81`, `IslandsShellViewModel.cs:49/202`, `DiffModalViewModel.cs:103`, `MergeSectionViewModel.cs:159` |
| B2 | Diff (open) | 34 | MergeSection "Open Diff", TaskHeaderBar "Review Merged Diff", WorktreesOverview "Show Diff", Planning "Review Combined" |
| B3 | List Settings dialog | 2 (was 3 — Lists context menu entry removed 2026-08-06) | Tasks header button, double-click on a list row, shell bridge `IslandsShellViewModel.cs:190-194` |
| B4 | Worktrees Overview | 23 | Repos menu (global), Lists context menu (per-list) |
| B5 | Repo Import | 2 | Repos menu, Lists footer button |
The conflict-resolution *target* is already single-point (`IslandsShellViewModel.RequestConflictResolutionAsync`, line 49). What is duplicated is the **seam plumbing**: five VMs each own the Func and it is threaded by hand.
### Bucket C — dead / leftover
| # | Item | Evidence |
|---|---|---|
| C1 | Dead hunks conflict API | `TaskMergeService.GetConflictsAsync` (`Lifecycle/TaskMergeService.cs:250`) ← `WorkerHub.GetMergeConflicts` (`Hub/WorkerHub.cs:378`) ← `WorkerClient` `"GetMergeConflicts"` (`Services/WorkerClient.cs:276`) ← `IWorkerClient`. Live resolver uses `GetMergeConflictDocuments` (`WorkerHub.cs:389`). Only `TaskMergeServiceTests.cs:672` still references the old one. |
| C2 | Two task-creation paths | UI quick-add `TasksIslandViewModel.AddAsync` writes EF directly (`db.Tasks.Add`); MCP `ExternalMcpService.AddTask` is the service path. They can drift. |
| C3 | Stale worktrees | `.claude/worktrees/feat+planning-sessions-ui/…` carries old copies of `DiffModalViewModel`/`ListSettingsModalViewModel`/`WorktreeModalViewModel`; layer-c resolver leftovers. Worktree hygiene, not main code. |
| C4 | Naming drift (deferred) | Hub `StartConflictMerge`/`ContinueConflictMerge`/`AbortConflictMerge` (`WorkerHub.cs:367/405/414`) vs service `MergeAsync`/`ContinueMergeAsync`/`AbortMergeAsync`. **Documented as intentional** at `Worker/CLAUDE.md:153`. |
## Targets — one component per feature
1. **MergeCoordinator (B1).** Replace the five `RequestConflictResolution` Func seams with one injected coordinator exposing `MergeAsync(taskId, targetBranch)` that owns the "merge → on-conflict open resolver" sequence. Every door (review Approve, Diff Merge button, WorktreesOverview single + batch, Details merge section) calls it. The single resolution point (`IslandsShellViewModel.RequestConflictResolutionAsync`) becomes the coordinator's body.
2. **DiffViewer (A1 + B2).** One `DiffViewerViewModel` + view with a `DiffSource` abstraction (`DirtyWorktree | BranchVsBase | CommitRange | PlanningAggregate | IntegrationBranch`) and an optional file-tree pane. Replaces `DiffModal` + `WorktreeModal` + `PlanningDiff` shells; keeps `UnifiedDiffParser`/`DiffLinesView`. All B2 doors open it with a different source.
3. **WorktreeActions (A3).** One `WorktreeActionsViewModel` for a single task's worktree (merge/diff/discard/keep/force-remove), reused by both the overview rows and the Details merge section instead of each owning copies.
4. **AgentConfigEditor (A2).** One editor component parameterized by scope (`Global | List | Task`) over `InheritanceResolver`, embedded in Settings, List Settings, and the Details panel. Collapses the duplicated property set + reset commands + badges.
5. **DialogService (B3B5).** Consolidate the per-modal `Show*` Func seams (`IslandsShellViewModel.cs:59-71`) into one `IDialogService` with typed open methods (`OpenListSettings(list)`, `OpenRepoImport()`, `OpenWorktreesOverview(listId?)`…). Menu, context menu, and footer all call the same method; duplicate command definitions across `ListsIsland`/shell collapse to one.
6. **Single task-creation path (C2).** Route UI quick-add through the same creation path MCP `AddTask` uses (repository/service), so both honor the same invariants.
Plus **C1** (delete dead hunks API + its test) and **C3** (prune stale worktrees) as groundwork. **C4** naming alignment is **deferred** — it is documented-intentional and would churn the hub + `WorkerClient` + every `IWorkerClient` fake (see the "fakes to sync" hazard) for cosmetic gain.
## Decisions
- **Phased, each phase ships green.** Six independently buildable/committable slices; cheapest and lowest-risk first (see the plan). No big-bang.
- **One plan file per slice.** Matching the 2026-06-05 layer-A/B/C convention, each slice gets its own `docs/superpowers/plans/2026-06-19-unify-<slice>.md` authored when it is picked up. This umbrella plan sequences them and details Phase 01.
- **DiffViewer (A1) is last.** Highest effort and most UX-sensitive (file-tree vs whole-unified are different layouts); deferring it lets the cheaper wins land first and de-risks the big one.
- **Keep the merge engine and the resolver seam contract.** `TaskMergeService`, `PlanningMergeOrchestrator`, `ConflictResolverViewModel` ctor/`OpenAsync`/`OpenForPlanningAsync`/`CloseRequested` are unchanged — unification is above them.
- **Naming alignment deferred, not done** (rationale above).
## Out of scope / deferred
- Hub/service merge-method renaming (C4).
- Subtask deletion in the UI (a missing feature surfaced during mapping, not a duplicate).
- Any DB migration, worker engine change, or push.
## Acceptance (per phase)
Each phase: `dotnet build -c Release` clean for touched projects; the relevant test
project green; locales in parity (Localization.Tests) where keys change; the feature
reachable through its single new path with the old doors removed or delegating. UI
phases (25) flag a visual-verification gap for Mika to confirm in the running app.
@@ -0,0 +1,132 @@
# Rider-style 3-pane merge editor (conflict resolver redesign)
Date: 2026-06-19
## Goal
Replace ClaudeDo's current conflict resolver (3 read-only columns Base|Ours|Theirs,
one conflict at a time, accept buttons + editable result below) with a JetBrains
Rider-style **3-pane merge editor**:
- LEFT = **Ours** (read-only) · current branch / merge target
- MIDDLE = **Result** (editable) · the merged file being assembled
- RIGHT = **Theirs** (read-only) · incoming task branch
Whole file per pane (not one conflict at a time), color-coded conflict blocks,
inline per-hunk accept controls (`` accept a side into the result, `✕` dismiss),
a `M conflicts · K resolved` readout, synced scrolling, Continue gated until every
conflict is resolved, Abort, and a binary-file guard. Visual reference: the
attached "Merge Revisions" screenshot.
## Background
- Avalonia 12 desktop app; the conflict editor already uses **AvaloniaEdit 12.0.0**
+ `AvaloniaEdit.TextMate` (theme `StyleInclude` in `src/ClaudeDo.App/App.axaml`).
- **Backend is kept unchanged.** `WorkerHub.GetMergeConflictDocuments(taskId)` returns
each conflicted file as ordered `MergeSegment`s: *stable* text (git's already
auto-merged content) interleaved with *conflict* blocks carrying `Ours/Base/Theirs`.
`StartConflictMerge` / `WriteConflictResolution` / `Continue[Planning]ConflictMerge` /
`Abort[Planning]ConflictMerge` and their `IWorkerClient` mirrors stay as-is.
`ConflictMarkerParser` (Data) already produces the segments. **ours = merge target
(current branch); theirs = incoming task branch.** Merges are LOCAL-only (no push).
- **Seam kept unchanged** so single-task AND planning conflict paths keep working:
`IslandsShellViewModel.ConflictResolverFactory` + `ShowConflictResolver`
(wired in `MainWindow.axaml.cs`), VM ctor `(IWorkerClient, taskId)`,
`OpenAsync(targetBranch)`, `OpenForPlanningAsync(parentId, subtaskId)`, `CloseRequested`.
The planning-path WIP currently uncommitted in the tree (`OpenForPlanningAsync`,
`_conflictTaskId`, `LoadDocumentsAsync`) is part of this seam and is preserved.
### Key insight: the segments already line the panes up
Because every conflicted file is split into *stable* (identical on both sides, git
auto-merged) and *conflict* (divergent) segments, reconstructing three documents —
- **Ours** = Σ over segments of (stable.Text | conflict.Ours)
- **Theirs** = Σ over segments of (stable.Text | conflict.Theirs)
- **Result** = Σ over segments of (stable.Text | conflict.Resolution ?? conflict.Ours)
— yields three documents that are byte-identical in their stable regions and differ
only inside conflict blocks. So the panes align line-for-line for free, and a real
client-side 3-way diff is **not** needed for the core feature.
## Decisions
- **Data source = segment-based (no backend change, no DiffPlex).** The worker already
applied git's auto-merge; only conflicts remain actionable. The screenshot's
"N changes" (non-conflicting hunks shown as separately flippable) are already merged
and have nothing to accept, so the readout is **`M conflicts · K resolved`**. True
"N changes" parity (raw `:1/:2/:3` blobs + DiffPlex 3-way) is an explicit later
add-on that does not touch the seam — see *Out of scope / fast-follow*.
- **One file at a time + file switcher.** Like Rider's title bar ("Merge Revisions for
…file"). When more than one file conflicts, a compact switcher selects the active
file; Continue still requires *all* files resolved. (Replaces today's cross-file
flattened one-at-a-time navigation as the primary model.)
- **Result-pane editing model.** The middle document is the merged file. Stable text is
read-only via `IReadOnlySectionProvider`; only conflict regions are editable. Each
conflict's result span is tracked in a `TextSegmentCollection` (anchors auto-adjust on
edit). Accepting ``(ours)/``(theirs) replaces that span; editing inside it or
accepting flips the block to **resolved**. Unresolved regions are seeded with the Ours
text and painted red until acted on.
- **Accept controls = overlay between panes** (not an AvaloniaEdit margin). A thin Canvas
overlay between Ours|Result and Result|Theirs hosts ``/`✕` (and ``) per conflict,
positioned at each block's visual Y (recomputed on scroll/resize). This matches the
screenshot's between-pane gutters and avoids the lack of a built-in right-side margin.
- **Synced scroll = proportional (Green).** Mirror each pane's vertical scroll offset to
the other two with a re-entrancy guard. Aligned/virtual-space scroll + bezier connector
curves are a deferred stretch.
- **Seam + existing VM tests preserved.** Keep `MergeConflictBlock` with its
`AcceptOurs/Theirs/Both/Base` commands and `MergeFile.Compose`; keep
`Current`/`CurrentIndex`/`Next`/`Previous` repurposed as the focused-conflict the top
arrows jump to. New state (active file, readout) is additive.
## Architecture
### ViewModel (`ConflictResolverViewModel`, `ConflictModels.cs`)
Unchanged seam: ctor, `OpenAsync`, `OpenForPlanningAsync`, `CloseRequested`,
`Continue`/`Abort` (incl. planning routing), `CanContinue` gating, binary guard.
Additive:
- `ActiveFile` (`MergeFile`) + the switcher list (`Files`) + `SelectFileCommand`.
- Per-active-file reconstruction exposed for the view and for tests:
`ActiveOursText`, `ActiveTheirsText`, `ActiveResultText` (result seeds unresolved =
Ours), plus an ordered list of conflict descriptors (the block + its segment index)
so the view can compute offsets/spans as it assembles each document.
- Readout `PositionText``"{M} conflicts · {K} resolved"` (active file and/or total);
`CanContinue` stays "all files resolved AND no binary".
- On switching files, block `Resolution` persists (state lives on `MergeConflictBlock`),
so progress survives navigation; the view rebuilds documents from the active file.
### View (`Views/Conflicts/ConflictResolverView.axaml` + `.cs`)
- AXAML: ModalShell host (kept), header (prev/next arrows, file switcher, readout),
`Grid` of three bordered panes with headers, two between-pane overlay Canvases,
footer (Continue/Abort), binary banner, `Escape`→Abort. Drop the Base column.
- Code-behind builds three `TextDocument`s from `ActiveFile`'s segments, recording each
conflict's line span per document; installs TextMate by file extension on all three;
rebuilds on file switch; pushes result-pane edits back into the active block's
`Resolution` and flips resolved.
- `IReadOnlySectionProvider` on the Result `TextArea` (stable = read-only, conflicts =
editable) backed by a `TextSegmentCollection` of the conflict result-spans.
- One `IBackgroundRenderer` per pane painting unresolved-conflict (red), resolved
(green/muted), and ours/theirs side tints, driven by the recorded spans + block state.
- Overlay accept controls positioned at each block's `TextView` visual top; click →
`block.AcceptOurs/AcceptTheirs` and the code-behind replaces the tracked result span.
- Proportional synced vertical scroll across the three panes.
### Localization / tokens
- New `conflictResolver.*` keys (pane headers, readout, accept tooltips) in
`en.json` + `de.json` (parity enforced by Localization.Tests).
- Block colors from `Tokens.axaml` (reuse Blood/Moss/Accent tints; add tokens only if a
needed shade is missing).
## Out of scope / fast-follow (not in this plan)
- **Raw 3-way diff "N changes" parity (Option B):** a new worker method returning raw
`:1/:2/:3` blobs per conflicted file + DiffPlex client-side 3-way diff so
non-conflicting changes also appear as accept-able hunks. Seam-preserving; later.
- **Intra-conflict word/line highlighting** (Rider's "Highlight words") via a line
transformer.
- **Bezier connector curves + aligned / virtual-space synced scroll** (Red stretch).
- No DB migration, no backend/seam changes, no push.
@@ -0,0 +1,49 @@
# Worker log → footer auto-route + Log Visualizer overlay
**Date:** 2026-06-23
**Status:** approved (design forks resolved with user)
## Goal
1. Auto-route **all Worker WARN/ERROR** Serilog events to the footer status strip (today only ~10 hand-curated business events reach it).
2. Make the footer log line **clickable** → opens a **Log Visualizer overlay** showing the **last 30 min** of logs at **all levels**, color-coded.
3. **Dedupe/rate-limit** the footer so repeating warnings (e.g. the current 60s OIDC-discovery failure) don't strobe.
## Decisions (locked)
- **Overlay source:** Worker-side **in-memory ring buffer** (30-min window, all levels), fetched via a hub call. No log-file parsing.
- **Levels:** overlay shows INF/WRN/ERR; footer flashes **WARN/ERROR only**.
- **Footer noise:** per-message dedupe within a rate-limit window (suppress the footer broadcast for an identical message seen recently; the event is still buffered for the overlay).
## Architecture
### Worker
- **`LogRingBuffer`** (singleton, `Logging/`): thread-safe, time-bounded (`TimeSpan` window, default 30 min) + hard cap (e.g. 5000) ring of `WorkerLogRecord(Message, Level, TimestampUtc)`. Evicts on append by age + cap. `Snapshot()` returns newest-last.
- **`BroadcastLogSink : Serilog.Core.ILogEventSink`** (`Logging/`): for every `LogEvent`
- map level: Verbose/Debug/Information→`Info`, Warning→`Warn`, Error/Fatal→`Error`;
- render `msg = evt.RenderMessage()` (+ `": {ex.GetType().Name}: {ex.Message}"` first-line if `evt.Exception != null`);
- append to `LogRingBuffer` (all levels);
- if `Warn|Error` **and** not rate-limited: fire-and-forget `HubBroadcaster.WorkerLog(msg, level, evt.Timestamp.UtcDateTime)`.
- **Loop guard:** wrap the broadcast in try/catch and swallow; skip broadcasting events whose `SourceContext` is SignalR/connections plumbing (still buffered). Broadcasting must never itself log.
- **Dedupe/rate-limit:** dict `message → lastBroadcastUtc`; suppress footer broadcast if `now - last < RateLimitWindow` (const, 120 s). Periodic prune of the dict.
- **DI wiring (chicken-egg):** `LogRingBuffer` + `BroadcastLogSink` are created as locals in `Program.cs` *before* `builder.Build()`, captured into `UseSerilog(... .WriteTo.Sink(broadcastSink))`, and registered as singletons. `HubBroadcaster` doesn't exist until post-build, so the sink starts detached; after `builder.Build()` we call `broadcastSink.Attach(app.Services.GetRequiredService<HubBroadcaster>())`. Buffering works from process start; broadcasting begins once attached.
- **Hub:** `WorkerHub.GetRecentLogs() -> IReadOnlyList<WorkerLogRecordDto>` reads `LogRingBuffer.Snapshot()`. (Read-only, no auth beyond existing hub.)
### UI
- **IWorkerClient / WorkerClient:** add `Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync(CancellationToken ct = default)`. ⚠ Update hand-rolled fakes in **both** test projects (StubWorkerClient + Worker.Tests UiVm fake).
- **Footer:** wrap the worker-log `TextBlock` so it's clickable (Button, transparent) → `IslandsShellViewModel.OpenLogVisualizerCommand`. Existing `OnWorkerLogReceived` already routes the (now more numerous) `WorkerLog` broadcasts to the strip — **no change needed** for footer routing itself.
- **`LogVisualizerViewModel`** (Modals/): on open, `GetRecentLogsAsync()``ObservableCollection<LogLineViewModel>` (msg, level→brush, HH:mm:ss). A level filter (All / Warn+Err) and a Refresh command. MVP = snapshot on open + Refresh; live-tail is a later nicety.
- **`LogVisualizerView`** (Modals/): `ModalShell`-based dialog (consistent with other modals), shown via `IDialogService.ShowLogVisualizerAsync(vm)`. Small, scrollable, monospaced, color-coded lines.
- **Localization:** new `vm.logVisualizer` (+ any view keys) in **en.json + de.json** (parity test enforces).
## Out of scope / follow-ups
- Live-tail while the overlay is open (snapshot + Refresh for MVP).
- The **OIDC-discovery-every-60s failure** is a *separate* bug (Online Inbox enabled, `auth.kuns.dev` SSL fails). Dedupe tames the footer symptom; the root cause is tracked separately.
## Tests
- Worker: `LogRingBufferTests` (age + cap eviction, snapshot order), `BroadcastLogSinkTests` (level mapping; all levels buffered; only Warn/Err broadcast; dedupe suppresses repeat broadcast within window but still buffers; exception rendering; loop-guard source filter).
- UI: `LogVisualizerViewModelTests` (loads from worker, populates, filter). Footer-click wiring smoke.
@@ -0,0 +1,102 @@
# Interactive "Answer Claude's Questions" — Design
**Date:** 2026-06-25
**Status:** Approved (brainstormed with Mika)
## Goal
Let the user answer a question Claude raises *mid-run* from inside Mission Control,
without leaving the autonomous-execution model. Not a chat panel, not a terminal, not
proactive steering — only: *Claude surfaces a question → the user types an answer → the
run continues with that answer in context.*
User decisions (brainstorm):
- Scope: "I mostly want to answer his questions if he surfaces any."
- Trigger: **any running task** may ask, with a **3-minute** answer window.
## Why not the alternatives
- **Embedded terminal / PTY** — would destroy the NDJSON contract the whole worker
pipeline depends on (StreamAnalyzer, token accounting, auto-commit, status flow) and
needs a terminal-emulator control Avalonia doesn't have. Rejected.
- **Streaming-stdin (`--input-format stream-json`)** — right tool for a free-form chat,
overkill here. Rejected for v1.
- **`--resume` per-turn** — already exists; not live (cold process per turn).
## Mechanism
The in-task MCP already blocks the `claude -p` process while a tool call is in flight.
That blocking *is* the pause. Add one in-task MCP tool, `AskUser(question)`:
1. The tool resolves the caller task id, registers a pending question + a
`TaskCompletionSource<string>` in a singleton `PendingQuestionRegistry`, and
broadcasts `TaskQuestionAsked(taskId, questionId, question)`.
2. Mission Control surfaces the question with an input box.
3. The user answers → `WorkerHub.AnswerTaskQuestion` resolves the TCS → the tool
returns the answer as its result → Claude continues.
4. No answer within **3 minutes** → the tool returns *"No response received within 3
minutes — proceed using your best judgment."* and the run carries on autonomously.
### Key facts that make this work
- **No persisted status change.** The task is still genuinely `Running` (process alive,
blocked mid-tool-call). "Waiting for input" is **ephemeral**: in-memory registry +
live SignalR events + a UI overlay. No `TaskStatus` enum value, no `TaskStateService`
transition, **no EF migration**. If the worker dies mid-wait, `StaleTaskRecovery`
flips the orphaned `Running` row to `Failed` like any interrupted run.
- **`MCP_TOOL_TIMEOUT` must be raised.** Claude Code caps HTTP MCP tool calls at **60 s**
by default. The `claudedo_run` MCP is HTTP, so `ClaudeProcess` must set
`MCP_TOOL_TIMEOUT=200000` (≈3 min + margin) on the spawned process or the 3-min window
is silently truncated to 60 s.
- **MCP wired for all runs.** Today `TaskRunner` only mints the run MCP for standalone
top-level tasks (for `SuggestImprovement`). To satisfy "any running task," move the
MCP-identity setup out of that gate so every `RunAsync` gets `claudedo_run`.
`AllowedTools` always includes `mcp__claudedo_run__AskUser`; `SuggestImprovement` stays
gated to improvement-eligible (standalone) runs.
## Surface changes
**Worker (mostly new files):**
- `Runner/PendingQuestionRegistry.cs` (new, singleton) — `Register`, `TryAnswer`, `Get`,
`Remove`; one pending question per task.
- `Runner/TaskRunMcpService.cs` (edit) — add `AskUser` `[McpServerTool]`; inject the
registry.
- `Runner/TaskRunner.cs` (edit) — wire MCP identity for all runs; add `AskUser` to
allowed tools.
- `Runner/ClaudeProcess.cs` (edit) — set `MCP_TOOL_TIMEOUT` env.
- `Hub/HubBroadcaster.cs` (edit) — `TaskQuestionAsked`, `TaskQuestionResolved`.
- `Hub/WorkerHub.cs` (edit) — `AnswerTaskQuestion`, `GetPendingQuestion` + DTO.
- `Program.cs` (edit) — register `PendingQuestionRegistry` singleton.
- System prompt (edit) — one line telling Claude the tool exists and to use it only when
a wrong guess would be costly/irreversible (otherwise proceed).
**UI:**
- `Services/IWorkerClient.cs` + `WorkerClient.cs` (edit) — `AnswerTaskQuestionAsync`,
`GetPendingQuestionAsync`, `TaskQuestionAskedEvent`, `TaskQuestionResolvedEvent`.
- `ViewModels/Islands/TaskMonitorViewModel.cs` (edit, **hot file**) — pending-question
state, `AnswerDraft`, `SubmitAnswerCommand`, clear on finish/resolve.
- `ViewModels/MissionControlViewModel.cs` (edit) — hydrate pending question on attach.
- `Views/MissionControl/MonitorPaneView.axaml` (edit, **hot file**) — additive
question/answer banner above the terminal.
- `Localization/locales/en.json` + `de.json``missionControl.question.*` keys.
**Tests:** `PendingQuestionRegistry` (answer/timeout/unknown/overwrite), `AskUser` tool
(answer + timeout fallback, fake broadcaster — no real Claude), `TaskMonitorViewModel`
(surface/submit/clear). Update IWorkerClient fakes in both test projects.
## Concurrency note
Two files (`TaskMonitorViewModel.cs`, `MonitorPaneView.axaml`) are also being touched by
a concurrent Mission Control drag-and-drop session on the shared main tree. Keep edits
additive, commit explicit paths only (never `git add -A`).
## Verification gaps (manual)
1. **Real-Claude smoke test** — confirm a blocking `AskUser` call survives ≥3 min with
`MCP_TOOL_TIMEOUT=200000` and that the model actually calls the tool when uncertain.
2. **Visual** — the question banner + input box in the pane (Mika does the visual pass).
## Non-goals
Free-form chat panel; proactive steering; tool-permission prompts (stays `auto`);
`ContinueAsync`/resumed runs gaining `AskUser` (deferred follow-up).
@@ -0,0 +1,144 @@
# Mission Control — multi-task live monitoring
Date: 2026-06-25
Status: approved (design); implementation not started
## Problem
The UI can observe only **one** running task at a time. `DetailsIslandViewModel` is hard 1:1
(single `Task`, single `_subscribedTaskId`); selecting another task in the middle pane *replaces*
what Details shows. Yet the worker runs several tasks concurrently (`MaxParallelExecutions`) and
already broadcasts every task's live output to all clients keyed by `taskId`. So the user cannot
watch multiple in-flight sessions, and monitoring blocks normal work (adding tasks, reviewing).
## Goal
Watch several running tasks at once **without** giving up the normal app. Requirements drawn from
the brainstorm:
- A **live console grid** — multiple full Claude output streams side by side.
- Each pane also shows **task details, blocking reasons**, and a **navigation helper** to open the
monitored task in the main app.
- Lives in a **separate, always-available window** so the main window stays fully usable (adding
tasks must never be blocked). Combines "full window" + "detachable".
## Non-goals
- No worker/SignalR changes. The broadcast layer is already N-capable (`TaskMessage(taskId,line)`,
`TaskStarted/Finished/Updated`, `GetActive()`). This is a UI/VM-only feature.
- No second SignalR connection. The new window shares the existing singleton `IWorkerClient`.
- No new merge/review engine. Review/merge stays in the main window's Details pane; Mission Control
is read-mostly (monitor + cancel + navigate).
## Hard constraint: no duplicated components or features
This feature is an **extract-and-reuse** exercise, not a rebuild. The single biggest risk is
forking a second live-streaming/parsing/status implementation. The reuse map below is binding.
### Reuse map (what already exists — use it, do not copy it)
| Concern | Existing asset | Location | How Mission Control uses it |
|---|---|---|---|
| Live console body (log list, LIVE/DONE/FAILED chip, auto-scroll) | `SessionTerminalView` (StyledProps `Entries`, `Label`, `IsRunning/IsDone/IsFailed`) | `Views/Islands/SessionTerminalView.axaml(.cs)` | Bind a pane's `Entries`→its `Log`, status flags + label. **No new console control.** |
| Log line model | `LogLineViewModel` + `LogKind` | `ViewModels/Islands/DetailsIslandViewModel.cs` (top) | Shared model — move to its own file so both consumers reference one type. |
| Live stream parse/replay | `OnTaskMessage` / `AppendStdoutLine` / `FlushClaudeBuffer` / `ReplayLogFileAsync` + `StreamLineFormatter` + `ExpandUserPath` | private in `DetailsIslandViewModel.cs` | **Extract to `TaskMonitorViewModel`** (Phase 1). One streaming engine, two consumers. |
| Status state machine | `AgentState` + `Is*` flags + `StatusToStateKey` / `FinishedStatusToStateKey` | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
| Outcome / roadblock split | `ApplyOutcome` + `RoadblockMarker` constant | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
| Status chip / terminal styling | `live-chip`, `terminal`, `log-*` style classes | `Design/IslandStyles.axaml` | Reuse the classes as-is. |
| Add a new task | `TasksIslandViewModel.AddAsync` (`NewTaskTitle`, user-list only, direct `TaskRepository`) | `TasksIslandViewModel.cs:406` | Optional quick-add reuses this path; **must not** introduce a second insert path. |
| Live task list | `IWorkerClient.GetActive()` + `TaskStarted/Finished` events | worker hub / `WorkerClient` | Populate the grid; add/remove panes. |
| DI / singletons | `IslandsShellViewModel`, `DetailsIslandViewModel`, `IWorkerClient` all singletons | `App/Program.cs` | Register `MissionControlViewModel` singleton; inject existing singletons. |
## Design
### TaskMonitorViewModel (the reusable core — new, but carved out of DetailsIslandViewModel)
One instance == one monitored task. Owns:
- `Log` (`ObservableCollection<LogLineViewModel>`), the filtered `TaskMessageEvent` subscription
(by `taskId`), stdout buffering, and NDJSON replay from disk on attach.
- `AgentState` + `Is*` flags; `SessionOutcome` / `Roadblocks` (the outcome split).
- Lightweight display: `Title`, `TaskIdBadge`, `Model`, `TurnsText`, `TokensFormatted`,
diff add/del, elapsed.
- `BlockingReason` (string/visible flag) derived from existing data: `BlockedByTaskId`
(planning/child chain), `WaitingForReview` / `WaitingForChildren` status, and roadblock markers.
- Commands: `OpenInApp`, `Detach`, `Cancel`.
- `IDisposable` — unsubscribes all worker events (mirror DetailsIslandViewModel.Dispose).
`DetailsIslandViewModel` is refactored to **own one `TaskMonitorViewModel` (`public Monitor`)** and
delegate streaming/status/outcome to it. Its heavy concerns (subtasks, attachments, editing, merge
cockpit, review verbs, child outcomes, notes/prep modes) stay put. **Phase 1 must be a no-behavior-
change refactor** — all existing Ui.Tests stay green.
> Binding-surface decision (Phase 1): repoint `WorkConsole.axaml`'s Output-tab bindings that
> reference streaming/status (`Log`, `IsRunning/IsDone/IsFailed`, `SessionOutcome`, `TurnsText`,
> diff text, `Model`) to `Monitor.*`. `x:DataType` stays `DetailsIslandViewModel`; compiled bindings
> handle the nested path. Review/merge/session bindings are untouched. Prefer repointing over adding
> ~15 forwarding properties (one source of truth, no boilerplate).
### MissionControlViewModel (new)
- `ObservableCollection<TaskMonitorViewModel> Monitors`, keyed by `taskId`.
- On open: seed from `GetActive()`. On `TaskStarted`: add a monitor. On `TaskFinished`: keep the
pane (so the final output stays readable) but flip its state; a "clear finished" action prunes them.
- Adaptive layout signal (column count) from `Monitors.Count`:
`1→1col, 2→2col, 34→2col(2 rows), 5+→fixed-width panes, horizontal scroll`. Least-active panes
beyond a threshold collapse to a compact card (title + last line + chip), click to expand — this is
the readability fallback so we never render N unreadable slivers.
- Optional `QuickAdd` (deferred within Phase 2): title + target user-list → the **same** creation
path as `TasksIslandViewModel.AddAsync` (shared method, not a copy).
- Disposes every monitor on window close.
### Windowing (new plumbing — thin)
- `MissionControlWindow` (Avalonia `Window`) hosting `MissionControlView`; DataContext =
the singleton `MissionControlViewModel`.
- No non-modal secondary-window precedent exists (all current dialogs use `ShowDialog(owner)`), so
this is genuinely new but small:
- Set `desktop.ShutdownMode = OnMainWindowClose` in `App.OnFrameworkInitializationCompleted` so
closing Mission Control never quits the app, and closing the main window does.
- Open via a **title-bar button in MainWindow** (toggle: show / focus-if-open). The window is
created lazily and hidden (not destroyed) on close so its monitors persist cheaply.
- Persist size/position (reuse the ui.config.json mechanism if present; otherwise defer).
### MonitorPaneView (new view, reuses SessionTerminalView)
```
┌─ #142 Refactor auth module ───────── ● running ─┐ header: title, live chip, tok/turn/elapsed
│ ⏱ 4m12s ◆ 18.3k tok ↻ turn 6 │
├───────────────────────────────────────────────────┤
│ ⚠ Blocked: waiting on #141 (planning parent) │ blocking banner (visible only when blocked)
├───────────────────────────────────────────────────┤
│ <SessionTerminalView Entries={Log} .../> │ the REUSED console
├───────────────────────────────────────────────────┤
│ [↗ Open in app] [⧉ Detach] [✕ Cancel] │ footer
└───────────────────────────────────────────────────┘
```
### Navigation helper "Open in app" (new shell method)
No select-by-id exists today. Add `IslandsShellViewModel.RevealTaskAsync(taskId)`:
1. resolve the task's list, set `Lists.SelectedList`; 2. await `Tasks.LoadForList`; 3. find the row in
`Tasks.Items` by id, set `Tasks.SelectedTask` (→ `Details.Bind`); 4. bring MainWindow to front.
`TaskMonitorViewModel.OpenInApp` calls this. Single navigation entry point — no duplicate selection logic.
### Detach (Phase 3)
`Detach` moves a `TaskMonitorViewModel` out of the grid into a small `TaskMonitorWindow`
(reuses `MonitorPaneView`), optionally always-on-top; closing it re-docks. Lowest priority.
## Risks / open items
- **Phase 1 binding repoint** is the main risk: a missed `WorkConsole` binding shows as a blank
field, not a build error. Mitigation: Ui.Tests + a manual visual pass on the Details pane.
- **Localization parity** (Localization.Tests): every new visible string needs en + de keys under a
`missionControl.*` namespace.
- **Quick-add coupling** across windows is the weakest part; kept optional/deferrable.
- Detached windows = most plumbing, least daily payoff → Phase 3, last.
## Verification
- Build `ClaudeDo.App` + run Ui.Tests / Localization.Tests after each phase.
- Manual visual pass (cannot be auto-verified): Details pane unchanged after Phase 1; grid populates
with 2+ concurrent tasks, blocking banner shows, Open-in-app surfaces the task, adding a task in the
main window works while Mission Control is open.
@@ -0,0 +1,147 @@
# In-App Interactive Sessions — Design
**Date:** 2026-06-26
**Status:** Proposed (awaiting approval)
## Goal
Replace the external Windows-Terminal "Run interactively" session with an **in-app
streaming chat**, rendered in the existing `SessionTerminalView` in **both task detail and
Mission Control**. Keep everything inside the app — no `wt.exe` pop-out. Autonomous task
execution is **untouched** (stays one-shot, non-interactive).
## Decisions (brainstorm)
1. **Engine: persistent streaming session.** One `claude` process kept alive with
`--input-format stream-json`; user messages pushed over stdin.
2. **Scope: interactive sessions only.** The autonomous `TaskRunner`/`ClaudeProcess` run
loop, review, queue, and worktree machinery are NOT changed.
3. **Placement: shared `SessionTerminalView`** — the in-app session + composer appear in the
task-detail session surface and in the Mission Control monitor pane.
4. **Full replace.** "Run interactively" now opens the in-app session; the
`WindowsTerminalLauncher.LaunchInteractiveAsync` path is removed. **Planning** sessions
keep using `wt` (untouched).
5. **Send semantics: interrupt + redirect** mid-turn (control protocol), with automatic
*queue-for-next-turn* fallback if interrupt is unavailable.
## What an interactive session is (unchanged semantics, new transport)
Today (`PlanningSessionManager.OpenInteractiveAsync` + `WindowsTerminalLauncher`):
`claude --model <PlanningAlias> --permission-mode auto "<task title+description>"` in the
**list's working dir**, env `MAX_THINKING_TOKENS=20000`, full default toolset, relies on the
globally-registered `claudedo` MCP. **Ephemeral** — no worktree, no `task_run` record, no
status change, no review.
We keep all of that. Only the transport changes: instead of a `wt` window, the same
`claude` invocation runs as a persistent stream-json process owned by the worker, its output
streamed into the app and its stdin fed from an in-app composer.
> Honest tradeoff: the `wt` terminal gave the full Claude Code TUI (slash-command UX,
> interactive prompts). An in-app stream-json chat is plainer — type messages, watch streamed
> output. `--permission-mode auto` means no blocking permission prompts (so headless works),
> but it is a simpler surface than the real TUI. Accepted per the "full replace" decision.
## The streaming engine
Flags: `--model <PlanningAlias> --permission-mode auto --input-format stream-json
--output-format stream-json --verbose --replay-user-messages` in the list working dir, env
`MAX_THINKING_TOKENS=20000`. No `--mcp-config`/`--allowedTools` (interactive uses the global
MCP + default tools, exactly as today).
- First stdin message = the seeded interactive prompt:
`{"type":"user","message":{"role":"user","content":[{"type":"text","text":"…"}]},"parent_tool_use_id":null}\n`
(stdin stays open).
- A stdout read task forwards each NDJSON line to a callback (→ broadcast + the session's log)
and detects `result` events (turn boundary; the process then idles for the next message).
- `SendUserMessageAsync(text)` writes a user-message JSON line; if a turn is in flight, also
`InterruptAsync()` (control-protocol interrupt) so Claude pivots immediately. If interrupt
is unavailable, the message lands when the current turn ends → automatic queue fallback.
- **Interrupt is verified working** (spike, 2026-06-26, CLI 2.1.191). Exact shape:
`{"type":"control_request","request_id":"<id>","request":{"subtype":"interrupt"}}` — no
`initialize` handshake needed; `control_response {"subtype":"success"}` confirms
synchronously; the same process then accepts the redirect and runs a fresh turn with
context intact.
- **Interrupt artifact:** the aborted turn emits a `result` with `is_error=true,
subtype="error_during_execution"`. The session must treat an interrupt-induced result as
*"turn aborted, continue"* (drain the queued redirect), **not** as a session failure.
Tolerate the incidental `system:init`/`system:status`/`rate_limit_event`/hook events that
also appear in the stream.
- `--replay-user-messages` echoes each sent message back on stdout as a `user` event, so it
rides the existing stream pipeline into the timeline (ordered + confirmed) with no extra
broadcast surface.
- The session ends only when the **user stops it** (kill the process tree) — an interactive
session has no auto-finalize and never enters review. No queue slot is involved (it is
launched directly, not via the autonomous picker).
## Surface changes
**Worker**
- `Runner/StreamingClaudeSession.cs` (new) — persistent process + send/interrupt/stop; reuse
the `ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT` from `ClaudeProcess`; streams via a line
callback; `IsTurnInFlight`. Cancellation kills the tree.
- `Runner/LiveSessionRegistry.cs` (new, singleton) — `taskId → StreamingClaudeSession`
(`Register`/`TryGet`/`Unregister`/`Stop`), mirrors `PendingQuestionRegistry`.
- `Planning/InteractiveSessionService.cs` (new) — owns interactive lifecycle: `StartAsync(
taskId)` resolves the list working dir + seeded prompt (reuse `OpenInteractiveAsync`'s
body), spawns the session, registers it, wires output to `HubBroadcaster.TaskMessage`,
broadcasts `InteractiveSessionStarted`; `SendAsync(taskId, text)`; `StopAsync(taskId)`
`InteractiveSessionEnded`.
- `Planning/WindowsTerminalLauncher.cs` + `Planning/Interfaces/ITerminalLauncher.cs` — remove
`LaunchInteractiveAsync` (+ `InteractiveLaunchContext`). Planning start/resume stay.
- `Hub/WorkerHub.cs``OpenInteractiveTerminalAsync` re-pointed to
`InteractiveSessionService.StartAsync` (no terminal); add `SendInteractiveMessage(taskId,
text)`, `StopInteractiveSession(taskId)` (+ optional `InterruptInteractiveSession`).
- `Hub/HubBroadcaster.cs``InteractiveSessionStarted(taskId)`,
`InteractiveSessionEnded(taskId)`. Log lines reuse the existing `TaskMessage(taskId, line)`.
- `Program.cs` — register `LiveSessionRegistry` + `InteractiveSessionService`.
**UI**
- `Views/Islands/SessionTerminalView.axaml(.cs)` — add an optional composer (styled
properties: `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`).
Both hosts (task detail + Mission Control) get it by binding their VM's composer state.
- `StreamLineFormatter` — render `type:"user"` NDJSON events as a `LogKind.User` bubble.
- A small shared composer concept on `TaskMonitorViewModel` **and** `DetailsIslandViewModel`
(factor a helper to avoid duplication): `ComposerDraft`, `SubmitComposerCommand`,
`IsInteractiveLive` (set by `InteractiveSessionStarted/Ended`). Submit →
`SendInteractiveMessageAsync`; clear draft. (If a pending AskUser question exists, the same
composer answers it — keep the existing answer route.)
- `MissionControlViewModel``EnsureMonitor(taskId)` on `InteractiveSessionStarted` so the
session appears as a monitor; mark it interactive.
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs``SendInteractiveMessageAsync`,
`StopInteractiveSessionAsync` (+ optional interrupt); events
`InteractiveSessionStartedEvent`/`InteractiveSessionEndedEvent`. `OpenInteractiveTerminalAsync`
keeps its name/signature (now starts the in-app session). Update hand-rolled fakes in **both**
test projects (`iworkerclient_fakes_sync`).
- `TasksIslandViewModel.RunInteractivelyAsync` — unchanged call site; now opens/focuses the
in-app session surface instead of a terminal.
- Localization `interactive.*` / `missionControl.chat.*` (en/de, parity enforced).
**Tests**
- `StreamingClaudeSessionTests` (fake process stream, no real Claude): first message streams;
`result` idles; a sent message starts another turn; mid-turn send calls `InterruptAsync`
then delivers; interrupt-failure degrades to queue; stop kills.
- `LiveSessionRegistryTests` — register/get/unregister/stop.
- `InteractiveSessionServiceTests` — start resolves working dir + seeds prompt + registers +
broadcasts started; send routes to the session; stop broadcasts ended (fake session +
broadcaster).
- `TaskMonitorViewModelTests` / `DetailsIslandViewModelTests` — composer enabled while
interactive-live; submit invokes client + clears; `user` line renders; question route still
answers.
## Risks / open questions
- **Interrupt protocol shape — RESOLVED** (spike 2026-06-26, see "The streaming engine").
Mid-turn interrupt works on CLI 2.1.191 with the documented shape; the queue fallback is a
genuine fallback now, not the expected path. Re-verify if the CLI version changes.
- **Plainer than the TUI** — slash-command/interactive-prompt UX differs (accepted).
- **Auto-mode editing the list working dir directly** (no worktree) — this is the *existing*
interactive behavior, unchanged here.
- **No real-Claude tests** (project rule) — the live loop is covered only by the fake stream;
real interrupt/redirect is a **manual verification gap** to flag.
## Non-goals
- Changing autonomous task execution / review / queue / worktrees.
- Interactive sessions producing run records, worktrees, or review (stays ephemeral).
- Worktree isolation for interactive edits; image/attachment messages in the composer.
- Removing planning's `wt` terminal launch.
@@ -0,0 +1,169 @@
# Session Skills — Design
**Date:** 2026-07-03
**Status:** Approved (design), implementation not started
## Problem
Mika wants to give headless task agents a specific Claude skill (e.g.
[ponytail](https://github.com/DietrichGebert/ponytail)) **without** installing it
globally in `~/.claude/skills/`, where it would leak into every interactive session.
Skills should be a first-class, per-level session setting alongside `model`,
`max_turns`, and `system_prompt` — configurable **global / per-list / per-task** — and
sourced from a GitHub URL.
## Key facts that shape the design
- The Claude CLI discovers skills from two places: the **global** `~/.claude/skills/`
(every session — undesirable here) and the **working directory's** `.claude/skills/`
(plus plugins). ClaudeDo fully controls each spawned session's `WorkingDirectory`
(`ClaudeProcess.cs:30`), so a skill dropped into the session cwd is scoped to exactly
that headless run.
- **Auto-commit uses `git add -A`** (`WorktreeManager.CommitIfChangedAsync`
`_git.AddAllAsync`, `WorktreeManager.cs:142`). Anything seeded into a worktree's
`.claude/skills/` would be committed unless explicitly excluded → the seeder must add
the seeded paths to the worktree's `info/exclude`.
- A skill is not just prompt text: `SKILL.md` may reference scripts that run via Bash.
Headless agents run with `--permission-mode auto` (effectively unattended), so a skill
pulled from an arbitrary URL is **unattended third-party code execution**. This is why
install is a deliberate, pinned, reviewable step — not a live per-run URL fetch.
## Decisions (locked)
1. **Install-and-pin, not live fetch.** A dedicated registry screen installs a skill
once: clone the repo, pin to the current commit, store locally. Per-level config then
references installed skills **by name** (checkboxes), never a URL.
2. **Three levels, additive union.** Effective skill set = `global list task`.
(Unlike `model`/`prompt`, which override — skills add up. Trade-off accepted: an
inherited skill can't be switched off for a single task in the MVP.)
3. **A repo can contribute multiple skills.** Installer detects the layout:
- `skills/*/SKILL.md` (plugin bundle) → import **each** subskill flat. This is
ponytail: it ships 6 skills (`ponytail`, `-help`, `-review`, `-audit`, `-debt`,
`-gain`) under `skills/<name>/SKILL.md` plus `.claude-plugin/`, hooks, commands, an
MCP — none of which we consume; we take only the `skills/<name>/` dirs.
- root `SKILL.md` → single skill.
- neither → reject.
The CLI expects `.claude/skills/<name>/SKILL.md` **flat**, so subskills are flattened
on install. Selection is **per individual skill name** (enable just `ponytail` +
`ponytail-help` if you want, not all six).
4. **Public repos only** (plain `git clone` over HTTPS, no auth) for MVP.
> **Correction (2026-07-03, from the smoke test):** the original "one repo = one skill
> at root" MVP was wrong for the very target repo — ponytail is a multi-skill plugin.
> Decision 3 above replaces it.
## Architecture
### Storage & registry
- Each discovered skill is copied **flat** to `~/.todo-app/session-skills/<name>/` (its
own self-contained dir with `SKILL.md` at the root of that dir), so the seeder just
copies `<name>/` → cwd.
- New DB table `session_skills`, one row **per skill** (a multi-skill repo writes N rows
sharing `source_url` + `pinned_ref`): `name` (PK), `source_url`, `pinned_ref` (commit
SHA), `subpath` (dir within the repo the skill came from, e.g. `skills/ponytail` or
`.` for root), `description`, `added_at`. Repo-level ops act on all rows with the same
`source_url` (no separate sources table — keep it flat).
- New worker service `SessionSkillRegistry` (in a new `Skills/` area under the Worker):
- `InstallAsync(url)` — clone to temp → **detect layout** (`skills/*/SKILL.md` bundle
vs root `SKILL.md`) → for each discovered skill parse frontmatter (`name`,
`description`), resolve HEAD SHA as `pinned_ref`, copy its dir flat into place, upsert
a row. Returns the list of installed skill names. Name collision (a skill name from a
*different* source) → error surfaced to UI; reinstalling the same source updates.
Clone is injected (`IRepoCloner`) so tests use a local source dir — **no real network,
no real CLI**.
- `UpdateAsync(sourceUrl)` — re-clone, re-detect, refresh that source's skills +
`pinned_ref`.
- `RemoveAsync(sourceUrl)` — delete all its skill dirs + rows.
- `ListAsync()` — registry entries for the UI (grouped by source for display).
### Resolution
`TaskRunner.ResolveConfigAsync` (`TaskRunner.cs:488`) already merges
task → list → global for the other fields. Add:
```
SkillNames = Union(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills)
```
deduped, filtered to names that still exist in the registry (a removed skill is silently
dropped — logged). Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig`
(`ClaudeArgsBuilder.cs:5`). **No CLI flag is emitted** — skills are seeded on disk, not
passed as args. `ClaudeArgsBuilder.Build` is unchanged for skills.
Per-level storage: a nullable TEXT column `session_skills` (JSON array of names) on
`tasks`, `list_config`, and `app_settings`.
### Seeding
New service `SessionSkillSeeder`, called by `TaskRunner` after the working dir is
resolved and before `ClaudeProcess.RunAsync`:
- For each resolved skill name, copy `~/.todo-app/session-skills/<name>/`
`<cwd>/.claude/skills/<name>/` (overwrite → idempotent for resume/re-run).
- If the cwd is a git worktree, append `/.claude/skills/<name>/` to the worktree's
`info/exclude` (path via `git rev-parse --git-path info/exclude`, so it targets the
per-worktree exclude), only if not already present. **Only the seeded subdirs are
excluded** — never blanket-exclude `/.claude/`, in case the target project commits its
own `.claude/`.
- Sandbox runs (not a repo) skip the exclude step.
- No separate cleanup: seeded dirs vanish with the worktree/sandbox.
### allowedTools caveat
`--allowedTools` is only emitted when set (`ClaudeArgsBuilder.cs:80`); normal task runs
leave it null → all tools allowed → the `Skill` tool is available. If a future per-task
allowedTools restriction is added, it must include `Skill`. Noted, not handled in MVP.
## UI
Mirror the existing agent-file pattern.
- **Registry screen ("extra mask"):** a new **Skills** tab in the Settings modal
(`SettingsModalView.axaml`) with `SessionSkillsSettingsTabViewModel`. **Add** (URL text
box → install a repo, which may yield several skills); lists installed skills grouped by
source (name, description, source, short ref); **Update** / **Remove** act per source
(repo). Status/error line like `FilesSettingsTabViewModel`.
- **Global selector:** multi-select (checkbox list) of installed skills in the General
settings tab → `AppSettings.SessionSkills`.
- **List + Task selectors:** add a skills multi-select to the shared
`AgentConfigEditor` control (`AgentConfigEditor.axaml` /
`AgentConfigEditorViewModel.cs`), which is already reused by both List settings and the
per-task flyout — one addition covers both levels, with the existing inheritance-badge
pattern.
### Hub / client surface
New `WorkerHub` methods + `IWorkerClient` entries (update hand-rolled fakes in both test
projects — see memory `iworkerclient_fakes_sync`):
`GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(sourceUrl)`,
`RemoveSessionSkill(sourceUrl)`. Extend `AppSettingsDto`, `ListConfigDto`,
`UpdateListConfigDto`, `UpdateTaskAgentSettingsDto` with the selected skill-name lists.
New `SessionSkillDto`.
## Edge cases
- **Removed skill still referenced** by a level → dropped at resolve time, logged, no
failure.
- **Name collision on install** → reject with a clear message; offer Update instead.
- **Repo without root `SKILL.md`** → reject at install.
- **Target project already has `.claude/skills/`** → additive copy; exclude only our
subdirs.
- **Resume / re-run** reuses the worktree → re-seed overwrites, exclude append is
idempotent.
## Verification (must-check, can't be unit-tested)
- **Does `claude -p` actually load and invoke a skill placed in cwd `.claude/skills/`?**
**CONFIRMED 2026-07-03.** Mika ran, in a stable terminal, a `claude -p` invocation in a
scratch cwd holding `.claude/skills/ponytail*/SKILL.md`; the model invoked the
`ponytail-help` skill and returned its exact Lite/Full/Ultra table. Headless mode does
surface cwd skills → the whole approach holds; no `CLAUDE_CONFIG_DIR` fallback needed.
- Seeded skill is **not** committed by the auto-commit step (worktree run).
- Skill does not appear in a normal interactive session (no global leak).
## Out of scope (MVP)
Private-repo auth; consuming a plugin's *non-skill* parts (hooks, commands, MCP — we take
only `skills/<name>/`); auto-update & update notifications; per-task *disabling* of an
inherited skill; surfacing skill invocation in the run log.
@@ -0,0 +1,171 @@
# ConPTY Interactive Sessions — Design
Date: 2026-07-23
Status: Approved (design), implementation not started
## Problem
The current in-app interactive session path streams `stream-json` from a
`claude` process spawned **in the Worker** and renders it as a chat log with a
composer. It does not surface permission requests, AskUser questions, and other
TUI-native interactions well — the rendering is a partial reimplementation of
what the real Claude Code TUI already does. We want full fidelity for
interactive work without rebuilding the TUI.
## Decision
**Hybrid execution model:**
- **Autonomous queue tasks** (`Status=Queued`, picked by the queue): unchanged.
Headless `stream-json`, full orchestration (status flow, diff, review, merge).
- **Interactive sessions**: an **embedded ConPTY terminal** running the real
`claude` CLI, rendered in the **UI process**. Full TUI fidelity (permission
prompts, questions, colors, everything the standalone CLI does). Detached from
the review/merge/status machinery — these are a manual cockpit.
This is the third direction for interactive (external `wt` terminal → streaming
chat → embedded ConPTY). The streaming interactive stack is **removed**, not run
in parallel — accepted as discarded work in exchange for one interactive path
and full fidelity.
## Architecture
### Process location
ConPTY terminal controls render in-process and spawn their child (`claude`) as a
child of the host process. Therefore interactive sessions move **out of the
Worker and into the UI process**. They no longer flow over SignalR. This mirrors
the existing `ResumeTaskInTerminal` behavior (launch real `claude`), but embedded
instead of via external `wt.exe`.
### Worktree preparation (task-based sessions)
Interactive task sessions get the **same worktree preparation as autonomous
runs**: session-skills seeding, agent files, MCP config, environment. The Worker
performs the prep and returns a launch spec to the UI:
```
LaunchSpec {
cwd: string // worktree path
exe: string // resolved claude executable / shell
args: string[] // e.g. --resume <sessionId>
env: Dictionary<string,string>
}
```
The command construction reuses `WindowsTerminalLauncher.BuildResumeArgs`/`Resolve`.
Guards mirror `ResumeTaskInTerminal` for Running/Queued (rejected). Worktree
handling (FINAL):
- Existing Active/Kept worktree + persisted SessionId → `--resume <id>`.
- No usable worktree but the list has a WorkingDir (git repo) → create a worktree
**on demand** via `WorktreeManager.CreateAsync` (the same path autonomous runs
use), then a fresh-start spec. This lets never-run tasks be opened interactively.
- Fresh (non-resume) session → the task's prompt (title + description) is passed
as claude's positional prompt so the session starts on the task.
- No worktree and no WorkingDir → clear error.
Interactive sessions are detached: ClaudeDo does NOT record their claude session
id (ConPTY is opaque, no stream-json), so resuming a specific past interactive
conversation is only via claude's own `--continue`/`--resume` in the worktree dir.
### Free / ad-hoc sessions
In addition to task-based sessions, the user can open an ad-hoc terminal in a
chosen directory (no task). These also get MCP config + env set up so the
`claudedo` tools are available, but no per-level session-skills seeding tied to a
task.
### Terminal host control — RESOLVED by spike (2026-07-23)
**Library: `Iciclecreek.Avalonia.Terminal` 2.0.3** (namespace `Iciclecreek.Terminal`).
It needs Avalonia >= 12.0.2; the repo is on 12.0.4 → compatible, no bump.
`SvcSystems.UI.Terminal` (latest) needs Avalonia 12.1+ → rejected.
Visual pass (user, 2026-07-23): the real `claude` TUI renders correctly inside
the embedded control — Claude Code opened and was usable.
**Binding approach — use the library's own `LaunchProcess()` (FINAL).**
An initial attempt drove `Porta.Pty` ourselves (own read loop, key tunneling via
`GenerateKeyInput`/`GenerateCharInput`, manual resize) to bypass
`LaunchProcess()` and inject a custom `PtyOptions.Environment`. That was a
mistake: it rendered wrong, lagged, and dropped input. The spike had proven the
library's own `TerminalControl.LaunchProcess()` pipeline renders correctly, stays
responsive, and handles input/resize/focus. So `PtyTerminalSession` is a thin
wrapper (`src/ClaudeDo.Ui/Services/PtyTerminalSession.cs`):
- Set `control.Process = descriptor.Exe`, `control.Args = descriptor.Args`,
`control.StartingDirectory = descriptor.Cwd`, then `await control.LaunchProcess()`.
- Relay the control's own `ProcessExited` event and `Kill()`.
- `Process=""` stays on the AXAML `TerminalControl` to suppress the library's
auto-launch-on-load, so exactly one process starts (our manual launch).
**Environment:** `LaunchProcess()` gives no per-launch env dict, but
`Porta.Pty.SpawnAsync` inherits the *current process* environment. So apply
`descriptor.Env` entries via `Environment.SetEnvironmentVariable(key, value)`
(process scope) before `LaunchProcess()`. The only var needed is
`MCP_TOOL_TIMEOUT`; session-skills/agent-files/MCP-config are on disk / globally
registered, independent of env. (The earlier "child gets zero env vars" claim was
wrong — Porta.Pty seeds from the process env.)
**Sizing gotcha (critical):** the control derives Cols/Rows from
`arranged-size / character-cell-size`. Without an EXPLICIT monospace font the cell
metrics are wrong and the child TUI renders into the wrong area. Set
`FontFamily="Cascadia Mono,Consolas,monospace"`, `FontSize`, `BufferSize`, and
`HorizontalAlignment/VerticalAlignment=Stretch` on the `TerminalControl` (matching
the spike) — this is what made rendering correct in the pane.
**Lesson:** do not hand-roll pty/input/render around this control — use its
`LaunchProcess()` pipeline.
### Command Center layout
- `MonitorPaneView` keeps the streamed log for autonomous tasks.
- Interactive panes host the terminal control instead of the log+composer.
- Layout is **toggleable**: focus mode (tabs, one session large) ↔ overview mode
(grid, several sessions at once).
## Removals
Worker:
- `StreamingClaudeSession`, `InteractiveSessionService`
- `WorkerHub` interactive methods: `OpenInteractiveTerminal`,
`SendInteractiveMessage`, `RemoveQueuedInteractiveMessage`,
`StopInteractiveSession`, `InterruptInteractiveSession`
- Broadcast events: `InteractiveSessionStarted/Ended`, `InteractiveQueueChanged`,
`InteractiveMessageSent`
- `IdleSessionReaper` and `LiveSessionRegistry` **iff** unused elsewhere
(verify during implementation — do not delete blindly).
UI:
- Composer on `TaskMonitorViewModel`: `ComposerDraft`, `SubmitComposerCommand`,
`InterruptInteractiveCommand`, `StopInteractiveCommand`, `QueuedMessages`,
`IsInteractiveLive`.
- The composer + queued-messages portion of `SessionTerminalView` (the log
portion stays for autonomous panes).
- `IWorkerClient` interactive methods.
## Open items (implementation time)
- Whether `LiveSessionRegistry` is referenced outside the interactive path.
- Session-id availability for a never-run task (no `--resume` → start fresh).
Resolved: env approach (see Terminal host control — add custom vars on top of the
inherited process env via `PtyOptions.Environment`); library + binding seam.
## Non-goals
- No screen-scraping of terminal output back into task status/diff/review.
- No change to the autonomous queue execution path.
## Status (2026-07-23)
Implemented on main (not pushed) and visually verified by the user (rendering,
input, responsiveness correct after switching to `LaunchProcess()` + setting a
monospace font). Done: worker launch-spec (task + ad-hoc, on-demand worktree,
prompt seeding), UI terminal host, Command Center hosting (grid↔tabs), streaming
stack removed. Permission mode: left to claude's default (not forced), per user.
Deferred: **Avalonia 12.1 upgrade** — blocked, not adopted. 12.1's XAML source
generator needs Roslyn 4.14 (.NET 9.0.3xx SDK); the repo pins .NET 8 in
`global.json`, and bumping the SDK floor risks the Gitea Actions release build.
Staying on Avalonia 12.0.x (Iciclecreek 2.0.3 works there). Revisit only with a
deliberate SDK-floor decision.
@@ -0,0 +1,61 @@
# ConPTY Planning Sessions — Design (2026-07-24)
**Task:** `5d627df8` — interaktive Planning-Session über embedded ConPTY statt externem `wt`-Fenster.
## Problem
Planning-Sessions (`StartPlanningSession`/`ResumePlanningSession`) öffnen heute ein externes
Windows-Terminal (`WindowsTerminalLauncher``wt.exe`). Seit ConPTY existiert (embedded
claude-TUI im UI-Prozess, Command Center), soll die interaktive Planning-Session denselben
Weg nutzen: konsistente UX, keine externen Fenster.
## Approved decisions (Brainstorm 2026-07-24)
- **Env-Isolation:** Prozess-global akzeptiert (Sessions werden sequenziell per Klick geöffnet;
jedes claude-Child snapshottet Env beim Spawn). Keine Änderung an `PtyTerminalSession`.
- **wt-Launcher:** Code bleibt; nur das Routing für Planning wird auf ConPTY umgestellt
(kein Rip-out von `LaunchPlanningStart/ResumeAsync`).
- Planning-Kachel im Command Center (Mission Control), wie andere ConPTY-Sessions.
Finalize/Discard/Queue-Plan bleiben unverändert auf den bestehenden Buttons.
## Change map
### Worker
1. `WindowsTerminalLauncher`: Planning-Start-Args in einen bare Builder
`BuildPlanningStartArgs(PlanningSessionStartContext) -> IReadOnlyList<string>` herausziehen
(analog `BuildResumeArgs`); `BuildPlanningStartCommand` nutzt ihn weiter (wt unverändert).
Resume-Args `BuildPlanningResumeArgs(sessionId) = ["--permission-mode","plan","--resume",id]`
(bisher inline in `LaunchPlanningResumeAsync`).
2. `InteractiveLaunchSpecService`: `BuildPlanningStart(PlanningSessionStartContext) -> LaunchSpec`
und `BuildPlanningResume(PlanningSessionResumeContext) -> LaunchSpec` — resolve claude,
Args aus (1), Env `MAX_THINKING_TOKENS=20000` + `CLAUDEDO_PLANNING_TOKEN=<token>`
(+ `MCP_TOOL_TIMEOUT` wie interaktiv), Cwd = ctx.WorkingDir. Nimmt den Kontext (keine
Manager-Kopplung).
3. Hub: `GetPlanningStartLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.StartAsync`,
broadcastet `TaskUpdated`, baut Spec; bei Fehler discard+rethrow wie heute),
`GetPlanningResumeLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.ResumeAsync`).
### UI
4. `IWorkerClient`/`WorkerClient`: `GetPlanningStartLaunchSpecAsync`/`GetPlanningResumeLaunchSpecAsync`
(mirror `GetInteractiveLaunchSpecAsync`).
5. `MissionControlViewModel`: `OpenPlanningConPtySessionAsync(taskId, resume)` — dedupt nach
TaskId, holt die Planning-Spec, baut `TerminalLaunchDescriptor``ConPtyPaneViewModel`
(Titel „<task> (Planning)").
6. `TasksIslandViewModel`: `OpenPlanningSessionAsync`/`ResumePlanningSessionAsync` (Resume-Zweig
der `UnfinishedPlanningModal`) öffnen die Planning-ConPTY-Kachel via neuem Event
`OpenPlanningConPtyRequested(taskId, resume)`, statt `StartPlanningSessionAsync`/wt.
`IslandsShellViewModel` verdrahtet das Event → `OpenMissionControl()` +
`MissionControl.OpenPlanningConPtySessionAsync`.
### #12 (Nebenbefund)
MCP-Permission-Prompt trotz `--allowedTools "mcp__claudedo__*"`: im interaktiven ConPTY vom
User bestätigbar (kein Blocker wie headless). Beim Testen prüfen, ob der Glob den Prompt in
der aktuellen CLI unterdrückt; falls nicht, allowedTools/`--permission-mode`-Kombi nachziehen.
## Out of scope
- Entfernen des wt-Codes / `ResumeTaskInTerminal`.
- Per-Child-Env-Isolation in `PtyTerminalSession`.
- Änderungen an Finalize/Discard/Queue-Plan-Lifecycle.
## Verification
- Build Worker + App (`-c Release`), Worker.Tests + Ui.Tests grün.
- Visual (User): Planning-Start öffnet Command-Center-Kachel mit claude-TUI im Plan-Modus;
create_child_task erzeugt Draft-Kinder live; Resume greift die Session; kein wt-Fenster.
@@ -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.
@@ -0,0 +1,191 @@
# "Let Claude handle it" — per-list handler with read / dedupe / enhance / run / merge
Date: 2026-07-27
Supersedes parts of: `2026-07-24-merge-helper-design.md` (global scope, run-then-merge-only prompt)
## 1. Problem
The merge helper shipped in v2.3.0 with two entry points — a per-list context-menu item and a
global footer Broom button — and a prompt that only runs and merges the selected tasks.
Two things are wrong with that:
- **The global scope is unwanted.** A run spanning several lists spans several repos, which
makes `cwd`, the `--add-dir` set and the merge order ambiguous for no benefit. The user
works one list (= one repo) at a time.
- **The helper starts too late.** It takes the task list as given: it never reads the tasks as
a set, so duplicates run twice and produce conflicting worktrees, and vague tasks go into an
autonomous run under-specified and come back wrong.
## 2. Goal
One entry point, on a user list. It opens an interactive ConPTY session that takes the selected
tasks through five phases: read them all, dedupe them, sharpen them for autonomous execution,
run them, then review and merge each worktree.
Non-goals: no change to the autonomous queue path, no change to the ConPTY tile plumbing,
no rename of the `MergeHelper*` identifiers (the user-facing label stays "Let Claude handle it").
## 3. Scope becomes list-only
`listId` becomes non-nullable across the whole chain:
| Layer | Change |
|---|---|
| `ListsIslandViewModel` | `MergeHelperRequest(string ListId, …)`; `LetClaudeHandleAllAsync` deleted |
| `IslandsShellViewModel:241` | unchanged (already forwards `req.ListId`) |
| `MissionControlViewModel:327` | `OpenMergeHelperConPtySessionAsync(string listId, …)` |
| `IWorkerClient:90` / `WorkerClient:525` | `GetMergeHelperLaunchSpecAsync(taskIds, string listId, ct)` |
| `WorkerHub:682` | `GetMergeHelperLaunchSpec(string[] taskIds, string listId)` |
| `IInteractiveLaunchSpecService:36` | `BuildForMergeHelperAsync(taskIds, string listId, ct)` |
Deleted UI surface:
- Broom button `ListsIslandView.axaml:205-210` and `LetClaudeHandleAllCommand`.
- `IsGlobal` on `MergeHelperSelectionModalViewModel` and the LIST column
(`MergeHelperSelectionModal.axaml:71`) — with a single list the column is constant.
- Localization keys `lists.letClaudeAllTip`, `modals.mergeHelper.scopeAll`,
`modals.mergeHelper.columnList` (en + de, parity test enforces both).
`Configure(string listId, string listName)` loses its nullable overload; `ScopeLabel` always
renders `modals.mergeHelper.scopeList`.
### 3.1 Single repo in the launch spec
`BuildForMergeHelperAsync` currently collects a distinct `repoDirs` set across the selected
tasks and picks `cwd` per scope. With a list scope every task shares the list's `WorkingDir`,
so this collapses to:
- Load the list; throw `InvalidOperationException` if it has no existing `WorkingDir`.
- `cwd` = that directory; `--add-dir` = the session dir + that one directory.
- The per-task brief line drops the now-constant `list:` and `repo:` fields.
The context-menu item is already hidden when `WorkingDir` is empty, so the throw is a guard,
not a normal path.
### 3.2 Entry point visibility
The item stays in the list row's context menu, next to "List settings", "Worktrees overview",
"Open in Explorer" and "Open in Terminal". That is the established place for list-scoped
actions; a second always-visible button in the row would break the pattern.
## 4. Worker: `Cancelled` becomes externally settable
`ExternalMcpService.UpdateTaskStatus` accepts only `Idle` and `Queued` and throws
`"Status '{target}' is not settable externally. Use run_task_now or cancel_task."` for the
rest — but neither escape hatch reaches an **Idle** task: `cancel_task` only cancels a
*running* task, and `review_task(decision="cancel")` requires WaitingForReview/Running/Queued.
The dedupe phase needs exactly that: retire an Idle duplicate without destroying it.
Add to the switch:
```csharp
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
```
`TaskStateService.CancelAsync` (`State/TaskStateService.cs:244`) already owns the transition
and its worktree/parent side effects. The existing error message in `UpdateTaskStatus` already
lists `Cancelled` as valid, so this also removes a lie. A cancelled task stays visible and can
be reset to Idle — nothing is lost, unlike `delete_task`.
## 5. The five-phase prompt
`PromptFiles.MergeHelperDefault` is rewritten. The session stays interactive and the helper is
told to ask whenever unsure — that is the point of a watched ConPTY session.
### Phase 0 — Read
`batch_get_tasks` over every id in the brief before touching anything: title, description,
status, parent/child links. The helper must hold the whole set in mind before acting on any
single task.
### Phase 1 — Dedupe
Compare the tasks pairwise for overlap. Emit a table of candidate pairs with the reason each
pair looks like a duplicate, then **ask per pair**:
- merge → fold the loser's unique content into the survivor via `update_task`, then
`update_task_status(loserId, "Cancelled")`;
- keep both → note why and move on.
Nothing is cancelled without an explicit answer.
### Phase 2 — Enhance
For each surviving task, sharpen title and description for autonomous execution:
- concrete acceptance criteria,
- the files/areas actually involved — grounded in the repo via Read/Grep/Glob, not guessed,
- explicit out-of-scope.
Write back with `update_task` (title / description / commitType are the settable fields; it
refuses while Running, which cannot happen this early). Rules: do not change intent, do not
invent requirements. A task too vague to sharpen safely gets a question, not a guess.
### Phase 3 — Run
`run_task_now` cannot be used for a batch: `OverrideSlotService.StartInSlot`
(`Queue/OverrideSlotService.cs:65-70`) holds a single slot and throws `"override slot busy"`
on the second concurrent call. The queue picker is the only parallel path.
So: read `get_app_settings`, tell the user how many parallel slots are configured
(`MaxParallelExecutions`, default 1 — `AppSettingsEntity.cs:14`), then
`update_task_status(id, "Queued")` for every surviving task, then poll `get_task` until each
has left Queued/Running — `WaitingForReview` on success, `Failed` on error. Announcing the
slot count up front stops the user wondering why "run them all" executes one at a time.
A task already `Running` or `WaitingForChildren` when the session starts is not re-queued, only
polled. A task already `WaitingForReview` skips straight to Phase 4.
### Phase 4 — Review and merge
Sequential, in list order. A task that came back `Failed` needs human judgement — ask whether
to `reset_failed_task` and re-queue it, or skip it. Otherwise, unchanged from the shipped
prompt: `get_task_diff` (stat first,
full diff when non-trivial), sanity-check against the task's intent, ask before merging
anything that looks wrong, then `review_task(taskId, decision="approve",
leaveConflictsInTree=true)` and the conflict loop (`continue_merge` / `abort_merge`, parent id
for unit merges, hand-resolution only where MCP cannot reach, always
`git commit -- <paths>` and never `git add -A` because the checkout is shared).
New in this phase: the prompt states that because Phase 3 branches all fork from the same
base, **conflicts are the normal case, not an exception** — resolve them rather than bailing
out of the run.
### Phase 5 — Summary
One line per task: title — dedupe action — enhanced? — final status — merge commit —
conflicts resolved. Then anything skipped and why, then follow-ups.
### Brief template
`MergeHelperInitialDefault` names the list and repo once in the header and drops the per-task
`list:`/`repo:` fields, leaving `- [{status}] {title} (id: {id})`. Descriptions stay out of the
brief; Phase 0 fetches them.
## 6. Testing
| Test | Change |
|---|---|
| `MergeHelperSelectionModalViewModelTests` | drop `IsGlobal`, list-scoped `Configure` |
| `InteractiveLaunchSpecServiceTests` | three `null`-listId cases → non-null; drop the two-repo global-scope test; add "list without WorkingDir throws" |
| `MissionControlViewModelTests` | four `OpenMergeHelperConPtySessionAsync(null, …)` calls |
| `StubWorkerClient`, `TasksIslandViewModelPlanningTests` fake | signature |
| `PromptFilesTests` | assert the five phase markers in the default prompt |
| `Localization.Tests` | parity after removing three keys |
| new: `ExternalMcpService` / worker test | `UpdateTaskStatus(id, "Cancelled")` cancels an Idle task; unknown status still throws |
No test spawns the real `claude` CLI — the prompt content is asserted as text, the session
itself is a manual smoke step.
## 7. Verification left to the user
- The list context menu shows "Let Claude handle it" only for lists with a working dir, and
the Broom button is gone from the footer row.
- The selection dialog has no LIST column and reads "List: <name>".
- A real ConPTY run: dedupe questions appear, enhancements land in the task descriptions,
queued tasks execute, merges complete or hand off to conflict resolution.
@@ -0,0 +1,235 @@
# Planning-Chain Children: Fork Base Commit (Design Options, No Implementation)
**Date:** 2026-08-06
**Status:** Options evaluated, recommendation given — decision pending (Mika)
**Scope:** Analysis only. No production code changed by this document.
## Problem
The planning chain gives **ordering**, not **code inheritance**. `PlanningChainCoordinator.SetupChainAsync`
(`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs:36-72`) links children via
`BlockedByTaskId` (child[i] blocks on child[i-1]) so they run one at a time. But each child's
worktree is still created from **main's current HEAD**, not from the predecessor's branch, so
child N+1 never sees child N's (unmerged) work.
**Observed failure (unit `44241dcb`, "Installer: Environment-Checks", 2026-08-05):** 7 of 9
children succeeded; the two that depended on a sibling's output could not:
- `4e196058` ("Claude Help Me" button) — reported: *"the task spec assumes an existing
SystemCheckPage/EnvironmentCheckReport/ExecutableResolver, but that code only exists on an
unmerged sibling branch (claudedo/06aca9b3...). A fast-forward merge of that branch was
attempted ... but was denied twice by the auto-mode permission classifier. No implementation
work was done."*
- `c22cdd06` (Diagnose section) — *"Blocked before implementation could start."*
Both correctly reported `CLAUDEDO_BLOCKED` and committed nothing (`ahead: 0`). A second-order
symptom of the same root cause: `ExecutableResolver.cs` was independently created **twice**
(`40272c0b`, `06aca9b3`, near-identical) and collided as an add/add conflict at unit-merge time.
## Ist-Zustand (verified against commit `816f247`, 2026-08-06)
### Where the base commit is chosen
`WorktreeManager.ResolveBaseCommitAsync``src/ClaudeDo.Worker/Runner/WorktreeManager.cs:191-207`:
```csharp
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
{
if (task.ParentTaskId is not null)
{
var parent = ...;
if (parent is not null && parent.PlanningPhase == PlanningPhase.None)
{
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit;
if (parentHead is not null)
return parentHead;
}
}
return await _git.RevParseHeadAsync(workingDir, ct); // planning children land here
}
```
**A "don't fork from main" mechanism already exists** — but only for *improvement* children
(a non-planning parent's own follow-up subtasks), which base off `task.ParentTaskId`'s worktree
HEAD. The guard `parent.PlanningPhase == PlanningPhase.None` explicitly **excludes** planning
children: their `ParentTaskId` points at the planning parent (which has no worktree of its own),
not at a sibling, so this branch never fires for them and they fall through to `RevParseHeadAsync`
= main HEAD. This is a deliberate exclusion in the existing code, not an oversight — it simply
never anticipated that a planning child's *predecessor in the chain* (not its parent) might be
the thing to inherit from.
Called from `WorktreeManager.CreateAsync` (`:35`), invoked by `TaskRunner` (`Runner/TaskRunner.cs:309`)
at the moment a task transitions to `Running` — i.e. worktree creation happens per-run, not once
at plan-finalize time.
### Children already run strictly sequentially — this is not a new constraint
`SetupChainAsync` sets `BlockedByTaskId` on every child but the first
(`Planning/PlanningChainCoordinator.cs:61-69`); the queue picker only claims rows with
`BlockedByTaskId IS NULL`. `OnChildFinishedAsync` (`:92-120`) unblocks the successor **only**
after the predecessor reaches `Done` (and cascades cancellation down the chain on
Failed/Cancelled). `OverrideSlotService.RunNow` (`Queue/OverrideSlotService.cs:32-42`) is the one
path that bypasses this — it calls `StartRunningAsync` directly with no `BlockedByTaskId` check,
so a user can manually force a later child to run out of turn.
Net: under the normal (queue-driven) path, by the time child N+1's worktree is created, child N
is already terminal. Forking N+1 from N's branch tip instead of main's HEAD does **not** introduce
any new concurrency constraint on the normal path — the sequencing already exists. It only matters
for the `RunNow` bypass edge case (see Option 1 below).
### The merge side already treats the unit as a sequential chain, twice over
`PlanningMergeOrchestrator.DrainAsync` (`Planning/PlanningMergeOrchestrator.cs:183-229`) merges
`Done` children into `targetBranch` **one at a time, in `SortOrder`**, via
`TaskMergeService.MergeAsync`; the first conflict pauses the whole drain
(`PlanningMergeConflict`, state kept for `ContinueAsync`/`AbortAsync`), and the first
non-conflict failure **aborts the drain outright** — remaining children are never merged and the
parent never reaches `Done` (`:208-215`).
`PlanningAggregator.BuildIntegrationBranchAsync` (`Planning/PlanningAggregator.cs:81-126`) — used
for the pre-approve combined-diff preview — does the same thing again: builds a scratch
integration branch off `targetBranch` and `MergeNoFfAsync`s each child's branch in, in
`SortOrder`, stopping at the first conflict.
So **both** the actual unit-merge and its preview already model the child set as an ordered
sequence where one bad link can stall everything downstream. The only place in the whole pipeline
that still treats planning children as N independent forks of main is worktree creation.
## Options evaluated
### Option 1 — child forks from the predecessor's branch
Extend `ResolveBaseCommitAsync` (or a planning-specific sibling of it) to also handle planning
children: look up the chain predecessor via `BlockedByTaskId` (not `ParentTaskId` — that points at
the planning parent, which has no worktree) and use its `WorktreeEntity.HeadCommit ?? BaseCommit`
the same way the improvement-child path already does.
**What actually breaks, given the Ist-Zustand above:**
- *Not* a new concurrency constraint (see above) — the chain already serializes execution.
- *Not* a new "one failure blocks everyone" behavior at merge time — `DrainAsync` already aborts
the whole drain on the first non-conflict failure, and the integration-branch preview already
stops at the first conflict. Option 1 brings execution-time behavior in line with what merge-time
behavior already is, rather than introducing a new failure mode.
- **Genuinely new:** child N+1's branch now contains N's commits as ancestors. When N is later
merged into `targetBranch` with `--no-ff` and N+1 is merged afterward, N+1's diff against N's
content is empty (identical trees) — merges cleanly as a no-op for that slice, verified by the
same mechanism `DrainAsync` already uses. No new conflict class is introduced; if anything this
*removes* one: the `ExecutableResolver.cs` add/add collision would not have occurred, since N+1
would start from a tree that already has the file.
- **Genuinely new edge case:** `OverrideSlotService.RunNow` on a chain member whose predecessor
hasn't produced a `WorktreeEntity`/`HeadCommit` yet. Needs an explicit fallback to main HEAD —
the same `?? ` pattern the improvement-child branch already uses for a predecessor with no
`HeadCommit` (never committed) covers most of this; a predecessor that was never even *run* needs
the same fallback-to-`RevParseHeadAsync` the code already falls through to today.
- **Genuinely new edge case:** a discarded/failed predecessor's worktree row still carries its last
`HeadCommit`/`BaseCommit` (the row isn't deleted on discard, only `State` flips) — same as the
improvement-child path already tolerates, so no new handling needed there.
- Children still go straight to `Done` with no individual review (Unified Parent Model), so there
is no "reject and rewrite a middle child after a successor already forked from it" scenario to
worry about — that action doesn't exist in the current state machine.
**Price:** deeper branch chains (cosmetic — they're squashed away by the sequential merge/prune
regardless); one new fallback path for the `RunNow` bypass. Meaningfully smaller than it first
looks, because it extends an existing pattern (improvement children) into a lane whose execution
and merge sides are *already* sequential — it only fixes the one place that wasn't.
### Option 2 — merge predecessor to main immediately on success, before the successor starts
Keeps children independent worktrees (forked from main-as-it-is-now, updated after each merge),
but requires a real merge to `main` per child, outside of Approve.
**Breaks:**
- Directly contradicts the documented invariant "**Approve is the single review+merge action**"
(`src/ClaudeDo.Worker/CLAUDE.md` → Status Model; `docs/explore-notes/review-merge.md` → "Approve
= merge the whole unit"). Unreviewed work would land on `main` automatically, mid-chain, before
the parent — or the user — ever sees it.
- Requires calling `TaskMergeService.MergeAsync` from `OnChildFinishedAsync` (state-transition
code), duplicating what `PlanningMergeOrchestrator` already owns, and doing it against a
`VerifyCommand`-gated repo N times instead of once — if the gate is configured, a mid-chain
verify failure now has to be handled somewhere there's currently no error path for it (parent is
still `WaitingForChildren`, not under merge orchestration yet).
- Unavoidably touches `TaskStateService`/status-transition semantics, which this task's scope
explicitly excludes ("Die Status-Logik oder `TaskStateService` anfassen" is out of scope) — this
option cannot be implemented without doing exactly that.
**Verdict:** rejected outright — it isn't just costly, it conflicts with a stated architectural
invariant and this task's own non-goals.
### Option 3 — child may merge the predecessor's branch into its own worktree if it needs to
Least invasive to the base-commit mechanism; delegates the decision to the agent at runtime.
**Breaks:**
- This is *exactly* what the blocked child in the observed incident already tried, and it was
denied twice by the auto-mode permission classifier. The real blocker isn't a missing
capability, it's that `git merge` trips the classifier's "risky/hard-to-reverse" heuristic
regardless of target — even though a merge confined to the task's own isolated worktree (never
touching the shared `workingDir`) is materially lower-risk than a merge on a shared checkout.
Fixing this option means carving a narrower permission rule (allow `git merge` only inside a
path under the task's own worktree root), which is a security-policy change, not a merge-model
change.
- Even with permission granted, it still relies on the agent (a) noticing it's missing a
prerequisite, (b) correctly identifying which sibling branch has it, and (c) successfully
resolving any conflict that surfaces — three separate failure points per occurrence, decided
fresh by an LLM each time rather than encoded once in the pipeline.
- Doesn't help children that fail *before* they'd even think to look (e.g., a child whose first
action is reading a file that doesn't exist yet has no signal to act on).
**Verdict:** possible as a narrow permission-scope fix layered *on top of* Option 1 (so an agent
that still needs something from further back than its immediate predecessor isn't stuck), but not
a substitute for it — on its own it reproduces the exact failure mode from the incident, just with
one fewer denial.
### Option 4 — change nothing; require independent children at planning time
Zero code changes; pushes the constraint into plan quality.
**Breaks:**
- No enforcement exists today (`PlanningSessionManager` doesn't validate structural independence
between proposed subtasks), and reliably detecting "child B depends on code child A will write"
from a plan draft is itself an unsolved code-review problem — not something a prompt tweak
guarantees.
- A shared-foundation-plus-N-consumers decomposition (exactly the `44241dcb` shape: environment
checks feeding two UI consumers) is often the *correct* decomposition, not a planning mistake.
Banning it either forces over-merging into one giant child (defeating the purpose of splitting)
or requires the planner to reject a structurally sound plan.
- Reproduces the observed failure verbatim under the same conditions: nothing about this option
would have caught `44241dcb` before it shipped two dead children.
**Verdict:** cheapest to write down, but doesn't fix the problem — it relocates it to "the plan
looked independent but wasn't," which is what already happened.
## Recommendation
**Option 1**, with the `RunNow`-bypass fallback described above, and with Option 3's narrower
permission-scope fix as an optional follow-up (not a prerequisite) for cases where a child needs
something from further back in the chain than its immediate predecessor.
**Why:** Option 1 is not a new mechanism — it's closing the one gap in a pattern that already
exists twice in this codebase: `WorktreeManager.ResolveBaseCommitAsync` already forks improvement
children from their parent's HEAD instead of main, and both `PlanningMergeOrchestrator.DrainAsync`
and `PlanningAggregator.BuildIntegrationBranchAsync` already treat the child set as an ordered
merge sequence where the first failure stalls everything after it. Planning-child worktree
creation is the outlier, not the rule. Extending the existing predecessor-lookup pattern (keyed
off `BlockedByTaskId` instead of `ParentTaskId` for this lane) makes the "sees predecessor's work"
guarantee hold everywhere the chain already implies it, without touching `TaskStateService`, the
review model, or introducing a merge-time failure mode that doesn't already exist. Options 2 and 4
either conflict with a stated invariant/this task's own non-goals, or fail to address the incident
at all; Option 3 alone reproduces the incident's exact failure.
**Price to pay knowingly:** a chain member that never got a chance to run (no `WorktreeEntity` yet)
needs an explicit main-HEAD fallback when its successor is forced via `RunNow` — a few lines,
mirroring the null-coalescing fallback the improvement-child path already has. No other new failure
surface was found.
## Explicitly out of scope (per task)
- Implementing Option 1 or any other option — Mika decides first.
- Any change to `TaskStateService` or status-transition logic.
- Changing blocked-child visibility/behavior — that's task `001ee94a`, running in parallel; it only
touches MCP visibility, no production code overlap with this document.
@@ -0,0 +1,225 @@
# Diff Viewer: Side-by-Side, Syntax Highlighting, Word Diff
Date: 2026-08-07
Status: approved (design), not implemented
## Problem
The diff viewer renders every change as a flat unified stream. Reading what actually changed
inside a modified line means mentally aligning a `` row with a `+` row several rows below it.
The user reads diffs faster side by side.
Three gaps, all in the same surface:
1. No side-by-side mode.
2. No syntax highlighting — the merge editor (`ConflictResolverView`) already has it via
TextMate, the diff viewer does not.
3. No intra-line (word) highlighting, so a one-character change looks like a whole-line rewrite.
## Current state
| Concern | Where |
|---|---|
| Parsing | `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs``DiffFileViewModel.Lines` |
| Models | `src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs` (`DiffLineViewModel{Kind,OldNo,NewNo,Text}`, `DiffLineKind{Add,Del,Ctx,File}`) |
| Rendering | `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml` — non-virtualized `ItemsControl`, one `Border`+`Grid`+4 `TextBlock`s per line, `TextWrapping="NoWrap"` |
| Host | `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` — Files mode (line 154, `SelectedFile.Lines`) and Planning mode (line 162, flattened `DiffLines` across all files) |
| Highlighting reference | `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:80-83``RegistryOptions(ThemeName.DarkPlus)` + `InstallTextMate` + `SetGrammar` by file extension |
`Avalonia.AvaloniaEdit`, `AvaloniaEdit.TextMate` and `TextMateSharp.Grammars` are already
referenced in `ClaudeDo.Ui.csproj`.
## Decision
Replace `DiffLinesView` with an AvaloniaEdit-based control. TextMate highlighting is bound to
the `TextEditor` control; it cannot be lifted into `TextBlock` inlines without reimplementing
the scope→brush layer that `AvaloniaEdit.TextMate` already provides. Building split/wrap/word
diff on the `TextBlock` model first and swapping the renderer later would be throwaway work.
Side effect worth having: AvaloniaEdit virtualizes, which removes the current non-virtualized
`ItemsControl` as a scaling limit on large diffs.
Rejected: keeping the `ItemsControl` and hand-rolling highlighting from `TextMateSharp`
tokenization — same output, materially more code, and a second highlighting path to maintain
alongside the merge editor's.
## Layout semantics
Left pane is the **old** state, right pane is the **new** state — each side carries the complete
version of the hunk, not "removals here, additions there".
```
LEFT (old) RIGHT (new)
12 public void Save() 12 public void Save()
13 var x = 1; 13 var x = 2; ← word diff on `1` / `2`
14 Log("old"); · (filler)
· (filler) 14 Log("new");
15 } 15 }
```
## Components
### 1. `DiffAlignment` (new, pure)
`src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs`
Turns `IReadOnlyList<DiffLineViewModel>` into a render-ready `AlignedDiff`. No Avalonia types,
fully unit-testable.
```csharp
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }
public readonly record struct TextSpan(int Start, int Length);
public sealed record SplitRow(
AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList<TextSpan> LeftSpans,
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
public sealed record UnifiedRow(
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
public sealed record AlignedDiff(
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText);
```
Row index `i` maps to document line `i + 1` in the corresponding text. That mapping is the
contract the margin and both renderers depend on.
**Pairing.** Walk the lines. A `Ctx` run emits rows with the same text on both sides. A change
block (a `Del` run followed by an `Add` run) pairs index-wise up to `min(delCount, addCount)`;
the overhang gets `Filler` rows on the opposite side.
**Gaps.** The parser drops `@@` headers, so a skipped region shows up as a jump in `OldNo`/`NewNo`
between consecutive lines. `DiffAlignment` detects that jump and inserts a `Gap` row on both
sides. The parser is not touched.
**Word diff.** Only for `(Del, Add)` rows that are paired 1:1. Tokenize each side into runs of
word characters / whitespace / single punctuation, run an LCS over the tokens, and emit the
changed token runs as character spans per side.
Two guards, both `const` and both covered by tests:
- Skip when either side exceeds `MaxWordDiffChars = 2000` — LCS cost, and such lines are
unreadable as word diffs anyway.
- Skip when token similarity is below `MinWordDiffSimilarity = 0.5` (common tokens / max token
count). Below that the two lines are unrelated rewrites and per-word tinting is noise.
### 2. `DiffTextView` (new control, replaces `DiffLinesView`)
`src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml` + `.axaml.cs`
Styled properties:
| Property | Type | Meaning |
|---|---|---|
| `File` | `DiffFileViewModel?` | Source; the control aligns it and caches the `AlignedDiff` per file instance |
| `Mode` | `DiffViewMode` (`Unified`\|`Split`) | Layout |
| `WrapLines` | `bool` | Bound to each editor's `WordWrap` |
Two `TextEditor`s in a two-column grid, both `IsReadOnly=true`, `ShowLineNumbers=false`.
`Unified` mode collapses the right editor and spans the left one across both columns, feeding
it `UnifiedText`. `Split` mode shows both, fed `LeftText` / `RightText`.
Per editor:
- **TextMate**: one shared `RegistryOptions(ThemeName.DarkPlus)`; grammar resolved from
`File.Path`'s extension via `GetLanguageByExtension``GetScopeByLanguageId``SetGrammar`,
exactly as `ConflictResolverView.ApplyGrammar` does. No extension match → no grammar, plain text.
- **`DiffLineNumberMargin : AbstractMargin`** — draws line numbers from the row list. Split: old
numbers left, new numbers right. Unified: two number columns in one margin. `Filler` and `Gap`
rows draw nothing.
- **`DiffLineBackgroundRenderer : IBackgroundRenderer`** — full-width tint per visual line by
row kind: add / del / filler / gap / ctx.
- **`WordDiffRenderer : IBackgroundRenderer`** — stronger tint over the changed spans, via
`BackgroundGeometryBuilder` at `lineStartOffset + span.Start`.
Both renderers resolve rows through a single `Func<int, RowInfo?>` keyed by document line.
**Highlighting on fragments.** Only hunks are in the document, not whole files, so TextMate's
line-by-line state can be wrong at a fragment boundary (a line inside a block comment may be
highlighted as code). Accepted — the same is true of every fragment-based diff viewer.
**Colors.** Line tints stay the existing low-alpha `RunningTintBrush` / `ErrorTintBrush` so
syntax foregrounds remain legible. The per-line foreground recolor from `DiffLinesView`
(green/red text) is dropped — syntax colors take over. `Filler` gets a new dim token brush,
`Gap` renders as a dim `⋯` separator row.
**Scroll sync** (split only), modeled on `ConflictResolverView.HookScrollSync` — find each
editor's descendant `ScrollViewer`, guard re-entry with a `_syncing` flag:
- `WrapLines = false`: sync `Offset.Y` directly. Line heights match, so alignment is exact.
- `WrapLines = true`: line heights diverge. Sync on the first visible document line instead
(`ScrollToLine`), which keeps the top of the viewport aligned and lets rows drift downward.
### 3. Host changes
`DiffViewerView.axaml`:
- Header gains a segmented Unified/Split toggle and a wrap toggle.
- Files mode: `DiffLinesView Lines="{Binding SelectedFile.Lines}"``DiffTextView File="{Binding SelectedFile}"`.
- Planning mode: the flattened single-stream `DiffLines` view is replaced by an `ItemsControl`
over the subtask's parsed files, each item a file header plus its own `DiffTextView`. One
editor can only carry one grammar, so per-file editors are required for highlighting to work
at all here. `DiffViewerViewModel` exposes the parsed per-file list for the selected subtask;
`DiffLines` and `UnifiedDiffParser.Flatten` lose their last consumer and are removed.
`DiffLinesView.axaml` + `.axaml.cs` are deleted once both usages are migrated.
### 4. Persistence
`src/ClaudeDo.Ui/AppSettings.cs` (`~/.todo-app/ui.config.json`) already holds UI-only
preferences (`Language`, `AccentPreset`) with plain `Load()`/`Save()`. Two properties are added
there:
```csharp
public string DiffViewMode { get; set; } = "unified"; // "unified" | "split"
public bool DiffWrapLines { get; set; }
```
`DiffViewerViewModel` takes the injected `AppSettings`, seeds its toggles on open and calls
`Save()` when either changes. No database column, no EF migration, no hub method — a view
preference does not belong in `AppSettingsEntity`.
### 5. Localization
New keys in both `locales/en.json` and `locales/de.json` (Localization.Tests enforces parity):
`diff.view.unified`, `diff.view.split`, `diff.view.wrap`.
## Testing
`tests/ClaudeDo.Ui.Tests``DiffAlignment` is pure and carries the logic worth testing:
- Context-only diff → identical rows on both sides, no fillers.
- Equal-size change block → 1:1 pairing, no fillers.
- Unequal change block (3 del / 5 add) → 3 paired rows + 2 right-side rows with left fillers.
- Add-only and delete-only blocks → fillers on the opposite side throughout.
- Non-contiguous line numbers → exactly one `Gap` row inserted.
- Word diff: single-token change yields one span per side at the right offsets.
- Word diff skipped above `MaxWordDiffChars` and below `MinWordDiffSimilarity`.
- Row index ↔ document line mapping holds for both `SplitRows` and `UnifiedRows`.
- Binary file and empty-content file → empty `AlignedDiff`, no crash.
`AppSettings` round-trip: persisted mode and wrap survive `Save()`/`Load()`.
Rendering (margin, both renderers, scroll sync, TextMate colors) is not unit-testable here and
is an explicit manual visual pass — see Open items.
## Known limitations
1. **Wrap + split drift.** With wrap on, the two panes align at the top of the viewport but rows
drift apart further down. Per-line vertical alignment as VS Code does it is out of scope.
2. **Fragment highlighting.** See above — highlighting state can be wrong at hunk boundaries.
3. **Editors per file in Planning mode.** A subtask touching many files instantiates one editor
per file. Same cost as viewing those files individually in Files mode; not capped. If it
proves slow, the fix is lazy instantiation on expand, not a silent truncation.
## Open items (manual verification)
- Visual pass on both modes: tints legible over DarkPlus syntax colors; line numbers aligned;
filler and gap rows readable.
- Scroll sync with wrap off (exact) and wrap on (top-anchored).
- Planning mode with a multi-file subtask.
- Toggle state survives an app restart.
## Docs to update on completion
- `src/ClaudeDo.Ui/CLAUDE.md` — Views/Controls list and the "Diff & Conflicts" section still
name `DiffLinesView`.
- `docs/explore-notes/review-merge.md` — diff stack description + "verified against" commit.
@@ -0,0 +1,169 @@
# Handler-Run: Verknüpfung zu den behandelten Tasks
**Date:** 2026-08-07
**Status:** Design approved (Mika), implementation pending
**Verified against:** commit `c792765`
## Problem
Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`,
`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run
behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im
Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`)
bekommt `survivingTaskIds` als flüchtige Liste.
Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen,
*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler
in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld.
Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt —
irreführend, denn es ist kein manueller Reminder.
## Ist-Zustand
### Es gibt kein Task-Kind
`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen:
| Feld | Bedeutung |
|---|---|
| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn |
| `ParentTaskId` | Kind einer Planning-/Improvement-Session |
| `PlanningPhase` | Planning-Parent |
| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es |
| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) |
Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert.
### `ParentTaskId` ist belegt
`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es
für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild`
(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile
im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler
schieben und mit echten Planning-Kindern kollidieren.
### Badge-Infrastruktur existiert
`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über
`Border Classes="badge <variant>"`. Basis-Style und Varianten liegen in
`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in
`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164).
### Kinder-Panel existiert
`DetailsIslandViewModel.LoadChildOutcomesAsync`
(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt
`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile
Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über
`TaskUpdated`/`WorktreeUpdated` (`:814-833`).
## Entscheidungen
| Frage | Entscheidung | Begründung |
|---|---|---|
| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. |
| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik |
| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. |
| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. |
| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. |
## Design
### 1. Daten
Neue nullable Spalte auf `TaskEntity`:
```csharp
/// <summary>Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner).</summary>
public string? HandlerTaskId { get; set; }
```
Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint
(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks
nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`.
Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n).
### 2. Schreiben
Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync`
`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) →
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt
eine neue Repository-Methode die Zuordnung in einem Batch-Update:
```csharp
Task<int> SetHandlerTaskIdAsync(IReadOnlyList<string> taskIds, string handlerTaskId, CancellationToken ct);
```
Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden
still übersprungen.
### 3. Badge
`TaskRowViewModel`:
```csharp
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null;
```
`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden.
HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig.
In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit
`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante
mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark.
Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft):
- `tasks.badgeHandler` — "HANDLER" / "HANDLER"
- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die
Tasks, die er bearbeitet hat"
### 4. Anzeige
Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden
Kinder-Panel:
- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync`
mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste.
- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount,
WorktreeState) — keine neue Row-Klasse.
- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende
`RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert.
- Sichtbar nur wenn `HandledTasks.Count > 0`.
- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige
(Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task
springen" wäre neues Verhalten und ist hier nicht enthalten.
### 5. Fehlerfälle
- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben
normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig.
- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks).
- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar.
## Tests
| Ebene | Test |
|---|---|
| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung |
| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst |
| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) |
| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` |
| Localization | Parität en/de — deckt der bestehende Test automatisch ab |
## Bewusst nicht enthalten
- Kein `TaskKind`-Enum.
- Keine n:m-Historie über mehrere Runs.
- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis.
- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task.
- Kein Badge auf den *behandelten* Tasks.
## Offen
- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im
Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll).

Some files were not shown because too many files have changed in this diff Show More