Compare commits

...
Author SHA1 Message Date
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
mika kunsandClaude Opus 4.7 549b87bb74 docs: reflect Startup-shortcut worker autostart
Release / release (push) Successful in 34s
Replace Windows-service/scheduled-task deployment docs with the Startup-folder
shortcut mechanism and the App's connection-failure prompt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:34:23 +02:00
mika kunsandClaude Opus 4.7 400a078aec refactor(installer): rename StopWorkerStep.TaskName to LegacyTaskName
The schtasks delete is now only legacy-migration cleanup; current installs
autostart via a Startup-folder shortcut. Clarifies the constant and comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:21:56 +02:00
mika kunsandClaude Opus 4.7 5baa1d7fbb docs: add worker lifecycle implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:19:32 +02:00
mika kuns 1246bf7b88 feat(ui): wire worker connection modal and make status pill clickable 2026-06-01 12:18:28 +02:00
mika kunsandClaude Sonnet 4.6 00dc7ebccc feat(ui): prompt once on worker connection failure with grace timer
Adds ShowWorkerConnectionModal hook, DecideShowConnectionPrompt one-shot gate, OpenWorkerConnectionHelp relay command, and a 12 s _connectTimer to IslandsShellViewModel; covered by two new unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:17:01 +02:00
mika kunsandClaude Sonnet 4.6 0139607008 feat(ui): add worker connection help modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:14:36 +02:00
mika kuns 4ecd855fb1 refactor(ui): stop auto-spawning the worker on app start 2026-06-01 12:12:49 +02:00
mika kuns 759d9057ff feat(installer): remove Startup worker shortcut on uninstall 2026-06-01 12:11:28 +02:00
mika kuns 2f1dcdc102 feat(installer): start worker via Process.Start, drop schtasks stop 2026-06-01 12:10:28 +02:00
mika kunsandClaude Sonnet 4.6 133f2d2f1d feat(installer): register autostart via Startup shortcut, drop scheduled task
Replaces schtasks /Create with AutostartShortcut.Install; migrates away
legacy scheduled task and Windows service on upgrade. Removes ScheduledTaskXml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:09:07 +02:00
mika kunsandClaude Sonnet 4.6 e2bb43ad6d feat(installer): add AutostartShortcut helper for Startup-folder lnk
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:07:32 +02:00
mika kunsandClaude Sonnet 4.6 867dc37228 refactor(installer): extract ShortcutFactory COM helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 12:05:54 +02:00
mika kunsandClaude Opus 4.7 4963a726de docs: add worker lifecycle redesign spec
Startup-folder shortcut replaces the scheduled task; App only connects and
prompts on connection failure instead of auto-spawning a worker.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:55:08 +02:00
mika kunsandClaude Sonnet 4.6 926471da6b refactor(ui): migrate PlanningDiffView to ModalShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:57:22 +02:00
mika kunsandClaude Sonnet 4.6 9be8e6b3e0 refactor(ui): drop double padding in Tasks island header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:57:17 +02:00
mika kunsandClaude Sonnet 4.6 b9e5dfccde refactor(ui): drop double padding in Lists island header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:57:12 +02:00
mika kunsandClaude Sonnet 4.6 c669370ecf refactor(ui): class schedule-flyout cancel in TaskRowView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:57:08 +02:00
mika kunsandClaude Sonnet 4.6 4688e884bd refactor(ui): class merge-section buttons in DetailsIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:57:03 +02:00
mika kunsandClaude Sonnet 4.6 8b21b0e646 refactor(ui): class update-banner buttons in MainWindow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:56:58 +02:00
mika kuns 4a786eb732 refactor(ui): normalize buttons/footer/padding in ConflictResolutionView 2026-05-30 18:54:17 +02:00
mika kuns cd64f287c3 refactor(ui): normalize buttons/footer/padding in DiffModal 2026-05-30 18:53:49 +02:00
mika kuns 3585ad5ee2 refactor(ui): normalize buttons/footer/padding in WorktreesOverviewModal 2026-05-30 18:53:29 +02:00
mika kuns 990935e67d refactor(ui): normalize buttons/footer/padding in RepoImportModal 2026-05-30 18:53:07 +02:00
mika kuns 1b5a9285e6 refactor(ui): normalize buttons/footer/padding in UnfinishedPlanningModal 2026-05-30 18:52:18 +02:00
mika kuns e8f880e72f refactor(ui): normalize buttons/footer/padding in AboutModal 2026-05-30 18:51:35 +02:00
mika kuns 3228a08c7a refactor(ui): normalize buttons/footer/padding in MergeModal 2026-05-30 18:50:57 +02:00
mika kuns ccec791fc1 refactor(ui): normalize buttons/footer/padding in ListSettingsModal 2026-05-30 18:50:32 +02:00
mika kuns 187fb641fe refactor(ui): normalize buttons/footer/padding in SettingsModal 2026-05-30 18:49:49 +02:00
mika kuns 0a719568ea refactor(ui): make primary/danger buttons self-contained, drop unused btn.primary 2026-05-30 18:47:17 +02:00
mika kunsandClaude Sonnet 4.6 ccec591ba2 refactor(ui): inherit terminal font for SelectableTextBlock
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:54:16 +02:00
mika kunsandClaude Sonnet 4.6 a4cb03b1b5 refactor(ui): use sidebar-pane in PlanningDiffView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:53:56 +02:00
mika kunsandClaude Sonnet 4.6 f53292e134 refactor(ui): use diff-lineno and sidebar-pane in DiffModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:53:34 +02:00
mika kunsandClaude Sonnet 4.6 539ebecf3a refactor(ui): use danger-box in MergeModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:53:07 +02:00
mika kunsandClaude Sonnet 4.6 dff5651db7 refactor(ui): use danger-box in SettingsModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:52:49 +02:00
mika kunsandClaude Sonnet 4.6 9f49b0131f refactor(ui): use shared section style in ListSettingsModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:52:30 +02:00
mika kunsandClaude Sonnet 4.6 fb3a6acf52 refactor(ui): reuse task-row style for worktree rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:52:12 +02:00
mika kunsandClaude Sonnet 4.6 4f84b15b6a refactor(ui): use section-divider in DetailsIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:51:46 +02:00
mika kunsandClaude Sonnet 4.6 27b0d51db0 refactor(ui): drop duplicate converters and normalize binding in ListsIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:51:19 +02:00
mika kuns 2a381048fe refactor(ui): merge task-row styles and add shared section/danger-box/sidebar/accent styles 2026-05-30 17:49:24 +02:00
mika kuns bddef5abef refactor(ui): unify text and close button in ThemedDatePicker 2026-05-30 17:40:07 +02:00
mika kuns 51d3ea2e1c refactor(ui): unify text and close button in ConflictResolutionView 2026-05-30 17:39:18 +02:00
mika kuns 335b422e23 refactor(ui): unify text and close button in PlanningDiffView 2026-05-30 17:38:44 +02:00
mika kuns 08f3babca4 refactor(ui): unify text and close button in DiffModalView 2026-05-30 17:38:09 +02:00
mika kuns 9082f2ed71 refactor(ui): unify text and close button in WorktreeModalView 2026-05-30 17:37:06 +02:00
mika kuns 0f64b1c6e0 refactor(ui): unify text and close button in WorktreesOverviewModalView 2026-05-30 17:36:23 +02:00
mika kuns dd453874ba refactor(ui): unify text and close button in RepoImportModalView 2026-05-30 17:33:52 +02:00
mika kuns 00e1d2d6c9 refactor(ui): unify text and close button in UnfinishedPlanningModalView 2026-05-30 17:33:29 +02:00
mika kuns 9a9113542d refactor(ui): unify text and close button in AboutModalView 2026-05-30 17:33:06 +02:00
mika kuns 8e595a1e43 refactor(ui): unify text and close button in MergeModalView 2026-05-30 17:32:29 +02:00
mika kuns 97fc715856 refactor(ui): unify text and close button in ListSettingsModalView 2026-05-30 17:32:02 +02:00
mika kuns ed8607d4c9 refactor(ui): unify text and close button in SettingsModalView 2026-05-30 17:31:31 +02:00
mika kunsandClaude Sonnet 4.6 929e0ca1ee refactor(ui): apply text classes to SessionTerminalView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:29:04 +02:00
mika kunsandClaude Sonnet 4.6 40a36308ae refactor(ui): apply text classes to AgentStripView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:28:35 +02:00
mika kunsandClaude Sonnet 4.6 b9f5d829c8 refactor(ui): apply text classes to TaskRowView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:27:49 +02:00
mika kunsandClaude Sonnet 4.6 e0dda3e71b refactor(ui): apply text classes to DetailsIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:27:13 +02:00
mika kunsandClaude Sonnet 4.6 d4c66dea63 refactor(ui): apply text classes to TasksIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:26:10 +02:00
mika kunsandClaude Sonnet 4.6 a132127e9e refactor(ui): apply text classes to ListsIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:25:48 +02:00
mika kunsandClaude Sonnet 4.6 6e3125e78d refactor(ui): apply text classes to MainWindow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:24:58 +02:00
mika kuns b00e4d994f feat(ui): unify type scale to 11/13/18/24 and add canonical text classes 2026-05-30 17:22:29 +02:00
mika kuns 16717ab9e9 fix(ui): restore resize and full-width rows in WorktreesOverview modal 2026-05-30 17:16:08 +02:00
mika kuns 7af892f410 refactor(ui): consolidate list-section-label into shared section-label 2026-05-30 17:07:47 +02:00
mika kuns e86464e802 fix(ui): unclip Edit/Preview buttons; enlarge section labels and use mono field labels 2026-05-30 17:02:35 +02:00
mika kuns df7337810e docs(ui): add visual-check checklist for normalization pass 2026-05-30 16:53:36 +02:00
mika kuns 8944074997 refactor(ui): fold selected-day White to TextBrush token 2026-05-30 16:52:56 +02:00
mika kuns fbd5d9f7ca refactor(ui): tokenize WorktreeModalView font sizes 2026-05-30 16:52:16 +02:00
mika kunsandClaude Sonnet 4.6 5fdd9f0b4c refactor(ui): tokenize and dynamic-ize PlanningDiffView
Convert StaticResource token attrs to DynamicResource, snap font sizes to tokens, replace Consolas,Menlo,monospace with MonoFont DynamicResource, and fold Orange warning color to BloodBrush.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:50:43 +02:00
mika kunsandClaude Sonnet 4.6 bce4e0a1e6 refactor(ui): migrate ConflictResolutionView to ModalShell and use dynamic resources
Replace manual titlebar/drag handler with ModalShell, move action buttons to footer, convert StaticResource token attrs to DynamicResource, replace OrangeRed with BloodBrush, and use MonoFont DynamicResource.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:50:38 +02:00
mika kunsandClaude Sonnet 4.6 229f865e7e refactor(ui): migrate DiffModal to ModalShell and use dynamic resources
Replace manual titlebar/drag handler with ModalShell, move Merge button to footer, convert StaticResource token attrs to DynamicResource, snap font sizes to tokens, use MonoFont DynamicResource, and fold tint color literals to RunningTintBrush/ErrorTintBrush.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:50:32 +02:00
mika kunsandClaude Sonnet 4.6 a444033aa9 refactor(ui): migrate WorktreesOverviewModal to ModalShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:47:32 +02:00
mika kunsandClaude Sonnet 4.6 2265829a29 refactor(ui): migrate RepoImportModal to ModalShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:45:51 +02:00
mika kunsandClaude Sonnet 4.6 50e05b9140 refactor(ui): migrate UnfinishedPlanningModal to ModalShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:45:16 +02:00
mika kunsandClaude Sonnet 4.6 538839c004 refactor(ui): migrate AboutModal to ModalShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:44:41 +02:00
mika kunsandClaude Sonnet 4.6 8d07fc298c refactor(ui): migrate MergeModal to ModalShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:44:04 +02:00
mika kunsandClaude Sonnet 4.6 e1bfbb0fa6 refactor(ui): migrate ListSettingsModal to ModalShell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:43:17 +02:00
mika kuns b1006ac7b0 fix(ui): correct SettingsModal font snap (11px is Mono, not Body) 2026-05-30 16:41:05 +02:00
mika kuns 4f5db367a7 refactor(ui): migrate SettingsModal to ModalShell 2026-05-30 16:40:09 +02:00
mika kuns c20fbe3613 feat(ui): add reusable ModalShell control 2026-05-30 16:38:02 +02:00
mika kunsandClaude Sonnet 4.6 16b0d1177a refactor(ui): tokenize ThemedDatePicker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:36:23 +02:00
mika kunsandClaude Sonnet 4.6 a1f05da97b refactor(ui): tokenize SessionTerminalView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:35:46 +02:00
mika kunsandClaude Sonnet 4.6 0c0c73bc9e refactor(ui): tokenize AgentStripView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:35:22 +02:00
mika kunsandClaude Sonnet 4.6 3d4a64a8fd fix(ui): use LineBrush for schedule flyout border and tokenize TaskRowView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:34:25 +02:00
mika kunsandClaude Sonnet 4.6 bff15c9bf3 refactor(ui): tokenize DetailsIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:33:46 +02:00
mika kunsandClaude Sonnet 4.6 f40de4bbe0 refactor(ui): tokenize TasksIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:32:03 +02:00
mika kunsandClaude Sonnet 4.6 e120b0fd70 refactor(ui): tokenize ListsIslandView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:31:39 +02:00
mika kunsandClaude Sonnet 4.6 e8ce725897 refactor(ui): tokenize MainWindow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 16:31:00 +02:00
mika kuns 7a6bfbe1b4 refactor(ui): tokenize IslandStyles values and add shared modal styles 2026-05-30 16:28:47 +02:00
mika kuns 5a25818e3a feat(ui): set global Inter Tight font default on all windows 2026-05-30 16:24:00 +02:00
mika kuns f0f8cd103d feat(ui): add named tint and hairline overlay brush tokens 2026-05-30 16:23:34 +02:00
mika kuns d52f23f7c8 docs(ui): add UI normalization design spec and implementation plan 2026-05-30 16:22:00 +02:00
mika kunsandClaude Opus 4.7 cfc45118e4 docs: sync CLAUDE.md files with current architecture
Release / release (push) Successful in 35s
Drop the removed tag system, fix the retired Manual status and the atomic
queue-claim location, refresh the App DI registrations to the Islands VMs,
update the Data table list, correct a stale test reference, and document the
interface-folder and single-consumer-fold conventions plus the .NET 8 build path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:59:43 +02:00
mika kunsandClaude Opus 4.7 1856943925 refactor: merge TaskRunner failure handlers and reuse NullIfBlank
Unify the near-identical HandleFailure/MarkFailed into a single MarkFailed that
always persists the failed state and never throws, and replace the inline
null-if-blank checks in ListMcpTools with the existing extension.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:51:14 +02:00
mika kunsandClaude Opus 4.7 ce9fadc0b5 refactor: fold single-consumer helper types into their owners
Consolidate small single-purpose types into the files that own them:
StreamResult into StreamAnalyzer, the Planning context records into
PlanningSessionContext, PrimeClock/PrimeSchedulerOptions into PrimeScheduler,
AgentMcpTools into LifecycleMcpTools, the locator subclasses into
InstallArtifactLocator, LogLineViewModel into DetailsIslandViewModel,
RepoImportItemViewModel into its modal, and StepViewModel into InstallPageViewModel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:47:46 +02:00
mika kunsandClaude Opus 4.7 25ee623c42 refactor: remove dead PlanningMergeEvents records and unused RunNowRequestedEvent
The PlanningMergeEvents record types were never instantiated (the broadcaster
uses identically-named methods), and RunNowRequestedEvent had no subscribers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:42:25 +02:00
mika kunsandClaude Opus 4.7 41da124a31 refactor: extract interfaces to Interfaces folders and consolidate filters
Move interface declarations into per-area Interfaces/ subfolders, merge the
small task-list filter classes into StatusFilter/SmartFlagFilter, and simplify
related services, converters and hub DTO handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:41:10 +02:00
mika kunsandClaude Opus 4.7 77100b6b3b Merge feat/external-mcp-ui-parity: external MCP UI parity for start/observe
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:37:58 +02:00
mika kunsandClaude Opus 4.7 32daa4a602 docs(worker): correct external MCP tool inventory, drop removed tag tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:16:48 +02:00
mika kunsandClaude Opus 4.7 b41a78ec29 feat(worker): register new external MCP tool classes
Wire ListMcpTools, ConfigMcpTools, RunHistoryMcpTools, AgentMcpTools,
LifecycleMcpTools, and AppSettingsMcpTools into the external MCP
container and expose them via WithTools<>().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:15:26 +02:00
mika kunsandClaude Opus 4.7 9ea60701d2 feat(worker): add external MCP app-settings read tool
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:12:18 +02:00
mika kunsandClaude Opus 4.7 5a592c4be6 feat(worker): add external MCP reset-failed-task tool
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:07:59 +02:00
mika kunsandClaude Opus 4.7 7196aab31f feat(worker): add external MCP agent-listing tool
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:05:30 +02:00
mika kunsandClaude Opus 4.7 fec2fe2dda fix(worker): cap run-log read size and harden run-history tests
- GetTaskLog reads at most last 256 KB; prepends truncation marker if file exceeds cap
- Wrap temp-file cleanup in finally block to prevent leak on assertion failure
- Add GetRun_NotFound_Throws, GetTaskLog_RunExistsButNoLogPath_Throws, and GetTaskLog_LargeFile_ReturnsTruncatedTail tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 14:04:22 +02:00
mika kunsandClaude Opus 4.7 3afe29d721 feat(worker): add external MCP run-history and log tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:59:54 +02:00
mika kunsandClaude Opus 4.7 f3f8af4b11 docs(worker): clarify SetTaskConfig null-clears-override wording
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:57:59 +02:00
mika kunsandClaude Opus 4.7 c3493a3a74 feat(worker): add external MCP list/task config tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:54:08 +02:00
mika kunsandClaude Opus 4.7 ac2f1d824e fix(worker): reuse shared hub fake and guard blank list name
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:51:34 +02:00
mika kunsandClaude Opus 4.7 53f4e2de0f feat(worker): add external MCP list-management tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:47:02 +02:00
mika kunsandClaude Opus 4.7 99dc08488b docs(worker): add external MCP UI-parity spec and plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:42:24 +02:00
mika kunsandClaude Opus 4.7 26c4e5771b feat(worker): run worker as per-user logon task instead of Windows service
A LocalSystem Windows service can't see the logged-in user's Claude CLI
authentication, so the worker now runs as the current user via a hidden
per-user logon Scheduled Task with restart-on-failure.

- Worker is WinExe (no console window) with a Serilog rolling file sink and
  a single-instance mutex so the logon task, app ensure-running, and Restart
  button can't fight over the SignalR port.
- Installer replaces the service steps (register/start/stop) with autostart
  task steps, migrates the legacy ClaudeDoWorker service away on update, and
  removes the task on uninstall. ServicePage drops the service-account UI.
- UI gains a WorkerLocator; the app ensures the worker is running at startup
  and the Restart button kills+relaunches this install's worker process.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:41 +02:00
mika kunsandClaude Opus 4.7 1e5b3a6c3e chore: add .gitattributes to normalize line endings
Default to LF, force CRLF for Windows script/solution files, and mark
common binary types — silences the CRLF-on-commit warnings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:19 +02:00
mika kunsandClaude Opus 4.7 59d72635da test(ui): rebase IWorkerClient fakes onto shared StubWorkerClient base
Add a StubWorkerClient base implementing the full IWorkerClient surface so
the planning/conflict/diff test fakes only override the members they exercise.
Eliminates the constructor-drift duplication across the three fakes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:12 +02:00
mika kunsandClaude Opus 4.7 7a88e8a848 fix(ui): apply blue PLANNED badge for finalized planning, drop dead converter statics
Bind the planning-parent badge to IsPlanActive/IsPlanFinalized so a
finalized plan shows the blue "planned" style instead of staying amber.
Remove the unused Instance statics on BoolToItalicConverter and
BoolToDraftOpacityConverter (registered via the App.axaml resource dictionary).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:39:04 +02:00
mika kunsandClaude Opus 4.7 b84716ff9c fix(releases): strip prerelease and build metadata before version compare
System.Version can't parse SemVer prerelease ("-alpha") or MinVer build
metadata ("+sha") suffixes, so an installed 1.0.2-alpha was treated as
unparseable. Reduce both sides to their numeric core before comparing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 09:38:57 +02:00
mika kuns ce879f6f70 Merge feat/repo-import-polish: remember folders, search, compact rows, no auto-select 2026-05-29 16:39:06 +02:00
mika kuns 2f7f00d4cc docs(ui): clarify repo-import checkbox default intent 2026-05-29 16:31:31 +02:00
mika kuns 6d0973c67c feat(ui): repo-import modal — remember folders, search, compact rows, no auto-select 2026-05-29 16:29:22 +02:00
mika kuns bb8b3e235a Merge feat/delete-list-button: add delete-list button to List Settings modal 2026-05-29 16:13:32 +02:00
mika kuns 6e3947c0b1 fix(ui): narrow delete-list FK catch to SqliteException 2026-05-29 16:12:15 +02:00
mika kuns 128fb7d4d2 feat(ui): add delete-list button to List Settings modal 2026-05-29 16:09:17 +02:00
mika kuns 3af8fb9aa0 Merge feat/repo-import-list-helper: add repos-as-lists import helper 2026-05-29 15:59:45 +02:00
mika kuns 5b15e30b8a docs: add repo import list helper implementation plan 2026-05-29 15:59:37 +02:00
mika kuns e5bce07719 docs(ui): document RepoImportModalView 2026-05-29 15:52:34 +02:00
mika kunsandClaude Sonnet 4.6 9c638e72b1 feat(ui): add 'Add repos as lists' Help-menu entry point
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:50:52 +02:00
mika kunsandClaude Sonnet 4.6 c43b06d83d feat(ui): add repo import button to Lists island
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:46:45 +02:00
mika kuns d4674cd74e chore(di): register RepoImportModalViewModel 2026-05-29 15:45:04 +02:00
mika kuns e4d958dcf3 feat(ui): add RepoImportModalView 2026-05-29 15:43:52 +02:00
mika kunsandClaude Opus 4.7 0f41384fa8 test(ui): assert FullPath in RepoImport candidate test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:42:05 +02:00
mika kuns 50b1589b23 feat(ui): add RepoImportModalViewModel with candidate merge logic 2026-05-29 15:39:43 +02:00
mika kuns 1c689a8472 feat(ui): add RepoImportItemViewModel 2026-05-29 15:37:10 +02:00
mika kunsandClaude Opus 4.7 4877c11aa2 fix(ui): narrow RepoScanner catch to filesystem exceptions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:36:12 +02:00
mika kunsandClaude Sonnet 4.6 03617ee3cd feat(ui): add RepoScanner for git repo discovery
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:34:32 +02:00
mika kunsandClaude Opus 4.7 7869c2a979 docs: add repo import list helper design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 15:26:32 +02:00
mika kunsandClaude Opus 4.7 ce79a2d0fe feat(planning): gate subtask queueing behind plan finalization
Planning subtasks are now "Draft" until their parent plan is finalized,
then "Planned" (queueable). Finalizing a plan no longer auto-queues the
child chain; the user sends the plan to the queue explicitly.

- TaskStateService rejects a child entering Queued/Running unless its parent
  is Finalized; this single invariant covers UI, queue, RunNow and MCP paths
- WorkerHub.SetTaskStatus routes Queued through the gated EnqueueAsync
- Finalize call sites pass queueAgentTasks: false
- PlanningChainCoordinator.QueuePlanAsync guards the chain build on Finalized
- TaskRowViewModel derives Draft/Planned from ParentFinalized; gates
  CanSendToQueue / CanQueuePlan; view shows a PLANNED badge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:41:48 +02:00
mika kunsandClaude Opus 4.7 09a930e28e docs: add planning draft/planned queue gate design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:52 +02:00
mika kunsandClaude Opus 4.7 c1c7862672 fix(ui): widen About modal so folder Open buttons are not clipped
Long folder paths in monospace pushed the Open buttons past the 480px
window edge. Widen to 620px, disable horizontal scroll so paths trim, and
add column spacing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:52 +02:00
mika kunsandClaude Opus 4.7 19f22d2d97 chore(ui): clear build warnings
- Guard Windows-only ServiceController/registry calls behind SupportedOSPlatform
  and OperatingSystem.IsWindows() (CA1416)
- Initialize test-only ctor fields with null! (CS8618)
- Migrate obsolete Avalonia APIs: Watermark -> PlaceholderText,
  SystemDecorations -> WindowDecorations

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:44 +02:00
mika kunsandClaude Opus 4.7 12668f684f fix(ui): restore Ui.Tests build by implementing ListUpdatedEvent in fakes
The IWorkerClient.ListUpdatedEvent member was added without updating three
test fakes, breaking compilation of the Ui.Tests project.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:25:33 +02:00
mika kunsandClaude Opus 4.7 967e0cd319 feat(ui): merge action and robust jump-to-task in worktrees overview
Add Merge entry to the worktrees overview context menu wiring the existing
MergeModalViewModel, replace fire-and-forget list selection with a
collection-change-aware JumpToTaskHelper, and propagate list renames to
visible task rows via a new ListUpdated event.

Harden worktree state changes: WorkerHub.SetWorktreeState now rejects
invalid transitions, WorktreeMaintenanceService only drops the DB row when
the on-disk worktree was actually removed, and Cleanup/Reset broadcast
WorktreeUpdated for affected tasks. SetWorktreeStateAsync returns the hub
error message so the modal can surface it.

Also: de-duplicate the worktrees overview modal opener, hook
OnParentTaskIdChanged to refresh IsDraft, fix MergeModal CanExecute
notifications, and add WorktreeStateHubTests for the transition rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:43:39 +02:00
mika kunsandClaude Opus 4.7 2223839595 feat(ui): hide list chip outside virtual list views
Task rows now expose a ShowListChip flag that the tasks island sets
only for Virtual list kinds, so the chip stops being redundant when
viewing a single concrete list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:27:04 +02:00
mika kunsandClaude Opus 4.7 7d61d38a34 fix(ui): dispatch WorkerLog events to UI thread
Worker log broadcasts arrive on a SignalR thread; raising the event
directly let UI subscribers touch bindable state off-thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:26:57 +02:00
mika kunsandClaude Opus 4.7 e55367af67 fix(ui): wire details-island buttons and drop dead handlers
- Bind star button to ToggleStarCommand; wrap header and subtask
  done-check ellipses in buttons (ToggleDone / ToggleSubtaskDone).
- Wire AgentStrip copy-path button to clipboard handler.
- Remove dead Notes/PromptInput/ApproveMerge/ShowWorktreeModal code
  with no UI bindings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:24:51 +02:00
mika kuns 0b19ea739c Merge feat/worktree-overview-modal 2026-05-19 11:55:34 +02:00
mika kuns 3587703fe8 feat(ui): auto-select first changed file in diff modal 2026-05-19 11:52:57 +02:00
mika kunsandClaude Sonnet 4.6 7e3ae704fe fix(ui): default-expand diff tree; reliable row-click toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:50:36 +02:00
mika kunsandClaude Sonnet 4.6 232d7cb647 fix(ui): toggle expand on full folder row click
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:48:07 +02:00
mika kunsandClaude Sonnet 4.6 6c8048d0be fix(ui): use BorderOnly chrome; color diff +/- lines
Apply SystemDecorations=BorderOnly + ExtendClientAreaTitleBarHeightHint=-1
to WorktreesOverviewModalView and WorktreeModalView for reliable OS resize
borders. Replace SelectedFileDiff SelectableTextBlock with per-line
ItemsControl using WorktreeDiffLineKind coloring via DiffLineKindToBrushConverter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:43:47 +02:00
mika kunsandClaude Sonnet 4.6 6670771040 fix(ui): make overview modal resizable; add diff content pane
Drop outer Border wrapper in WorktreesOverviewModalView so Avalonia edge
resize handles reach the window frame. Add split pane to WorktreeModalView
with file tree on left and per-file unified diff on right; wire SelectedNode
via SelectedItem TwoWay binding + SelectionChanged fallback; add
GetFileDiffAsync to GitService.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:33:00 +02:00
mika kunsandClaude Sonnet 4.6 bc15c16e44 fix(ui): resizable modal, drop branch column, show committed diff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 11:08:52 +02:00
mika kunsandClaude Opus 4.7 ca71275fc4 feat(ui): polish worktrees overview modal
- Restyle to match ListSettingsModalView: custom title bar, DeepBrush
  toolbar, LineBrush footer, SurfaceBrush outer border, no system chrome
- Add column header row (TASK / BRANCH / STATE / DIFF / AGE) with
  TextFaintBrush + MonoFont + LetterSpacing, separator line below
- Replace wt-row style with task-row-equivalent: transparent bg,
  CornerRadius 8, 1px border, :pointerover + .selected transitions
- Add IsSelected to WorktreeOverviewRowViewModel; SelectRow() helper
  on modal VM clears previous selection before setting new one
- Wire OnRowTapped in code-behind for click-to-select
- Wire ShowDiff: VM takes Func<WorktreeModalViewModel> factory, builds
  diffVm and delegates window creation to both call sites (MainWindow
  and ListsIslandView); register Func<WorktreeModalViewModel> in DI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:38:19 +02:00
mika kunsandClaude Sonnet 4.6 8f4e37ef56 fix(ui): preserve status message after cleanup; English label
Remove StatusMessage reset from LoadAsync so CleanupFinished result survives the reload; reset moved to Refresh command only. Also rename German context-menu label to "Worktrees…".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:55:32 +02:00
mika kunsandClaude Sonnet 4.6 789094fcd9 feat(ui): wire worktree overview modal entry points
Add list context-menu command (per-list mode) and Help menu entry (global mode) for the WorktreesOverviewModal; register VM and factory in DI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:49:44 +02:00
mika kunsandClaude Sonnet 4.6 9f70f6747e feat(ui): add WorktreesOverviewModalView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:44:20 +02:00
mika kuns 182a9df7f3 feat(ui): add WorktreesOverviewModalViewModel 2026-05-19 09:42:37 +02:00
mika kuns 79131f83c1 feat(ui): add WorktreeStateColorConverter 2026-05-19 09:42:33 +02:00
mika kunsandClaude Sonnet 4.6 b888a5f0cd feat(ui): expose worktree overview client methods
Add GetWorktreesOverviewAsync, SetWorktreeStateAsync, ForceRemoveWorktreeAsync wrappers; update CleanupFinishedWorktreesAsync to accept optional listId; append WorktreeOverviewDto and ForceRemoveResultDto records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:39:01 +02:00
mika kunsandClaude Sonnet 4.6 046da0fd81 feat(hub): expose worktree overview, state mutation, force-remove
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:37:29 +02:00
mika kunsandClaude Sonnet 4.6 b095a29f97 feat(worktrees): add ForceRemoveAsync for targeted removal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:34:32 +02:00
mika kunsandClaude Sonnet 4.6 ce30d01b72 feat(worktrees): add GetOverviewAsync for overview modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:32:07 +02:00
mika kunsandClaude Sonnet 4.6 89f6b836ba feat(worktrees): allow CleanupFinishedAsync to filter by list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 09:29:27 +02:00
mika kuns b944597af4 docs: add worktree overview modal spec and plan 2026-05-19 09:27:19 +02:00
mika kuns 5da69ee6aa refactor(config): consolidate commit types into CommitTypeRegistry
Replaces six scattered "chore" literals across TaskEntity, ListEntity,
WorkerHub, ListsIslandViewModel, ListNavItemViewModel and the inline
commit type list in ListSettingsModalViewModel.
2026-05-19 09:00:00 +02:00
mika kuns 5308ba3136 refactor(config): consolidate permission modes into PermissionModeRegistry
Also fixes WorkerHub.UpdateAppSettings falling back to "bypassPermissions"
when AppSettingsEntity and the runtime default are "auto". The fallback
now matches the entity default.
2026-05-19 08:59:16 +02:00
mika kuns a62ef240d1 refactor(config): consolidate model aliases into ModelRegistry
Replaces three scattered model lists (ListSettingsModalViewModel,
DetailsIslandViewModel, GeneralSettingsTabViewModel) and the hardcoded
planning model with a single source. Planning launcher now uses the
opus alias instead of pinning claude-opus-4-7.
2026-05-19 08:58:43 +02:00
mika kunsandClaude Opus 4.7 623ebf147b refactor(tags): remove tag entity and all references
Drops TagEntity, TagRepository, and tag wiring across data layer, worker,
and UI. Adds RemoveTags migration to clean up schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:07:24 +02:00
mika kunsandClaude Opus 4.7 8d34db3f9b feat(ui): add Restart worker menu entry under Help
Stops and starts the ClaudeDoWorker Windows service via
ServiceController. SignalR auto-reconnect plus the existing
ConnectionRestoredEvent handle the refresh, so the UI repopulates
counters and the active list once the worker is back up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:39:40 +02:00
mika kunsandClaude Opus 4.7 0d55002e5e refactor(planning): dequeue orphans instead of promoting, restore lost lineage
Three behavioral changes around stuck planning subtasks:

- OrphanRecovery no longer clears ParentTaskId. Queued children of a
  parent that is not in a planning phase are dequeued (Status: Queued
  -> Idle, BlockedByTaskId cleared) but stay attached to the parent so
  the historical lineage is preserved.
- DiscardPlanningAsync stops promoting terminal (Done/Failed/Cancelled)
  children to top-level for the same reason - they remain ChildTasks of
  the (now non-planning) parent.
- New PlanningLineageRecovery hosted service scans
  ~/.todo-app/planning-sessions/ and re-attaches a single, unambiguous
  blocked-by chain to its original planning parent when the
  parent_task_id links were lost. Refuses to guess when multiple
  candidate chains exist.

UI now exposes ConnectionRestoredEvent on IWorkerClient, fired on first
connect and every reconnect. ListsIslandViewModel refreshes counters
and TasksIslandViewModel reloads the current list - so stale counts no
longer survive a worker restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:28:57 +02:00
mika kunsandClaude Opus 4.7 d094a21e09 feat(planning): prevent orphaned subtasks via guards + startup repair
Three coordinated guards close the orphan-creation paths:

- CreateChildAsync refuses when the parent is not in a planning phase.
- DiscardPlanningAsync now returns a structured DiscardPlanningOutcome
  and refuses when children are queued or running; callers can opt into
  auto-dequeuing queued kids via dequeueQueuedChildren=true. Terminal
  children (Done/Failed/Cancelled) are promoted to top-level instead of
  becoming orphans when the parent's PlanningPhase is reset.
- OrphanRecovery hosted service clears ParentTaskId on any rows whose
  parent is missing or no longer in a planning phase on worker startup,
  mirroring the StaleTaskRecovery pattern.

UI surfaces the block reason: a confirm dialog offers to dequeue queued
children and retry; a running-children block is shown as a hard error
asking the user to cancel first.

WorkerClient now negotiates the JsonStringEnumConverter so the
DiscardPlanningResult enum round-trips correctly over SignalR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:02:15 +02:00
mika kunsandClaude Opus 4.7 e68bb737e3 refactor(filtering): consolidate task list filters into single strategy registry
Replace the three drifting filter implementations (counter, list loader,
regroup) with one ITaskListFilter strategy per list kind. Counter and list
loader now share the same predicates, so they cannot diverge again. Planning
hierarchy rules (parent-as-context, orphan handling) live in PlanningRules
and are unit-tested via 29 new tests in ClaudeDo.Data.Tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:18:33 +02:00
mika kunsandClaude Opus 4.7 a6608bf8b3 docs(open): regenerate against current code state
Old open.md was dated 2026-04-13 and predated Planning Sessions, Prime
Claude, Self-Update, External MCP, editable status/tags, BlockedBy
chains, and the worker state consolidation. New version audits each
plan/improvement-plan item against the source tree, marks DONE/PARTIAL/
OPEN with file evidence, adds falsifiable pass-criteria to the
verification matrix, and lists the slices that shipped between
2026-04-13 and 2026-04-30 in a dedicated §0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:17:55 +02:00
mika kunsandClaude Opus 4.7 df66c4af46 feat(worker): add Claude CLI preflight on startup
Worker now runs `claude --version` before listening; on non-zero exit
it logs critical and exits with code 1. Skippable via env var
CLAUDEDO_SKIP_CLI_PREFLIGHT=1 for environments without the CLI (tests,
dev). Closes verification step 2 / open.md item 3.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:17:44 +02:00
mika kunsandClaude Opus 4.7 4c92da55ad feat(ui): cascade dequeue to queued children for any parent
RemoveFromQueue previously gated cascade on PlanningPhase != None,
leaving manually-built chains stuck if their parent had no planning
phase. The handler now matches the X button's HasQueuedSubtasks gate:
queued children are unqueued and unblocked regardless of the parent's
planning phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:17:37 +02:00
mika kunsandClaude Opus 4.7 d4d5a4b8e7 feat(worker): refine planning chain re-shape on re-run
SetupChainAsync now sequences only non-terminal children (Idle/Queued).
Done/Failed/Cancelled rows are left in place so a re-run on a partially
executed chain keeps history intact and only reshapes the tail. Running
children abort the op since the chain cannot be reshaped mid-flight.
First non-terminal child is explicitly unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:17:29 +02:00
mika kunsandClaude Opus 4.7 9ba238f4ad feat(ui): status/tag context menu + ThemedDatePicker in task row
Adds "Set status" and "Tags" submenus to the row context menu (tags
list is built lazily on Opening from AllTags ∪ row tags). Replaces
the schedule flyout's separate DATE / TIME pickers with a single
ThemedDatePicker in date+time mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:40:09 +02:00
mika kunsandClaude Opus 4.7 c1856657b5 feat(ui): editable task status and tags from details panel
Adds a status ComboBox in the Details header (no transition guards)
and a Tags section with chips + AutoCompleteBox. TaskRowViewModel.Tags
becomes an ObservableCollection so chip lists stay live. TasksIsland
caches AllTags for the row context menu and exposes Set/Toggle helpers.
Test fakes updated for the new IWorkerClient methods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:40:03 +02:00
mika kunsandClaude Opus 4.7 47b07373af feat(ui): add ThemedDatePicker control and adopt in Prime settings
New themed picker supports single-date, date+time, and range modes
(replaces inconsistent CalendarDatePicker / DatePicker / TimePicker
visuals). Used in the Prime schedules row to combine StartDate /
EndDate into a single range picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:39:53 +02:00
mika kunsandClaude Opus 4.7 121e8cd476 feat(worker): add hub methods to set task status and tags freely
Adds ForceSetStatusAsync on ITaskStateService (no transition guards)
plus SetTaskStatus / SetTaskTags / GetAllTags hub methods so the UI
can edit a task's status and tags directly. PlanningHubTests ctor
updated for the new ITaskStateService dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:39:44 +02:00
mika kunsandClaude Opus 4.7 cfbe2fd7e3 feat(worker): drop 'agent' tag gate from queue claim
Queueing a task is itself the explicit "run me" signal — the extra
tag/list filter was redundant and surprised users whose queued tasks
were silently skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 10:39:36 +02:00
Mika KunsandClaude Opus 4.7 5079a5fc5c feat(ui): show transient prime status in footer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:29:25 +02:00
Mika KunsandClaude Opus 4.7 618235d8ed feat(ui): add About modal opened from Help menu
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:25:34 +02:00
Mika KunsandClaude Opus 4.7 bca8c9e4cb feat(ui): refactor Settings to TabControl + add Prime Claude tab
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:22:16 +02:00
Mika KunsandClaude Opus 4.7 8b02b63d3d feat(ui): split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:18:39 +02:00
Mika KunsandClaude Opus 4.7 f890fa85b9 feat(ui): add Prime schedule client + PrimeFired event
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:10:21 +02:00
Mika KunsandClaude Opus 4.7 fd5562b6e8 test(hub): pass primeSignal null to WorkerHub in PlanningHubTests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:08:49 +02:00
Mika KunsandClaude Opus 4.7 71c6c68c84 feat(worker): register Prime services in DI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:05:21 +02:00
Mika KunsandClaude Opus 4.7 507f59f1d1 feat(worker): add Prime schedule hub methods
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:04:20 +02:00
Mika KunsandClaude Opus 4.7 13c280f6d5 feat(worker): broadcast PrimeFired SignalR event
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:03:15 +02:00
Mika KunsandClaude Opus 4.7 09e3e7e8b5 feat(worker): add PrimeScheduler hosted service
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 09:02:12 +02:00
Mika KunsandClaude Opus 4.7 975db8ab54 feat(worker): add NextDueCalculator with workday + catch-up logic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:59:19 +02:00
Mika KunsandClaude Opus 4.7 f383645360 feat(worker): add Prime scheduler abstractions + runner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:57:02 +02:00
Mika KunsandClaude Opus 4.7 4e90828653 feat(worker): add PrimeScheduleDto
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:55:19 +02:00
Mika KunsandClaude Opus 4.7 a335a3b684 feat(data): add PrimeScheduleRepository
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:54:30 +02:00
Mika KunsandClaude Opus 4.7 0b90df6ff0 feat(data): add AddPrimeSchedules migration
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:50:38 +02:00
Mika KunsandClaude Opus 4.7 6c9ccf68b6 feat(data): add PrimeScheduleEntity + configuration
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:47:43 +02:00
Mika KunsandClaude Opus 4.7 2ff0971dce docs: add design + plan for tabbed settings + Prime Claude
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:46:43 +02:00
Mika KunsandClaude Opus 4.7 8eafa71ed3 fix: restore green test suite across all projects
* TaskRepository.UpdateAsync defensively detaches any locally tracked
  entity with the same Id before attaching the patched copy, preventing
  EF identity conflicts when callers load via AsNoTracking and write
  back through the same DbContext (surfaced by ExternalMcpService
  UpdateTask integration tests).
* TasksIslandViewModel auto-collapse now only fires for Finalized
  planning parents that are not yet Done. Active-phase parents stay
  expanded while the user is editing the plan, and Done parents stay
  expanded so all completed children land in CompletedItems alongside
  the parent.
* Update three Ui.Tests fakes (ConflictResolution, PlanningDiff,
  DetailsIslandPlanning) to implement the two new IWorkerClient
  members (OpenInteractiveTerminalAsync, QueuePlanningSubtasksAsync).
* Rewrite StreamLineFormatterTests to exercise the current
  assistant/user/result/system message format instead of the legacy
  stream_event parsing that was removed in the formatter rewrite.
* Align AppSettingsRepository seed-default assertion with the
  permission-mode default that flipped from bypassPermissions to auto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 08:30:26 +02:00
Mika Kuns dc3fc443b4 refactor(data): retire legacy TaskStatus values and backfill existing rows
Slice 6 of the worker state and queue consolidation refactor.

* Drop Manual, Planning, Planned, Draft, Waiting from the TaskStatus enum
  and from the EF value converter; only the lifecycle values remain
  (Idle, Queued, Running, Done, Failed, Cancelled).
* Add migration RetireLegacyTaskStatus that rewrites existing rows:
  manual/draft -> idle, planning -> idle+planning_phase=active,
  planned -> idle+planning_phase=finalized, waiting -> queued+blocked_by
  derived from sort_order via a CTE with LAG().
* Reroute every call site that compared/set legacy values to the new
  three-field model (Status + PlanningPhase + BlockedByTaskId), including
  the planning repo helpers, MCP services, the planning chain coordinator,
  and the UI view-models. TaskRowViewModel now exposes PlanningPhase to
  drive the planning badge.
* Refresh Worker/CLAUDE.md and Data/CLAUDE.md, the docs/plan.md status
  section, and the planning verification notes in docs/open.md.
2026-04-27 15:28:55 +02:00
Mika KunsandClaude Opus 4.7 ff7c239959 refactor(worker): extract OverrideSlotService and reorganize Worker/Services into domain folders
Slice 5 of the worker state consolidation refactor.

OverrideSlotService (new in Worker/Queue/) owns RunNow, ContinueTask,
and the override-slot piece of CancelTask. QueueService keeps the
queue-slot guard for "task is already running" rejection and delegates
to OverrideSlotService for execution; CancelTask tries the override
slot first, then the queue slot. QueueSlotState is extracted to its own
file.

Folder reorg (via git mv to preserve history):
- Worker/Queue/      QueueService, OverrideSlotService, QueueSlotState
                     (alongside existing waker/picker)
- Worker/Lifecycle/  StaleTaskRecovery, TaskResetService, TaskMergeService
- Worker/Worktrees/  WorktreeMaintenanceService
- Worker/Agents/     AgentFileService, DefaultAgentSeeder

Worker/Services/ folder removed. All consumers updated to the new
namespaces (Program.cs, WorkerHub, ExternalMcpService,
PlanningMergeOrchestrator, all Worker tests).

OverrideSlotService is registered as a DI singleton in both the main
worker app and the external MCP app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:42:13 +02:00
Mika KunsandClaude Opus 4.7 4ab906ff0b feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup
Slice 4 of the worker state consolidation refactor. Eliminates the
"queue never picks up planning tasks" bug structurally by routing both
the manager and MCP finalize paths through TaskStateService and
PlanningChainCoordinator.SetupChainAsync, where the auto-wake on enqueue
guarantees the queue picker claims the first child immediately.

- Delete TaskRepository.FinalizePlanningAsync; PlanningSessionManager
  now orchestrates via _state.FinalizePlanningAsync + _chain.SetupChainAsync.
- Rename QueueSubtasksSequentiallyAsync to SetupChainAsync (internal);
  layout is now Status=Queued + BlockedByTaskId, with auto-attached agent tag.
- OnChildFinishedAsync looks up the successor by BlockedByTaskId, drops
  the legacy Waiting status lookup.
- PlanningMcpService.Finalize routes through state+chain; EditableStatuses
  drops Waiting and adds Idle; gate uses PlanningPhase==Active.
- TaskStateService.FinalizePlanningAsync clears the planning session token.
- UI: TaskRowViewModel adds BlockedByTaskId; IsQueued/IsWaiting reflect
  the new layout; TasksIslandViewModel.RemoveFromQueueAsync clears
  BlockedByTaskId on dequeue.
- New regression test PlanningEndToEndTests.FinalizeAsync_FirstChildIs
  ClaimedByPicker_WithinDeadline asserts the picker claims the first
  child within 200ms with no manual WakeQueue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:16:12 +02:00
Mika KunsandClaude Opus 4.7 064a903076 refactor(worker/queue): split queue waker and picker, auto-wake on enqueue
Slice 3 of the worker state and queue consolidation refactor.

- Add IQueueWaker / QueueWaker (singleton holding the wake semaphore).
- Add IQueuePicker / QueuePicker; raw SQL UPDATE...RETURNING moves out of
  TaskRepository.GetNextQueuedAgentTaskAsync (deleted) and now also filters
  on blocked_by_task_id IS NULL and writes started_at on claim.
- TaskStateService takes IQueueWaker directly; the Func<QueueService>
  indirection is gone. State transitions to Queued auto-wake the dispatcher.
- QueueService waits via the shared waker and dispatches via the picker.
- Drop explicit _queue.WakeQueue() calls in WorkerHub.QueuePlanningSubtasksAsync
  and ExternalMcpService.AddTask. The hub WakeQueue endpoint stays for
  diagnostics, delegating to _waker.Wake().
- Migrate tests; pre-existing flaky AppSettings/ExternalMcp tests untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:05:54 +02:00
Mika KunsandClaude Opus 4.7 8823265e5a refactor(worker/state): introduce TaskStateService and route mutations through it
Slice 2 of the worker state consolidation refactor (spec sections 2 and 8).

Adds Worker/State/ITaskStateService + TaskStateService as the single component
that mutates Status, PlanningPhase, and BlockedByTaskId. Each transition is one
atomic ExecuteUpdate with a WHERE filter on the expected source status, so
parallel claims are TOCTOU-free. Side effects (queue wake on -> Queued, hub
TaskUpdated broadcast, chain advance + parent completion on terminal child)
are owned by the service so callers no longer need to remember them.

Migrated callers (mechanical, behavior preserved):
- TaskRunner: HandleSuccess/HandleFailure/MarkFailed/RunAsync/ContinueAsync
- StaleTaskRecovery: bulk recover stale Running tasks
- TaskResetService: status flip (worktree cleanup stays in service)
- PlanningSessionManager.StartAsync: status flip via state, token write via repo
- PlanningChainCoordinator.OnChildFinishedAsync: routes the next-sibling write
  through state.UnblockAsync (Slice 4 finishes the rewrite)
- ExternalMcpService.UpdateTaskStatus: Queued case via state.EnqueueAsync

Repo Mark*Async helpers (MarkRunning/MarkDone/MarkFailed/FlipAllRunningToFailed)
are now internal; ClaudeDo.Data grants InternalsVisibleTo to ClaudeDo.Worker
and ClaudeDo.Worker.Tests for the existing repo-level tests.

DI: TaskStateService is registered as Singleton in both the main app and the
external-MCP app; the queue-wake delegate captures sp -> QueueService.WakeQueue
to break the TaskStateService -> QueueService -> TaskRunner -> TaskStateService
construction cycle. PlanningChainCoordinator takes Func<ITaskStateService> for
the same reason; Slice 3 will replace both with IQueueWaker.

Tests: TaskStateServiceTests covers happy + reject for every transition, the
parallel StartRunningAsync claim race, child-terminal chain advancement, and
stale recovery. Existing service/repo tests are updated to construct the new
state-service via a TaskStateServiceBuilder helper. Pre-existing constructor
drift in QueueService/ExternalMcp/PlanningHub tests is patched to keep the
test project building (the surrounding test logic is otherwise untouched).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:31:57 +02:00
Mika Kuns cf7a6e413c docs(superpowers): add session prompts for worker state consolidation slices 2-6
Self-contained prompts to paste into fresh sessions, one per remaining slice.
Each prompt includes scope, allowed transitions, caller-migration list, test
expectations, and the conventional-commit message to use.
2026-04-27 10:52:55 +02:00
Mika Kuns 7b737e6717 feat(data): add Idle/Cancelled status, PlanningPhase enum, BlockedByTaskId field
Slice 1 of the worker-state-and-queue-consolidation refactor — additive only,
no caller changes. Introduces the new orthogonal status model:

- TaskStatus gains canonical Idle and Cancelled values; legacy values
  (Manual, Planning, Planned, Draft, Waiting) stay around until slice 6.
- New PlanningPhase enum (None/Active/Finalized) for parent tasks.
- New BlockedByTaskId FK on TaskEntity for sequential chain ordering;
  ON DELETE SET NULL so orphaned children become pickable.
- EF migration adds planning_phase and blocked_by_task_id columns plus
  the idx_tasks_blocked_by index. Also picks up an unrelated drift in
  app_settings.default_permission_mode that had been changed in code
  (commit 14cc9fb) without a migration.
2026-04-27 10:25:53 +02:00
Mika Kuns 43af17e546 docs(superpowers): add worker state and queue consolidation spec
Approved design for centralizing task status mutations in a TaskStateService,
splitting TaskStatus into orthogonal lifecycle/planning/blocking fields, and
making queue wakes automatic. Sets up the 6-slice refactor of Worker/Services.
2026-04-27 10:16:55 +02:00
Mika Kuns 5c55f6c6cf chore(docs): trim leading whitespace in prompts inventory 2026-04-27 10:16:45 +02:00
Mika Kuns bdb709b264 feat(ui): show dequeue affordance on planning parents with queued children
Planning parents stay in Planning/Planned status while their children are
Queued/Waiting, so the existing IsQueued-only visibility rule hid the dequeue
button. Add HasQueuedSubtasks tracking and a CanRemoveFromQueue helper; the
parent-row dequeue cascades to all queued/waiting children. Also attach the
'agent' tag on explicit enqueue so the queue picker accepts the task.
2026-04-27 10:16:40 +02:00
Mika Kuns 2d7f825ff3 feat(mcp/planning): allow status changes and post-finalize edits in active session
Extend UpdateChildTask with a status parameter (restricted to Draft, Manual,
Queued, Waiting) and replace the 'only Draft is editable' rule with 'planning
session is active'. Same loosening applied to DeleteChildTask. Lets planning
agents iterate on children that already escaped Draft state.
2026-04-27 10:16:32 +02:00
Mika Kuns 721c36a66b fix(planning): attach agent tag to chained children for queue pickup
Worker queue picker requires the 'agent' tag — without it children created
through QueueSubtasksSequentiallyAsync sat in 'Queued' forever. Attach the
tag automatically when wiring up the chain.
2026-04-27 10:16:24 +02:00
Mika Kuns 10b2ca817b docs(superpowers): add external MCP CRUD extensions spec and plan
Capture the design and execution plan for the AddTask/UpdateTask/DeleteTask/
SetTaskTags external MCP work that landed in commits 1a74e1c..59dc1e2.
2026-04-27 10:16:19 +02:00
mika kunsandClaude Sonnet 4.6 1b9f2d4de1 docs(worker): document new external MCP tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 11:31:11 +02:00
mika kuns 59dc1e2357 feat(mcp/external): add SetTaskTags 2026-04-25 11:29:58 +02:00
mika kunsandClaude Sonnet 4.6 31a394e694 feat(mcp/external): add DeleteTask
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 11:28:47 +02:00
mika kunsandClaude Sonnet 4.6 d99cb68afb feat(mcp/external): add UpdateTask for content/tag patching
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 11:27:16 +02:00
mika kunsandClaude Sonnet 4.6 1a74e1c058 feat(mcp/external): AddTask accepts tags on creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 11:25:42 +02:00
mika kunsandClaude Sonnet 4.6 e6846b7e6d feat(mcp/external): add ListTags + inject TagRepository
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 11:24:10 +02:00
mika kunsandClaude Sonnet 4.6 e767d57640 test(external): scaffold ExternalMcpServiceTests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 11:21:13 +02:00
mika kuns 25493528de feat(data): add TaskRepository.SetTagsAsync for full tag-set replacement 2026-04-25 11:18:26 +02:00
mika kunsandClaude Opus 4.7 14cc9fb891 feat(settings): default permission mode to auto and surface it in UI
Replaces "bypassPermissions" with "auto" as the default for new installs
and adds "auto" as the first option in the settings dropdown. Existing
rows keep their stored value; ClaudeArgsBuilder still maps the legacy
"bypassPermissions" -> "auto" at dispatch time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:11:02 +02:00
mika kunsandClaude Opus 4.7 7f96ae9508 feat(prompts): add editable system/planning/agent prompt files
Introduces ~/.todo-app/prompts/{system,planning,agent}.md as the canonical
location for prompt content. The settings modal exposes "Open in editor"
shortcuts for each, and TaskRunner merges system.md (always) and agent.md
(for "agent"-tagged tasks) into the effective system prompt alongside the
existing global/list/task layers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:10:50 +02:00
mika kunsandClaude Opus 4.7 6c54759aa0 feat(ui): add Run interactively action to task context menu
Spawns Windows Terminal in the list working directory running
`claude --permission-mode auto` with the task title and description
prefilled as the initial prompt. Reuses the planning launcher
infrastructure but skips worktree, system prompt, and MCP setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:02:21 +02:00
mika kunsandClaude Opus 4.7 615c1da665 docs: add planning UX spec/plan and prompts/mailbox proposals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:32 +02:00
mika kunsandClaude Opus 4.7 e192285f5d feat(ui): make island layout user-resizable with grid splitters
Replaces fixed 260/*/320 columns with two GridSplitters between Lists/Tasks/Details. Min widths preserved (200/320/280). Right splitter hides when ShowDetails is false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:25 +02:00
mika kunsandClaude Opus 4.7 a6ca1c0108 feat(ui): add MarkdownView control and editable description in details island
New MarkdownView UserControl renders a markdown preview. Details island gains an editable Description section with edit/preview toggle, collapsible header, copy-to-clipboard, and debounced auto-save (400ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:14 +02:00
mika kunsandClaude Opus 4.7 8f94dddbc5 feat(ui): queue planning subtasks sequentially and surface waiting status
Adds a "Queue subtasks sequentially" context-menu entry on rows with planning children, wires it to WorkerHub.QueuePlanningSubtasksAsync via IWorkerClient. TaskRowViewModel exposes IsWaiting/StatusChipClass for the new Waiting status, and HasPlanningChildren keeps parents expandable after they leave the planning state. TasksIslandViewModel auto-collapses parents whose every child is Done and includes Waiting children in the queued virtual list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:37:04 +02:00
mika kunsandClaude Opus 4.7 45320427e8 feat(worker): add external MCP endpoint with API-key auth
A second WebApplication runs the external MCP server on its own port (default 47822) so it can expose a different tool set under different auth than the internal /mcp endpoint. Shared singletons (config, broadcaster, queue, db factory) are injected by instance so both apps share runtime state. ExternalMcpAuthMiddleware enforces an optional X-ClaudeDo-Key header; loopback-only trust when no key is configured.

Tools: ListTaskLists, ListTasks, GetTask, AddTask, UpdateTaskStatus, RunTaskNow, CancelTask.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:36:46 +02:00
mika kunsandClaude Opus 4.7 16e1ddd129 feat(worker): add PlanningChainCoordinator for sequential subtask execution
Coordinates Waiting -> Queued transitions between sibling subtasks: when a child finishes Done, the next Waiting sibling is promoted to Queued. WorkerHub.QueuePlanningSubtasksAsync exposes this to the UI; TaskRunner advances the chain on completion. Also tightens the planning-session prompt: planner must use MCP tools, not direct edits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:36:01 +02:00
mika kunsandClaude Opus 4.7 288d2ece8b feat(data): add Waiting task status and CreatedBy column
- New TaskStatus.Waiting for sequential subtask chains.
- New TaskEntity.CreatedBy column with migration AddTaskCreatedBy.
- TaskRepository.GetByCreatorAsync for filtering by creator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:35:15 +02:00
mika kunsandClaude Opus 4.7 2ad6f20258 chore: bump default max turns from 30 to 100
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:35:01 +02:00
mika kunsandClaude Opus 4.7 b2eb5fcfa4 refactor(worker): use --permission-mode auto instead of --dangerously-skip-permissions
Map legacy "bypassPermissions" config to "auto" at dispatch time; pass-through other modes (acceptEdits, plan, default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:34:48 +02:00
mika kunsandClaude Opus 4.7 8e9f09a8e6 feat(worker): run planning agent in plan permission mode and enforce brainstorming skill
Adds --permission-mode plan to both launch paths (start and resume) so the
planning agent cannot perform file-modifying actions during the planning
conversation. Also appends instructions to the system prompt telling the
agent to always invoke the superpowers:brainstorming skill before creating
any child tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:38:09 +02:00
mika kunsandClaude Sonnet 4.6 ce23f64dc3 fix(worker): emit PlanningMergeAborted (not Conflict) on non-conflict merge failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:32:52 +02:00
mika kunsandClaude Sonnet 4.6 3008c36921 feat(worker): register planning services and add Merge-all hub methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:28:38 +02:00
mika kunsandClaude Sonnet 4.6 e58cac24e1 feat(worker): add pre-flight checks and idempotent restart to PlanningMergeOrchestrator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:24:41 +02:00
mika kuns b9896399fa feat(worker): add PlanningMergeOrchestrator.AbortAsync 2026-04-24 18:18:49 +02:00
mika kunsandClaude Sonnet 4.6 7d87c03cfa feat(worker): add PlanningMergeOrchestrator.ContinueAsync to resume merge after conflict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:15:19 +02:00
mika kuns ef070ddab5 fix(worker): prevent PlanningMergeOrchestrator double-drain race and orphaned state 2026-04-24 18:12:21 +02:00
mika kunsandClaude Sonnet 4.6 3142ba203f feat(worker): add PlanningMergeOrchestrator happy path with merge event broadcasts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:08:58 +02:00
mika kunsandClaude Sonnet 4.6 bc788e1e0f feat(ui): add conflict resolution dialog for planning merge-all
Opens a modal when PlanningMergeConflict fires, listing conflicted files
with options to open in VS Code, continue, or abort the merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:08:45 +02:00
mika kunsandClaude Opus 4.7 a6ebff3f34 feat(ui): add aggregated diff viewer for planning tasks
Implements Task 14: PlanningDiffView (Window), PlanningDiffViewModel,
ShowPlanningDiffModal callback wired in DetailsIslandView, and 5 xUnit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:39:38 +02:00
mika kunsandClaude Sonnet 4.6 389d9045d5 feat(worker): add PlanningAggregator.CleanupIntegrationBranchAsync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:34:25 +02:00
mika kunsandClaude Opus 4.7 1aead9dad0 refactor(ui): test planning detail pane via real ViewModel and restore merge-all IsEnabled binding
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:31:54 +02:00
mika kunsandClaude Sonnet 4.6 9d04d1d9f6 fix(worker): reorder PlanningAggregator checkout/delete and kill git on cancel
Also stub new IWorkerClient planning members in FakeWorkerClient to restore build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:24:24 +02:00
mika kunsandClaude Sonnet 4.6 4c6fd9f024 feat(ui): add merge-target dropdown and merge-all controls to planning detail
- Add SubtaskDiffDto and CombinedDiffResultDto to PlanningDtos.cs
- Extend IWorkerClient with 5 planning merge methods and 5 events
- Implement methods and hub subscriptions on WorkerClient
- Add Status and WorktreeState to SubtaskRowViewModel
- Add MergeTargetBranches, SelectedMergeTarget, CanMergeAll,
  MergeAllDisabledReason, MergeAllError, RecomputeCanMergeAll,
  MergeAllCommand, ReviewCombinedDiffCommand (Task 14 TODO)
  to DetailsIslandViewModel
- Add planning merge section to DetailsIslandView.axaml
  (merge target ComboBox + buttons + error label), gated on
  Task.IsPlanningParent
- Add 4 xUnit tests covering CanMergeAll logic and DTO shape

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:22:27 +02:00
mika kuns 2cab33d708 feat(worker): add PlanningAggregator.BuildIntegrationBranchAsync 2026-04-24 16:18:45 +02:00
mika kunsandClaude Sonnet 4.6 a1727b647c feat(worker): add PlanningAggregator.GetAggregatedDiffAsync
Returns per-subtask diff entries (title, branch, base/head commit, DiffStat, unified diff) for all children of a Planning task in SortOrder order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 16:08:56 +02:00
mika kuns 6bdfa73150 fix(ui): align virtual list semantics and complete planning roll-up coverage 2026-04-24 16:03:27 +02:00
mika kuns ada4d9fd9b fix(worker): wrap MergeAbortAsync in AbortMergeAsync for consistent error handling 2026-04-24 15:51:40 +02:00
mika kuns 6d460ea996 fix(ui): planning parents roll up child status; children stay nested until parent Done 2026-04-24 15:47:47 +02:00
mika kuns bc0f1e3122 feat(worker): add AbortMergeAsync to cancel a conflicted merge 2026-04-24 15:42:15 +02:00
mika kuns 63759ee7dc fix(worker): tighten ContinueMergeAsync guards and commit error handling 2026-04-24 15:22:52 +02:00
mika kunsandClaude Sonnet 4.6 62106ff644 feat(worker): add ContinueMergeAsync to resume a conflicted merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:17:57 +02:00
mika kunsandClaude Sonnet 4.6 e77ba35b0e feat(worker): add leaveConflictsInTree option to TaskMergeService.MergeAsync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 15:06:33 +02:00
mika kunsandClaude Opus 4.7 8afbf20613 docs(planning): add spec and plan for planning merge-all feature
Covers subtask visibility fix, aggregated diff viewer, and single
Merge-all action with VS-Code-assisted conflict resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:55:11 +02:00
mika kunsandClaude Opus 4.7 5a03dc8430 feat(worker): broadcast child TaskUpdated events on planning CRUD
So the UI refreshes individual child rows alongside the parent during
create/update/delete/finalize from the planning MCP service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:54:46 +02:00
mika kunsandClaude Opus 4.7 e62485db3b fix(worker): derive planning MCP URL from configured SignalRPort
Hard-coded 47821 meant .mcp.json pointed at the wrong port for any
worker running on a custom signalr_port (e.g. 37821), causing
"Unable to connect" auth failures in the planning session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:07:44 +02:00
mika kunsandClaude Opus 4.7 a5ebfd12f8 test(worker): clean up non-git test tmp dir and assert self-heal setup exit code
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:24:01 +02:00
mika kunsandClaude Opus 4.7 2262ab0e13 test(worker): cover planning worktree lifecycle and self-heal
Adds four tests to PlanningSessionManagerTests: worktree removal on
discard, error on non-git working dir, self-heal when branch already
exists, and resume returning the correct token and session id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:20:29 +02:00
mika kunsandClaude Opus 4.7 0da527dbbc test(worker): adapt planning tests to git-backed worktree flow
Update constructor calls (6-arg), seed AppSettings with sibling strategy,
git-init working dirs via GitRepoFixture.InitRepoWithInitialCommit, and
replace McpConfigPath assertions with worktree-path / .mcp.json checks.
Also fixes PlanningHubTests which had the same 3-arg constructor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:14:46 +02:00
mika kunsandClaude Opus 4.7 9beda55681 chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:52:20 +02:00
mika kunsandClaude Opus 4.7 6800852ae4 feat(worker): launcher passes planning token via env, drops --mcp-config
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:47:35 +02:00
mika kunsandClaude Opus 4.7 48899b3df8 feat(worker): cleanup planning worktree and branch on finalize/discard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:43:53 +02:00
mika kunsandClaude Opus 4.7 fce91bcf86 feat(worker): create ephemeral worktree and write .mcp.json in StartAsync
Rewrites StartAsync to provision a git worktree before transitioning the
task to Planning state, writes .mcp.json and .claude/settings.local.json
into the worktree, and fixes ResumeAsync to supply the updated
PlanningSessionResumeContext fields (Token, WorktreePath).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:37:42 +02:00
mika kunsandClaude Opus 4.7 975e1ce50c refactor(worker): switch MCP config to env-var token expansion
BuildMcpConfigJson drops the token argument; the literal
\${CLAUDEDO_PLANNING_TOKEN} placeholder is written to mcp.json so
claude expands it from the spawned process environment at load time.
Also declares SettingsLocalJson constant for use in later tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:30:11 +02:00
mika kunsandClaude Opus 4.7 1d61df8160 refactor(worker): add worktree path and token file helpers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:27:35 +02:00
mika kunsandClaude Opus 4.7 1370bf3dcc refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager
Adds AppSettingsRepository to the test constructor, GitService and
WorkerConfig to both constructors, and updates CreateRepos() and all
its call-sites to expose the new settings tuple element.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:24:28 +02:00
mika kunsandClaude Opus 4.7 f2db5f4ad0 refactor(worker): drop McpConfigPath from PlanningSessionFiles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:20:58 +02:00
mika kunsandClaude Opus 4.7 fd2ac4842f refactor(worker): extend planning contexts with token and worktree
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:17:28 +02:00
mika kunsandClaude Opus 4.7 4de2deaebe docs(planning): add worktree-isolated MCP session design and plan
Design: run each planning session in an ephemeral git worktree so .mcp.json
and .claude/settings.local.json can be placed without touching the user's
working dir. Plan breaks the change into 12 TDD tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:12:40 +02:00
mika kunsandClaude Opus 4.7 b7c60f5838 feat(ui): live task updates from worker events + planning polish
Wire TasksIslandViewModel to TaskUpdated/WorktreeUpdated/TaskMessage worker
events so rows refresh without a full reload; add ForegroundHelper to permit
wt.exe to take foreground on planning launch; misc UI polish on lists, task
rows and settings modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:12:27 +02:00
mikakuns e455d85578 Merge pull request 'feat/planning-sessions-worker' (#7) from feat/planning-sessions-worker into main
Reviewed-on: #7
2026-04-24 06:02:49 +00:00
mika kuns 0782ba574b test(worker): planning session end-to-end 2026-04-23 23:31:01 +02:00
mika kuns 7b67e35720 feat(worker): SignalR hub endpoints for planning sessions 2026-04-23 23:26:12 +02:00
mika kuns c048264b95 fix(worker): register TaskRepository in DI and guard null WorkingDir 2026-04-23 23:17:30 +02:00
mika kunsandClaude Sonnet 4.6 6cb20a9213 feat(worker): map MCP HTTP endpoint and broadcast TaskUpdated
- Add PlanningMcpContextAccessor (Option A) to read PlanningMcpContext
  from HttpContext.Items set by PlanningTokenAuthMiddleware
- Annotate PlanningMcpService with [McpServerToolType]/[McpServerTool]
  and remove PlanningMcpContext ctx parameter from all tool methods
- Broadcast TaskUpdated(parentTaskId) via HubBroadcaster after every
  mutation in PlanningMcpService
- Refactor PlanningSessionManager to accept IDbContextFactory for
  singleton-safe use in DI; keep direct-repo ctor for tests
- Register PlanningSessionManager (singleton), IPlanningTerminalLauncher,
  PlanningMcpContextAccessor, PlanningMcpService, and MCP server in
  Program.cs; wire PlanningTokenAuthMiddleware and MapMcp("/mcp")
- Update PlanningMcpServiceTests with fake HttpContext accessor and
  no-op HubBroadcaster (avoids Moq dependency)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 23:12:24 +02:00
mika kuns 99c6a71e4c feat(worker): MCP tools update_planning_task and finalize 2026-04-23 23:03:42 +02:00
mika kuns 0088d6e0e0 feat(worker): MCP tools for child-task CRUD 2026-04-23 22:57:27 +02:00
mika kuns b115a4c512 feat(worker): MCP bearer-token auth middleware 2026-04-23 21:15:38 +02:00
mika kuns 9e09ae6b4e fix(worker): planning launcher — avoid cmd shell to prevent prompt injection 2026-04-23 21:13:23 +02:00
mika kuns 43a3740980 feat(worker): WindowsTerminalPlanningLauncher with pre-flight checks 2026-04-23 21:08:15 +02:00
mika kuns d28164caf4 feat(worker): PlanningSessionManager.GetPendingDraftCountAsync 2026-04-23 21:04:06 +02:00
mika kuns 77f7cf1423 feat(worker): PlanningSessionManager.FinalizeAsync 2026-04-23 21:01:22 +02:00
mika kuns 84e6c2d5fc feat(worker): PlanningSessionManager.DiscardAsync 2026-04-23 20:58:55 +02:00
mika kuns 84b0ba8670 feat(worker): PlanningSessionManager.ResumeAsync 2026-04-23 20:55:01 +02:00
mika kunsandClaude Sonnet 4.6 b6bec1e63c feat(worker): PlanningSessionManager.StartAsync
Add PlanningSessionFiles, PlanningSessionStartContext/ResumeContext DTOs,
PlanningSessionManager.StartAsync (file scaffolding + status transition),
and integration tests. Also fix migration discovery by adding [DbContext]
attribute to all migration classes and switch DbFixture to EnsureCreated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 20:49:41 +02:00
mika kuns b32621a4e5 chore(worker): add ModelContextProtocol package 2026-04-23 20:49:41 +02:00
mikakuns 993851009b Merge pull request 'feat(ui): planning sessions UI (Plan C)' (#5) from feat/planning-sessions-ui into main
Reviewed-on: #5
2026-04-23 17:38:08 +00:00
mika kuns 450e685580 docs(open): add planning-session manual verification checklist 2026-04-23 19:32:34 +02:00
mika kuns 0e116bec7b feat(ui): friendly error when deleting task with children 2026-04-23 19:22:28 +02:00
mika kunsandClaude Sonnet 4.6 47b49743c0 feat(ui): unfinished planning session dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 19:19:16 +02:00
mika kuns 506caa2c53 feat(ui): draft and planning badge styles 2026-04-23 19:04:26 +02:00
mika kuns 388a8c1fae feat(ui): planning entries in task context menu 2026-04-23 19:02:06 +02:00
mika kunsandClaude Sonnet 4.6 42b208ff28 feat(ui): TaskRowView hierarchy indentation, chevron, badges, draft italic
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:58:08 +02:00
mika kunsandClaude Sonnet 4.6 309f84b388 feat(ui): planning commands and expand/collapse in TasksIslandViewModel
- Add IWorkerClient interface; WorkerClient implements it
- TasksIslandViewModel accepts IWorkerClient? and gains OpenPlanningSession,
  ResumePlanningSession, DiscardPlanningSession, FinalizePlanningSession,
  and ToggleExpand commands
- Regroup() is hierarchy-aware: children of collapsed planning parents are hidden
- InternalsVisibleTo ClaudeDo.Worker.Tests for Regroup()
- 4 new unit tests covering collapse/expand and guard logic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:51:22 +02:00
mika kuns 00608401aa feat(ui): WorkerClient planning-session methods 2026-04-23 18:41:04 +02:00
mika kunsandClaude Sonnet 4.6 229d4bbb2b feat(ui): TaskRowViewModel gains planning hierarchy flags
Adds ParentTaskId, IsExpanded, IsChild, IsPlanningParent, IsDraft, and
PlanningBadge to TaskRowViewModel with property-changed notifications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:39:44 +02:00
claude 845359b885 feat: planning sessions foundation (Plan A) (#4)
Merges Plan A: schema + repos + auto-parent-completion hook.
2026-04-23 16:31:37 +00:00
906 changed files with 144150 additions and 6375 deletions
+4 -1
View File
@@ -6,7 +6,10 @@
"mcp__plugin_context-mode_context-mode__batch_execute",
"mcp__plugin_context-mode_context-mode__execute",
"mcp__plugin_context7_context7__query-docs",
"mcp__plugin_context-mode_context-mode__search"
"mcp__plugin_context-mode_context-mode__search",
"Bash(git fetch *)",
"PowerShell(cmdkey *)",
"mcp__plugin_context7_context7__resolve-library-id"
]
}
}
+16
View File
@@ -0,0 +1,16 @@
* text=auto eol=lf
*.sln text eol=crlf
*.slnx text eol=crlf
*.cmd text eol=crlf
*.bat text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.exe binary
*.dll binary
+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
+1391
View File
File diff suppressed because it is too large Load Diff
+42 -8
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,22 +42,49 @@ 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: Manual | Queued -> Running -> Done | Failed
- 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
- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
- 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)
- Small single-consumer helper types live in their consumer's file, not standalone files
- Commit messages use conventional format: `{commitType}(slug): title`
- 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 with `-c Release` (a running Worker locks the `Debug` output).
```bash
dotnet build ClaudeDo.slnx
dotnet test tests/ClaudeDo.Worker.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`.
+3
View File
@@ -6,11 +6,14 @@
<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" />
<Project Path="tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj" />
<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
+49
View File
@@ -0,0 +1,49 @@
# 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 |
## 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.
+273
View File
@@ -0,0 +1,273 @@
# ConPTY interactive sessions & launch specs
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `8dbdfb3` (2026-08-06).
> 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` | Effort from list config; `--permission-mode auto` (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` now run the repo
through `TrimTrailingSeparator`; session dirs the worker builds never carry one.
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` uses `--permission-mode auto` so it runs unattended. The
`--allowedTools` allowlist is the security boundary:
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`.
`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 | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperExecute` — phases 35 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`.
+221
View File
@@ -0,0 +1,221 @@
# External MCP tool surface
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `20bce9b` (2026-08-06).
> Drift check: `git log --oneline 20bce9b..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.
## Hard conventions (enforced by tests)
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.
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. Not test-enforced — review it in PRs.
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) or `taskFull` (full), never both.
**`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).
+239
View File
@@ -0,0 +1,239 @@
# Review, merge & conflict resolution
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against `2f3f938` (2026-08-07), which finished the diff-viewer rework:
> 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.
## 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.
**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.
## `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.
---
+102
View File
@@ -0,0 +1,102 @@
# Task Mailbox — Push Messages Into Running Sessions
**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
Claude CLI processes one turn at a time. While a subagent (or any long tool) runs, no new user input can be injected. The harness offers no mid-execution interrupt. The workable window is *between* tool calls — so we need a cheap "inbox check" the agent can poll at natural checkpoints, plus a UI affordance and a cross-session sender.
## Design
### 1. Data
New table `task_messages`:
| col | type | notes |
|---|---|---|
| `id` | INTEGER PK | |
| `task_id` | TEXT FK → tasks.id | recipient |
| `sender` | TEXT | `'user'` \| `'task:<id>'` (for cross-session) |
| `body` | TEXT | markdown |
| `created_at` | TEXT | ISO |
| `delivered_at` | TEXT NULL | set when inbox pulls it |
EF Core migration + repository. Async, CancellationToken, matches existing conventions.
### 2. Worker MCP tools (extend existing `mcp__claudedo__*` server)
- **`check_inbox(task_id)`** → returns undelivered messages for this task and marks them delivered. Idempotent. Empty array if nothing pending.
- **`send_to_task(task_id, body)`** → inserts a row. Callable from *any* session — this is how the frontend session tells the backend session something.
- **`inbox_status(task_id)`** → `{ pending: int }` for a cheap "is there anything?" poll.
All three run in-proc in the Worker, go through the existing repository layer.
### 3. SignalR additions on `WorkerHub`
Server methods (UI → Worker):
- `SendTaskMessage(taskId, body)` — UI calls this; worker inserts the row and fires `TaskMessageQueued`.
Client events (Worker → UI):
- `TaskMessageQueued(taskId, pendingCount)` — so the UI can show an unread badge.
- `TaskMessageDelivered(taskId, pendingCount)` — when the agent pulls it, badge clears.
### 4. UI
On every `Running` task row + detail pane:
- "Send to session" textarea + Enter to submit → `SendTaskMessage`.
- Unread badge showing `pendingCount`.
- Read-only message timeline (who sent what, when delivered).
### 5. Agent-side poll discipline
Two complementary mechanisms so it's robust whether or not the agent remembers:
**a) CLAUDE.md instruction** (seeded by worker into each worktree's `CLAUDE.md`):
> After every subagent completes and before starting the next step, call `mcp__claudedo__check_inbox`. Treat returned messages as user input with priority over the current plan.
**b) PostToolUse hook on `Agent`** (written into the worktree's `.claude/settings.json` by the Worker when it creates the tree):
- Runs `mcp__claudedo__inbox_status` via a tiny CLI shim the worker ships.
- If `pending > 0`, the hook emits a system reminder: "Inbox has N pending messages — call `mcp__claudedo__check_inbox` now."
- Keeps the burden off the agent's memory. Belt + suspenders.
### 6. Cross-session pattern
Backend session and frontend session are just two tasks with known IDs. Either can call `send_to_task(other_id, body)` via the MCP server. No shared folder needed — the DB is already the shared channel.
To make this ergonomic:
- A "linked tasks" concept: tag two tasks as peers at creation time. The Worker exposes `send_to_peer(body)` as sugar around `send_to_task` so neither session needs to hardcode the other's UUID.
## Limits (honest)
- Messages arrive *between* tool calls, not mid-tool. A 20-minute subagent still blocks 20 minutes. Splitting work into shorter subagents is still the right discipline.
- If the agent ignores the CLAUDE.md instruction, the hook catches it next tool call — but we can't force immediate consumption.
- `-p` (print) mode with stdin prompt is one-shot and can't be extended. This design targets *interactive* sessions (Planning Sessions already use this mode). For queued `-p` runs, the mailbox is effectively a post-run instruction carrier.
## Why this is the repeatable "Grundgerüst"
Once this lands in ClaudeDo, the workflow becomes:
1. Create two linked tasks (`backend`, `frontend`) with `working_dir` set.
2. Start each — each gets its own worktree, its own Planning Session terminal, its own inbox with `check_inbox` + `send_to_peer`.
3. Push messages from the UI or from the other session. No per-project scaffolding, no custom hooks, no shared folder.
Every future parallel-session project inherits the mailbox.
## Build order (suggested)
1. Migration + repo + model. Tests first.
2. MCP tools (`check_inbox`, `send_to_task`, `inbox_status`) + unit tests.
3. SignalR method + events + UI textarea/badge.
4. Worker writes CLAUDE.md addendum + `.claude/settings.json` hook into each new worktree.
5. Linked-tasks sugar (`send_to_peer`).
6. Manual verification: queue a long subagent, send a message, confirm it's picked up at the next tool boundary.
## Open questions
- Should messages be deleted or soft-kept after delivery? Leaning soft-kept for the timeline UI.
- Priority / interrupt semantics — do we want a "high priority" flag that the agent should surface immediately vs. batch?
- Should `send_to_peer` also work when the peer is `Queued` (i.e. not yet running)? Probably yes — deliver on start.
+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.
+209 -186
View File
@@ -1,209 +1,232 @@
# ClaudeDo — Offene Punkte
Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings.
Dieses Dokument listet alles, was noch fehlt — gruppiert nach Aufwand/Risiko und mit konkreten Datei-Pointern, damit wir es in der IDE der Reihe nach durchgehen können.
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`.
---
## 1. Verification (vor allem anderen)
## Bugs (offen)
Die in `plan.md` definierten Verification-Steps sind teilweise nur durch Build/Tests abgedeckt. Diese sollten manuell einmal durchlaufen werden, BEVOR wir Polish bauen — damit wir wissen, was tatsächlich kaputt ist.
- **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.
| # | Plan | Status | Was tun |
|---|------|--------|---------|
| 1 | Schema-Init | Auto verifiziert (Worker startet ohne Crash, WAL-Files entstehen) | OK |
| 1a | SignalR-Endpoint | Manuell verifiziert (HTTP 400 auf `/hub` ohne Handshake) | OK |
| 1b | Hub-Roundtrip `Ping` | **Nicht getestet** | Test-Client schreiben oder UI starten und im Log nach "pong" schauen |
| 2 | `claude --version` Preflight | **Nicht implementiert** | `Worker/Program.cs`: vor `app.Run()` einmal `claude --version` shellen und bei Exit≠0 abbrechen |
| 3 | Smoke-Spawn (`claude -p` mit Prompt "ping") | **Nicht getestet** | Integrationstest schreiben oder einmal manuell laufen lassen |
| 4 | E2E Happy Path (Non-Worktree) | **Nicht getestet** | UI starten → Liste "Test" anlegen → Task mit Tag `agent` + Status `queued` + Description "Schreibe ein Haiku über Intralogistik" → Run abwarten → Result prüfen |
| 5 | Worktree Happy Path | **Nicht getestet** | Manueller Test mit echtem Repo (z.B. einem temp-Repo) |
| 6 | No-Changes-Run | **Nicht getestet** | Prompt der nichts ändert → `head_commit` bleibt NULL |
| 7 | Kein Git-Repo | **Nicht getestet** | working_dir auf `C:\Temp` → Task `failed`, keine `worktrees`-Row |
| 8 | Merge-UI | **Nicht getestet** (UI ruft `GitService.MergeFfOnlyAsync`, aber nie ausgeführt) | Manuell |
| 9 | Override-Parallelität | Tests vorhanden für Slot-Logik, **End-to-End nicht** | UI: zwei Tasks queuen, `Run Now` auf der zweiten → beide laufen parallel |
| 10 | Schedule | Logik per Test abgedeckt, **End-to-End nicht** | Task mit `scheduled_for = now+2min` |
| 11 | Worker-Offline-Erkennung | UI hat Status-Bar, aber **nicht visuell verifiziert** | Worker killen, schauen ob Status auf "offline" wechselt |
| 12 | Live-Stream | **Nicht getestet** | Während Run TaskDetail öffnen, beobachten ob ndjson-Zeilen erscheinen |
| 13 | Wake-up (UI ruft `WakeQueue` nach Anlage) | Implementiert in `TaskListViewModel`, **nicht visuell verifiziert** | Tasks nach Anlage in <1s gepickert |
## UX / Nits (offen)
**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig.
- **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).
---
## 2. UI-Polish (kritisch für Benutzbarkeit)
## Offene Verifikation (2026-07-27)
Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` markiert. Reihenfolge nach Schmerz:
- **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.)
### 2.1 Folder-Picker für `Working Directory`
- **Datei:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml` + `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs`
- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
- **Aufwand:** klein, ~30 Zeilen.
## Offene Verifikation (2026-08-05)
### 2.2 Delete-Confirmation
- **Dateien:** `MainWindowViewModel.DeleteList`, `TaskListViewModel.DeleteTask`
- **Aktuell:** löscht direkt ohne Rückfrage. Datenverlust-Risiko.
- **Soll:** Mini-Dialog "Wirklich löschen?" mit Ja/Nein.
- **Aufwand:** klein, generisches `ConfirmDialog` lohnt sich (1× bauen, mehrfach nutzen).
- **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.
### 2.3 Markdown-Rendering für Result + Description
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml`
- **Aktuell:** `TextBox IsReadOnly="True"` mit Plaintext.
- **Soll:** `Markdown.Avalonia` Package einbinden und auf `MarkdownScrollViewer` umstellen.
- **Aufwand:** mittel — Package + ein paar XAML-Anpassungen. Theme-Integration kann nerven.
- **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.
### 2.4 Live-Log Auto-Scroll
- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` (oder im VM)
- **Aktuell:** ndjson-Zeilen werden angehängt, aber Scrollposition bleibt stehen.
- **Soll:** Bei jeder neuen Zeile `ScrollViewer.ScrollToEnd()` solange User nicht manuell hochgescrollt hat (Sticky-Bottom-Pattern).
- **Aufwand:** klein, ein attached behavior reicht.
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
### 2.5 Diff-Viewer
- **Datei:** `TaskDetailViewModel.ShowDiffAsync`
- **Aktuell:** `Process.Start("cmd", "/k git diff …")` — separates Konsolenfenster, hässlich.
- **Soll:** entweder unified-diff inline anzeigen (`git diff` Output in `TextBox` mit Mono-Font + Color für +/-) oder einen externen Diff-Tool-Hook (`git difftool`).
- **Aufwand:** mittel. MVP: einfach nur den Diff-Output in einem Modal.
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):
### 2.6 Status-Bar Active-Tasks Live-Update
- **Datei:** `StatusBarViewModel`
- **Risiko:** das Slot-State-Update kommt vom WorkerClient, aber `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei `IsConnected`-Wechsel (vom Slice-F-Agent dokumentiert).
- **Soll:** Über `WeakReferenceMessenger` (CommunityToolkit.Mvvm) eine Connection-Change-Message verteilen, an die alle `TaskItemViewModel` lauschen.
- **Aufwand:** klein, aber muss sauber gemacht werden.
- **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.)
### 2.7 Settings-Dialog
- **Datei:** *neu*`Views/SettingsDialog.axaml` + VM
- **Aktuell:** `~/.todo-app/ui.config.json` muss von Hand editiert werden.
- **Soll:** Dialog mit Feldern: DB-Pfad, SignalR-Port, Default-Tags. Persistiert zurück in JSON.
- **Aufwand:** mittel. Achtung: Port-Wechsel braucht Worker-Restart.
## Offene Verifikation (2026-08-06, Resume planning session)
`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**:
- 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).
## 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).
---
## 3. Worker-Robustheit
## Bewusst verworfen (nicht erneut vorschlagen)
### 3.1 CLI-Preflight beim Worker-Start
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
- **Soll:** vor `app.Run()` `claude --version` ausführen; bei Fehler `app.Logger.LogCritical` + `Environment.Exit(1)`.
- **Aufwand:** klein, ~20 Zeilen. Liefert Verification Step 2.
### 3.2 Worktree-Cleanup beim Anlege-Failed
- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
- **Aktuell:** Wenn `WorktreeAddAsync` zwischen `CreateAsync`-Schritten failt (z.B. Branch existiert schon), bleibt evtl. ein halbangelegter Worktree-Dir auf der Platte.
- **Soll:** try/finally — bei Fehler `git worktree remove --force` als Best-Effort-Cleanup.
- **Aufwand:** klein.
### 3.3 Logging über `Microsoft.Extensions.Logging` strukturieren
- **Datei:** alle Worker-Komponenten
- **Aktuell:** ILogger wird benutzt, aber kein File-Sink konfiguriert.
- **Soll:** Optional Serilog oder einfach `AddFile` (Karambolage.Extensions.Logging.File) — Service-Modus braucht persistente Logs außerhalb der Console.
- **Aufwand:** klein.
### 3.4 Tag-Negation / Exclusion (Plan-TODO)
- **Plan-Sektion:** "Tag-Modell"
- **Aktuell:** Tags sind rein additiv (`list_tags task_tags`).
- **Soll:** Mechanismus, um auf Task-Ebene einen List-Tag auszuschließen. Z.B. neue Tabelle `task_tag_exclusions` ODER ein Prefix `!tag` im task_tags-Eintrag.
- **Aufwand:** mittel — Schema + Repo + Tests + UI.
---
## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service")
### 4.1 Windows-Service-Hosting in Code
- **Datei:** `src/ClaudeDo.Worker/Program.cs`
- **Pakete:** `Microsoft.Extensions.Hosting.WindowsServices`
- **Soll:**
```csharp
builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
builder.Logging.AddEventLog(...);
```
- **Aufwand:** klein.
### 4.2 Pfad-Auflösung absolut machen
- Bereits in `WorkerConfig.Load` per `Paths.Expand` gemacht — verifizieren, dass auch `cfg.ClaudeBin` ggf. in Service-PATH gefunden wird.
### 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 (später) Installer-Projekt
- WiX/MSIX, registriert Service + UI-Shortcut. Plan-Sektion „Offene Punkte".
---
## 5. Tests / CI
### 5.1 GitHub-Actions / Gitea-Actions Pipeline
- **Datei:** *neu* — `.gitea/workflows/ci.yml` (oder `.github/workflows/ci.yml`)
- **Inhalt:** `dotnet restore` → `dotnet build --no-restore` → `dotnet test --no-build`. Auf Push + PR.
- **Aufwand:** klein.
### 5.2 Echter SignalR-Roundtrip-Test
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
- **Soll:** mit `WebApplicationFactory` + `HubConnectionBuilder` testen, dass `Ping`, `GetActive`, `RunNow`-Throw-Verhalten korrekt sind. Plan-Verification 1b + 9.
- **Aufwand:** mittel.
### 5.3 Smoke-Test gegen echten `claude`
- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
- **Soll:** Real-CLI-Test, der mit `[Fact(Skip="..."]` ausgegraut bleibt und nur lokal aktiviert wird, wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
- **Aufwand:** klein.
---
## 6. Dokumentation
### 6.1 README.md
- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config.
- **Aufwand:** klein.
### 6.2 `docs/architecture.md`
- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden.
### 6.3 ADRs für die getroffenen Entscheidungen
- Z.B. „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „SignalR über Loopback ohne Auth".
- **Aufwand:** klein, hilfreich für später.
---
## 7. Bekannte Code-Schulden / Smells
| Stelle | Issue |
|---|---|
| `WorkerHub.GetActive` returnt `IReadOnlyList<object>` mit anonymen Typen | Sollte ein expliziter DTO sein (`ActiveTaskDto`), den Worker UND Ui teilen. Aktuell duplizieren beide das Schema. |
| `TaskRunner` führt eine `if (list.WorkingDir != null)` Verzweigung mitten in der Methode | Strategy-Pattern (`IRunStrategy`: SandboxStrategy, WorktreeStrategy) wenn die Methode wächst. Aktuell noch klein genug. |
| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern. Toleriert, weil nur in `App.OnFrameworkInitializationCompleted` verwendet. Falls mehr Code drauf zugreift → echtes DI durchziehen. |
| Embedded `schema.sql` ohne Versionierung | Solange das Schema nicht in Production läuft, OK. Sobald User-Daten existieren → `migrations/` Folder + Version-Tabelle. |
| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer. |
---
## Empfohlene Reihenfolge für die nächste Session
1. **Verification Step 4** zusammen durchspielen → falls etwas grundlegend kaputt ist, jetzt finden, nicht später.
2. **CLI-Preflight (3.1)** + **Folder-Picker (2.1)** + **Delete-Confirm (2.2)** — kleine, isolierte Wins.
3. **Auto-Scroll (2.4)** + **Active-Tasks Live-Update (2.6)** — User-Experience im Detail-Pane.
4. **Markdown-Rendering (2.3)** — größer, lohnt sich aber für Lesbarkeit.
5. **Worktree-Cleanup (3.2)** — Robustheit, bevor wir Worktrees ernsthaft nutzen.
6. **CI-Pipeline (5.1)** — automatisches Sicherheitsnetz für alles weitere.
7. **Service-Deployment (4)** — wenn die App lokal stabil läuft.
8. **Settings-Dialog (2.7)** + **Diff-Viewer (2.5)** — Polish.
9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
Punkte 13 sind ein realistischer Block für eine Session.
---
## Self-Update — Manual Verification
Preconditions: a working Gitea release at `git.kuns.dev/releases/ClaudeDo` with three assets — `ClaudeDo-<version>-win-x64.zip`, `ClaudeDo.Installer-<version>.exe`, and `checksums.txt` listing both.
1. Install a baseline version (e.g. `0.2.x`) normally.
2. Publish a new release tagged `v0.3.0` with fresh installer + app zip + checksums.
3. Launch the app — confirm the banner appears: `Update available: v0.2.x → v0.3.0`.
4. Click **Update now** — app closes, installer opens in Update mode, runs, restarts the worker.
5. Re-launch the app — banner is gone; `Help → Check for updates` briefly shows "You're up to date (v0.3.0)".
6. Run the `v0.2.x` installer manually — confirm it prompts to self-update to v0.3.0. Click **Update** → running exe is replaced and the wizard opens on the new version.
7. Repeat step 6 with **Continue anyway** → wizard opens without self-update.
8. Repeat step 6 with **Cancel** → installer exits without any action.
9. Kill network during startup in both app and installer → confirm silent fallback (no errors, no banner, wizard opens normally).
- **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.
+14 -25
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.
@@ -49,7 +51,9 @@ Schema in 3NF. Keine Mehrwert-Felder (z.B. JSON-Arrays), keine transitiven Abhä
- `list_id` TEXT NOT NULL REFERENCES `lists(id)` ON DELETE CASCADE
- `title` TEXT NOT NULL
- `description` TEXT NULL
- `status` TEXT NOT NULL — `manual` | `queued` | `running` | `done` | `failed` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt)
- `status` TEXT NOT NULL — Lifecycle-only: `idle` | `queued` | `running` | `done` | `failed` | `cancelled` (`running` bleibt persistiert für Crash-Recovery: stale `running`-Tasks werden beim Worker-Start auf `failed` gesetzt). Planungs-Hierarchie und Chain-Blocking laufen über zwei separate Felder.
- `planning_phase` TEXT NOT NULL DEFAULT `'none'` — Parent-only Marker: `none` | `active` (Planung läuft) | `finalized` (Plan committed, Children existieren). Ein Parent kann `status='idle'` sein und gleichzeitig `planning_phase='finalized'` (für Re-Runs).
- `blocked_by_task_id` TEXT NULL REFERENCES `tasks(id)` ON DELETE SET NULL — Vorgänger in einem sequenziellen Subtask-Chain. Ein `queued`-Row mit `blocked_by_task_id IS NOT NULL` wird vom Picker übersprungen.
- `scheduled_for` TIMESTAMP NULL — "nicht vor"
- `result` TEXT NULL (Markdown)
- `log_path` TEXT NULL — Pfad zur ndjson-Log-Datei
@@ -229,36 +233,21 @@ Beispiel: `feat(lager-app): add barcode scan retry logic`
DB-Zugriff via Microsoft.Data.Sqlite + Repository-Layer (`TaskRepository`, `ListRepository`). Git-Operationen (UI + Worker) über gemeinsamen `GitService` in `ClaudeDo.Data`. MVVM via CommunityToolkit.Mvvm.
## Worker als Windows-Service (Ziel-Deployment)
## Worker-Deployment (Autostart via Startup-Shortcut)
Initial läuft der Worker als Console-Prozess (lokales Dev-Setup). Im Endzustand soll er als **Windows-Service** automatisch starten.
Der Worker läuft als **WinExe** (kein Konsolenfenster) — kein Windows-Service, kein Scheduled Task.
**Code-seitig:**
- Paket `Microsoft.Extensions.Hosting.WindowsServices` referenzieren.
- In `Program.cs`: `builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker")`.
- Logging zusätzlich über `EventLog` (`builder.Logging.AddEventLog(...)`), damit Service-Fehler im Windows Event Viewer landen.
- Alle Pfade in `worker.config.json` **absolut** auflösen (`%USERPROFILE%` / `~` expandieren) — der Service-Working-Directory ist standardmäßig `C:\Windows\System32`.
- `StaleTaskRecovery` (siehe oben) sorgt nach Service-Restart automatisch für das Aufräumen hängender `running`-Tasks.
- Restart-Verhalten via `sc.exe failure`-Konfig oder beim Install.
**Autostart:** Der Installer legt eine Verknüpfung `ClaudeDo Worker.lnk` im Startup-Ordner des Users an (`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\`). Dafür nutzt `ClaudeDo.Installer` den Helper `AutostartShortcut` (mit extrahiertem `ShortcutFactory` COM-Helper). Beim Windows-Logon startet Windows die Verknüpfung automatisch — ohne Elevated-Rechte und mit vollem Zugriff auf die `~/.claude/`-Session des Users.
**Install:**
- Veröffentlichen mit `dotnet publish -c Release -r win-x64 --self-contained false`.
- Service registrieren:
```cmd
sc.exe create ClaudeDoWorker binPath= "C:\Path\To\ClaudeDo.Worker.exe" start= auto
sc.exe failure ClaudeDoWorker reset= 60 actions= restart/5000/restart/10000/restart/30000
```
- Später optional: kleines `ClaudeDo.Installer`-Projekt (WiX oder MSIX), das das auch macht.
**Manueller Start (App-seitig):** Der Installer-Step `StartWorkerStep` startet den Worker beim Install/Update via `Process.Start` direkt. Die App (`IslandsShellViewModel`) startet den Worker **nicht** selbst. Stattdessen: ist der Worker ~12 Sekunden nach App-Start noch offline, erscheint einmalig ein `WorkerConnectionModal` mit drei Optionen (Start Worker / Rerun Installer / Dismiss). Der Connection-Status-Pill in der Fußzeile ist ein klickbarer Button, der das Modal auf Anfrage erneut öffnet.
**Auth-Konflikt mit "User-CLI-Session" beachten:**
Der Worker-Service läuft per Default unter `LocalSystem` — der hat **keinen Zugriff** auf die `~/.claude/`-Session des interaktiven Users, in der der CLI-Login liegt. Optionen:
**Stop/Uninstall:** `StopWorkerStep` beendet den Worker via prozessbasiertem Kill (kein `schtasks /End` mehr). `UninstallRunner` löscht die Startup-`.lnk`. Als Migrations-Schritt für ältere Installationen löscht der Uninstaller auch den Legacy-Scheduled-Task „ClaudeDoWorker" und den Legacy-Windows-Service (best-effort).
1. **Empfohlen:** Service unter dem **User-Account** laufen lassen (`sc.exe config ClaudeDoWorker obj= ".\<username>" password= "..."` oder via `services.msc` → "Log On As"). Dann greift die bestehende `claude login`-Session des Users. Voraussetzung: User-Account hat das Recht "Log on as a service".
2. **Fallback:** Wieder auf API-Key wechseln (`ANTHROPIC_API_KEY` als Umgebungsvariable des Service oder im `worker.config.json`). Dann ist der Service unabhängig vom User-Profil — verliert aber den Vorteil "kein Key-Handling".
**Logging:** Serilog-File-Sink nach `~/.todo-app/logs/worker-*.log`. Single-Instance-Mutex verhindert parallele Instanzen.
Entscheidung wird beim Service-Deployment getroffen, bleibt für die initiale Console-Variante irrelevant. Service-Modus erfordert keine Schema- oder API-Änderungen am Worker.
**Pfade:** `WorkerConfig.Load` expandiert `~`/`%USERPROFILE%` für alle Pfad-Felder.
**SignalR im Service-Modus:** Bindung bleibt `127.0.0.1:47821`. Da die UI auf demselben Rechner läuft, ist Loopback-Erreichbarkeit gegeben — Windows-Firewall greift bei Loopback nicht.
**SignalR:** Bindung bleibt `127.0.0.1:47821`. Da die UI auf demselben Rechner läuft, ist Loopback-Erreichbarkeit gegeben — Windows-Firewall greift bei Loopback nicht.
## Project-Layout (Monorepo)
@@ -317,4 +306,4 @@ Vorteil Monorepo: gemeinsames `schema.sql`, atomische Änderungen über UI+Worke
- Bulk-Discard alter Worktrees.
- Anzeige der ndjson-Message-Chronik im UI.
- Windows Job Objects für garantierten Child-Cleanup beim Worker-Crash.
- Installer-Projekt (`ClaudeDo.Installer`, WiX/MSIX), das den Service registriert + UI shortcut anlegt.
- Install-Skripte/Doku für manuelles Deployment ohne Installer.
+217
View File
@@ -0,0 +1,217 @@
# ClaudeDo — Prompt & CLI Inventory
Snapshot of every string ClaudeDo sends to Claude CLI, plus the CLI-flag surface that shapes each run. Intended as a working doc for tomorrow's prompt-tuning pass.
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)
Used for every "agent" task that the queue picks up or that `RunNow` dispatches.
Orchestration lives in `src/ClaudeDo.Worker/Runner/TaskRunner.cs` and `ClaudeArgsBuilder.cs`.
### 1.1 User prompt (stdin) — `TaskRunner.RunAsync` ~L101L110
Plain text, no template around it:
```
{task.Title}
{task.Description?.Trim()} ← only if non-empty
## Sub-Tasks ← only if subtasks exist
- [ ] {subtask.Title} ← "[x]" if completed
...
```
Notes
- Title is included verbatim — no leading `#` heading.
- No role tags, no XML, no delimiters between title and description — just blank lines.
- Sub-Tasks section uses markdown checkboxes. This is the only structural scaffolding.
- No context about the project, working dir, or git state is added here.
### 1.2 Retry prompt (on failure, when a session ID exists) — `TaskRunner` ~L126
```
The previous attempt failed with:
{result.ErrorMarkdown}
Try again and fix the issues.
```
Fired once per task via `--resume <session_id>`; if the retry also fails, the task is marked Failed.
### 1.3 Follow-up prompt (multi-turn `ContinueAsync`) — `TaskRunner.ContinueAsync` L159
The UI/hub supplies `followUpPrompt` as-is; no wrapping. The session is resumed via `--resume`. So the effective "prompt template" is whatever the user types in the Continue textbox.
### 1.4 System prompt — merged in `TaskRunner` ~L413L418
Built by `TaskRunner.MergeInstructions(global, list, task)` which concatenates three optional strings with `\n\n`:
1. `AppSettings.DefaultClaudeInstructions` (global, set in Settings modal, default `""`)
2. `list_config.SystemPrompt` (per-list override)
3. `task.SystemPrompt` (per-task override)
The merged string is passed as `--append-system-prompt <instructions>` to the CLI. Empty/whitespace → flag is omitted entirely.
**Currently the global `DefaultClaudeInstructions` ships as empty string** (see `AppSettingsEntity.cs` L9). Anything in the system prompt today is whatever the user typed into Settings / List-Settings / Task-Settings.
### 1.5 CLI args — `ClaudeArgsBuilder.Build` (`ClaudeArgsBuilder.cs`)
Always on:
- `-p`
- `--output-format stream-json`
- `--verbose`
- `--permission-mode {auto|acceptEdits|plan|default}` (legacy `bypassPermissions``auto`)
Conditional:
- `--model {sonnet|opus|haiku|...}` — from `task.Model ?? list.Model ?? AppSettings.DefaultModel` (default `sonnet`)
- `--max-turns {n}``AppSettings.DefaultMaxTurns` (default `100`)
- `--append-system-prompt "{merged instructions}"` — see 1.4
- `--agents '[{"file":"{path}"}]'` — from task or list override, points at an agent `.md`
- `--resume {session_id}` — for retries and `ContinueAsync`
Unused but pre-declared:
- `ResultSchema` — a `{summary, files_changed, commit_type}` JSON schema is serialized but **never attached** to args in `Build`. Dead code today; relevant if we turn on `--output-schema`.
---
## 2. Planning-agent prompts (`/plan` / Planning session)
Used by the Planning feature, which spawns a Claude session inside a git worktree with MCP tools so the agent can create Subtasks under the parent.
Source: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`.
### 2.1 System prompt — `BuildSystemPrompt()` L290L308
```
You are a planning assistant for ClaudeDo.
Your role is to help break down a task into smaller, actionable subtasks.
Your final goal WILL ALWAYS be the creation of Subtasks
ALWAYS invoke the `superpowers:brainstorming` skill via the Skill tool at the
start of every planning session, and follow its process end-to-end. It guides
you through clarifying questions, approach exploration, and design approval
BEFORE any subtasks are created. Do not create child tasks until the user has
approved a design.
NEVER Change files yourself.
ALWAYS Use the available MCP tools (mcp__claudedo__*) to create child tasks once the
design is approved. When you are done planning, finalize the session.
Be concise and focused. Each subtask should be independently executable.
```
Written to `{session-dir}/system-prompt.md` at session start and fed via `--append-system-prompt`.
Notes / known oddities
- Trailing space on "NEVER Change files yourself. " and on the blank line above the ALWAYS/MCP block.
- Mixes voice ("Your role is", "ALWAYS invoke") — could be tightened.
- Implicitly relies on the `superpowers:brainstorming` skill being installed in the worktree's Claude config.
- Does not name the MCP tools explicitly (the `mcp__claudedo__*` wildcard assumes the agent discovers them via tool listing).
### 2.2 Initial prompt — `BuildInitialPrompt(task)` L310L323
```
# Task: {task.Title}
{task.Description} ← only if non-empty
---
Please analyze this task and break it down into concrete subtasks.
```
Written to `{session-dir}/initial-prompt.txt`; the Windows Terminal launcher pipes it to the Claude CLI on start.
### 2.3 Planning session CLI flags
`PlanningSessionManager` itself does not build CLI args — the `WindowsTerminalPlanningLauncher` does. Relevant facts:
- Permission mode: **plan** (per recent commit `8e9f09a` "run planning agent in plan permission mode and enforce brainstorming skill").
- Runs with an `.mcp.json` that points at our local MCP server (`http://127.0.0.1:{port}/mcp`) with a per-session bearer token.
- `.claude/settings.local.json` sets `"enableAllProjectMcpServers": true` so the MCP tools auto-activate.
---
## 3. Commit-message template (not a prompt, but agent-visible)
Built by `CommitMessageBuilder.Build` (`CommitMessageBuilder.cs`). Format:
```
{commitType}({listSlug}): {title ≤60 chars}
{description ≤400 chars} ← only if set
ClaudeDo-Task: {taskId}
```
- `commitType` comes from `task.CommitType` (default `chore`, list default configurable).
- Slug = lowercased list name with non-alphanumerics stripped, runs collapsed to `-`.
- The agent sees the resulting commit in `git log` during retries and follow-ups, so phrasing here bleeds into model behavior on multi-turn work.
---
## 4. Where each prompt is edited (UI surface)
| Prompt slot | Edited in | Stored as |
|-------------------------------------|--------------------------------------------|--------------------------------------------|
| Global `DefaultClaudeInstructions` | Settings modal (`SettingsModalViewModel`) | `app_settings.DefaultClaudeInstructions` |
| Per-list system prompt | List-Settings modal | `list_config.SystemPrompt` |
| Per-task system prompt | Details island / task agent settings | `tasks.system_prompt` |
| Per-task agent file | Details island | `tasks.agent_path` (absolute `.md` path) |
| Default model / max turns / perms | Settings modal | `app_settings.*` |
| Planning system prompt | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
| Planning initial prompt template | **Hard-coded** in `PlanningSessionManager` | not editable from UI |
| Retry prompt | **Hard-coded** in `TaskRunner` | not editable |
| Task prompt structure (title/desc) | **Hard-coded** in `TaskRunner` | not editable |
---
## 5. Things worth reviewing tomorrow
1. **Task-execution prompt has no frame at all.** Just title + description. Consider whether a thin wrapper (goal / constraints / done-criteria) improves agent focus without bloating small tasks.
2. **Global DefaultClaudeInstructions is empty out of the box.** This is the cleanest place to put project-wide guardrails (commit format, branch etiquette, verify-before-done, no force push). Right now nothing is there.
3. **Planning system prompt**:
- Typo-level: trailing spaces, inconsistent capitalization ("ALWAYS"/"NEVER"/"Always").
- "Your final goal WILL ALWAYS be the creation of Subtasks" conflicts slightly with "Do not create child tasks until the user has approved a design" — rewordable.
- Does not state how many subtasks is reasonable, nor how granular.
- Does not describe the MCP tool surface; the agent has to discover `mcp__claudedo__*` tools.
4. **Retry prompt is minimal.** `"Try again and fix the issues."` — could be firmer about not repeating the same failure mode.
5. **Sub-Tasks block** is dumped as plain checkboxes with no instruction ("please complete all open items", "do them in order", etc.). If the user relies on subtasks for ordering, that intent isn't conveyed.
6. **ResultSchema is defined but unused.** Decide: drop it, or wire it up (`--output-schema`) and start asking for structured summaries.
7. **Commit-message template** never tells the agent what `commit_type` to pick when it has flexibility — the value is hard-coded per task. Consider exposing as a prompt hint or inferring from diffs.
---
## 6. File pointers
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — user/retry/follow-up prompts, MergeInstructions
- `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs` — CLI args + ResultSchema
- `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs` — commit template
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — planning system + initial prompts
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — planning CLI invocation
- `src/ClaudeDo.Data/Models/AppSettingsEntity.cs` — global defaults
- `src/ClaudeDo.Ui/ViewModels/Modals/SettingsModalViewModel.cs` — UI for global defaults
- `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` — UI for per-list overrides
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,799 @@
# Planning UX Polish + Sequential Subtask Queue — 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:** Add sequential execution of planning subtasks (new `Waiting` status, context-menu trigger, worker-side chain advancement) plus three small UX changes (auto-collapse done planning parents in the task list, collapsible Description in the Details pane, narrower island GridSplitters).
**Architecture:** Foundation first — add the new `Waiting` enum value and its surface in the UI (chip, virtual-queued filter, row plumbing). Then ship the three UI polish items independently. Finally build the worker-side chain coordinator behind TDD and wire up the SignalR method + context-menu entry.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm, EF Core (Sqlite), SignalR, xUnit.
**Spec:** `docs/superpowers/specs/2026-04-24-planning-ux-and-sequential-subtasks-design.md`
---
## Task 1: Add `Waiting` status to the enum
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs` (TaskStatus enum)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` (chip class switch)
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (virtual-queued match predicate)
- [ ] **Step 1: Add `Waiting` to the enum**
Append `Waiting` as the last value (keeps existing numeric slots stable for any int-serialized rows).
`src/ClaudeDo.Data/Models/TaskEntity.cs`:
```csharp
public enum TaskStatus
{
Manual,
Queued,
Running,
Done,
Failed,
Planning,
Planned,
Draft,
Waiting,
}
```
- [ ] **Step 2: Extend `StatusChipClass` switch in TaskRowViewModel**
`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — update the switch:
```csharp
public string StatusChipClass => Status switch
{
TaskStatus.Running => "running",
TaskStatus.Failed => "error",
TaskStatus.Done => "review",
TaskStatus.Queued => "queued",
TaskStatus.Waiting => "waiting",
_ => "idle",
};
```
- [ ] **Step 3: Add `IsWaiting` and include it in virtual-queued matching**
In the same `TaskRowViewModel.cs`, add alongside `IsQueued`:
```csharp
public bool IsWaiting => Status == TaskStatus.Waiting;
```
In `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, find the `TaskMatchesList` static method and update the `virtual:queued` branch so tasks in `Waiting` also match. Locate the existing match for `ListKind.Virtual when list.Id == "virtual:queued"` and change it to match `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`. If the existing line reads `t.Status == TaskStatus.Queued` exactly, replace it with `t.Status == TaskStatus.Queued || t.Status == TaskStatus.Waiting`.
- [ ] **Step 4: Build**
Run:
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: both build with 0 errors. Existing warnings OK.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/TaskEntity.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs
git commit -m "feat(data): add Waiting task status and include it in virtual:queued"
```
---
## Task 2: Narrower island GridSplitters
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml` (lines 158 and 170)
- [ ] **Step 1: Halve the splitter width**
Both `GridSplitter` elements currently use `Width="5"`. Change both to `Width="3"`. Leave all other attributes untouched.
- [ ] **Step 2: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "style(ui): narrow island GridSplitters from 5 to 3"
```
---
## Task 3: Collapsible Description section in Details pane
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml`
- [ ] **Step 1: Add observable flag + toggle command**
In `DetailsIslandViewModel.cs`, add beside the existing editable fields:
```csharp
[ObservableProperty] private bool _isDescriptionExpanded = true;
[RelayCommand]
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
```
- [ ] **Step 2: Reset flag when a new task is loaded**
Find the method that handles a new `Task` being bound (the existing `OnTaskChanged` / `Bind` path — it's the spot that already sets `EditableTitle`, `EditableDescription`, etc.). At the start of the load path where fields get reset, add:
```csharp
IsDescriptionExpanded = true;
```
(If the reset is scattered, put it next to the `EditableDescription = ""` assignment.)
- [ ] **Step 3: Wrap the description TextBox in a collapsible section**
In `DetailsIslandView.axaml`, locate the description TextBox. Wrap it so it looks like:
```xml
<StackPanel Spacing="4">
<Button Classes="flat"
Command="{Binding ToggleDescriptionExpandedCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="0">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding IsDescriptionExpanded}"/>
<PathIcon Width="10" Height="10"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding !IsDescriptionExpanded}"/>
<TextBlock Classes="eyebrow" Text="DESCRIPTION"/>
</StackPanel>
</Button>
<!-- existing description TextBox goes here unchanged, but add: -->
<TextBox ...existing attributes...
IsVisible="{Binding IsDescriptionExpanded}"/>
</StackPanel>
```
If the existing `Icon.ChevronDown` / `Icon.ChevronRight` static resources don't exist, inspect `App.axaml` (or wherever `StaticResource Icon.*` icons live) and pick the closest existing chevron pair. If only one direction exists, use a simple `▾` / `▸` TextBlock substitute:
```xml
<TextBlock Text="▾" IsVisible="{Binding IsDescriptionExpanded}"/>
<TextBlock Text="▸" IsVisible="{Binding !IsDescriptionExpanded}"/>
```
- [ ] **Step 4: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 5: Manual verify**
Launch the app (`dotnet run --project src/ClaudeDo.App`), open a task with a description, click the chevron. Verify the body collapses/expands; verify opening a different task restores the expanded default.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml
git commit -m "feat(ui): collapsible description section in details pane"
```
---
## Task 4: Auto-collapse done planning parents in task list
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- [ ] **Step 1: Add expansion state + "all children done" flag to `TaskRowViewModel`**
In `TaskRowViewModel.cs`, add below the existing observable properties:
```csharp
[ObservableProperty] private bool _areChildrenExpanded = true;
[ObservableProperty] private bool _allChildrenDone;
partial void OnAllChildrenDoneChanged(bool value)
{
// Default children to collapsed once the planning parent is fully done.
if (value) AreChildrenExpanded = false;
}
[RelayCommand]
private void ToggleChildrenExpanded() => AreChildrenExpanded = !AreChildrenExpanded;
```
- [ ] **Step 2: Compute `AllChildrenDone` during Regroup in `TasksIslandViewModel`**
In `TasksIslandViewModel.cs`, locate the `Regroup()` method (the one that clears and repopulates `OverdueItems`/`OpenItems`/`CompletedItems`). Before it distributes rows, build a lookup of children by parent id:
```csharp
var childrenByParent = Items
.Where(r => r.IsChild && r.ParentTaskId is not null)
.GroupBy(r => r.ParentTaskId!)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild))
{
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
parent.AllChildrenDone = kids.All(c => c.Status == TaskStatus.Done);
else
parent.AllChildrenDone = false;
}
```
Then inside the existing distribution loop, skip child rows whose parent row has `AreChildrenExpanded == false`:
```csharp
foreach (var row in Items)
{
if (row.IsChild && row.ParentTaskId is not null)
{
var parentRow = Items.FirstOrDefault(p => p.Id == row.ParentTaskId);
if (parentRow is not null && !parentRow.AreChildrenExpanded) continue;
}
// ... existing distribution into Overdue/Open/Completed ...
}
```
If `Regroup()` currently uses LINQ expressions instead of a loop, split them out into explicit foreach so the skip is clear. Keep the overdue/completed logic intact — children of a collapsed parent are excluded from every bucket.
- [ ] **Step 3: Re-run Regroup when a row's expansion flag toggles**
In `TasksIslandViewModel.cs`, in the constructor (after `Items` is created), subscribe to changes so toggling one row triggers a regroup:
```csharp
Items.CollectionChanged += (_, e) =>
{
if (e.NewItems is not null)
foreach (TaskRowViewModel r in e.NewItems)
r.PropertyChanged += OnItemPropertyChanged;
if (e.OldItems is not null)
foreach (TaskRowViewModel r in e.OldItems)
r.PropertyChanged -= OnItemPropertyChanged;
};
```
Add the handler:
```csharp
private void OnItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(TaskRowViewModel.AreChildrenExpanded))
Regroup();
}
```
- [ ] **Step 4: Add chevron toggle button to the planning-parent row**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the main task card where the title/eyebrow row lives (co-located with `PlanningBadge`), add a chevron button visible only when `IsPlanningParent && HasPlanningChildren`:
```xml
<Button Classes="flat"
Command="{Binding ToggleChildrenExpandedCommand}"
IsVisible="{Binding HasPlanningChildren}"
Padding="0" Margin="0,0,6,0"
VerticalAlignment="Center">
<TextBlock FontSize="10"
Text="▾"
IsVisible="{Binding AreChildrenExpanded}"/>
<TextBlock FontSize="10"
Text="▸"
IsVisible="{Binding !AreChildrenExpanded}"/>
</Button>
```
Place it immediately before the title TextBlock in the parent-row layout. Leave child rows untouched.
- [ ] **Step 5: Build**
```bash
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 6: Manual verify**
Create a planning parent with ≥2 children. Mark both children `Done` (manually via DB if needed, or via a full planning run). Reload the list — the children should be hidden by default. Click the chevron on the parent — children appear. Click again — collapse.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml
git commit -m "feat(ui): auto-collapse done planning parents in task list"
```
---
## Task 5: PlanningChainCoordinator — worker-side chain advancement (TDD)
**Files:**
- Create: `src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`
- Create: `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`
- [ ] **Step 1: Write the first failing test — queueing sets first child Queued, rest Waiting**
Create `tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs`:
```csharp
using System.Threading.Tasks;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using Microsoft.EntityFrameworkCore;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
public class PlanningChainCoordinatorTests
{
private static DbContextOptions<ClaudeDoDbContext> InMemoryOptions() =>
new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite("DataSource=:memory:;Cache=Shared")
.Options;
private static async Task<(ClaudeDoDbContext ctx, TaskRepository repo)> NewDbAsync()
{
var ctx = new ClaudeDoDbContext(InMemoryOptions());
await ctx.Database.OpenConnectionAsync();
await ctx.Database.EnsureCreatedAsync();
return (ctx, new TaskRepository(ctx));
}
private static async Task SeedPlanningFamily(TaskRepository repo, string parentId, int childCount)
{
await repo.AddAsync(new TaskEntity
{
Id = parentId, ListId = "L1", Title = "Parent",
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Planned,
});
for (int i = 0; i < childCount; i++)
{
await repo.AddAsync(new TaskEntity
{
Id = $"{parentId}-c{i}", ListId = "L1", Title = $"Child {i}",
CreatedAt = System.DateTime.UtcNow, Status = TaskStatus.Manual,
ParentTaskId = parentId, SortOrder = i,
});
}
}
[Fact]
public async Task QueueSubtasksSequentially_SetsFirstQueued_RestWaiting()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Queued, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
}
```
- [ ] **Step 2: Run the test — expect failure (class doesn't exist)**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: compile error "PlanningChainCoordinator not found".
- [ ] **Step 3: Create the coordinator with the minimum to pass**
`src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
namespace ClaudeDo.Worker.Planning;
public sealed class PlanningChainCoordinator
{
private readonly TaskRepository _tasks;
public PlanningChainCoordinator(TaskRepository tasks) => _tasks = tasks;
public async Task QueueSubtasksSequentiallyAsync(string parentTaskId, CancellationToken ct)
{
var parent = await _tasks.GetByIdAsync(parentTaskId, ct)
?? throw new InvalidOperationException($"Task {parentTaskId} not found.");
var children = (await _tasks.GetChildrenAsync(parentTaskId, ct))
.OrderBy(t => t.SortOrder)
.ToList();
if (children.Count == 0)
throw new InvalidOperationException("Parent has no subtasks.");
var bad = children.FirstOrDefault(c => c.Status is not (TaskStatus.Manual or TaskStatus.Planned));
if (bad is not null)
throw new InvalidOperationException($"Child {bad.Id} is in status {bad.Status}; expected Manual or Planned.");
for (int i = 0; i < children.Count; i++)
{
children[i].Status = i == 0 ? TaskStatus.Queued : TaskStatus.Waiting;
await _tasks.UpdateAsync(children[i], ct);
}
}
}
```
If `TaskRepository.GetChildrenAsync` does not yet exist, add it:
```csharp
// in src/ClaudeDo.Data/Repositories/TaskRepository.cs
public Task<List<TaskEntity>> GetChildrenAsync(string parentTaskId, CancellationToken ct = default) =>
_ctx.Tasks.Where(t => t.ParentTaskId == parentTaskId).ToListAsync(ct);
```
(If the repo uses `AsNoTracking()` elsewhere for reads, match that pattern. For this method we want tracked entities so `UpdateAsync` works without extra attach.)
- [ ] **Step 4: Run the test — expect pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 1 passed.
- [ ] **Step 5: Add failing test — on child Done, next Waiting sibling flips to Queued**
Append to `PlanningChainCoordinatorTests.cs`:
```csharp
[Fact]
public async Task OnChildDone_FlipsNextWaitingToQueued()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
// Simulate first child finishing Done.
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Done;
await ctx.SaveChangesAsync();
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Done, default);
Assert.Equal("P-c1", advanced);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Done, kids[0].Status);
Assert.Equal(TaskStatus.Queued, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
```
- [ ] **Step 6: Run — expect failure**
Expected: compile error "OnChildFinishedAsync does not exist".
- [ ] **Step 7: Implement `OnChildFinishedAsync`**
In `PlanningChainCoordinator.cs`:
```csharp
/// <summary>
/// Call after a child task transitions to a terminal status.
/// Returns the id of the newly-queued sibling (if any), else null.
/// </summary>
public async Task<string?> OnChildFinishedAsync(string childTaskId, TaskStatus finalStatus, CancellationToken ct)
{
if (finalStatus != TaskStatus.Done) return null;
var child = await _tasks.GetByIdAsync(childTaskId, ct);
if (child?.ParentTaskId is null) return null;
var siblings = (await _tasks.GetChildrenAsync(child.ParentTaskId, ct))
.OrderBy(t => t.SortOrder)
.ToList();
var next = siblings
.Where(s => s.SortOrder > child.SortOrder && s.Status == TaskStatus.Waiting)
.FirstOrDefault();
if (next is null) return null;
next.Status = TaskStatus.Queued;
await _tasks.UpdateAsync(next, ct);
return next.Id;
}
```
- [ ] **Step 8: Run — expect pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 2 passed.
- [ ] **Step 9: Add failing test — on Failed, chain stops**
```csharp
[Fact]
public async Task OnChildFailed_DoesNotAdvanceChain()
{
var (ctx, repo) = await NewDbAsync();
await using var _ = ctx;
await SeedPlanningFamily(repo, "P", 3);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync("P", default);
var first = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
first.Status = TaskStatus.Failed;
await ctx.SaveChangesAsync();
var advanced = await coord.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
Assert.Null(advanced);
var kids = await ctx.Tasks.Where(t => t.ParentTaskId == "P").OrderBy(t => t.SortOrder).ToListAsync();
Assert.Equal(TaskStatus.Failed, kids[0].Status);
Assert.Equal(TaskStatus.Waiting, kids[1].Status);
Assert.Equal(TaskStatus.Waiting, kids[2].Status);
}
```
- [ ] **Step 10: Run — expect pass (existing guard handles it)**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter FullyQualifiedName~PlanningChainCoordinatorTests
```
Expected: 3 passed.
- [ ] **Step 11: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs \
src/ClaudeDo.Data/Repositories/TaskRepository.cs \
tests/ClaudeDo.Worker.Tests/Planning/PlanningChainCoordinatorTests.cs
git commit -m "feat(worker): add PlanningChainCoordinator with sequential subtask advancement"
```
---
## Task 6: Hook chain advancement into TaskRunner finish path
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
- Modify: `src/ClaudeDo.Worker/Program.cs` (DI registration)
- [ ] **Step 1: Register `PlanningChainCoordinator` in DI**
Locate `src/ClaudeDo.Worker/Program.cs` where other services are registered (look for `services.AddSingleton<PlanningSessionManager>` or similar). Add:
```csharp
services.AddScoped<PlanningChainCoordinator>();
```
Use `AddScoped` if `TaskRepository` is scoped (check how it's registered — match its lifetime). If `TaskRepository` is constructed ad-hoc inside the worker, add a constructor overload on `PlanningChainCoordinator` that takes `IDbContextFactory<ClaudeDoDbContext>` and builds its own `TaskRepository` per call, then register as Singleton. Mirror the pattern used by `PlanningSessionManager`.
- [ ] **Step 2: Inject coordinator into `TaskRunner`**
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`, add `PlanningChainCoordinator` to the constructor parameter list and store it in a readonly field (match the style used for `_broadcaster`).
If `TaskRunner` is not a good fit for direct injection (e.g., it's used in contexts without DI), instead inject `IServiceProvider` / `IDbContextFactory<ClaudeDoDbContext>` and new-up a coordinator inside the finish handler. Pick whichever matches existing `TaskRunner` patterns.
- [ ] **Step 3: Call coordinator after Done/Failed emission**
Immediately after each `await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);` on line ~338 and the two failed emissions on lines ~355 and ~372, add:
```csharp
if (task.ParentTaskId is not null)
{
var advancedId = await _chainCoordinator.OnChildFinishedAsync(
task.Id,
/* Done or Failed based on path */,
CancellationToken.None);
if (advancedId is not null)
await _broadcaster.TaskUpdated(advancedId);
}
```
Use `TaskStatus.Done` in the done-path call site and `TaskStatus.Failed` in the failed-path call sites. For the failed paths that use `justFailed` rather than `task`, read `justFailed?.ParentTaskId` and `justFailed?.Id` to stay consistent with the surrounding code.
After this call the existing queue-pickup loop will see the newly-Queued sibling and dispatch it on its next tick.
- [ ] **Step 4: Build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: 0 errors.
- [ ] **Step 5: Run full test suite**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
```
Expected: all pre-existing tests + 3 new ones pass.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs \
src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): advance planning subtask chain on child finish"
```
---
## Task 7: Hub method + client + context menu entry
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Ui/Services/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`
- [ ] **Step 1: Add hub method**
In `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, add (match the style of other planning methods):
```csharp
public async Task QueuePlanningSubtasks(string parentTaskId)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(ctx);
var coord = new PlanningChainCoordinator(repo);
await coord.QueueSubtasksSequentiallyAsync(parentTaskId, CancellationToken.None);
// Broadcast updates for the parent and all its children so the UI refreshes.
var children = await ctx.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.Select(t => t.Id)
.ToListAsync();
await _broadcaster.TaskUpdated(parentTaskId);
foreach (var id in children)
await _broadcaster.TaskUpdated(id);
// Make sure the queue picks up the now-Queued first child immediately.
_queueSignal.Wake();
}
```
If the existing hub constructs `PlanningSessionManager` via DI directly, inject `PlanningChainCoordinator` the same way and call `_chainCoordinator.QueueSubtasksSequentiallyAsync(...)` instead of newing one up. If the hub exposes a queue-wakeup via a different name than `_queueSignal`, use that (search the file for `WakeQueue` or `.Wake()`).
- [ ] **Step 2: Add method to `IWorkerClient`**
In `src/ClaudeDo.Ui/Services/IWorkerClient.cs`, add next to the other planning methods:
```csharp
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
```
- [ ] **Step 3: Implement in `WorkerClient`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, add (match the pattern of `StartPlanningSessionAsync` etc.):
```csharp
public Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) =>
_connection.InvokeAsync("QueuePlanningSubtasks", parentTaskId, ct);
```
- [ ] **Step 4: Add `CanQueueSubtasksSequentially` + `HasPlanningChildren` observable to `TaskRowViewModel`**
Confirm `HasPlanningChildren` exists (it's referenced in the spec). If not, add it as `[ObservableProperty] bool _hasPlanningChildren;` and ensure `TasksIslandViewModel.Regroup()` already sets it (there should be a parent-side "has children" pass similar to the `AllChildrenDone` one added in Task 4 — if not, set it there).
Then add:
```csharp
public bool CanQueueSubtasksSequentially =>
IsPlanningParent && HasPlanningChildren && !IsChild;
```
Add `OnPropertyChanged(nameof(CanQueueSubtasksSequentially))` inside `OnStatusChanged` and `OnHasPlanningChildrenChanged` so the flag refreshes when status or children change.
- [ ] **Step 5: Add context-menu entry**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, inside the existing `<ContextMenu>`, directly after the "Discard planning session" item:
```xml
<Separator IsVisible="{Binding CanQueueSubtasksSequentially}"/>
<MenuItem Header="Queue subtasks sequentially"
IsVisible="{Binding CanQueueSubtasksSequentially}"
Click="OnQueueSubtasksSequentiallyClick"/>
```
- [ ] **Step 6: Add click handler in code-behind**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs`, add (match the other `On*Click` handlers — they pull the `TaskRowViewModel` from `DataContext` and call the shell / worker):
```csharp
private async void OnQueueSubtasksSequentiallyClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel row) return;
var worker = App.Services.GetRequiredService<IWorkerClient>();
try
{
await worker.QueuePlanningSubtasksAsync(row.Id);
}
catch (Exception ex)
{
// Match the toast/log pattern used by OnSendToQueueClick et al.
System.Diagnostics.Debug.WriteLine($"QueuePlanningSubtasks failed: {ex}");
}
}
```
Use the same `App.Services` / `IWorkerClient` lookup pattern as `OnSendToQueueClick` — do not introduce a new DI pattern. If the existing handlers use a shell/mediator indirection, use that instead.
- [ ] **Step 7: Build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
```
Expected: 0 errors.
- [ ] **Step 8: Manual verify end-to-end**
1. Launch app: `dotnet run --project src/ClaudeDo.App`.
2. Open a planning task with ≥2 subtasks (all in `Manual`/`Planned`).
3. Right-click parent → **Queue subtasks sequentially**.
4. Confirm in the task list: first child shows `Queued` chip, others show `Waiting` chip.
5. Let the first run to completion (or, for a quick smoke test, edit the DB to mark it `Done` and emit `TaskUpdated` via a restart).
6. Confirm the next child's status flips `Waiting → Queued` without user interaction.
7. Force-fail a child (cancel it mid-run) — confirm remaining `Waiting` children stay `Waiting`.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs \
src/ClaudeDo.Ui/Services/IWorkerClient.cs \
src/ClaudeDo.Ui/Services/WorkerClient.cs \
src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml \
src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml.cs
git commit -m "feat(ui+worker): context menu to queue planning subtasks sequentially"
```
---
## Self-review checklist (for the plan author before handing off)
- All four spec items mapped: auto-collapse (Task 4), collapsible description (Task 3), narrower splitters (Task 2), sequential subtask queue (Tasks 1, 5, 6, 7).
- `Waiting` enum touches: enum, chip class, virtual:queued filter — covered in Task 1.
- TDD applied where it pays off (the coordinator); UI tasks rely on manual verification (correct for this codebase).
- No placeholders. Every code step shows the code to paste.
- Type names consistent: `PlanningChainCoordinator`, `QueueSubtasksSequentiallyAsync`, `OnChildFinishedAsync`, `QueuePlanningSubtasksAsync`, `AreChildrenExpanded`, `AllChildrenDone`, `IsDescriptionExpanded` — used the same across tasks.
- Commits are small and conventional.
@@ -0,0 +1,999 @@
# Planning Session Worktree 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 `mcp__claudedo__*` tools available inside planning sessions by running each session in an ephemeral git worktree that holds a project-scope `.mcp.json` and a settings override that auto-trusts project MCP servers.
**Architecture:** `PlanningSessionManager` creates a short-lived git worktree from `HEAD` of the list's working directory on `StartAsync`, writes `.mcp.json` (with env-var expansion for the bearer token) and `.claude/settings.local.json` into it, and returns the worktree path as the spawn directory. `WindowsTerminalPlanningLauncher` passes the token via env var (`CLAUDEDO_PLANNING_TOKEN`) and stops passing `--mcp-config`. Finalize/Discard force-remove the worktree and branch.
**Tech Stack:** .NET 8, xUnit, real SQLite (DbFixture), real git worktrees via `ClaudeDo.Data.Git.GitService`.
**Spec:** `docs/superpowers/specs/2026-04-24-planning-worktree-design.md`
---
## File Structure
**Modify:**
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` — add `Token`, `WorktreePath`, `BranchName` to start context; add `Token` and rename `McpConfigPath``WorktreePath` on resume context
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` — drop `McpConfigPath` field
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` — worktree create/cleanup, token persistence, new ctor deps
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs` — env var, drop `--mcp-config`
- `src/ClaudeDo.Worker/Program.cs` — DI wiring for new ctor signature
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` — add git init, update existing assertions
- `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` — add git init in setup
- `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs` — assert env var, no `--mcp-config`
Each file has one clear responsibility; no new files needed.
---
## Task 1: Extend context records with token and worktree info
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs`
- [ ] **Step 1: Edit the records**
Replace the full file content with:
```csharp
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningSessionStartContext(
string ParentTaskId,
string WorkingDir,
string Token,
string WorktreePath,
string BranchName,
PlanningSessionFiles Files);
public sealed record PlanningSessionResumeContext(
string ParentTaskId,
string WorkingDir,
string ClaudeSessionId,
string Token,
string WorktreePath);
```
Note: `WorkingDir` on both records now points at the worktree (callers that used it as "spawn dir" remain correct; callers that needed "list working dir" must be updated separately — no such callers exist today).
- [ ] **Step 2: Build to see breakage**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: FAIL — `PlanningSessionManager` and `WindowsTerminalPlanningLauncher` no longer match these signatures.
- [ ] **Step 3: Commit stub**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs
git commit -m "refactor(worker): extend planning contexts with token and worktree"
```
---
## Task 2: Drop `McpConfigPath` from `PlanningSessionFiles`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs`
- [ ] **Step 1: Edit the record**
Replace the full file content with:
```csharp
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningSessionFiles(
string SessionDirectory,
string SystemPromptPath,
string InitialPromptPath);
```
- [ ] **Step 2: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs
git commit -m "refactor(worker): drop McpConfigPath from PlanningSessionFiles"
```
---
## Task 3: Extend `PlanningSessionManager` constructors
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (fields + constructors only)
- [ ] **Step 1: Add using directives**
At the top of `PlanningSessionManager.cs`, add these imports alongside the existing ones:
```csharp
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
```
- [ ] **Step 2: Replace fields and constructors**
Replace the block from `private const string McpServerUrl` down to the end of `CreateRepos()` with:
```csharp
private const string McpServerUrl = "http://127.0.0.1:47821/mcp";
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
private readonly TaskRepository? _tasksOverride;
private readonly ListRepository? _listsOverride;
private readonly AppSettingsRepository? _settingsOverride;
private readonly GitService _git;
private readonly WorkerConfig _cfg;
private readonly string _rootDirectory;
// DI constructor.
public PlanningSessionManager(
IDbContextFactory<ClaudeDoDbContext> factory,
GitService git,
WorkerConfig cfg,
string rootDirectory)
{
_factory = factory;
_git = git;
_cfg = cfg;
_rootDirectory = rootDirectory;
}
// Test constructor.
public PlanningSessionManager(
TaskRepository tasks,
ListRepository lists,
AppSettingsRepository settings,
GitService git,
WorkerConfig cfg,
string rootDirectory)
{
_tasksOverride = tasks;
_listsOverride = lists;
_settingsOverride = settings;
_git = git;
_cfg = cfg;
_rootDirectory = rootDirectory;
}
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
{
if (_tasksOverride is not null)
return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
var ctx = _factory!.CreateDbContext();
return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
}
```
- [ ] **Step 3: Update all `CreateRepos()` call-sites in this file**
Every call currently binds `(tasks, lists, ctx)`. Change each to `(tasks, lists, settings, ctx)` (search the file for `= CreateRepos();`).
The `_` and `__` discard patterns on the returned `ctx` (lines like `await using var _ = ctx;`) remain valid.
- [ ] **Step 4: Build — expect test breakage**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS (production code compiles).
Run: `dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj`
Expected: FAIL — test ctor calls don't match. Will be fixed in Task 10.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): inject GitService and WorkerConfig into PlanningSessionManager"
```
---
## Task 4: Add a worktree-path helper and the token-file helpers
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (add private helpers)
- [ ] **Step 1: Add three private helpers at the bottom of the class (before the closing `}`)**
```csharp
private static string BranchNameFor(string taskId) =>
$"claudedo/planning/{taskId.Replace("-", "")}";
private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
{
var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
? centralRootOverride!
: _cfg.CentralWorktreeRoot;
var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
? Path.Combine(centralRoot, "planning", taskId)
: Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);
return Path.GetFullPath(raw);
}
private static string TokenFilePathFor(string sessionDir) =>
Path.Combine(sessionDir, "token");
private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
{
await File.WriteAllTextAsync(path, token, ct);
// Best-effort current-user-only ACL on Windows. On non-Windows the inherited
// perms from the parent dir apply; acceptable because sessionDir is already
// under the user's home (~/.todo-app/sessions/).
if (OperatingSystem.IsWindows())
{
try
{
var fi = new FileInfo(path);
var ac = fi.GetAccessControl();
ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
me,
System.Security.AccessControl.FileSystemRights.FullControl,
System.Security.AccessControl.AccessControlType.Allow));
fi.SetAccessControl(ac);
}
catch { /* ACL hardening is best-effort */ }
}
}
private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
{
if (!File.Exists(path))
throw new InvalidOperationException($"Token file missing: {path}");
return (await File.ReadAllTextAsync(path, ct)).Trim();
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): add worktree path and token file helpers"
```
---
## Task 5: Rewrite `BuildMcpConfigJson` to use env-var expansion
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
- [ ] **Step 1: Replace `BuildMcpConfigJson` body**
Find the existing `private static string BuildMcpConfigJson(string token)` method. Replace with:
```csharp
private static string BuildMcpConfigJson()
{
var payload = new
{
mcpServers = new
{
claudedo = new
{
type = "http",
url = McpServerUrl,
headers = new Dictionary<string, string>
{
["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
}
}
}
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
}
```
(The token argument is dropped — claude expands `${CLAUDEDO_PLANNING_TOKEN}` at load time from the spawned process environment.)
- [ ] **Step 2: Also add settings override builder below it**
```csharp
private const string SettingsLocalJson = """
{
"enableAllProjectMcpServers": true
}
""";
```
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "refactor(worker): switch MCP config to env-var token expansion"
```
---
## Task 6: Rewrite `StartAsync` to create the worktree
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (body of `StartAsync` only)
- [ ] **Step 1: Replace `StartAsync` body (keep signature)**
Replace the entire method body with:
```csharp
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var _ = ctx;
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.ParentTaskId is not null)
throw new InvalidOperationException("Cannot start a planning session on a child task.");
if (task.Status != TaskStatus.Manual)
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var listWorkingDir = list.WorkingDir
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
var branchName = BranchNameFor(taskId);
var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
try
{
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
}
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
// Self-heal: remove phantom worktrees, prune, delete branch, retry once.
var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
foreach (var stale in stalePaths)
{
try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
}
try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
}
// Write .mcp.json and .claude/settings.local.json into the worktree.
var mcpPath = Path.Combine(worktreePath, ".mcp.json");
await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);
var claudeDir = Path.Combine(worktreePath, ".claude");
Directory.CreateDirectory(claudeDir);
await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);
// Session dir + token + prompt files.
var token = GenerateToken();
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
?? throw new InvalidOperationException("Failed to transition task to Planning.");
var sessionDir = Path.Combine(_rootDirectory, taskId);
Directory.CreateDirectory(sessionDir);
var files = new PlanningSessionFiles(
sessionDir,
Path.Combine(sessionDir, "system-prompt.md"),
Path.Combine(sessionDir, "initial-prompt.txt"));
await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
return new PlanningSessionStartContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
Token: token,
WorktreePath: worktreePath,
BranchName: branchName,
Files: files);
}
```
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): create ephemeral worktree and write .mcp.json in StartAsync"
```
---
## Task 7: Rewrite `ResumeAsync` and add cleanup to `FinalizeAsync` / `DiscardAsync`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs` (three methods)
- [ ] **Step 1: Replace `ResumeAsync` body**
```csharp
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var _ = ctx;
var task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Planning)
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
if (string.IsNullOrEmpty(task.PlanningSessionId))
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (!Directory.Exists(sessionDir))
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
var list = await lists.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException($"List {task.ListId} not found.");
var listWorkingDir = list.WorkingDir
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
if (!Directory.Exists(worktreePath))
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
return new PlanningSessionResumeContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
ClaudeSessionId: task.PlanningSessionId,
Token: token,
WorktreePath: worktreePath);
}
```
- [ ] **Step 2: Extend `FinalizeAsync` to clean up worktree + branch**
Replace the existing `FinalizeAsync` body with:
```csharp
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var __ = ctx;
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
// Best-effort cleanup — don't block finalization on git state.
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (Directory.Exists(sessionDir))
{
try { Directory.Delete(sessionDir, recursive: true); } catch { }
}
return count;
}
```
- [ ] **Step 3: Extend `DiscardAsync` with the same cleanup**
Replace the body of `DiscardAsync` with:
```csharp
public async Task DiscardAsync(string taskId, CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var __ = ctx;
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (Directory.Exists(sessionDir))
{
try { Directory.Delete(sessionDir, recursive: true); } catch { }
}
if (!ok)
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
}
```
- [ ] **Step 4: Add the `TryCleanupWorktreeAsync` helper**
Add this private method near the other helpers:
```csharp
private async Task TryCleanupWorktreeAsync(
string taskId,
ListRepository lists,
AppSettingsRepository settings,
CancellationToken ct)
{
try
{
var (tasks, _, _, ctx2) = CreateRepos();
await using var __ = ctx2;
var task = await tasks.GetByIdAsync(taskId, ct);
if (task is null) return;
var list = await lists.GetByIdAsync(task.ListId, ct);
var listWorkingDir = list?.WorkingDir;
if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;
var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
var branchName = BranchNameFor(taskId);
if (Directory.Exists(worktreePath))
{
try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
catch { /* best effort */ }
}
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
}
catch { /* best effort — never block finalize/discard */ }
}
```
- [ ] **Step 5: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
git commit -m "feat(worker): cleanup planning worktree and branch on finalize/discard"
```
---
## Task 8: Update `WindowsTerminalPlanningLauncher`
**Files:**
- Modify: `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
- [ ] **Step 1: Rewrite `LaunchStartAsync`**
Replace the full method body with:
```csharp
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
if (!File.Exists(ctx.Files.SystemPromptPath))
throw new PlanningLaunchException($"System prompt file not found: {ctx.Files.SystemPromptPath}");
if (!File.Exists(ctx.Files.InitialPromptPath))
throw new PlanningLaunchException($"Initial prompt file not found: {ctx.Files.InitialPromptPath}");
var resolvedWt = Resolve(_wtPath);
if (resolvedWt is null)
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
var resolvedClaude = Resolve(_claudePath);
if (resolvedClaude is null)
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
var psi = new ProcessStartInfo
{
FileName = resolvedWt,
UseShellExecute = false,
CreateNoWindow = false,
};
psi.Environment["MAX_THINKING_TOKENS"] = "20000";
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
// Arg order: --allowedTools is variadic (space-separated). The positional
// prompt must follow a single-value flag, or it will be swallowed.
// --append-system-prompt-file serves as that buffer.
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--model");
psi.ArgumentList.Add(Model);
psi.ArgumentList.Add("--allowedTools");
psi.ArgumentList.Add(AllowedTools);
psi.ArgumentList.Add("--append-system-prompt-file");
psi.ArgumentList.Add(ctx.Files.SystemPromptPath);
psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath));
var proc = Process.Start(psi)
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
return Task.CompletedTask;
}
```
- [ ] **Step 2: Rewrite `LaunchResumeAsync`**
Replace the full method body with:
```csharp
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{
if (!Directory.Exists(ctx.WorkingDir))
throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}");
var resolvedWt = Resolve(_wtPath);
if (resolvedWt is null)
throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}");
var resolvedClaude = Resolve(_claudePath);
if (resolvedClaude is null)
throw new PlanningLaunchException($"claude executable not found: {_claudePath}");
var psi = new ProcessStartInfo
{
FileName = resolvedWt,
UseShellExecute = false,
CreateNoWindow = false,
};
psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token;
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(ctx.WorkingDir);
psi.ArgumentList.Add(resolvedClaude);
psi.ArgumentList.Add("--resume");
psi.ArgumentList.Add(ctx.ClaudeSessionId);
var proc = Process.Start(psi)
?? throw new PlanningLaunchException("Failed to start Windows Terminal process.");
return Task.CompletedTask;
}
```
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs
git commit -m "feat(worker): launcher passes planning token via env, drops --mcp-config"
```
---
## Task 9: Update DI wiring in `Program.cs`
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs` (around line 5962)
- [ ] **Step 1: Update the registration**
Find:
```csharp
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
planningSessionsDir));
```
Replace with:
```csharp
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<GitService>(),
cfg,
planningSessionsDir));
```
- [ ] **Step 2: Build full worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "chore(worker): wire GitService and WorkerConfig into PlanningSessionManager DI"
```
---
## Task 10: Fix existing tests (add git init, update constructor calls, drop McpConfigPath assertions)
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs`
- [ ] **Step 1: Add a shared git-init helper**
Create `tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs`:
```csharp
using System.Diagnostics;
namespace ClaudeDo.Worker.Tests.Infrastructure;
public static class GitRepoFixture
{
public static void InitRepoWithInitialCommit(string dir)
{
Directory.CreateDirectory(dir);
Run(dir, "init", "-b", "main");
Run(dir, "config", "user.email", "test@claudedo.local");
Run(dir, "config", "user.name", "test");
File.WriteAllText(Path.Combine(dir, "README.md"), "seed\n");
Run(dir, "add", "-A");
Run(dir, "commit", "-m", "chore: seed");
}
private static void Run(string cwd, params string[] args)
{
var psi = new ProcessStartInfo("git") { WorkingDirectory = cwd, RedirectStandardError = true, RedirectStandardOutput = true };
foreach (var a in args) psi.ArgumentList.Add(a);
var p = Process.Start(psi)!;
p.WaitForExit();
if (p.ExitCode != 0)
throw new InvalidOperationException($"git {string.Join(" ", args)} failed: {p.StandardError.ReadToEnd()}");
}
}
```
- [ ] **Step 2: Update `PlanningSessionManagerTests` constructor and seed helper**
In `PlanningSessionManagerTests.cs`, find the constructor and add after `_rootDir = …;`:
```csharp
_git = new ClaudeDo.Data.Git.GitService();
_cfg = new ClaudeDo.Worker.Config.WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") };
_settingsRepo = new ClaudeDo.Data.Repositories.AppSettingsRepository(_ctx);
// Seed settings row so the manager can read strategy.
_settingsRepo.UpsertAsync(new ClaudeDo.Data.Models.AppSettingsEntity { Id = 1, WorktreeStrategy = "sibling" }).GetAwaiter().GetResult();
_sut = new PlanningSessionManager(_tasks, _lists, _settingsRepo, _git, _cfg, _rootDir);
```
Add three private fields to the class:
```csharp
private readonly ClaudeDo.Data.Git.GitService _git;
private readonly ClaudeDo.Worker.Config.WorkerConfig _cfg;
private readonly ClaudeDo.Data.Repositories.AppSettingsRepository _settingsRepo;
```
Change `SeedListAsync` to init a git repo:
```csharp
private async Task<(string listId, string workingDir)> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
ClaudeDo.Worker.Tests.Infrastructure.GitRepoFixture.InitRepoWithInitialCommit(wd);
await _lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test",
WorkingDir = wd,
CreatedAt = DateTime.UtcNow,
});
return (listId, wd);
}
```
- [ ] **Step 3: Update assertions in the existing `StartAsync_…` test**
The old test asserts `ctx.Files.McpConfigPath`. Replace with worktree-based assertions:
```csharp
Assert.Equal(parent.Id, ctx.ParentTaskId);
Assert.Equal(ctx.WorktreePath, ctx.WorkingDir);
Assert.True(Directory.Exists(ctx.WorktreePath));
var mcpPath = Path.Combine(ctx.WorktreePath, ".mcp.json");
Assert.True(File.Exists(mcpPath));
Assert.True(File.Exists(Path.Combine(ctx.WorktreePath, ".claude", "settings.local.json")));
Assert.True(File.Exists(ctx.Files.SystemPromptPath));
Assert.True(File.Exists(ctx.Files.InitialPromptPath));
var mcp = await File.ReadAllTextAsync(mcpPath);
Assert.Contains("${CLAUDEDO_PLANNING_TOKEN}", mcp);
Assert.DoesNotContain(ctx.Token, mcp);
```
- [ ] **Step 4: Update `PlanningEndToEndTests` SUT construction similarly**
Add the same fields + ctor arguments. Replace any `new PlanningSessionManager(tasks, lists, rootDir)` with `new PlanningSessionManager(tasks, lists, settingsRepo, git, cfg, rootDir)` and ensure the seeded working directory is git-initialized.
- [ ] **Step 5: Update `WindowsTerminalPlanningLauncherTests`**
If the existing tests construct `PlanningSessionStartContext` manually, update to supply the new `Token`, `WorktreePath`, `BranchName` fields. Add an assertion that the test observes (via a fake `IPlanningTerminalLauncher`-level check or by verifying the psi after a refactor seam) that the env var is set.
If the existing launcher test only verifies behavior that's no longer directly testable (it spawns wt.exe), leave those tests as-is but ensure they still compile with the new ctor shape.
- [ ] **Step 6: Run all planning tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: PASS for all tests that previously passed.
- [ ] **Step 7: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/Planning/ tests/ClaudeDo.Worker.Tests/Infrastructure/GitRepoFixture.cs
git commit -m "test(worker): adapt planning tests to git-backed worktree flow"
```
---
## Task 11: New tests — worktree creation, cleanup, self-heal, resume
**Files:**
- Modify: `tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs` (append new tests)
- [ ] **Step 1: Write the failing "worktree is removed on discard" test**
Append to the test class:
```csharp
[Fact]
public async Task DiscardAsync_RemovesWorktreeAndBranch()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
Assert.True(Directory.Exists(ctx.WorktreePath));
await _sut.DiscardAsync(parent.Id, CancellationToken.None);
Assert.False(Directory.Exists(ctx.WorktreePath));
// branch deleted
var paths = await _git.ListWorktreePathsForBranchAsync(wd, ctx.BranchName);
Assert.Empty(paths);
}
```
- [ ] **Step 2: Run — expect PASS**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "DiscardAsync_RemovesWorktreeAndBranch"`
Expected: PASS.
- [ ] **Step 3: Add "non-git working dir errors" test**
```csharp
[Fact]
public async Task StartAsync_ThrowsWhenWorkingDirIsNotGitRepo()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_nogit_{Guid.NewGuid():N}");
Directory.CreateDirectory(wd);
await _lists.AddAsync(new ListEntity { Id = listId, Name = "NoGit", WorkingDir = wd, CreatedAt = DateTime.UtcNow });
var t = await SeedManualTaskAsync(listId);
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.StartAsync(t.Id, CancellationToken.None));
}
```
Run and expect PASS.
- [ ] **Step 4: Add self-heal test**
```csharp
[Fact]
public async Task StartAsync_SelfHealsWhenBranchAlreadyExists()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
// Pre-create a colliding branch.
var branch = $"claudedo/planning/{parent.Id.Replace("-", "")}";
var head = await _git.RevParseHeadAsync(wd);
var procInfo = new System.Diagnostics.ProcessStartInfo("git") { WorkingDirectory = wd };
procInfo.ArgumentList.Add("branch");
procInfo.ArgumentList.Add(branch);
procInfo.ArgumentList.Add(head);
var p = System.Diagnostics.Process.Start(procInfo)!;
p.WaitForExit();
var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None);
Assert.True(Directory.Exists(ctx.WorktreePath));
}
```
Run and expect PASS.
- [ ] **Step 5: Add resume test**
```csharp
[Fact]
public async Task ResumeAsync_ReturnsContextWithTokenAndWorktree()
{
var (listId, wd) = await SeedListAsync();
var parent = await SeedManualTaskAsync(listId);
var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None);
// Simulate the claude session capturing its session id.
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "session-abc", CancellationToken.None);
var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None);
Assert.Equal(startCtx.Token, resumeCtx.Token);
Assert.Equal(startCtx.WorktreePath, resumeCtx.WorktreePath);
Assert.Equal("session-abc", resumeCtx.ClaudeSessionId);
}
```
Run and expect PASS. If `UpdatePlanningSessionIdAsync` doesn't exist, use whatever repository method captures the Claude session id in this codebase (search the repo for the existing pattern) and substitute; do **not** skip this step.
- [ ] **Step 6: Run all planning tests**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~Planning"`
Expected: all PASS.
- [ ] **Step 7: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs
git commit -m "test(worker): cover planning worktree lifecycle and self-heal"
```
---
## Task 12: Manual end-to-end verification
**Files:** none (manual)
- [ ] **Step 1: Build all projects**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj && dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 2: Start Worker + UI, create a manual task on a list whose WorkingDir is a real git repo, hit "Start planning"**
Expected:
- A Windows Terminal opens with `claude` running in a worktree under `<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>` (or the central root if strategy=central).
- No trust prompt appears for the `claudedo` MCP server.
- Inside claude, `/mcp` lists `claudedo` as connected.
- Asking claude "create a subtask" invokes `mcp__claudedo__*` tools and the new child task appears in the UI.
- [ ] **Step 3: Click Discard**
Expected:
- The worktree directory is gone; `git branch --list claudedo/planning/*` returns nothing; `~/.todo-app/sessions/<taskId>` is gone.
- [ ] **Step 4: Repeat with Finalize** — same expected cleanup.
- [ ] **Step 5: Close Windows Terminal mid-session, then "Resume"** — same worktree opens again with `--resume`.
---
## Deferred / follow-up
- **Defensive startup cleanup of orphaned planning worktrees.** Enumerate `.claudedo-worktrees/planning/*` (both sibling and central) and GC any whose session dir no longer exists. Ship as a follow-up plan if orphans become a real problem in practice.
---
## Self-Review Notes
- **Spec coverage:** Every section in `docs/superpowers/specs/2026-04-24-planning-worktree-design.md` maps to a task above (data flow → Task 6; launcher → Task 8; cleanup → Task 7; self-heal → Task 6 + Task 11.4; non-git error → Task 11.3; resume → Task 7 + Task 11.5; trust prompt bypass → Task 5 + Task 6). The one spec item deferred is the defensive startup cleanup.
- **Placeholder scan:** One conditional in Task 11.5 ("use whatever repository method captures the Claude session id") — this is deliberate: the existing codebase has an accessor whose exact name depends on local conventions and it's faster for the engineer to grep than for me to guess wrong. Every other step has full code.
- **Type consistency:** `PlanningSessionStartContext.WorktreePath` and `ResumeContext.WorktreePath` both `string`. `BranchName` only on Start (Resume recomputes via `BranchNameFor`). `Token` on both. `Files.McpConfigPath` removed everywhere.
@@ -0,0 +1,897 @@
# External MCP — CRUD Extensions 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:** Extend the always-on `ExternalMcpService` with full task CRUD plus tag management so a normal Claude CLI session can fully manage scope-creep tasks via MCP.
**Architecture:** Pure extension of the existing service. New repository helper for tag replacement; five new/extended `[McpServerTool]` methods. No DI changes (`TagRepository` is already registered for `TaskRepository`/`ListRepository`). Uses the same `X-ClaudeDo-Key` middleware already in place.
**Tech Stack:** .NET 8, EF Core (SQLite), `ModelContextProtocol.Server` (MCP SDK), xUnit.
---
## Pre-flight
The test assembly `tests/ClaudeDo.Worker.Tests` currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (stale `TaskRunner` / `WorkerHub` constructor calls in `QueueServiceTests.cs`, `QueueServiceSlotGuardTests.cs`, `PlanningHubTests.cs`). This is unrelated to this work and must NOT be fixed here.
Consequence: `dotnet test` cannot execute until that refactor lands. Each task's "Run test, verify it fails" step uses `dotnet build` of the **test csproj** to confirm only the new test's compile expectations, and `dotnet build` of the **production csproj** to confirm production code is correct. When the refactor lands, the engineer or user re-runs `dotnet test --filter "FullyQualifiedName~ExternalMcpServiceTests"` to validate the new tests for real.
Build commands used throughout (per the project memory note "use csproj, not .slnx, on .NET 8"):
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
```
---
## File Map
| File | Change |
|---|---|
| `src/ClaudeDo.Data/Repositories/TaskRepository.cs` | Add `SetTagsAsync` (replace tag set, auto-create rows) |
| `src/ClaudeDo.Worker/External/ExternalMcpService.cs` | Inject `TagRepository`; extend `AddTask` with `tags`; add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` |
| `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` | New test file with fakes mirroring `Planning/PlanningMcpServiceTests.cs` |
`TagRepository.GetAllAsync` already exists — no change needed there.
---
### Task 1: `TaskRepository.SetTagsAsync`
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (add new method inside the `#region Tags` block, after `RemoveTagAsync`)
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (use the existing `MakeTask`/list-seed helpers from that file — match the pattern used in adjacent tests):
```csharp
[Fact]
public async Task SetTagsAsync_AttachesNewTagsAndCreatesMissingRows()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "novel-tag" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "novel-tag");
Assert.Equal(2, tags.Count);
}
[Fact]
public async Task SetTagsAsync_ReplacesExistingTagSet()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
await _tasks.SetTagsAsync(task.Id, new[] { "manual" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
}
[Fact]
public async Task SetTagsAsync_DeduplicatesCaseInsensitively()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "AGENT", "Agent" });
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
}
[Fact]
public async Task SetTagsAsync_EmptyListClearsAllTags()
{
var listId = await CreateListAsync("L");
var task = MakeTask(listId, "t");
await _tasks.AddAsync(task);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
await _tasks.SetTagsAsync(task.Id, Array.Empty<string>());
Assert.Empty(await _tasks.GetTagsAsync(task.Id));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
```
Expected: compile error `CS1061: 'TaskRepository' does not contain a definition for 'SetTagsAsync'`. (Existing unrelated `CS7036` errors from `PlanningChainCoordinator` work also appear — ignore.)
- [ ] **Step 3: Write minimal implementation**
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, inside `#region Tags`, after `RemoveTagAsync`:
```csharp
public async Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
task.Tags.Clear();
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
{
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
if (tag is null)
{
tag = new TagEntity { Name = name };
_context.Tags.Add(tag);
}
task.Tags.Add(tag);
}
await _context.SaveChangesAsync(ct);
}
```
- [ ] **Step 4: Run test to verify it compiles + production build still passes**
```bash
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "TaskRepositoryTests" || echo "no errors in TaskRepositoryTests"
```
Expected: no errors specific to `TaskRepositoryTests` (assembly may still fail due to unrelated `PlanningChainCoordinator` issues).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs
git commit -m "feat(data): add TaskRepository.SetTagsAsync for full tag-set replacement"
```
---
### Task 2: New test file scaffolding for `ExternalMcpService`
**Files:**
- Create: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
This task creates the shared test fakes and one trivial passing test. Subsequent tasks reuse the same fakes.
- [ ] **Step 1: Inspect existing patterns**
Read `tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs` for the `FakeHubContext`/`RecordingClientProxy` pattern and `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` for how to construct a real `QueueService` for tests (the same approach is used here — `ExternalMcpService` depends on it for `WakeQueue`/`RunNow`/`CancelTask`).
- [ ] **Step 2: Write the test scaffolding**
Create `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Services;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
file sealed class RecordingHubClients : IHubClients
{
public RecordingClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Client(string connectionId) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
public IClientProxy Group(string groupName) => Proxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
public IClientProxy User(string userId) => Proxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
}
file sealed class RecordingClientProxy : IClientProxy
{
public List<(string Method, object?[] Args)> Calls { get; } = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add((method, args));
return Task.CompletedTask;
}
}
file sealed class FakeHubContext : IHubContext<WorkerHub>
{
public RecordingHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class ExternalMcpServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly TagRepository _tags;
private readonly FakeHubContext _hub;
private readonly HubBroadcaster _broadcaster;
public ExternalMcpServiceTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_tags = new TagRepository(_ctx);
_hub = new FakeHubContext();
_broadcaster = new HubBroadcaster(_hub);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<string> SeedListAsync(string name = "L")
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow });
return id;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Manual)
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Status = status,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
// QueueService is needed by ExternalMcpService's constructor. For tests that
// only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags,
// we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService
// built with the same approach used in QueueServiceTests is sufficient.
private ExternalMcpService BuildSut(QueueService queue) =>
new(_tasks, _lists, queue, _broadcaster, _tags);
[Fact]
public async Task SeededListAndTask_AreRetrievable()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
Assert.NotNull(await _tasks.GetByIdAsync(task.Id));
}
}
```
The trivial `SeededListAndTask_AreRetrievable` test exists to confirm the scaffolding compiles and the fakes work, without depending on `ExternalMcpService` itself yet.
Note: `BuildSut` uses a 5-argument constructor signature that does not exist yet — this matches the future signature added in Task 3. The compiler will accept this method only after Task 3.
- [ ] **Step 3: Verify the file references resolve**
Build the test csproj and check for errors specific to `ExternalMcpServiceTests`:
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpServiceTests"
```
Expected output: only one error referring to the 5-arg `ExternalMcpService` constructor (resolved in Task 3). No missing-namespace or syntax errors.
- [ ] **Step 4: Commit**
```bash
git add tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "test(external): scaffold ExternalMcpServiceTests"
```
---
### Task 3: Inject `TagRepository` into `ExternalMcpService` + add `ListTags`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
Smallest possible change to unblock everything else: take the new dependency and ship the simplest tool first.
- [ ] **Step 1: Write the failing test**
Add to `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`. The `BuildSut` helper is defined in Task 2; tests construct `QueueService` the same way `QueueServiceTests.cs` does (look there for the exact constructor argument list and adopt it verbatim):
```csharp
[Fact]
public async Task ListTags_ReturnsSeededAndCustomTags()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" });
using var queue = QueueServiceFactory.Create(_ctx, _broadcaster); // see helper note below
var sut = BuildSut(queue);
var tags = await sut.ListTags(CancellationToken.None);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "custom-tag");
}
```
If a `QueueServiceFactory` helper does not already exist in the test project, inline the construction by mirroring the setup found in `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs` (it builds `QueueService` directly with `IDbContextFactory`, `HubBroadcaster`, fake claude process, etc.). Do NOT call `StartAsync`; just construct and dispose.
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "ExternalMcpService|ExternalMcpServiceTests"
```
Expected: errors about the 5-arg constructor and `ListTags` not existing.
- [ ] **Step 3: Implement**
In `src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
1. Add `TagRepository` field and constructor parameter:
```csharp
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly QueueService _queue;
private readonly HubBroadcaster _broadcaster;
private readonly TagRepository _tags;
public ExternalMcpService(
TaskRepository tasks,
ListRepository lists,
QueueService queue,
HubBroadcaster broadcaster,
TagRepository tags)
{
_tasks = tasks;
_lists = lists;
_queue = queue;
_broadcaster = broadcaster;
_tags = tags;
}
```
2. Add a tag DTO above the class (next to `TaskListDto`):
```csharp
public sealed record TagDto(long Id, string Name);
```
3. Add the new tool method (place at the end of the class, before `ToDto`):
```csharp
[McpServerTool, Description("List all known tags. Useful for discovering existing tag names (including 'agent' which marks tasks for auto-execution) before tagging.")]
public async Task<IReadOnlyList<TagDto>> ListTags(CancellationToken cancellationToken)
{
var tags = await _tags.GetAllAsync(cancellationToken);
return tags.Select(t => new TagDto(t.Id, t.Name)).ToList();
}
```
- [ ] **Step 4: Verify production build + new test compiles**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "ExternalMcpService"
```
Expected: no errors mentioning `ExternalMcpService` or `ListTags`. (Unrelated `PlanningChainCoordinator` errors persist.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add ListTags + inject TagRepository"
```
---
### Task 4: Extend `AddTask` to accept `tags`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs` (`AddTask` method)
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task AddTask_WithTags_AttachesTags()
{
var listId = await SeedListAsync();
using var queue = /* same construction as ListTags test */;
var sut = BuildSut(queue);
var dto = await sut.AddTask(
listId, "scope-creep handoff", "desc", "claude-cli",
queueImmediately: false,
tags: new[] { "agent", "custom" },
CancellationToken.None);
var tags = await _tasks.GetTagsAsync(dto.Id);
Assert.Contains(tags, t => t.Name == "agent");
Assert.Contains(tags, t => t.Name == "custom");
}
[Fact]
public async Task AddTask_NullTags_BehavesAsBefore()
{
var listId = await SeedListAsync();
using var queue = /* same construction */;
var sut = BuildSut(queue);
var dto = await sut.AddTask(
listId, "no tags", null, "claude-cli",
queueImmediately: false, tags: null, CancellationToken.None);
Assert.Empty(await _tasks.GetTagsAsync(dto.Id));
}
```
(Replace the `/* same construction */` placeholder with the actual `QueueService` construction used in Task 3 — repeat the code, do not extract.)
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "AddTask"
```
Expected: error that `AddTask` does not accept a 7th `tags` parameter.
- [ ] **Step 3: Implement**
Replace the existing `AddTask` method in `ExternalMcpService.cs` with:
```csharp
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. Optional tags are attached on creation; missing tag names auto-create.")]
public async Task<TaskDto> AddTask(
string listId,
string title,
string? description,
string createdBy,
bool queueImmediately,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(listId))
throw new InvalidOperationException("listId is required.");
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
if (string.IsNullOrWhiteSpace(createdBy))
throw new InvalidOperationException("createdBy is required.");
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Description = description,
Status = queueImmediately ? TaskStatus.Queued : TaskStatus.Manual,
CreatedAt = DateTime.UtcNow,
CommitType = list.DefaultCommitType,
CreatedBy = createdBy,
};
await _tasks.AddAsync(entity, cancellationToken);
if (tags is not null && tags.Count > 0)
await _tasks.SetTagsAsync(entity.Id, tags, cancellationToken);
if (queueImmediately)
_queue.WakeQueue();
await _broadcaster.TaskUpdated(entity.Id);
return ToDto(entity);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): AddTask accepts tags on creation"
```
---
### Task 5: `UpdateTask`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task UpdateTask_PatchesNonNullFieldsOnly()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, "old title");
using var queue = /* same construction */;
var sut = BuildSut(queue);
var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None);
Assert.Equal("new title", dto.Title);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal("new title", loaded!.Title);
}
[Fact]
public async Task UpdateTask_TagsReplaceFullSet()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
using var queue = /* same construction */;
var sut = BuildSut(queue);
await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None);
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
}
[Fact]
public async Task UpdateTask_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateTask_NotFound_Throws()
{
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "UpdateTask"
```
Expected: errors that `UpdateTask` does not exist on `ExternalMcpService`.
- [ ] **Step 3: Implement**
Add to `ExternalMcpService.cs` (after `AddTask`):
```csharp
[McpServerTool, Description("Update an existing task's title, description, commit type, and/or tags. Pass null to leave a field unchanged. Tags are replaced as a full set when non-null. Refuses if the task is currently Running.")]
public async Task<TaskDto> UpdateTask(
string taskId,
string? title,
string? description,
string? commitType,
IReadOnlyList<string>? tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
await _tasks.UpdateAsync(task, cancellationToken);
if (tags is not null)
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add UpdateTask for content/tag patching"
```
---
### Task 6: `DeleteTask`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task DeleteTask_RemovesTaskAndTagJoins()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
using var queue = /* same construction */;
var sut = BuildSut(queue);
await sut.DeleteTask(task.Id, CancellationToken.None);
Assert.Null(await _tasks.GetByIdAsync(task.Id));
}
[Fact]
public async Task DeleteTask_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.DeleteTask(task.Id, CancellationToken.None));
}
[Fact]
public async Task DeleteTask_NotFound_Throws()
{
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.DeleteTask("does-not-exist", CancellationToken.None));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "DeleteTask"
```
Expected: errors that `DeleteTask` does not exist on `ExternalMcpService`.
- [ ] **Step 3: Implement**
Add to `ExternalMcpService.cs` (after `UpdateTask`):
```csharp
[McpServerTool, Description("Delete a task. Refuses if the task is currently Running — cancel it first.")]
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
await _tasks.DeleteAsync(taskId, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add DeleteTask"
```
---
### Task 7: `SetTaskTags`
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `ExternalMcpServiceTests.cs`:
```csharp
[Fact]
public async Task SetTaskTags_ReplacesTagSetAndBroadcasts()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
await _tasks.SetTagsAsync(task.Id, new[] { "agent" });
using var queue = /* same construction */;
var sut = BuildSut(queue);
var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None);
var tags = await _tasks.GetTagsAsync(task.Id);
Assert.Single(tags);
Assert.Equal("manual", tags[0].Name);
Assert.Contains(_hub.RecordingClients.Proxy.Calls,
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id);
}
[Fact]
public async Task SetTaskTags_OnRunning_Throws()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Running);
using var queue = /* same construction */;
var sut = BuildSut(queue);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None));
}
```
- [ ] **Step 2: Run test to verify it fails**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep "SetTaskTags"
```
Expected: errors that `SetTaskTags` does not exist.
- [ ] **Step 3: Implement**
Add to `ExternalMcpService.cs` (after `DeleteTask`):
```csharp
[McpServerTool, Description("Replace the full tag set on an existing task. Missing tag names auto-create. Refuses if the task is Running.")]
public async Task<TaskDto> SetTaskTags(
string taskId,
IReadOnlyList<string> tags,
CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot retag a running task. Cancel it first.");
await _tasks.SetTagsAsync(taskId, tags, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
return ToDto(reload);
}
```
- [ ] **Step 4: Verify production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(mcp/external): add SetTaskTags"
```
---
### Task 8: Final verification + docs touch
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md` (one-line update reflecting the new tools)
- [ ] **Step 1: Full production build**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
```
Expected: both succeed with 0 errors.
- [ ] **Step 2: Update Worker CLAUDE.md**
In `src/ClaudeDo.Worker/CLAUDE.md`, locate the existing line near the bottom of the file describing external MCP tools (search for `ExternalMcpService` or `External/`). If a list of tools is already there, append the new tool names: `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags`. If no such line exists, add one short line under an existing structural section, for example under "Architecture":
```markdown
- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus`, `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header.
```
If the file already has a similar line — replace it; do not duplicate.
- [ ] **Step 3: Verify the full test assembly state is unchanged**
```bash
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj 2>&1 | grep -E "error CS" | grep -v "PlanningChainCoordinator\|TaskRunner.*chain\|WorkerHub.*planningChain"
```
Expected: empty output (every remaining error must be one of the pre-existing `PlanningChainCoordinator`-related errors and nothing new).
- [ ] **Step 4: When the unrelated refactor lands, run the new tests**
(Defer to whoever lands the `PlanningChainCoordinator` refactor — they should run:)
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj --filter "FullyQualifiedName~ExternalMcpServiceTests|FullyQualifiedName~TaskRepositoryTests.SetTagsAsync"
```
Expected: all new tests green.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): document new external MCP tools"
```
---
## Self-review
**Spec coverage:**
- `AddTask` extension with tags → Task 4 ✓
- `UpdateTask` → Task 5 ✓
- `DeleteTask` → Task 6 ✓
- `SetTaskTags` → Task 7 ✓
- `ListTags` → Task 3 ✓
- `TaskRepository.SetTagsAsync` → Task 1 ✓
- Auth (no change) → out of scope, called out in pre-flight ✓
- Tests for each tool → Tasks 1, 3-7 ✓
- Docs touch → Task 8 ✓
**Placeholder scan:** The phrase `/* same construction */` in tasks 47 is intentional — the engineer fills it in by mirroring the `QueueService` construction in Task 3 (which itself mirrors `QueueServiceTests.cs`). All other placeholders eliminated. No "TBD".
**Type consistency:**
- `IReadOnlyList<string>` for tag inputs everywhere ✓
- `TaskDto` returned by `AddTask`, `UpdateTask`, `SetTaskTags`
- `TagDto(long Id, string Name)` consistent across `ListTags`
- Constructor signature `(TaskRepository, ListRepository, QueueService, HubBroadcaster, TagRepository)` consistent between Task 3 implementation and Task 2 scaffold's `BuildSut` call ✓
- Method `TaskRepository.SetTagsAsync(string, IReadOnlyList<string>, CancellationToken)` consistent with all callers ✓
No issues found.
@@ -0,0 +1,225 @@
# Session Prompts — Worker State & Queue Consolidation Slices 26
Paste-ready prompts for each remaining slice. Run **one slice per session** so the diff stays reviewable and tests stay green between commits. Spec lives at `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` — reference it when the prompt asks.
**Common ground rules** (carry across all slices):
- Direct on `main`, one commit per slice, conventional commit messages.
- Build green (`dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` + Data + Ui) before commit.
- Pre-existing test errors (TaskRunner/WorkerHub constructor drift in 4 test files) are **not** in scope to fix — they exist on `main` already. New compile errors my changes introduce ARE in scope.
- No drive-by refactors outside the slice's stated scope.
- New files must follow existing naming/folder conventions; legacy enum values stay until Slice 6.
- After each slice, update `~/.claude/projects/C--Private-ClaudeDo/memory/` if I learn something durable about the codebase.
---
## Slice 2 — `TaskStateService` (centralized state machine)
**Prompt to paste into a fresh session:**
> Slice 2 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (sections 2 and 8). Slice 1 already landed (commit 7b737e6) — `TaskStatus` has `Idle`/`Cancelled`, `PlanningPhase` enum exists, `BlockedByTaskId` field exists. Legacy enum values still around.
>
> **Goal:** introduce `Worker/State/ITaskStateService` + `TaskStateService` as the single component that mutates `Status`, `PlanningPhase`, `BlockedByTaskId`. Migrate every existing caller. Mark repo `Mark*Async` helpers `internal`.
>
> **Public surface (verbatim from spec):**
> ```csharp
> Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
> Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
> Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
> Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
> Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
> Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
> Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
> Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
> Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
> Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
> Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
> ```
>
> **Allowed transition table:** see spec §2. Reject invalid transitions with `TransitionResult(false, "<reason>")` — no exceptions. Each transition is one atomic `ExecuteUpdate` with `WHERE Status = <expected>` for TOCTOU-freedom.
>
> **Side effects after successful DB write** (do these inside the service so callers don't need to remember):
> - On any `→ Queued`: call `_queue.WakeQueue()` directly for now (Slice 3 will replace with `IQueueWaker`). Inject `QueueService` lazily via `Func<QueueService>` to break the DI cycle if needed.
> - On any successful transition: `_broadcaster.TaskUpdated(taskId)`.
> - On `Done`/`Failed`/`Cancelled` for a child task: invoke `_chain.OnChildFinishedAsync(taskId, finalStatus, ct)`. If it returns a next-task-id, call `UnblockAsync` on it. Then run `_repo.TryCompleteParentAsync(parentId, ct)`.
>
> **Important:** `BlockOnAsync` and `UnblockAsync` should write `BlockedByTaskId` directly. `EnqueueAsync` for a Planning child should keep `BlockedByTaskId` null when it's the head of the chain. The chain coordinator will compose these calls in Slice 4 — for now just expose the API.
>
> **Caller migration (mechanical — preserve current behavior):**
> - `TaskRunner.HandleSuccess` → replace `taskRepo.MarkDoneAsync` + `TryCompleteParentAsync` + `_chain.OnChildFinishedAsync` block with a single `_state.CompleteAsync(taskId, finishedAt, result, CancellationToken.None)`.
> - `TaskRunner.HandleFailure` → `_state.FailAsync(taskId, finishedAt, errorMarkdown, CancellationToken.None)`.
> - `TaskRunner.MarkFailed` (early-fail path) → same.
> - `TaskRunner.RunAsync` start of run → `_state.StartRunningAsync(taskId, startedAt, ct)`.
> - `StaleTaskRecovery.StartAsync` → `_state.RecoverStaleRunningAsync("worker restart", ct)`.
> - `TaskResetService.ResetAsync` → `_state.ResetToIdleAsync(taskId, ct)` for the status flip; service keeps owning worktree cleanup.
> - `PlanningSessionManager.StartAsync` (the `SetPlanningStartedAsync` call) → `_state.StartPlanningAsync(parentId, ct)`. The manager still owns token/session-dir setup; only the status flip moves.
> - `PlanningChainCoordinator.OnChildFinishedAsync` (the `next.Status = TaskStatus.Queued` write) → keep its existing logic but use `_state.UnblockAsync(next.Id, ct)` for the actual write. The Slice 4 rewrite finishes the rest.
> - `ExternalMcpService.UpdateTaskStatus` (status flip in the Queued case) → `_state.EnqueueAsync(taskId, ct)`. The Manual case stays as-is until Slice 6 since `Manual` is still a valid legacy value.
>
> **Repo helpers to mark `internal`:** `MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`, `FlipAllRunningToFailedAsync`. Verify nothing outside `ClaudeDo.Worker.State` calls them after migration. (`Worker.Tests` may need `InternalsVisibleTo` — add it if so.)
>
> **DI wiring:** register `TaskStateService` as Singleton in `Program.cs` for both the main app and the external-MCP app. The service holds no per-request state.
>
> **Tests:** new file `tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs`. At minimum:
> - Happy path for each transition (verify DB state + side-effect mocks invoked).
> - Reject path for each invalid transition (verify result + DB unchanged).
> - Concurrency: two parallel `StartRunningAsync` for the same `Queued` task → exactly one returns `Ok=true`.
> - Mock or fake the broadcaster, queue, and chain-coordinator dependencies. Use real SQLite for the DB (existing test pattern).
>
> Build all projects, run the worker test project (the 4 pre-existing constructor-drift errors are out of scope — but my changes shouldn't add new errors), commit as `refactor(worker/state): introduce TaskStateService and route mutations through it`.
---
## Slice 3 — `IQueueWaker` + `IQueuePicker`
**Prompt to paste into a fresh session:**
> Slice 3 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 3). Slices 1 and 2 already landed.
>
> **Goal:** extract queue-wake and queue-pick from `QueueService` and `TaskRepository` into dedicated single-responsibility components. Make wakes automatic.
>
> **New components in `Worker/Queue/`:**
> - `IQueueWaker` (interface, `void Wake()`). Backed by `QueueWaker` singleton holding the existing `SemaphoreSlim`. Inject into `TaskStateService` (replaces the direct `QueueService` ref from Slice 2) and into `QueueService` itself.
> - `IQueuePicker` with `Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)`. Implementation `QueuePicker` moves the raw SQL out of `TaskRepository.GetNextQueuedAgentTaskAsync` and **adds a `blocked_by_task_id IS NULL` filter to the WHERE clause**. Order stays `sort_order ASC, created_at ASC` (verify the existing query — add ORDER BY if missing). Atomic `UPDATE … RETURNING` flips `Queued → Running` and writes `started_at`.
>
> **Caller updates:**
> - `TaskStateService` swaps its `Func<QueueService>` for `IQueueWaker`. The `→ Queued` side-effect now calls `_waker.Wake()`.
> - `QueueService.ExecuteAsync` calls `_picker.ClaimNextAsync` instead of `_taskRepo.GetNextQueuedAgentTaskAsync`. The slot-claim, broadcaster, and `WakeQueue()` after slot release stay where they are.
> - `WorkerHub.WakeQueue()` and `ExternalMcpService.WakeQueue` calls in app code → remove the explicit invocations. The state-service triggers waking automatically. **Keep** the SignalR/MCP endpoint that exposes `WakeQueue()` for diagnostics/manual use — that one delegates to `_waker.Wake()`.
> - `TaskRepository.GetNextQueuedAgentTaskAsync` becomes a thin shim that forwards to `IQueuePicker` for any remaining tests, OR delete it and update tests to use the picker. Prefer delete if tests are easy to migrate.
>
> **Tests:** new `tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs`:
> - Skipped: `BlockedByTaskId` set; missing agent tag; `scheduled_for > now`; status not Queued.
> - Picked: correct order (`sort_order, created_at`).
> - Atomic claim: two parallel pickers → exactly one row returned non-null, the other null.
>
> Update existing `TaskRepositoryTests.GetNextQueuedAgentTaskAsync_*` tests if they exercised the removed method.
>
> Build, test, commit as `refactor(worker/queue): split queue waker and picker, auto-wake on enqueue`.
---
## Slice 4 — Planning flow consolidation (kills the original bug)
**Prompt to paste into a fresh session:**
> Slice 4 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 4). Slices 13 already landed. **This slice eliminates the original "queue never picks up planning tasks" bug structurally.**
>
> **Goal:** one path through planning. Delete the dual-flow problem.
>
> **Changes:**
> - **Delete** `TaskRepository.FinalizePlanningAsync` entirely. Also delete its tests in `TaskRepositoryPlanningTests.cs`.
> - **Rewrite** `PlanningSessionManager.FinalizeAsync(taskId, queueAgentTasks, ct)`:
> 1. `_state.FinalizePlanningAsync(parentId, ct)` (sets parent `PlanningPhase=Finalized`, `Status=Idle`).
> 2. If `queueAgentTasks` is true, call the new `_chainCoordinator.SetupChainAsync(parentId, ct)`.
> 3. Existing worktree-cleanup + session-dir-deletion remains.
> 4. Return the count of children that ended up in the chain.
> - **Rename** `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` → `SetupChainAsync`. Make it `internal`. New behavior:
> - Eligibility check: children must be in `Status=Idle` (was `Manual` or `Planned` legacy values — keep tolerating those for one slice via OR).
> - Auto-attach `agent` tag to all children (already in WIP — keep that behavior).
> - For first child: `_state.EnqueueAsync(child[0].Id, ct)` (no BlockedBy, head of chain).
> - For rest: `_state.EnqueueAsync(child[i].Id, ct)` followed immediately by `_state.BlockOnAsync(child[i].Id, child[i-1].Id, ct)`. (Or: add a single `EnqueueBlockedAsync` helper to TaskStateService if call-site clutter bothers you.)
> - **Update** `PlanningChainCoordinator.OnChildFinishedAsync`: replace status-via-LINQ logic with: query for the next child where `BlockedByTaskId == childTaskId`, call `_state.UnblockAsync` on it. Drop the `Waiting` lookup entirely.
> - Audit `Status == TaskStatus.Waiting` in UI/tests — replace with `Status == Queued && BlockedByTaskId != null`. (UI changes confirmed against `TaskRowViewModel`, `TasksIslandViewModel` from Slice 1's WIP.)
>
> **Regression test:** new `tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs` (or extend existing) — `Active` parent + 3 drafts → call `FinalizeAsync(queueAgentTasks: true)` → assert within 200 ms the first child has `Status=Running` (queue picker claimed it) without anyone calling `WakeQueue()` manually. This was the bug the user originally reported.
>
> **Update** `PlanningMcpService.EditableStatuses` — replace `Waiting` with `Queued` (since blocked tasks are now `Queued + BlockedByTaskId`). Verify the MCP tool still gates on `parent.PlanningPhase == Active` (legacy: `parent.Status == Planning`).
>
> Build, test, commit as `feat(planning): consolidate finalize+chain via TaskStateService, fix queue pickup`.
---
## Slice 5 — `OverrideSlotService` + folder reorg
**Prompt to paste into a fresh session:**
> Slice 5 of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 5). Slices 14 already landed.
>
> **Goal:** split the override slot out of QueueService and reorganize `Worker/Services/` into domain folders.
>
> **`OverrideSlotService` (new in `Worker/Queue/`):**
> - Owns the `_overrideSlot` field, `RunNow(taskId)`, `ContinueTask(taskId, followUpPrompt)`, and the override-slot piece of `CancelTask`.
> - Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim is fine; serialized by slot lock).
> - `QueueService.CancelTask` delegates to `OverrideSlotService.TryCancel` first, falls back to its own queue slot.
> - WorkerHub's `RunNow`/`ContinueTask`/`CancelTask` SignalR endpoints route to the new service via `OverrideSlotService` when applicable; keep the signatures stable.
>
> **Folder reorg** (use `git mv`, don't copy/delete):
> ```
> Worker/State/ ← ITaskStateService.cs, TaskStateService.cs, TransitionResult.cs (already exist; no move needed if already there)
> Worker/Queue/ ← IQueueWaker.cs, QueueWaker.cs, IQueuePicker.cs, QueuePicker.cs, QueueService.cs, OverrideSlotService.cs, QueueSlotState.cs
> Worker/Lifecycle/ ← StaleTaskRecovery.cs, TaskResetService.cs, TaskMergeService.cs
> Worker/Worktrees/ ← WorktreeMaintenanceService.cs
> Worker/Agents/ ← AgentFileService.cs, DefaultAgentSeeder.cs
> Worker/Runner/ ← unchanged
> Worker/Planning/ ← unchanged
> Worker/External/ ← unchanged
> Worker/Hub/ ← unchanged
> ```
>
> Update namespaces to match folders (existing convention: namespace == folder path under `ClaudeDo.Worker`). Delete the old `Worker/Services/` folder once empty.
>
> Update DI registrations in `Program.cs` (both apps) — most calls just need `using` updates. `OverrideSlotService` is a new singleton.
>
> Update test `using` statements to follow.
>
> Build, test, commit as `refactor(worker): extract OverrideSlotService and reorganize Worker/Services into domain folders`.
---
## Slice 6 — Cleanup, legacy retirement, docs
**Prompt to paste into a fresh session:**
> Slice 6 (final) of the worker state consolidation refactor. Spec: `docs/superpowers/specs/2026-04-27-worker-state-and-queue-consolidation-design.md` (section 6 + slice plan). Slices 15 already landed.
>
> **Goal:** retire legacy enum values, backfill DB rows, update docs.
>
> **EF migration `RetireLegacyTaskStatus`:**
> ```sql
> UPDATE tasks SET status='idle' WHERE status IN ('manual', 'draft');
> UPDATE tasks SET status='idle', planning_phase='active' WHERE status='planning';
> UPDATE tasks SET status='idle', planning_phase='finalized' WHERE status='planned';
>
> -- Waiting → Queued + blocked_by from sort_order:
> WITH ordered AS (
> SELECT id,
> LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
> FROM tasks WHERE status='waiting'
> )
> UPDATE tasks
> SET status='queued',
> blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
> WHERE id IN (SELECT id FROM ordered);
> ```
> Use `migrationBuilder.Sql(...)` for these. Down() is best-effort: `Cancelled` → `Failed`, `(idle, finalized)` → `planned`, `(idle, active)` → `planning`, `queued + blocked_by_task_id != null` → `waiting`. Document lossiness in a comment.
>
> **Code changes:**
> - Remove legacy values from `TaskStatus` enum: `Manual, Planning, Planned, Draft, Waiting`.
> - Strip the legacy branches from `TaskEntityConfiguration.StatusToString`/`StatusFromString`.
> - Default for `TaskEntity.Status` is `TaskStatus.Idle` (already correct after Slice 1's revert).
> - Audit + remap every remaining caller — they should already use new values from Slices 24, but search for any leftover `TaskStatus.Manual` etc. in:
> - tests (~10 files seed status — flip to `Idle`/`Queued`/etc.)
> - UI (`TaskRowViewModel.IsPlanningParent`, `IsDraft`, `CanOpenPlanningSession`, status maps — replace with `PlanningPhase` checks where appropriate)
> - any leftover guards in MCP/services
> - Mark `Mark*Async` repo helpers as `internal` if not already (Slice 2 should have done this — verify).
>
> **Docs to update:**
> - `src/ClaudeDo.Worker/CLAUDE.md` — new folder structure, new state-service flow, new wake mechanics, removal of legacy values.
> - `src/ClaudeDo.Data/CLAUDE.md` — TaskEntity new fields (`PlanningPhase`, `BlockedByTaskId`), retired legacy enum values, new tag-attach behavior.
> - `docs/plan.md` — update status flow section.
> - `docs/open.md` — close the "queue doesn't pick up planning tasks" item if it's tracked there; add any follow-ups discovered along the way.
> - Memory: update `~/.claude/projects/C--Private-ClaudeDo/memory/` with a new entry summarizing the new architecture (state-service + queue split + planning chain via blocked-by).
>
> **Sanity tests** — full test run. The 4 pre-existing constructor-drift errors should still be the only failures. If new ones surfaced from missed legacy-value remappings, fix them before commit.
>
> Build, full test run, commit as `refactor(data): retire legacy TaskStatus values and backfill existing rows`.
---
## After Slice 6
- All 6 slices on `main`.
- The original bug ("queue doesn't pick up planning tasks") is structurally impossible.
- Worker has clear domain folders, single state-mutator, single queue-picker.
- Spec doc + this prompt file can be deleted or moved to `docs/superpowers/done/`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,834 @@
# Repo Import List Helper 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:** Add a helper that scans parent folders for git repos and bulk-creates lists (with `WorkingDir` pre-filled) for the repos the user ticks.
**Architecture:** A pure `RepoScanner` finds git repos under a parent folder. A `RepoImportModalViewModel` loads existing lists' working dirs, merges scanned candidates into a checklist (marking already-added repos), and creates `ListEntity` rows for ticked-new repos via `ListRepository`. `RepoImportModalView` hosts the checklist and a folder picker. Two entry points open the modal: a Help-menu item (handled by `IslandsShellViewModel`) and a folder button in the Lists island (handled by `ListsIslandViewModel`). Each entry point reloads the Lists island after the modal closes.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, EF Core (SQLite), xUnit.
---
## File Structure
**Create:**
- `src/ClaudeDo.Ui/Services/RepoScanner.cs` — pure filesystem scan; `RepoCandidate` record + `RepoScanner.Scan`.
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs` — one checklist row.
- `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs` — modal VM (load, merge, create) + static `BuildCandidates`.
- `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml` (+ `.axaml.cs`) — modal window + folder picker.
- `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs` — scanner unit tests.
- `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs` — merge/dedupe/already-added unit tests.
**Modify:**
- `src/ClaudeDo.App/Program.cs` — register `RepoImportModalViewModel` (transient) + a `Func<RepoImportModalViewModel>`; pass the Func into `IslandsShellViewModel`.
- `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs``ShowRepoImportModal` Func + `OpenRepoImportCommand`.
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml` — folder button beside `+ New list`.
- `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs` — wire `ShowRepoImportModal`.
- `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs``ShowRepoImportModal` Func + `OpenRepoImportCommand`; inject `Func<RepoImportModalViewModel>`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` — Help-menu item `Add repos as lists…`.
- `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowRepoImportModal`.
- `src/ClaudeDo.Ui/CLAUDE.md` — document the new modal + entry points.
---
## Task 1: RepoScanner
**Files:**
- Create: `src/ClaudeDo.Ui/Services/RepoScanner.cs`
- Test: `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoScannerTests : IDisposable
{
private readonly string _root =
Path.Combine(Path.GetTempPath(), "repo-scan-" + Guid.NewGuid().ToString("N"));
public RepoScannerTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
private string MakeDir(string name)
{
var p = Path.Combine(_root, name);
Directory.CreateDirectory(p);
return p;
}
[Fact]
public void Scan_ReturnsSubfoldersWithGitDirectory()
{
var repo = MakeDir("repo-a");
Directory.CreateDirectory(Path.Combine(repo, ".git"));
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("repo-a", result[0].Name);
Assert.Equal(repo, result[0].FullPath);
}
[Fact]
public void Scan_TreatsDotGitFileAsRepo()
{
var repo = MakeDir("worktree-repo");
File.WriteAllText(Path.Combine(repo, ".git"), "gitdir: ../somewhere");
var result = RepoScanner.Scan(_root);
Assert.Single(result);
Assert.Equal("worktree-repo", result[0].Name);
}
[Fact]
public void Scan_IgnoresPlainFolders()
{
MakeDir("not-a-repo");
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_IsNotRecursive()
{
var nested = MakeDir(Path.Combine("outer", "inner"));
Directory.CreateDirectory(Path.Combine(nested, ".git"));
// outer itself has no .git
var result = RepoScanner.Scan(_root);
Assert.Empty(result);
}
[Fact]
public void Scan_ReturnsEmptyForMissingFolder()
{
var result = RepoScanner.Scan(Path.Combine(_root, "does-not-exist"));
Assert.Empty(result);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
Expected: FAIL — `RepoScanner` / `RepoCandidate` do not exist (compile error).
- [ ] **Step 3: Implement RepoScanner**
Create `src/ClaudeDo.Ui/Services/RepoScanner.cs`:
```csharp
namespace ClaudeDo.Ui.Services;
public sealed record RepoCandidate(string Name, string FullPath);
public static class RepoScanner
{
public static IReadOnlyList<RepoCandidate> Scan(string parentFolder)
{
if (string.IsNullOrWhiteSpace(parentFolder) || !Directory.Exists(parentFolder))
return Array.Empty<RepoCandidate>();
var result = new List<RepoCandidate>();
IEnumerable<string> subdirs;
try { subdirs = Directory.EnumerateDirectories(parentFolder); }
catch { return Array.Empty<RepoCandidate>(); }
foreach (var dir in subdirs)
{
var gitPath = Path.Combine(dir, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
result.Add(new RepoCandidate(Path.GetFileName(dir), dir));
}
return result;
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoScannerTests`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Services/RepoScanner.cs tests/ClaudeDo.Ui.Tests/RepoScannerTests.cs
git commit -m "feat(ui): add RepoScanner for git repo discovery"
```
---
## Task 2: RepoImportItemViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`
No dedicated test (trivial display VM; covered indirectly by Task 3).
- [ ] **Step 1: Implement the item VM**
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs`:
```csharp
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportItemViewModel : ViewModelBase
{
public string Name { get; init; } = "";
public string FullPath { get; init; } = "";
// True when a list already points at this path. Such rows are shown ticked + disabled.
public bool AlreadyAdded { get; init; }
public bool CanToggle => !AlreadyAdded;
[ObservableProperty] private bool _isChecked;
}
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportItemViewModel.cs
git commit -m "feat(ui): add RepoImportItemViewModel"
```
---
## Task 3: RepoImportModalViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`
The pure `BuildCandidates` static method is the tested seam (dedupe + already-added marking). `LoadAsync`/`CreateAsync` touch the DB and are verified manually.
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests;
public sealed class RepoImportCandidatesTests
{
[Fact]
public void BuildCandidates_NewRepo_IsCheckedAndNotAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].IsChecked);
Assert.False(items[0].AlreadyAdded);
Assert.Equal("repo-a", items[0].Name);
}
[Fact]
public void BuildCandidates_ExistingWorkingDir_IsMarkedAlreadyAdded()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Single(items);
Assert.True(items[0].AlreadyAdded);
Assert.True(items[0].IsChecked); // already-added rows render ticked
}
[Fact]
public void BuildCandidates_SkipsPathsAlreadyShown()
{
var found = new[] { new RepoCandidate("repo-a", @"C:\src\repo-a") };
var current = new HashSet<string>(new[] { @"c:\src\repo-a" }, StringComparer.OrdinalIgnoreCase);
var existing = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var items = RepoImportModalViewModel.BuildCandidates(found, current, existing);
Assert.Empty(items);
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
Expected: FAIL — `RepoImportModalViewModel` does not exist (compile error).
- [ ] **Step 3: Implement the modal VM**
Create `src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs`:
```csharp
using System.Collections.ObjectModel;
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.Input;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class RepoImportModalViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
public Action? CloseAction { get; set; }
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
public bool CanCreate => CreateCount > 0;
public string CreateButtonText => $"Create {CreateCount} list(s)";
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task LoadAsync(CancellationToken ct = default)
{
Repos.Clear();
_existingDirs.Clear();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
foreach (var l in await lists.GetAllAsync(ct))
{
if (!string.IsNullOrWhiteSpace(l.WorkingDir))
_existingDirs.Add(l.WorkingDir!);
}
NotifyCreateState();
}
public void AddFolders(IEnumerable<string> folders)
{
var current = new HashSet<string>(
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
foreach (var folder in folders)
{
var found = RepoScanner.Scan(folder);
foreach (var item in BuildCandidates(found, current, _existingDirs))
{
item.PropertyChanged += OnItemChanged;
Repos.Add(item);
current.Add(item.FullPath);
}
}
NotifyCreateState();
}
public static List<RepoImportItemViewModel> BuildCandidates(
IEnumerable<RepoCandidate> found,
IReadOnlySet<string> currentPaths,
IReadOnlySet<string> existingDirs)
{
var items = new List<RepoImportItemViewModel>();
foreach (var c in found)
{
if (currentPaths.Contains(c.FullPath)) continue;
items.Add(new RepoImportItemViewModel
{
Name = c.Name,
FullPath = c.FullPath,
AlreadyAdded = existingDirs.Contains(c.FullPath),
IsChecked = true,
});
}
return items;
}
private void OnItemChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(RepoImportItemViewModel.IsChecked))
NotifyCreateState();
}
private void NotifyCreateState()
{
OnPropertyChanged(nameof(CreateCount));
OnPropertyChanged(nameof(CanCreate));
OnPropertyChanged(nameof(CreateButtonText));
}
[RelayCommand]
private async Task CreateAsync()
{
var toCreate = Repos.Where(r => r.IsChecked && !r.AlreadyAdded).ToList();
if (toCreate.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
foreach (var r in toCreate)
{
await lists.AddAsync(new ListEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = r.Name,
WorkingDir = r.FullPath,
DefaultCommitType = CommitTypeRegistry.DefaultType,
CreatedAt = DateTime.UtcNow,
});
}
}
CloseAction?.Invoke();
}
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter RepoImportCandidatesTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/RepoImportModalViewModel.cs tests/ClaudeDo.Ui.Tests/RepoImportCandidatesTests.cs
git commit -m "feat(ui): add RepoImportModalViewModel with candidate merge logic"
```
---
## Task 4: RepoImportModalView
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`
Modeled on `AboutModalView.axaml` (header/body/footer) and `ListSettingsModalView.axaml.cs` (folder picker).
- [ ] **Step 1: Create the view XAML**
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml`:
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Modals.RepoImportModalView"
x:DataType="vm:RepoImportModalViewModel"
Title="Add repos as lists"
Width="560" Height="480"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
</Window.KeyBindings>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1">
<Grid RowDefinitions="36,Auto,*,52">
<!-- Header -->
<Border Grid.Row="0" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="ADD REPOS AS LISTS" FontFamily="{DynamicResource MonoFont}" FontSize="11"
LetterSpacing="1.4" Foreground="{DynamicResource TextBrush}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕" FontSize="12"
Command="{Binding CancelCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Add folder row -->
<Border Grid.Row="1" Padding="16,12,16,4">
<Button Content="Add folder…" Click="AddFolderClicked" HorizontalAlignment="Left"/>
</Border>
<!-- Repo checklist -->
<ScrollViewer Grid.Row="2" Padding="16,4,16,8">
<ItemsControl ItemsSource="{Binding Repos}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:RepoImportItemViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,4">
<CheckBox Grid.Column="0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding CanToggle}"
VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Margin="6,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource TextBrush}" FontSize="13"/>
<TextBlock Text="{Binding FullPath}" Foreground="{DynamicResource TextFaintBrush}"
FontFamily="{DynamicResource MonoFont}" FontSize="10"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="(already added)"
Foreground="{DynamicResource TextFaintBrush}" FontSize="11"
VerticalAlignment="Center"
IsVisible="{Binding AlreadyAdded}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!-- Footer -->
<Border Grid.Row="3" Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
VerticalAlignment="Center" Margin="16,0">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="{Binding CreateButtonText}" Command="{Binding CreateCommand}"
IsEnabled="{Binding CanCreate}" MinWidth="120" Classes="accent"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>
```
- [ ] **Step 2: Create the code-behind with folder picker**
Create `src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class RepoImportModalView : Window
{
public RepoImportModalView()
{
InitializeComponent();
}
private void TitleBar_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private async void AddFolderClicked(object? sender, RoutedEventArgs e)
{
if (DataContext is not RepoImportModalViewModel vm) return;
var top = TopLevel.GetTopLevel(this);
if (top is null) return;
var folders = await top.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose folders containing repos",
AllowMultiple = true,
});
if (folders.Count == 0) return;
vm.AddFolders(folders.Select(f => f.Path.LocalPath));
}
}
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded. (`TitleBar_PointerPressed` is unused for now but kept for parity with other modals; if the build warns as error, leave it — other modals keep the same handler.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml src/ClaudeDo.Ui/Views/Modals/RepoImportModalView.axaml.cs
git commit -m "feat(ui): add RepoImportModalView"
```
---
## Task 5: DI registration
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs:106` (after `ListSettingsModalViewModel` registration)
- [ ] **Step 1: Register the modal VM and its factory**
In `src/ClaudeDo.App/Program.cs`, after the line `sc.AddTransient<ListSettingsModalViewModel>();` add:
```csharp
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
```
(`RepoImportModalViewModel` is in namespace `ClaudeDo.Ui.ViewModels.Modals`, already imported in `Program.cs` via the existing modal VM usings — verify the using is present; if not, add `using ClaudeDo.Ui.ViewModels.Modals;`.)
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.App/Program.cs
git commit -m "chore(di): register RepoImportModalViewModel"
```
---
## Task 6: Lists island entry point
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`
- [ ] **Step 1: Add Func + command to the VM**
In `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs`, next to the existing `ShowListSettingsModal` property (around line 30), add:
```csharp
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
```
Then add a command (place it near `CreateListAsync`, e.g. after the `OpenWorktreesOverviewAsync` command around line 71):
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task OpenRepoImportAsync()
{
if (ShowRepoImportModal is null || _services is null) return;
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
await LoadAsync();
}
```
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported at the top of this file.)
- [ ] **Step 2: Add the folder button in XAML**
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml`, replace the existing `+ New list` button block (lines 171-183) with a row that holds both the new-list button and a folder-scan button:
```xml
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="{StaticResource Icon.Plus}"
Width="13" Height="13"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
<TextBlock Text="New list" FontSize="12"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
<Button Grid.Column="1" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding OpenRepoImportCommand}"
ToolTip.Tip="Add repos as lists">
<PathIcon Data="{StaticResource Icon.Folder}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid>
```
- [ ] **Step 3: Wire the Func in the code-behind**
In `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs`, inside the `DataContextChanged` handler (after the `vm.ShowWorktreesOverviewModal = ...` assignment, before the closing brace of the `if` block around line 66), add:
```csharp
vm.ShowRepoImportModal = async modal =>
{
var window = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => window.Close();
var top = TopLevel.GetTopLevel(this) as Window;
if (top is null) window.Show();
else await window.ShowDialog(top);
};
```
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
- [ ] **Step 4: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs
git commit -m "feat(ui): add repo import button to Lists island"
```
---
## Task 7: Help-menu entry point
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.App/Program.cs` (pass the Func into the shell VM)
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
- [ ] **Step 1: Add Func, factory field, and command to the shell VM**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
(a) Near the `ShowAboutModal` property (line 44), add:
```csharp
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
```
(b) Add a backing field for the factory next to `_worktreesOverviewVmFactory` (declared as a private readonly field elsewhere in the class). Add:
```csharp
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
```
(c) Add a parameter to the public constructor (line 162-171) — append after `mergeVmFactory`:
```csharp
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory)
```
and in the constructor body assign it (next to `_mergeVmFactory = mergeVmFactory;`):
```csharp
_repoImportVmFactory = repoImportVmFactory;
```
(d) Add the command near `OpenAbout` (line 256):
```csharp
[RelayCommand]
private async Task OpenRepoImport()
{
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
var vm = _repoImportVmFactory();
await vm.LoadAsync();
await ShowRepoImportModal(vm);
if (Lists is not null) await Lists.LoadAsync();
}
```
(`RepoImportModalViewModel` is in `ClaudeDo.Ui.ViewModels.Modals`, already imported in this file.)
- [ ] **Step 2: Pass the Func into the shell VM in DI**
`IslandsShellViewModel` is registered with `sc.AddSingleton<IslandsShellViewModel>();` (Program.cs:123), which resolves constructor params from the container. Since Task 5 registered `Func<RepoImportModalViewModel>`, no change to the registration call is required — the new constructor parameter resolves automatically. Verify by building in Step 5.
- [ ] **Step 3: Add the Help-menu item**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, inside the Help `MenuItem` (after the `About…` item at line 74), add:
```xml
<MenuItem Header="Add repos as lists…" Command="{Binding OpenRepoImportCommand}"/>
```
- [ ] **Step 4: Wire the Func in MainWindow code-behind**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged` (after the `vm.ShowWorktreesOverviewModal = ...` block, before the closing brace of the `if` at line 65), add:
```csharp
vm.ShowRepoImportModal = async (modal) =>
{
var dlg = new RepoImportModalView { DataContext = modal };
modal.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
```
(`RepoImportModalView` is in `ClaudeDo.Ui.Views.Modals`, already imported in this file.)
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded (this also builds the Ui project).
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): add 'Add repos as lists' Help-menu entry point"
```
---
## Task 8: Manual verification + docs
**Files:**
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- [ ] **Step 1: Run the full Ui test suite**
Run: `dotnet test tests/ClaudeDo.Ui.Tests`
Expected: PASS (all tests, including the new `RepoScannerTests` and `RepoImportCandidatesTests`).
- [ ] **Step 2: Manual smoke test**
Launch the app (`dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`). Verify:
- Lists island shows a folder button next to `+ New list`; clicking it opens the modal.
- Help menu shows `Add repos as lists…`; clicking it opens the same modal.
- `Add folder…` → pick a parent folder containing git repos → repos appear as ticked rows; non-repo subfolders are absent.
- A repo that already has a list appears ticked, disabled, with `(already added)`.
- The confirm button reads `Create N list(s)` and is disabled when N is 0.
- Confirming creates the lists; they appear in the Lists island immediately after the modal closes.
Note: if you cannot run the GUI in this environment, state that explicitly rather than claiming the UI works.
- [ ] **Step 3: Update CLAUDE.md**
In `src/ClaudeDo.Ui/CLAUDE.md`, under the `## Views` section, add a bullet:
```markdown
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
```
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs(ui): document RepoImportModalView"
```
---
## Self-Review Notes
- **Spec coverage:** Entry points (Help menu — Task 7; Lists island button — Task 6); `RepoScanner` non-recursive `.git` dir/file detection (Task 1); `RepoImportModalViewModel` load existing dirs + merge + create (Task 3); already-added disabled rows + `(already added)` label (Tasks 2/3/4); combined multi-folder checklist with path dedupe (Task 3 `AddFolders`); defaults Name/WorkingDir/DefaultCommitType (Task 3 `CreateAsync`); reload Lists island after close (Tasks 6/7); DI registration (Task 5); tests for scanner + merge logic (Tasks 1/3). All spec sections map to a task.
- **Type consistency:** `RepoCandidate(Name, FullPath)`, `RepoScanner.Scan`, `RepoImportItemViewModel{Name,FullPath,AlreadyAdded,CanToggle,IsChecked}`, `RepoImportModalViewModel{Repos,CreateCount,CanCreate,CreateButtonText,LoadAsync,AddFolders,BuildCandidates,CreateCommand,CancelCommand,ShowRepoImportModal,CloseAction}` used consistently across tasks.
- **YAGNI:** No recursive scan, no inline rename, no per-list model/prompt/agent during import — all explicitly out of scope.
@@ -0,0 +1,655 @@
# Worker Per-User Autostart 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:** Replace the worker's Windows service with a per-user logon Scheduled Task so the worker runs as the logged-in user (Claude auth works), windowless, with file logging and auto-restart.
**Architecture:** Worker becomes a windowless (`WinExe`) process with Serilog file logging and a single-instance mutex. The installer registers a hidden logon Scheduled Task (via `schtasks /Create /XML`), migrates away the old `ClaudeDoWorker` service, and manages the worker as a process. The app launches/restarts the worker as a process and ensures it's running.
**Tech Stack:** .NET 8, ASP.NET Core (worker), WPF (installer), Avalonia (app), Serilog, Windows Task Scheduler (`schtasks`), `sc.exe`.
**Build note:** `.slnx` fails on .NET 8 — always build individual `.csproj` files.
---
## File Structure
**Worker**
- Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — WinExe, Serilog packages, drop Hosting.WindowsServices.
- Modify `src/ClaudeDo.Worker/Program.cs` — mutex, Serilog, remove `UseWindowsService`.
**Installer**
- Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` — pure XML builder.
- Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` — migrate service + register task.
- Rename/rewrite `StopServiceStep.cs``StopWorkerStep.cs`, `StartServiceStep.cs``StartWorkerStep.cs`.
- Delete `src/ClaudeDo.Installer/Steps/RegisterServiceStep.cs`.
- Modify `Pages/ServicePage/ServicePageViewModel.cs` + `ServicePageView.xaml` — drop account radios.
- Modify `Core/InstallContext.cs` — drop `ServiceAccount`.
- Modify `Pages/InstallPage/InstallPageViewModel.cs` — pipeline wiring.
- Modify `App.xaml.cs` — DI registration.
- Modify `Core/UninstallRunner.cs` — task delete + process kill.
- Modify `Views/SettingsViewModel.cs` — use renamed steps.
**App**
- Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs` — resolve worker exe path.
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — process restart + ensure-running.
- Modify `src/ClaudeDo.App/Program.cs` — register `WorkerLocator`, pass to shell VM if needed.
**Tests**
- Create `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`.
- Create `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`.
---
## Task 1: Worker → WinExe + Serilog packages
**Files:** Modify `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- [ ] **Step 1:** In the main `<PropertyGroup>` add `<OutputType>WinExe</OutputType>`. Remove the `Microsoft.Extensions.Hosting.WindowsServices` PackageReference. Add:
```xml
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
```
- [ ] **Step 2:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds (packages restore).
---
## Task 2: Worker single-instance mutex + Serilog + drop UseWindowsService
**Files:** Modify `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1:** At the very top of the file (before `var cfg = WorkerConfig.Load();`), add the single-instance guard:
```csharp
using System.Threading;
// Single-instance per user session. Multiple launch paths exist (logon task,
// app ensure-running, Restart button); a second instance exits cleanly instead
// of fighting over the SignalR port.
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew);
if (!createdNew)
return; // another instance already owns the port; exit 0
```
- [ ] **Step 2:** Remove the `builder.Host.UseWindowsService(...)` line (lines ~21-23 incl. the comment).
- [ ] **Step 3:** After `var builder = WebApplication.CreateBuilder(args);`, add Serilog file logging:
```csharp
using Serilog;
var logRoot = ClaudeDo.Data.Paths.Expand(cfg.LogRoot);
Directory.CreateDirectory(logRoot);
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true));
```
(If `cfg.LogRoot` is already absolute/expanded, `Paths.Expand` is a safe no-op. Verify `WorkerConfig` exposes `LogRoot`; if the property differs, use the actual name.)
- [ ] **Step 4:** At the very end of the file, after the run block, add `GC.KeepAlive(mutex);` to ensure the mutex isn't collected.
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` — Expected: succeeds.
- [ ] **Step 6:** Run worker tests: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj` — Expected: all pass (set `CLAUDEDO_SKIP_CLI_PREFLIGHT=1` if needed; existing tests already handle this).
---
## Task 3: Scheduled-task XML builder (pure, TDD)
**Files:** Create `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`, Test `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
- [ ] **Step 1: Write the failing test:**
```csharp
using ClaudeDo.Installer.Core;
using Xunit;
namespace ClaudeDo.Installer.Tests;
public class ScheduledTaskXmlTests
{
[Fact]
public void Build_EmbedsUserExeAndLogonTrigger()
{
var xml = ScheduledTaskXml.Build(
userId: "MACHINE\\mika",
workerExePath: @"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe",
restartIntervalMinutes: 1);
Assert.Contains("<LogonTrigger>", xml);
Assert.Contains("<UserId>MACHINE\\mika</UserId>", xml);
Assert.Contains("<LogonType>InteractiveToken</LogonType>", xml);
Assert.Contains("<Hidden>true</Hidden>", xml);
Assert.Contains("<RunLevel>LeastPrivilege</RunLevel>", xml);
Assert.Contains(@"C:\Program Files\ClaudeDo\worker\ClaudeDo.Worker.exe", xml);
Assert.Contains("<Interval>PT1M</Interval>", xml);
}
[Fact]
public void Build_ClampsRestartIntervalToOneMinuteMinimum()
{
var xml = ScheduledTaskXml.Build("M\\u", @"C:\w.exe", restartIntervalMinutes: 0);
Assert.Contains("<Interval>PT1M</Interval>", xml);
}
}
```
- [ ] **Step 2: Run it, verify fail:** `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj --filter ScheduledTaskXmlTests` — Expected: FAIL (type missing).
- [ ] **Step 3: Implement:**
```csharp
using System.Security;
namespace ClaudeDo.Installer.Core;
/// <summary>Builds a Task Scheduler definition XML for the per-user worker autostart.
/// Pure function so it can be unit-tested without admin rights.</summary>
public static class ScheduledTaskXml
{
public static string Build(string userId, string workerExePath, int restartIntervalMinutes)
{
var minutes = restartIntervalMinutes < 1 ? 1 : restartIntervalMinutes;
var user = SecurityElement.Escape(userId);
var cmd = SecurityElement.Escape(workerExePath);
return $"""
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>ClaudeDo background worker (per-user).</Description>
</RegistrationInfo>
<Triggers>
<LogonTrigger>
<Enabled>true</Enabled>
<UserId>{user}</UserId>
</LogonTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>{user}</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
<Hidden>true</Hidden>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<RestartOnFailure>
<Interval>PT{minutes}M</Interval>
<Count>3</Count>
</RestartOnFailure>
</Settings>
<Actions Context="Author">
<Exec>
<Command>{cmd}</Command>
</Exec>
</Actions>
</Task>
""";
}
}
```
- [ ] **Step 4: Run, verify pass:** same filter — Expected: PASS.
---
## Task 4: RegisterAutostartStep (migrate service + register task)
**Files:** Create `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
- [ ] **Step 1: Implement** (no unit test — shells out to `sc`/`schtasks`; logic kept thin):
```csharp
using System.IO;
using System.Security.Principal;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterAutostartStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
private const string LegacyServiceName = "ClaudeDoWorker";
public string Name => "Register Autostart";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// 1) Migrate away the legacy Windows service if present.
progress.Report("Checking for legacy worker service...");
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (queryExit == 0)
{
progress.Report("Removing legacy worker service...");
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (q != 0) break;
await Task.Delay(1000, ct);
}
}
// 2) Register (or replace) the per-user logon task.
var userId = WindowsIdentity.GetCurrent().Name;
var minutes = Math.Max(1, ctx.RestartDelayMs / 60000);
var xml = ScheduledTaskXml.Build(userId, workerExe, minutes);
var xmlPath = Path.Combine(Path.GetTempPath(), $"ClaudeDoWorker-{Guid.NewGuid():N}.xml");
await File.WriteAllTextAsync(xmlPath, xml, new System.Text.UnicodeEncoding(false, true), ct);
try
{
progress.Report("Registering logon task...");
var (exit, output) = await ProcessRunner.RunAsync(
"schtasks.exe", $"/Create /TN \"{TaskName}\" /XML \"{xmlPath}\" /F", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Create failed (exit {exit}): {output}");
}
finally
{
try { File.Delete(xmlPath); } catch { /* best effort */ }
}
return StepResult.Ok();
}
}
```
- [ ] **Step 2: Build:** `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds (after Task 5/6 it compiles fully; if `RestartDelayMs` exists on `InstallContext` already, this compiles now).
---
## Task 5: StopWorkerStep + StartWorkerStep (replace service steps)
**Files:** Create `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`. Delete `StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
- [ ] **Step 1: Create `StopWorkerStep.cs`:**
```csharp
using System.Diagnostics;
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StopWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public const string ProcessName = "ClaudeDo.Worker";
public string Name => "Stop Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Stopping worker task (if running)...");
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
progress.Report("Stopping worker process (if running)...");
var installDir = ctx.InstallDirectory;
foreach (var p in Process.GetProcessesByName(ProcessName))
{
try
{
var path = p.MainModule?.FileName;
if (path is not null && !IsUnder(path, installDir)) continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* process may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
await Task.CompletedTask;
return StepResult.Ok();
}
private static bool IsUnder(string filePath, string dir)
{
try
{
if (string.IsNullOrWhiteSpace(dir)) return true; // can't scope — be permissive
var full = Path.GetFullPath(filePath);
var root = Path.GetFullPath(dir).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return full.StartsWith(root, StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
}
```
- [ ] **Step 2: Create `StartWorkerStep.cs`:**
```csharp
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartWorkerStep : IInstallStep
{
public const string TaskName = "ClaudeDoWorker";
public string Name => "Start Worker";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
progress.Report("Starting worker...");
var (exit, output) = await ProcessRunner.RunAsync("schtasks.exe", $"/Run /TN \"{TaskName}\"", null, progress, ct);
if (exit != 0)
return StepResult.Fail($"schtasks /Run failed (exit {exit}): {output}");
return StepResult.Ok();
}
}
```
- [ ] **Step 3:** Delete `src/ClaudeDo.Installer/Steps/StopServiceStep.cs`, `StartServiceStep.cs`, `RegisterServiceStep.cs`.
- [ ] **Step 4:** Grep for remaining references: `StopServiceStep`, `StartServiceStep`, `RegisterServiceStep` across `src/` — fix each (Tasks 6-9 cover them).
---
## Task 6: InstallContext + ServicePage cleanup
**Files:** Modify `src/ClaudeDo.Installer/Core/InstallContext.cs`, `Pages/ServicePage/ServicePageViewModel.cs`, `Pages/ServicePage/ServicePageView.xaml`
- [ ] **Step 1:** In `InstallContext.cs` remove the `ServiceAccount` property (keep `AutoStart`, `RestartDelayMs`, `SignalRPort`, `ClaudeBin`, etc.).
- [ ] **Step 2:** In `ServicePageViewModel.cs` remove `IsLocalSystem`/`IsCurrentUser` `[ObservableProperty]` fields and the `_context.ServiceAccount = ...` line in `ApplyAsync`. Keep port/claudeBin/autostart/restartDelay.
- [ ] **Step 3:** In `ServicePageView.xaml` remove the radio buttons / account-selection UI bound to those properties. Leave the rest.
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 7-9.
---
## Task 7: Pipeline wiring + DI
**Files:** Modify `Pages/InstallPage/InstallPageViewModel.cs`, `App.xaml.cs`
- [ ] **Step 1:** In `InstallPageViewModel.LoadAsync`, update the **Update** display steps to:
```csharp
Steps.Add(new StepViewModel("Stop Worker"));
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Start Worker"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
```
And the **Fresh** display steps to:
```csharp
Steps.Add(new StepViewModel("Download and Extract"));
Steps.Add(new StepViewModel("Write Configuration"));
Steps.Add(new StepViewModel("Initialize Database"));
Steps.Add(new StepViewModel("Register Autostart"));
Steps.Add(new StepViewModel("Create Shortcuts"));
Steps.Add(new StepViewModel("Register in Add/Remove Programs"));
Steps.Add(new StepViewModel("Write Install Manifest"));
Steps.Add(new StepViewModel("Start Worker"));
```
- [ ] **Step 2:** In `RunInstallAsync`, set the Update execution list to:
```csharp
steps = new IInstallStep[]
{
_serviceProvider.GetRequiredService<StopWorkerStep>(),
_serviceProvider.GetRequiredService<DownloadAndExtractStep>(),
_serviceProvider.GetRequiredService<RegisterAutostartStep>(),
_serviceProvider.GetRequiredService<StartWorkerStep>(),
_serviceProvider.GetRequiredService<WriteInstallManifestStep>(),
_serviceProvider.GetRequiredService<WriteUninstallRegistryStep>(),
};
```
- [ ] **Step 3:** In `App.xaml.cs` `BuildServices`, replace the service-step registrations. Fresh-install `IInstallStep` order must be: Download, WriteConfig, InitDatabase, **RegisterAutostart**, CreateShortcuts, WriteUninstallRegistry, WriteInstallManifest, **StartWorker**. Register:
```csharp
sc.AddSingleton<DownloadAndExtractStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<DownloadAndExtractStep>());
sc.AddSingleton<IInstallStep, WriteConfigStep>();
sc.AddSingleton<IInstallStep, InitDatabaseStep>();
sc.AddSingleton<RegisterAutostartStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<RegisterAutostartStep>());
sc.AddSingleton<IInstallStep, CreateShortcutsStep>();
sc.AddSingleton<WriteUninstallRegistryStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteUninstallRegistryStep>());
sc.AddSingleton<WriteInstallManifestStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<WriteInstallManifestStep>());
sc.AddSingleton<StartWorkerStep>();
sc.AddSingleton<IInstallStep>(sp => sp.GetRequiredService<StartWorkerStep>());
// Not part of the default fresh IEnumerable<IInstallStep> — pulled individually.
sc.AddSingleton<StopWorkerStep>();
```
Remove old `StopServiceStep`/`StartServiceStep`/`RegisterServiceStep` registrations.
- [ ] **Step 4:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: succeeds after Tasks 8-9.
---
## Task 8: SettingsViewModel + UninstallRunner
**Files:** Modify `Views/SettingsViewModel.cs`, `Core/UninstallRunner.cs`
- [ ] **Step 1:** In `SettingsViewModel.cs`, change ctor params/fields `StopServiceStep`/`StartServiceStep``StopWorkerStep`/`StartWorkerStep` (rename type usages only; the Save/Repair logic stays). Update the `Repair` step array to `{ _stopWorker, _downloadStep, _startWorker }`.
- [ ] **Step 2:** In `UninstallRunner.cs`:
- Constructor param `StopServiceStep``StopWorkerStep` (field too).
- Replace `sc.exe delete ClaudeDoWorker` with task removal + legacy service cleanup:
```csharp
// 3) Unregister autostart task + remove any legacy service.
progress.Report("Removing autostart task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{StopWorkerStep.TaskName}\" /F", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", "delete ClaudeDoWorker", null, progress, ct); // legacy, best-effort
```
- The existing `_stopService.ExecuteAsync` call becomes `_stopWorker.ExecuteAsync` (kills the worker process before deleting files).
- [ ] **Step 3:** Build: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj` — Expected: **succeeds, 0 errors**.
- [ ] **Step 4:** Run installer tests: `dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj` — Expected: all pass (incl. new `ScheduledTaskXmlTests`).
---
## Task 9: App WorkerLocator (TDD)
**Files:** Create `src/ClaudeDo.Ui/Services/WorkerLocator.cs`, Test `tests/ClaudeDo.Ui.Tests/Services/WorkerLocatorTests.cs`
- [ ] **Step 1: Write failing test:**
```csharp
using ClaudeDo.Ui.Services;
using Xunit;
namespace ClaudeDo.Ui.Tests.Services;
public class WorkerLocatorTests
{
[Fact]
public void FindByWalkingUp_FindsWorkerExeBesideInstallJson()
{
var root = Path.Combine(Path.GetTempPath(), "claudedo_wl_" + Guid.NewGuid().ToString("N"));
var appDir = Path.Combine(root, "app");
var workerDir = Path.Combine(root, "worker");
Directory.CreateDirectory(appDir);
Directory.CreateDirectory(workerDir);
File.WriteAllText(Path.Combine(root, "install.json"), "{}");
var exe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(exe, "");
try
{
var found = new WorkerLocator().FindByWalkingUp(appDir);
Assert.Equal(exe, found);
}
finally { Directory.Delete(root, recursive: true); }
}
[Fact]
public void FindByWalkingUp_ReturnsNullWhenNoManifest()
{
var dir = Path.Combine(Path.GetTempPath(), "claudedo_wl_none_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try { Assert.Null(new WorkerLocator().FindByWalkingUp(dir)); }
finally { Directory.Delete(dir, recursive: true); }
}
}
```
- [ ] **Step 2: Run, verify fail:** `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj --filter WorkerLocatorTests` — Expected: FAIL.
- [ ] **Step 3: Implement** (mirror `InstallerLocator`):
```csharp
namespace ClaudeDo.Ui.Services;
public sealed class WorkerLocator
{
private const string InstallJson = "install.json";
private const string WorkerExe = "ClaudeDo.Worker.exe";
private const string WorkerSubdir = "worker";
public string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
public string? FindByWalkingUp(string startDir)
{
var dir = new DirectoryInfo(startDir);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, InstallJson)))
{
var candidate = Path.Combine(dir.FullName, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
dir = dir.Parent;
}
return null;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
public string? FindByRegistry()
{
if (!OperatingSystem.IsWindows()) return null;
try
{
using var key = Microsoft.Win32.Registry.LocalMachine
.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Uninstall\ClaudeDo");
var location = key?.GetValue("InstallLocation") as string;
if (string.IsNullOrEmpty(location)) return null;
var candidate = Path.Combine(location, WorkerSubdir, WorkerExe);
return File.Exists(candidate) ? candidate : null;
}
catch { return null; }
}
}
```
- [ ] **Step 4: Run, verify pass.**
---
## Task 10: App restart-worker + ensure-running
**Files:** Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, `src/ClaudeDo.App/Program.cs`
- [ ] **Step 1:** In `App/Program.cs` register the locator: `sc.AddSingleton<WorkerLocator>();` and ensure `IslandsShellViewModel` receives it (constructor injection; the VM is `AddSingleton<IslandsShellViewModel>()` so DI supplies it).
- [ ] **Step 2:** In `IslandsShellViewModel`, add a `WorkerLocator` constructor dependency and store it. Replace `RestartWorkerService` (the `ServiceController` version) with a process relaunch:
```csharp
private void RestartWorkerService()
{
var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
try { p.Kill(entireProcessTree: true); p.WaitForExit(10000); }
catch { /* may have exited */ }
finally { p.Dispose(); }
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
```
Update `RestartWorkerAsync` messages accordingly (drop the "service not installed" `InvalidOperationException` branch wording → generic failure).
- [ ] **Step 3:** Add ensure-running on startup. After the VM wires up the worker connection, schedule a one-shot check:
```csharp
private bool _ensureRunningAttempted;
private async Task EnsureWorkerRunningAsync()
{
if (_ensureRunningAttempted) return;
_ensureRunningAttempted = true;
await Task.Delay(TimeSpan.FromSeconds(4));
if (_worker.IsConnected) return;
var exe = _workerLocator.Find();
if (exe is null) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* logon task is the primary mechanism; this is a convenience */ }
}
```
Call `_ = EnsureWorkerRunningAsync();` from the VM's existing init path (where the connection is started). Use the actual `WorkerClient` field name and its `IsConnected` member.
- [ ] **Step 4:** Remove `using System.ServiceProcess;` and the `ServiceController` usage. Remove the `System.ServiceProcess.ServiceProcess` package reference from `ClaudeDo.Ui.csproj` if present and now unused.
- [ ] **Step 5:** Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` — Expected: succeeds.
- [ ] **Step 6:** Run UI tests: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj` — Expected: all pass (incl. `WorkerLocatorTests`). If `IslandsShellViewModel` construction is exercised in a test, supply a `WorkerLocator` instance.
---
## Task 11: Full build + test sweep
- [ ] **Step 1:** Build each project:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
```
Expected: all succeed, 0 errors.
- [ ] **Step 2:** Run all test projects:
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj
dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj
dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj
```
Expected: all pass.
- [ ] **Step 3:** Grep for leftovers: `ServiceController`, `UseWindowsService`, `RegisterServiceStep`, `StopServiceStep`, `StartServiceStep`, `ServiceAccount` in `src/` — Expected: no matches (except the legacy `sc delete ClaudeDoWorker` migration/cleanup strings).
---
## Notes for the implementer
- Worker config property for the log directory: confirm the exact name on `WorkerConfig` (spec assumes `LogRoot`). Use the real one.
- `ProcessRunner.RunAsync` signature is `(string file, string args, string? workingDir, IProgress<string> progress, CancellationToken ct)` returning `(int ExitCode, string Output)` — match existing call sites.
- Keep the legacy `sc delete ClaudeDoWorker` calls (migration + uninstall) so existing service installs are cleaned up.
@@ -0,0 +1,970 @@
# External MCP — UI Parity (Start & Observe) 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:** Add MCP tools so an external Claude session can fully *start* and *observe* ClaudeDo sessions (list/config management, run history, logs, agent listing, reset-failed, app-settings read), reaching UI parity for those concerns.
**Architecture:** New focused `[McpServerToolType]` classes in `src/ClaudeDo.Worker/External/`, each injecting an existing worker service (no logic duplication). All registered in the *external* `WebApplication` DI container in `Program.cs`. Mutations broadcast the same SignalR events the hub raises, keeping the UI in sync.
**Tech Stack:** .NET 8, `ModelContextProtocol.Server`, EF Core (SQLite), xUnit integration tests (real SQLite via `DbFixture`).
> **Build/test note (from project memory):** `dotnet build ClaudeDo.slnx` fails on .NET 8. Build the csproj directly:
> `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
> Test: `dotnet test tests/ClaudeDo.Worker.Tests`
---
## File Structure
**Create:**
- `src/ClaudeDo.Worker/External/ListMcpTools.cs` — list create/update/delete tools
- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` — list-config + task-config tools + DTO
- `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs` — run history + log read tools + DTO
- `src/ClaudeDo.Worker/External/AgentMcpTools.cs` — agent listing tool
- `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs` — reset-failed-task tool
- `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs` — app-settings read tool
- `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
- `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
**Modify:**
- `src/ClaudeDo.Worker/Program.cs:188-217` — register new tool classes + services in the external builder
- `src/ClaudeDo.Worker/CLAUDE.md:27` — remove stale tag tools, refresh the External MCP tool inventory
**Reference (existing, do not change):**
- `ListRepository``AddAsync`, `UpdateAsync`, `DeleteAsync`, `GetByIdAsync`, `GetAllAsync`, `GetConfigAsync`, `SetConfigAsync`, `DeleteConfigAsync`
- `TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)`
- `TaskRunRepository``GetByTaskIdAsync`, `GetByIdAsync`, `GetLatestByTaskIdAsync`
- `TaskResetService.ResetAsync(taskId, ct)` — refuses Running, discards worktree, resets to Idle
- `AgentFileService.ScanAsync(ct)``List<AgentInfo>`; `AgentInfo(string Name, string Description, string Path)`
- `AppSettingsRepository.GetAsync()``AppSettingsEntity`
- `TaskRunEntity` fields: `Id, TaskId, RunNumber, SessionId, IsRetry, ResultMarkdown, StructuredOutputJson, ErrorMarkdown, ExitCode, TurnCount, TokensIn, TokensOut, LogPath, StartedAt, FinishedAt`
- `CommitTypeRegistry.DefaultType`
- `HubBroadcaster.ListUpdated(id)`, `.TaskUpdated(id)`
> **Spec refinement (YAGNI):** the spec listed an agent "refresh" tool. `AgentFileService.ScanAsync` reads disk fresh on every call, so a separate refresh is redundant for an MCP client. We implement `ListAgents` only.
---
## Task 1: List management tools (`ListMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/ListMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
namespace ClaudeDo.Worker.Tests.External;
internal sealed class ListToolsHubClients : IHubClients
{
public ListToolsClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> e) => Proxy;
public IClientProxy Client(string c) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
public IClientProxy Group(string g) => Proxy;
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
public IClientProxy User(string u) => Proxy;
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
}
internal sealed class ListToolsClientProxy : IClientProxy
{
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
}
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
{
public ListToolsHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class ListMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly ListMcpTools _sut;
public ListMcpToolsTests()
{
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext()));
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
[Fact]
public async Task CreateList_PersistsWithDefaults()
{
var dto = await _sut.CreateList("My List", null, null, CancellationToken.None);
Assert.Equal("My List", dto.Name);
var loaded = await _lists.GetByIdAsync(dto.Id);
Assert.NotNull(loaded);
Assert.Equal("chore", loaded!.DefaultCommitType);
}
[Fact]
public async Task UpdateList_PatchesNameWorkingDirAndCommitType()
{
var created = await _sut.CreateList("orig", null, null, CancellationToken.None);
var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None);
Assert.Equal("renamed", dto.Name);
Assert.Equal("C:/work", dto.WorkingDir);
var loaded = await _lists.GetByIdAsync(created.Id);
Assert.Equal("feat", loaded!.DefaultCommitType);
}
[Fact]
public async Task UpdateList_NotFound_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.UpdateList("missing", "x", null, null, CancellationToken.None));
}
[Fact]
public async Task DeleteList_RemovesList()
{
var created = await _sut.CreateList("gone", null, null, CancellationToken.None);
await _sut.DeleteList(created.Id, CancellationToken.None);
Assert.Null(await _lists.GetByIdAsync(created.Id));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
Expected: FAIL — `ListMcpTools` does not exist (compile error).
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
[McpServerToolType]
public sealed class ListMcpTools
{
private readonly ListRepository _lists;
private readonly HubBroadcaster _broadcaster;
public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster)
{
_lists = lists;
_broadcaster = broadcaster;
}
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
public async Task<ListSummaryDto> CreateList(
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidOperationException("name is required.");
var entity = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = name,
WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir,
DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType,
CreatedAt = DateTime.UtcNow,
};
await _lists.AddAsync(entity, cancellationToken);
await _broadcaster.ListUpdated(entity.Id);
return ToDto(entity);
}
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
public async Task<ListSummaryDto> UpdateList(
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
{
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
if (name is not null) entity.Name = name;
if (workingDir is not null)
entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir;
if (commitType is not null)
entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType;
await _lists.UpdateAsync(entity, cancellationToken);
await _broadcaster.ListUpdated(listId);
return ToDto(entity);
}
[McpServerTool, Description("Delete a list and its tasks. Irreversible.")]
public async Task DeleteList(string listId, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
await _lists.DeleteAsync(listId, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
private static ListSummaryDto ToDto(ListEntity l) =>
new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType);
}
```
> If `CommitTypeRegistry` is not in scope, add `using ClaudeDo.Data;` (verify its namespace with a quick grep before assuming).
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ListMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list-management tools"
```
---
## Task 2: List & task config tools (`ConfigMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.External;
public sealed class ConfigMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly ConfigMcpTools _sut;
public ConfigMcpToolsTests()
{
_ctx = _db.CreateContext();
_lists = new ListRepository(_ctx);
_tasks = new TaskRepository(_ctx);
_sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new ListToolsHubContext()));
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<string> SeedListAsync()
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
return id;
}
[Fact]
public async Task SetAndGetListConfig_RoundTrips()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None);
var cfg = await _sut.GetListConfig(listId, CancellationToken.None);
Assert.NotNull(cfg);
Assert.Equal("sonnet", cfg!.Model);
Assert.Equal("be terse", cfg.SystemPrompt);
Assert.Null(cfg.AgentPath);
}
[Fact]
public async Task SetListConfig_AllNull_ClearsConfig()
{
var listId = await SeedListAsync();
await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None);
await _sut.SetListConfig(listId, null, null, null, CancellationToken.None);
Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None));
}
[Fact]
public async Task SetTaskConfig_PersistsOverrides()
{
var listId = await SeedListAsync();
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "t",
Status = ClaudeDo.Data.Models.TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(task);
await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal("opus", loaded!.Model);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
Expected: FAIL — `ConfigMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath);
[McpServerToolType]
public sealed class ConfigMcpTools
{
private readonly ListRepository _lists;
private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster;
public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster)
{
_lists = lists;
_tasks = tasks;
_broadcaster = broadcaster;
}
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")]
public async Task<TaskConfigDto?> GetListConfig(string listId, CancellationToken cancellationToken)
{
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath);
}
[McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")]
public async Task SetListConfig(
string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var m = Nullify(model);
var sp = Nullify(systemPrompt);
var ap = Nullify(agentPath);
if (m is null && sp is null && ap is null)
await _lists.DeleteConfigAsync(listId, cancellationToken);
else
await _lists.SetConfigAsync(new ListConfigEntity
{
ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap,
}, cancellationToken);
await _broadcaster.ListUpdated(listId);
}
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")]
public async Task SetTaskConfig(
string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}
```
> Verify `UpdateAgentSettingsAsync` accepts a `CancellationToken` (read `TaskRepository.cs:157`). If it does not, drop the `cancellationToken` argument from that call.
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
git commit -m "feat(worker): add external MCP list/task config tools"
```
---
## Task 3: Run history & log tools (`RunHistoryMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs`
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.External;
public sealed class RunHistoryMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRunRepository _runs;
private readonly RunHistoryMcpTools _sut;
public RunHistoryMcpToolsTests()
{
_ctx = _db.CreateContext();
_runs = new TaskRunRepository(_ctx);
_sut = new RunHistoryMcpTools(_runs);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task SeedTaskAsync(string taskId)
{
var lists = new ListRepository(_ctx);
var tasks = new TaskRepository(_ctx);
var listId = Guid.NewGuid().ToString();
await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
await tasks.AddAsync(new TaskEntity
{
Id = taskId, ListId = listId, Title = "t",
Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore",
});
}
[Fact]
public async Task ListRuns_ReturnsProjectedRuns()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20,
});
var list = await _sut.ListRuns(taskId, CancellationToken.None);
Assert.Single(list);
Assert.Equal("done", list[0].ResultMarkdown);
Assert.Equal(10, list[0].TokensIn);
}
[Fact]
public async Task GetTaskLog_NoLog_Throws()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_sut.GetTaskLog(taskId, CancellationToken.None));
}
[Fact]
public async Task GetTaskLog_ReadsLatestRunLogFile()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
await File.WriteAllTextAsync(logPath, "hello log");
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath,
});
var content = await _sut.GetTaskLog(taskId, CancellationToken.None);
Assert.Equal("hello log", content);
File.Delete(logPath);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
Expected: FAIL — `RunHistoryMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record RunDto(
string Id, int RunNumber, string? SessionId, bool IsRetry,
string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown,
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
DateTime? StartedAt, DateTime? FinishedAt);
[McpServerToolType]
public sealed class RunHistoryMcpTools
{
private readonly TaskRunRepository _runs;
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
[McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
{
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
return runs.Select(ToDto).ToList();
}
[McpServerTool, Description("Get a single execution run by its run id.")]
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
{
var run = await _runs.GetByIdAsync(runId, cancellationToken)
?? throw new InvalidOperationException($"Run {runId} not found.");
return ToDto(run);
}
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")]
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken)
{
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"No runs found for task {taskId}.");
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
throw new InvalidOperationException("No log available for the latest run.");
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
}
private static RunDto ToDto(TaskRunEntity r) => new(
r.Id, r.RunNumber, r.SessionId, r.IsRetry,
r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown,
r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut,
r.StartedAt, r.FinishedAt);
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
git commit -m "feat(worker): add external MCP run-history and log tools"
```
---
## Task 4: Agent listing tool (`AgentMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/AgentMcpTools.cs`
- Test: none new — covered indirectly; `AgentFileService` already has unit coverage. (This tool is a thin pass-through.)
- [ ] **Step 1: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Agents;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class AgentMcpTools
{
private readonly AgentFileService _agents;
public AgentMcpTools(AgentFileService agents) => _agents = agents;
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
=> await _agents.ScanAsync(cancellationToken);
}
```
- [ ] **Step 2: Verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/External/AgentMcpTools.cs
git commit -m "feat(worker): add external MCP agent-listing tool"
```
---
## Task 5: Reset-failed-task tool (`LifecycleMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs`
`TaskResetService.ResetAsync` already refuses Running tasks and discards the worktree. The MCP tool adds a guard that the task must be `Failed` (the only sensible reset target via this surface) and delegates.
- [ ] **Step 1: Write the failing test**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Config;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
public sealed class LifecycleMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public LifecycleMcpToolsTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private LifecycleMcpTools BuildSut()
{
var cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
};
var dbFactory = _db.CreateFactory();
var broadcaster = new HubBroadcaster(new ListToolsHubContext());
var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
return new LifecycleMcpTools(_tasks, reset);
}
private async Task<TaskEntity> SeedTaskAsync(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 ResetFailedTask_OnFailed_ResetsToIdle()
{
var task = await SeedTaskAsync(TaskStatus.Failed);
var sut = BuildSut();
await sut.ResetFailedTask(task.Id, CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Idle, loaded!.Status);
}
[Fact]
public async Task ResetFailedTask_OnNonFailed_Throws()
{
var task = await SeedTaskAsync(TaskStatus.Done);
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ResetFailedTask(task.Id, CancellationToken.None));
}
[Fact]
public async Task ResetFailedTask_NotFound_Throws()
{
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ResetFailedTask("missing", CancellationToken.None));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
Expected: FAIL — `LifecycleMcpTools` does not exist.
- [ ] **Step 3: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Lifecycle;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
[McpServerToolType]
public sealed class LifecycleMcpTools
{
private readonly TaskRepository _tasks;
private readonly TaskResetService _reset;
public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset)
{
_tasks = tasks;
_reset = reset;
}
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")]
public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Failed)
throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool.");
await _reset.ResetAsync(taskId, cancellationToken);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests`
Expected: PASS (3 tests). (Git-dependent worktree discard is skipped when no worktree row exists — these tasks have none.)
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/LifecycleMcpTools.cs tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
git commit -m "feat(worker): add external MCP reset-failed-task tool"
```
---
## Task 6: App-settings read tool (`AppSettingsMcpTools`)
**Files:**
- Create: `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs`
- Test: none new — thin read-only pass-through over `AppSettingsRepository.GetAsync`.
This tool is read-only by design (writing app settings is out of scope). It uses the db factory (registered as a singleton in the external builder) to open a context per call, mirroring the hub's pattern.
- [ ] **Step 1: Write minimal implementation**
```csharp
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record AppSettingsReadDto(
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
string WorktreeStrategy, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
[McpServerToolType]
public sealed class AppSettingsMcpTools
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
{
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var row = await new AppSettingsRepository(ctx).GetAsync();
return new AppSettingsReadDto(
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
row.WorktreeStrategy, row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
}
}
```
> Verify `AppSettingsRepository.GetAsync` signature (it may take a `CancellationToken`). Adjust the call if so. Confirm `AppSettingsEntity` property names match (`DefaultModel`, `DefaultMaxTurns`, `DefaultPermissionMode`, `WorktreeStrategy`, `CentralWorktreeRoot`, `WorktreeAutoCleanupEnabled`, `WorktreeAutoCleanupDays`) — they are used identically in `WorkerHub.GetAppSettings` (lines 206-219).
- [ ] **Step 2: Verify it compiles**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
git commit -m "feat(worker): add external MCP app-settings read tool"
```
---
## Task 7: Register new tools in the external MCP app
**Files:**
- Modify: `src/ClaudeDo.Worker/Program.cs:188-217`
The external `WebApplication` has its own DI container. Each new tool class and every service it needs must be registered there, and each class added via `.WithTools<T>()`.
- [ ] **Step 1: Add service + tool registrations**
In the `if (cfg.ExternalMcpPort > 0)` block, after the existing
`externalBuilder.Services.AddScoped<ExternalMcpService>();` line, add:
```csharp
externalBuilder.Services.AddScoped<TaskRunRepository>();
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
externalBuilder.Services.AddScoped<TaskResetService>();
externalBuilder.Services.AddScoped<ListMcpTools>();
externalBuilder.Services.AddScoped<ConfigMcpTools>();
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
```
And extend the `AddMcpServer()` chain:
```csharp
externalBuilder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<ExternalMcpService>()
.WithTools<ListMcpTools>()
.WithTools<ConfigMcpTools>()
.WithTools<RunHistoryMcpTools>()
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<AppSettingsMcpTools>();
```
> **Verify before editing:** confirm `WorktreeManager` and `AgentFileService` are registered as singletons in the *main* `app` container (grep `Program.cs` for `WorktreeManager` and `AgentFileService`). If `AgentFileService` is constructed with a directory string rather than DI-resolved, register it in the external builder the same way the main app does (e.g. `new AgentFileService(agentsDir)`), not via `GetRequiredService`. `TaskResetService` depends on `WorktreeManager`, `IDbContextFactory`, `HubBroadcaster`, `ITaskStateService`, `ILogger<TaskResetService>` — all already singletons in the external builder except `WorktreeManager` (added above) and the logger (provided by default logging).
- [ ] **Step 2: Build the worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
Expected: Build succeeded, no DI-related compile errors.
- [ ] **Step 3: Run the full worker test suite**
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
Expected: PASS (all existing + new tests).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Program.cs
git commit -m "feat(worker): register new external MCP tool classes"
```
---
## Task 8: Documentation cleanup
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md:27`
- [ ] **Step 1: Replace the stale External MCP inventory line**
Replace the line beginning `- **External/ExternalMcpService** — always-on MCP tools…` with an accurate inventory that drops the (non-existent) tag tools and lists the new surface:
```markdown
- **External/*** — always-on MCP tools for general Claude sessions, organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle`/`Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask`
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog`
- `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only)
- Purpose is scoped to *starting* and *observing* sessions — no worktree/merge, multi-turn, planning, or app-settings writes. Auth via optional `X-ClaudeDo-Key` header.
```
- [ ] **Step 2: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md
git commit -m "docs(worker): correct external MCP tool inventory, drop removed tags"
```
---
## Self-Review
**Spec coverage:**
- List management → Task 1 ✓
- List & task config → Task 2 ✓
- Run history & logs → Task 3 ✓
- Agents (read-only) → Task 4 ✓
- Reset failed task → Task 5 ✓
- App settings (read-only) → Task 6 ✓
- DI wiring (separate external app) → Task 7 ✓
- Tag doc cleanup → Task 8 ✓
- Out-of-scope items (multi-turn, worktree ops, planning, app-settings writes, tags, agent create/edit) → not implemented ✓
**Placeholder scan:** No TBD/TODO. The three "verify before editing" notes point at real signatures the implementer must confirm (cancellation-token overloads, `AgentFileService` construction, registry namespaces) — these are verification steps with concrete fallbacks, not placeholders.
**Type consistency:** `ListSummaryDto`, `TaskConfigDto`, `RunDto`, `AppSettingsReadDto` defined once and used consistently. `AgentInfo` reused directly (no new DTO). Tool method names match between implementation, tests, and the Task-8 doc inventory (`CreateList`/`UpdateList`/`DeleteList`, `GetListConfig`/`SetListConfig`/`SetTaskConfig`, `ListRuns`/`GetRun`/`GetTaskLog`, `ListAgents`, `ResetFailedTask`, `GetAppSettings`).
@@ -0,0 +1,36 @@
# UI Normalization — Visual Check
Run the app and walk each surface. Lane B intentionally shifted some values (12px→13px, 9px→10px, 16px→18px, off-palette colors folded to the palette), so small differences are expected — you're checking nothing looks *broken*.
## Global
- [ ] All text renders in **Inter Tight** (sans), not Segoe UI. Labels that were previously "off" (Settings field labels) now match.
- [ ] Mono text (chips, log lines, file paths, eyebrows, titlebar titles) still renders in JetBrains Mono.
## Main window
- [ ] Status-bar connection dot color: online = moss green, reconnecting = peat/amber, offline = blood red.
- [ ] Islands, task rows, chips, agent strips, terminal all look unchanged.
## Task row
- [ ] Schedule flyout (the date popup) renders with a visible border (was a broken/missing `BorderBrush` key — now `LineBrush`).
## Modals — now wrapped in ModalShell (check titlebar drag, ✕ close, footer buttons)
- [ ] **Settings** — titlebar "SETTINGS", drag works, ✕ closes, Cancel/Save footer. Tabs (General/Worktrees/Files/Prime Claude) intact.
- [ ] **List settings** — Delete (left) + Cancel/Save (right) footer; section panels intact.
- [ ] **Merge** — task summary + action buttons.
- [ ] **About** — version/data/logs/config labels.
- [ ] **Unfinished planning** — body text + primary action.
- [ ] **Repo import** — toolbar at top of body, repo list scrolls, footer.
- [ ] **Worktrees overview** — rows render; force-remove/phantom text is red (StatusError); state badge text legible. NOTE: window decorations changed to borderless (ModalShell draws the border) — confirm it still looks right.
- [ ] **Diff modal** — diff text mono, add/del colors, merge button in footer.
- [ ] **Conflict resolution** — now ModalShell; conflict list mono; error text red.
## Not wrapped in ModalShell (intentional — distinct chrome)
- [ ] **Worktree modal** (the big 1100×720 acrylic-blur diff window) — unchanged look, fonts slightly normalized.
- [ ] **Planning diff view** (embedded) — diff renders, mono font, warning text red.
## Date picker
- [ ] Selected day: accent background with light text (was hardcoded white → TextBrush).
## If something looks wrong
- Font/size off → check the snap mapping in `2026-05-30-ui-normalization.md` (11→Mono=11, 12→Body=13).
- A modal's layout broke → that modal's body may have coupled to the old Grid rows; revert just that file's ModalShell wrap and keep only the token changes (the fallback noted in the plan).
@@ -0,0 +1,473 @@
# UI Normalization 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 the design tokens the single source of truth for every visual value in the Avalonia UI, remove duplicated styles, and add a reusable `ModalShell` control for the copy-pasted modal chrome.
**Architecture:** Establish global control defaults in `App.axaml`, expand/repoint brushes in `Tokens.axaml`, promote shared styles into `IslandStyles.axaml`, then mechanically migrate every view to reference tokens (snapping stray values to the nearest token per "lane B"). Off-palette colors fold into the existing palette. A new `ModalShell` templated control replaces the per-modal titlebar/border/footer markup.
**Tech Stack:** .NET 8, Avalonia 12 (Fluent theme, dark variant), compiled XAML (`x:DataType`), CommunityToolkit.Mvvm.
**Verification model:** There are no unit tests for XAML. The "test" for every task is a clean build:
- `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` (compiles Ui + Data; validates all StaticResource keys and compiled bindings)
Build with the `.csproj` directly — `.slnx` requires .NET 9 and will fail on this machine (.NET 8).
**Normalization rules (apply everywhere unless a task says otherwise):**
Font sizes — replace every `FontSize="N"` literal with the token whose value it snaps to:
| literal | token |
|---|---|
| 9 | `{StaticResource FontSizeEyebrow}` (10) |
| 10 | `{StaticResource FontSizeEyebrow}` (10) |
| 11 | `{StaticResource FontSizeMono}` (11) |
| 12 | `{StaticResource FontSizeBody}` (13) |
| 13 | `{StaticResource FontSizeBody}` (13) |
| 14 | `{StaticResource FontSizeTaskTitle}` (14) |
| 16 | `{StaticResource FontSizeH3}` (18) |
| 18 | `{StaticResource FontSizeH3}` (18) |
| 24 | `{StaticResource FontSizeH2}` (24) |
| 32 | `{StaticResource FontSizeH1}` (32) |
Spacing — modal body padding literals `16` and `20` snap to `18`; keep other axis values mapped to the nearest of SpaceXs=4/SpaceSm=8/SpaceMd=12/SpaceLg=14/SpaceXl=18/Space2Xl=24. Leave values that already equal a token as plain numbers (do **not** churn every margin into a resource ref — only modal body padding is standardized).
Corner radius — `4``6`; TextBox inputs use `8`.
Colors — fold off-palette to palette:
| literal / named | replacement |
|---|---|
| `#4CAF50` (online dot) | `{DynamicResource StatusRunningBrush}` |
| `#FFA726` (reconnecting dot) | `{DynamicResource StatusReviewBrush}` |
| `#EF5350` (offline / phantom) | `{DynamicResource StatusErrorBrush}` |
| `OrangeRed`, `Orange` | `{DynamicResource BloodBrush}` |
| `White` (badge / danger text) | `{DynamicResource TextBrush}` |
| `White` (on accent primary button) | `{DynamicResource DeepBrush}` |
| `#FF080C0B` (terminal bg) | `{DynamicResource VoidBrush}` |
| `#0DFFFFFF` (island hairline) | `{DynamicResource HairlineOverlayBrush}` |
---
## Phase 1 — Foundation
### Task 1: Add new brushes & repoint badges in Tokens.axaml
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/Tokens.axaml`
- [ ] **Step 1: Add named tint, hairline brushes**
In the BRUSHES section (after the Status*Brush block ending ~line 85), add:
```xml
<!-- Subtle white overlay (island hairline border) -->
<SolidColorBrush x:Key="HairlineOverlayBrush" Color="#0DFFFFFF" />
<!-- Status tints (12% fill / 30% border of the status hue) — reused by chips & agent strips -->
<SolidColorBrush x:Key="RunningTintBrush" Color="#1F7C9166" />
<SolidColorBrush x:Key="RunningTintBorderBrush" Color="#4C7C9166" />
<SolidColorBrush x:Key="ReviewTintBrush" Color="#1FD4A574" />
<SolidColorBrush x:Key="ReviewTintBorderBrush" Color="#4CD4A574" />
<SolidColorBrush x:Key="ErrorTintBrush" Color="#1FC87060" />
<SolidColorBrush x:Key="ErrorTintBorderBrush" Color="#4CC87060" />
<SolidColorBrush x:Key="QueuedTintBrush" Color="#1F8B9D7A" />
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
```
- [ ] **Step 2: Build to verify tokens parse**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS (no errors).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Design/Tokens.axaml
git commit -m "feat(ui): add named tint and hairline overlay brush tokens"
```
---
### Task 2: Global control defaults in App.axaml
**Files:**
- Modify: `src/ClaudeDo.App/App.axaml`
- [ ] **Step 1: Add Window default style**
Inside `<Application.Styles>`, after `<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />` and before the ListBoxItem styles, add:
```xml
<!-- Global defaults: every Window inherits Inter Tight + body size.
Controls that need mono opt in via their own class/style. -->
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource SansFont}" />
<Setter Property="FontSize" Value="{DynamicResource FontSizeBody}" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
```
(FontFamily/FontSize/Foreground are inherited properties in Avalonia, so setting them on the Window root propagates to all descendant text controls.)
- [ ] **Step 2: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.App/App.axaml
git commit -m "feat(ui): set global Inter Tight font default on all windows"
```
---
### Task 3: Promote shared styles into IslandStyles.axaml
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- [ ] **Step 1: Add shared modal styles**
At the end of the `<Styles>` element (before the closing `</Styles>` at line ~901), add:
```xml
<!-- ============================================================ -->
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
<!-- ============================================================ -->
<Style Selector="TextBlock.field-label">
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="Margin" Value="0,0,0,4" />
</Style>
<Style Selector="TextBlock.path-mono">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<!-- Standalone modal action buttons (not the .btn family) -->
<Style Selector="Button.primary">
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.danger">
<Setter Property="Background" Value="{StaticResource BloodBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
```
Note: `TextBlock.section-label` already exists at line ~864 — do NOT re-add it.
- [ ] **Step 2: Replace hardcoded values inside existing IslandStyles rules**
Apply the normalization rules to the existing style setters in this file:
- Every `FontSize="N"` setter → the snapped token ref (table above). Specific lines: 149 (10→FontSizeEyebrow), 206 (11→FontSizeMono), 252 (13→FontSizeBody), 397 (11→FontSizeMono), 453 (9→FontSizeEyebrow), 475 (10→FontSizeEyebrow), 483 (10→FontSizeEyebrow), 556 (12→FontSizeBody), 573 (9→FontSizeEyebrow), 597 (12→FontSizeBody), 622 (10→FontSizeEyebrow), 638 (12→FontSizeBody), 697 (14→FontSizeTaskTitle), 771 (10→FontSizeEyebrow), 783 (10→FontSizeEyebrow), 788 (10→FontSizeEyebrow), 819 (11→FontSizeMono), 867 (10→FontSizeEyebrow), 884 (9→FontSizeEyebrow).
- Chip tint backgrounds/borders → named brushes:
- line 155/156 `#1F7C9166`/`#4C7C9166``{StaticResource RunningTintBrush}`/`{StaticResource RunningTintBorderBrush}`
- 163/164 review tints → `ReviewTintBrush`/`ReviewTintBorderBrush`
- 171/172 error tints → `ErrorTintBrush`/`ErrorTintBorderBrush`
- 179/180 queued tints → `QueuedTintBrush`/`QueuedTintBorderBrush`
- agent-strip tints at 361/362 (`#147C9166`/`#4C7C9166`), 365/366, 368/369, 374/375 → the matching `*TintBrush`/`*TintBorderBrush` (snap the `#14` alpha to the shared `#1F` tint).
- line 123 `#0DFFFFFF``{StaticResource HairlineOverlayBrush}`.
- line 389 & 810 `#FF080C0B``{StaticResource VoidBrush}`.
- line 887 badge `White``{StaticResource TextBrush}`.
- Badge brushes at lines 88-90: replace the three `<SolidColorBrush>` definitions with palette refs:
```xml
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
```
- Corner radius `4` setters (447 live-chip, 813 task-live-tail `5`→leave, badges 878 `3`→leave) → only snap `4``6` where it appears as `CornerRadius="4"` on live-chip (447) and kbd (614) and badge tints. Leave `3` and `5` as-is (no nearby token; they're intentional micro-radii). NOTE: if unsure, leave radius alone — radius churn is lowest priority.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml
git commit -m "refactor(ui): tokenize IslandStyles values and add shared modal styles"
```
---
## Phase 2 — Per-view token migration (independent; parallelizable)
For each task: open the file, apply the **normalization rules** (font/color/spacing/radius tables at top). Remove any local `Window.Styles` block that only redefines `section-label`, `field-label`, `path-mono`, `Button.primary`, or `Button.danger` (now shared from IslandStyles). Keep local styles that are genuinely unique to that view. After each file, build and commit.
Each task ends with:
- Build: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` → PASS
- Commit: `git add <file> && git commit -m "refactor(ui): tokenize <view>"`
### Task 4: MainWindow.axaml
- Snap all `FontSize` literals (lines ~46,52,59,67,112,136,209,222,231).
- Status dots: `#4CAF50``StatusRunningBrush`, `#FFA726``StatusReviewBrush`, `#EF5350``StatusErrorBrush` (lines ~200,203,205).
### Task 5: Islands — ListsIslandView.axaml, TasksIslandView.axaml
- ListsIslandView: snap FontSize (18,10,12 at lines ~18,49,57,58,59); username TextBlock (~57) gets no explicit FontFamily (inherits SansFont now — correct, leave it).
- TasksIslandView: snap FontSize (24,11 at ~15,19).
### Task 6: DetailsIslandView.axaml
- Snap all FontSize (10,14,11,10,13,12 at lines ~54,57,92,114,138,142,199,269).
- `OrangeRed``BloodBrush` (~154).
- TextBox `CornerRadius="6"``8` (~172,274). TextBox `Padding="8"` leave.
- Remove any redundant inline label styles superseded by shared `field-label`.
### Task 7: TaskRowView.axaml (includes the BorderBrush bug fix)
- Snap FontSize (10,14 at ~85,103).
- **Bug fix:** `BorderBrush="{DynamicResource BorderBrush}"``{DynamicResource LineBrush}` (the schedule-flyout border, ~line 188/222). `BorderBrush` is not a defined key.
- Schedule flyout: title/labels inherit SansFont now (leave unset).
### Task 8: AgentStripView.axaml, SessionTerminalView.axaml
- AgentStrip: snap FontSize (10,9 at ~22,29,73,78); commit chip radius `4``6` (~102).
- SessionTerminal: snap FontSize (10,11 at ~17,69).
### Task 9: ThemedDatePicker.axaml
- Snap any FontSize literals; popup border `CornerRadius="10"` → leave (10 = ChipCornerRadius value, acceptable) OR `{StaticResource ChipCornerRadius}`. Tokenize colors if any literals present.
---
## Phase 3 — ModalShell control
### Task 10: Create ModalShell control
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs`
- Create: `src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml`
- [ ] **Step 1: Write the code-behind (templated control)**
`ModalShell.axaml.cs`:
```csharp
using System;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>Reusable modal chrome: titlebar (drag + close) wrapping a body and optional footer.</summary>
public class ModalShell : ContentControl
{
public static readonly StyledProperty<string?> TitleProperty =
AvaloniaProperty.Register<ModalShell, string?>(nameof(Title));
public static readonly StyledProperty<object?> FooterProperty =
AvaloniaProperty.Register<ModalShell, object?>(nameof(Footer));
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
bar.PointerPressed += OnTitleBarPressed;
}
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed
&& VisualRoot is Window w)
w.BeginMoveDrag(e);
}
}
```
- [ ] **Step 2: Write the ControlTheme**
`ModalShell.axaml`:
```xml
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls">
<ControlTheme x:Key="{x:Type ctl:ModalShell}" TargetType="ctl:ModalShell">
<Setter Property="Template">
<ControlTemplate>
<Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource ModalCornerRadius}"
ClipToBounds="True">
<DockPanel>
<!-- Title bar -->
<Border Name="PART_TitleBar" DockPanel.Dock="Top" Height="36"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<TextBlock Text="{TemplateBinding Title}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{DynamicResource FontSizeMono}"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕"
FontSize="{DynamicResource FontSizeBody}"
Command="{TemplateBinding CloseCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- Footer (optional) -->
<Border Name="PART_Footer" DockPanel.Dock="Bottom"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
IsVisible="{TemplateBinding Footer, Converter={x:Static ObjectConverters.IsNotNull}}">
<ContentPresenter Content="{TemplateBinding Footer}" Margin="16,8"/>
</Border>
<!-- Body -->
<ContentPresenter Content="{TemplateBinding Content}"/>
</DockPanel>
</Border>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
```
- [ ] **Step 3: Register the ControlTheme**
In `src/ClaudeDo.App/App.axaml`, inside `<ResourceDictionary.MergedDictionaries>` (after the Tokens include), add:
```xml
<ResourceInclude Source="avares://ClaudeDo.Ui/Views/Controls/ModalShell.axaml" />
```
- [ ] **Step 4: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml.cs src/ClaudeDo.App/App.axaml
git commit -m "feat(ui): add reusable ModalShell control"
```
---
### Task 11: Migrate SettingsModalView to ModalShell (reference migration)
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml`
- Modify (if needed): `src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs`
- [ ] **Step 1: Replace chrome with ModalShell**
- Add namespace if missing: `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` (already present).
- Remove the local `Window.Styles` entries for `section-label`, `field-label`, `path-mono`, `Button.danger`, `Button.primary` (now shared). Keep any genuinely unique styles.
- Replace the outer `<Border>...<Grid RowDefinitions="36,*,52">` structure with:
```xml
<ctl:ModalShell Title="SETTINGS" CloseCommand="{Binding CancelCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Content="Cancel" Command="{Binding CancelCommand}" MinWidth="90"/>
<Button Content="Save" Classes="primary" Command="{Binding SaveCommand}" IsEnabled="{Binding !IsBusy}" MinWidth="90"/>
</StackPanel>
</ctl:ModalShell.Footer>
<!-- existing DockPanel body (tabs + validation strip) goes here unchanged -->
</ctl:ModalShell>
```
- The body is the existing `<DockPanel Grid.Row="1">` content minus `Grid.Row`.
- Snap remaining FontSize literals in the body per the rules.
- [ ] **Step 2: Remove obsolete drag handler if now unused**
If `TitleBar_PointerPressed` in `SettingsModalView.axaml.cs` is no longer referenced (ModalShell handles dragging), delete the method and the `x:Name="TitleBar"`/`PointerPressed` wiring. If the build complains about an unused handler, that's the signal.
- [ ] **Step 3: Build**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml src/ClaudeDo.Ui/Views/Modals/SettingsModalView.axaml.cs
git commit -m "refactor(ui): migrate SettingsModal to ModalShell"
```
---
### Task 12: Migrate remaining modals to ModalShell
Repeat the Task 11 pattern for each modal below. One commit per file. Each: swap chrome → `ModalShell`, lift action buttons into `ModalShell.Footer`, drop local duplicate styles, delete now-unused `*_PointerPressed` drag handlers, snap FontSize/colors per rules, build, commit.
- [ ] **12a:** `ListSettingsModalView.axaml` (+ `.axaml.cs`)
- [ ] **12b:** `MergeModalView.axaml` (+ `.axaml.cs`)
- [ ] **12c:** `AboutModalView.axaml` (+ `.axaml.cs`) — labels inherit SansFont now.
- [ ] **12d:** `UnfinishedPlanningModalView.axaml` (+ `.axaml.cs`)
- [ ] **12e:** `RepoImportModalView.axaml` (+ `.axaml.cs`)
- [ ] **12f:** `WorktreesOverviewModalView.axaml` (+ `.axaml.cs`) — also fold `Border.wt-row` to reuse `task-row` if trivial; snap FontSize; `#EF5350``StatusErrorBrush`; `White` badge text→`TextBrush`.
Each ends with build PASS + `git commit -m "refactor(ui): migrate <Modal> to ModalShell"`.
---
### Task 13: DiffModalView, PlanningDiffView, ConflictResolutionView (Static→Dynamic + chrome)
These three currently use `StaticResource` for token lookups. Migrate chrome to `ModalShell` where they are full windows, and convert token references.
- [ ] **Step 1: Convert resource references**
In each of `DiffModalView.axaml`, `PlanningDiffView.axaml`, `ConflictResolutionView.axaml`: change every `{StaticResource <Brush/Token>}` used in an **element attribute** to `{DynamicResource ...}`. Leave `{StaticResource ...}` inside `<Style>`/`Setter` blocks (Avalonia styles resolve StaticResource fine and DynamicResource in setters is discouraged).
- [ ] **Step 2: Apply normalization rules**
- Snap FontSize literals.
- `Consolas,Menlo,monospace` raw font (PlanningDiffView ~98, ConflictResolution ~47) → `{DynamicResource MonoFont}`.
- `Orange`/`OrangeRed``{DynamicResource BloodBrush}`.
- DiffModal tints `#1A4A6B4A`/`#1AC87060``{DynamicResource RunningTintBrush}`/`{DynamicResource ErrorTintBrush}`.
- Migrate window chrome to `ModalShell` if the file is a Window with the titlebar/footer pattern (DiffModalView, ConflictResolutionView). PlanningDiffView is an embedded view — only convert resources + fonts, no ModalShell.
- [ ] **Step 3: Build + commit (one per file)**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj` → PASS
Commit: `git commit -m "refactor(ui): tokenize and dynamic-ize <view>"`
---
## Phase 4 — Final verification
### Task 14: Full build + visual checklist
- [ ] **Step 1: Build both projects**
Run:
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: both PASS.
- [ ] **Step 2: Grep for stragglers**
Confirm no remaining hardcoded values slipped through:
- `FontSize="` with a numeric literal in any `Views/**/*.axaml` (should be near-zero; only token refs remain).
- Off-palette hex (`#4CAF50`, `#FFA726`, `#EF5350`, `#FF080C0B`, `OrangeRed`, `Orange`) — should be zero.
- [ ] **Step 3: Produce the human visual-check checklist**
Write a short checklist (`docs/superpowers/plans/2026-05-30-ui-normalization-visualcheck.md`) listing each view/modal and what to eyeball (font looks like Inter Tight, status dots correct color, modal titlebars/footers intact, badges distinguishable, diff/planning views render). This is the regression gate the user runs by launching the app.
---
## Self-Review notes
- **Spec coverage:** global defaults (T2), token source-of-truth fonts/spacing/radius (rules + T3T13), color fold (T1,T3,T4,T6,T12,T13), shared styles (T3), ModalShell (T10T13), bug fixes — BorderBrush (T7), Static→Dynamic (T13). All spec sections mapped.
- **Risk note:** ModalShell migration (T11T13) is the highest-risk part because each modal's body layout differs. Tasks are per-file so a failure is isolated. If a modal's body has tight coupling to the old Grid rows, keeping that modal's hand-rolled chrome (and only tokenizing it) is an acceptable fallback — note it in the commit.
- **Line numbers** are from the pre-change audit and may drift as edits land; treat them as guides, locate by content.
@@ -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,829 @@
# Worker Lifecycle Redesign 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 the worker owned by a single external mechanism (a per-user Startup-folder shortcut in production), stop the App from auto-spawning its own worker, and show an actionable prompt when the App can't connect.
**Architecture:** Installer creates a `.lnk` in the Windows Startup folder instead of a Scheduled Task (migrating existing installs by deleting the old task). The App's `IslandsShellViewModel` drops `EnsureWorkerRunningAsync` and instead runs a one-shot grace timer that opens a `WorkerConnectionModal` (Start Worker / Rerun Installer / Dismiss) if still offline; the footer status pill becomes a button that reopens it.
**Tech Stack:** .NET 8, WPF installer (COM `IShellLink` for shortcuts), Avalonia + CommunityToolkit.Mvvm UI, xUnit.
---
## File Structure
**Installer (`src/ClaudeDo.Installer`)**
- Create: `Core/ShortcutFactory.cs` — shared `IShellLink` COM helper (`CreateShortcut`).
- Create: `Core/AutostartShortcut.cs` — install/remove the worker Startup-folder `.lnk`.
- Modify: `Steps/CreateShortcutsStep.cs` — use `ShortcutFactory`, drop embedded COM.
- Modify: `Steps/RegisterAutostartStep.cs` — Startup shortcut + legacy-task delete (no more task XML).
- Modify: `Steps/StartWorkerStep.cs``Process.Start` instead of `schtasks /Run`.
- Modify: `Steps/StopWorkerStep.cs` — drop `schtasks /End`.
- Modify: `Core/UninstallRunner.cs` — remove the Startup `.lnk`.
- Delete: `Core/ScheduledTaskXml.cs` (and its test).
**App (`src/ClaudeDo.Ui`)**
- Create: `ViewModels/Modals/WorkerConnectionModalViewModel.cs`.
- Create: `Views/Modals/WorkerConnectionModalView.axaml` (+ `.axaml.cs`).
- Modify: `ViewModels/IslandsShellViewModel.cs` — remove auto-spawn; add hook, command, grace timer, decision gate.
- Modify: `Views/MainWindow.axaml.cs` — wire the new modal.
- Modify: `Views/MainWindow.axaml` — clickable status pill.
**Tests**
- Modify: `tests/ClaudeDo.Installer.Tests/` — delete `ScheduledTaskXmlTests.cs`; add `ShortcutFactoryTests.cs`, `AutostartShortcutTests.cs`.
- Add: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`.
---
## Task 1: ShortcutFactory (shared COM helper)
**Files:**
- Create: `src/ClaudeDo.Installer/Core/ShortcutFactory.cs`
- Modify: `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs`
- Test: `tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs`
- [ ] **Step 1: Write the failing test**
`tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs`:
```csharp
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public class ShortcutFactoryTests
{
[Fact]
public void CreateShortcut_writes_lnk_file()
{
var dir = Path.Combine(Path.GetTempPath(), "cdshortcut-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var target = Path.Combine(dir, "fake.exe");
File.WriteAllText(target, "");
var lnk = Path.Combine(dir, "x.lnk");
ShortcutFactory.CreateShortcut(lnk, target, dir, "desc");
Assert.True(File.Exists(lnk));
}
finally { Directory.Delete(dir, recursive: true); }
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests`
Expected: FAIL — `ShortcutFactory` does not exist (compile error).
- [ ] **Step 3: Create `ShortcutFactory` (move COM interop out of `CreateShortcutsStep`)**
`src/ClaudeDo.Installer/Core/ShortcutFactory.cs`:
```csharp
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using System.Text;
namespace ClaudeDo.Installer.Core;
public static class ShortcutFactory
{
public static void CreateShortcut(string shortcutPath, string targetPath, string workingDir, string description)
{
var link = (IShellLink)new ShellLink();
link.SetPath(targetPath);
link.SetWorkingDirectory(workingDir);
link.SetDescription(description);
link.SetIconLocation(targetPath, 0);
var file = (IPersistFile)link;
file.Save(shortcutPath, false);
}
[ComImport]
[Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLink { }
[ComImport]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[Guid("000214F9-0000-0000-C000-000000000046")]
private interface IShellLink
{
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, IntPtr pfd, int fFlags);
void GetIDList(out IntPtr ppidl);
void SetIDList(IntPtr pidl);
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName);
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath);
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath);
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
void GetHotkey(out short pwHotkey);
void SetHotkey(short wHotkey);
void GetShowCmd(out int piShowCmd);
void SetShowCmd(int iShowCmd);
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon);
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved);
void Resolve(IntPtr hwnd, int fFlags);
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
}
}
```
- [ ] **Step 4: Replace the embedded COM in `CreateShortcutsStep` with the helper**
In `src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs`: delete the private `CreateShortcut` method and the entire `#region COM Interop for IShellLink` block (lines 47-90), remove the now-unused `using System.Runtime.InteropServices;`, `using System.Runtime.InteropServices.ComTypes;`, and `using System.Text;`. Replace the two `CreateShortcut(...)` call sites with `ShortcutFactory.CreateShortcut(...)`:
```csharp
ShortcutFactory.CreateShortcut(startMenuPath, appExe, workingDir, "ClaudeDo Task Manager");
```
```csharp
ShortcutFactory.CreateShortcut(desktopPath, appExe, workingDir, "ClaudeDo Task Manager");
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter ShortcutFactoryTests`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Installer/Core/ShortcutFactory.cs src/ClaudeDo.Installer/Steps/CreateShortcutsStep.cs tests/ClaudeDo.Installer.Tests/ShortcutFactoryTests.cs
git commit -m "refactor(installer): extract ShortcutFactory COM helper"
```
---
## Task 2: AutostartShortcut helper
**Files:**
- Create: `src/ClaudeDo.Installer/Core/AutostartShortcut.cs`
- Test: `tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs`
- [ ] **Step 1: Write the failing tests**
`tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs`:
```csharp
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Tests;
public class AutostartShortcutTests
{
private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "cdautostart-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
[Fact]
public void Install_creates_lnk_with_expected_name()
{
var startup = TempDir();
var workerDir = TempDir();
try
{
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(workerExe, "");
AutostartShortcut.Install(startup, workerExe);
Assert.True(File.Exists(Path.Combine(startup, AutostartShortcut.FileName)));
}
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
}
[Fact]
public void Remove_deletes_existing_lnk()
{
var startup = TempDir();
var workerDir = TempDir();
try
{
var workerExe = Path.Combine(workerDir, "ClaudeDo.Worker.exe");
File.WriteAllText(workerExe, "");
AutostartShortcut.Install(startup, workerExe);
AutostartShortcut.Remove(startup);
Assert.False(File.Exists(Path.Combine(startup, AutostartShortcut.FileName)));
}
finally { Directory.Delete(startup, true); Directory.Delete(workerDir, true); }
}
[Fact]
public void Remove_is_noop_when_missing()
{
var startup = TempDir();
try { AutostartShortcut.Remove(startup); } // must not throw
finally { Directory.Delete(startup, true); }
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests`
Expected: FAIL — `AutostartShortcut` does not exist.
- [ ] **Step 3: Create `AutostartShortcut`**
`src/ClaudeDo.Installer/Core/AutostartShortcut.cs`:
```csharp
using System.IO;
namespace ClaudeDo.Installer.Core;
public static class AutostartShortcut
{
public const string FileName = "ClaudeDo Worker.lnk";
public static string DefaultStartupDir =>
Environment.GetFolderPath(Environment.SpecialFolder.Startup);
public static string PathIn(string startupDir) => Path.Combine(startupDir, FileName);
public static void Install(string startupDir, string workerExe)
{
Directory.CreateDirectory(startupDir);
var workingDir = Path.GetDirectoryName(workerExe) ?? startupDir;
ShortcutFactory.CreateShortcut(PathIn(startupDir), workerExe, workingDir, "ClaudeDo background worker");
}
public static void Remove(string startupDir)
{
var path = PathIn(startupDir);
if (File.Exists(path)) File.Delete(path);
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Installer.Tests --filter AutostartShortcutTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Installer/Core/AutostartShortcut.cs tests/ClaudeDo.Installer.Tests/AutostartShortcutTests.cs
git commit -m "feat(installer): add AutostartShortcut helper for Startup-folder lnk"
```
---
## Task 3: RegisterAutostartStep → Startup shortcut + task migration
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`
- Delete: `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs`
- Delete: `tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs`
- [ ] **Step 1: Replace the step body**
Rewrite `src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs` to:
```csharp
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class RegisterAutostartStep : IInstallStep
{
public const string LegacyTaskName = "ClaudeDoWorker";
private const string LegacyServiceName = "ClaudeDoWorker";
public string Name => "Register Autostart";
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return StepResult.Fail($"Worker executable not found: {workerExe}");
// 1) Migrate away the legacy Windows service if present.
progress.Report("Checking for legacy worker service...");
var (queryExit, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (queryExit == 0)
{
progress.Report("Removing legacy worker service...");
await ProcessRunner.RunAsync("sc.exe", $"stop {LegacyServiceName}", null, progress, ct);
await ProcessRunner.RunAsync("sc.exe", $"delete {LegacyServiceName}", null, progress, ct);
for (var i = 0; i < 30; i++)
{
ct.ThrowIfCancellationRequested();
var (q, _) = await ProcessRunner.RunAsync("sc.exe", $"query {LegacyServiceName}", null, progress, ct);
if (q != 0) break;
await Task.Delay(1000, ct);
}
}
// 2) Migrate away the legacy logon scheduled task if present (best-effort).
progress.Report("Removing legacy logon task...");
await ProcessRunner.RunAsync("schtasks.exe", $"/Delete /TN \"{LegacyTaskName}\" /F", null, progress, ct);
// 3) Register per-user autostart via a Startup-folder shortcut.
progress.Report("Creating Startup shortcut...");
try
{
AutostartShortcut.Install(AutostartShortcut.DefaultStartupDir, workerExe);
}
catch (Exception ex)
{
return StepResult.Fail($"Failed to create Startup shortcut: {ex.Message}");
}
return StepResult.Ok();
}
}
```
- [ ] **Step 2: Delete the obsolete scheduled-task code and its test**
Run:
```bash
git rm src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs tests/ClaudeDo.Installer.Tests/ScheduledTaskXmlTests.cs
```
- [ ] **Step 3: Build the installer to verify it compiles**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
Expected: Build succeeded. (If `RegisterAutostartStep.TaskName` was referenced elsewhere, the build will flag it — Task 4 and Task 5 update those references; if the build fails only there, proceed to those tasks before re-running.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs
git commit -m "feat(installer): register autostart via Startup shortcut, drop scheduled task"
```
---
## Task 4: StartWorkerStep + StopWorkerStep
**Files:**
- Modify: `src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`
- Modify: `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`
- [ ] **Step 1: Rewrite `StartWorkerStep` to launch the exe directly**
`src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`:
```csharp
using System.Diagnostics;
using System.IO;
using ClaudeDo.Installer.Core;
namespace ClaudeDo.Installer.Steps;
public sealed class StartWorkerStep : IInstallStep
{
public string Name => "Start Worker";
public Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
{
var workerExe = Path.Combine(ctx.InstallDirectory, "worker", "ClaudeDo.Worker.exe");
if (!File.Exists(workerExe))
return Task.FromResult(StepResult.Fail($"Worker executable not found: {workerExe}"));
progress.Report("Starting worker...");
try
{
Process.Start(new ProcessStartInfo(workerExe) { UseShellExecute = true });
return Task.FromResult(StepResult.Ok());
}
catch (Exception ex)
{
return Task.FromResult(StepResult.Fail($"Failed to start worker: {ex.Message}"));
}
}
}
```
- [ ] **Step 2: Drop the `schtasks /End` call in `StopWorkerStep`**
In `src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`, remove these two lines (the task no longer exists; the process kill below is the real stop):
```csharp
progress.Report("Stopping worker task (if running)...");
await ProcessRunner.RunAsync("schtasks.exe", $"/End /TN \"{TaskName}\"", null, progress, ct);
```
Keep the `public const string TaskName = "ClaudeDoWorker";` line — `UninstallRunner` still references it for legacy-task cleanup (Task 5). The method keeps its `async` modifier (it still has `await Task.CompletedTask;`).
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Installer/Steps/StartWorkerStep.cs src/ClaudeDo.Installer/Steps/StopWorkerStep.cs
git commit -m "feat(installer): start worker via Process.Start, drop schtasks stop"
```
---
## Task 5: UninstallRunner removes the Startup shortcut
**Files:**
- Modify: `src/ClaudeDo.Installer/Core/UninstallRunner.cs`
- [ ] **Step 1: Add Startup `.lnk` removal**
In `src/ClaudeDo.Installer/Core/UninstallRunner.cs`, the shortcut-removal block (step 4, around lines 53-60) currently removes the Desktop and Start Menu `.lnk`s. Add the Startup shortcut removal right after them:
```csharp
// 4) Remove shortcuts (best-effort — a stuck .lnk must not block the rest).
progress.Report("Removing shortcuts...");
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory),
"ClaudeDo.lnk"));
TryDeleteFile(Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu),
"Programs", "ClaudeDo.lnk"));
TryDeleteFile(AutostartShortcut.PathIn(AutostartShortcut.DefaultStartupDir));
```
The existing `schtasks /Delete /TN "{StopWorkerStep.TaskName}" /F` line (step 3) stays — it cleans up the legacy task on machines that still have it.
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Installer/Core/UninstallRunner.cs
git commit -m "feat(installer): remove Startup worker shortcut on uninstall"
```
---
## Task 6: App stops auto-spawning the worker
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- [ ] **Step 1: Remove the auto-spawn call**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`, delete this line from the constructor (line 224):
```csharp
_ = EnsureWorkerRunningAsync();
```
- [ ] **Step 2: Remove the `EnsureWorkerRunningAsync` method and its flag**
Delete the `_ensureRunningAttempted` field (line 308) and the whole `EnsureWorkerRunningAsync` method (lines 310-320):
```csharp
private bool _ensureRunningAttempted;
private async Task EnsureWorkerRunningAsync()
{
if (_ensureRunningAttempted) return;
_ensureRunningAttempted = true;
await Task.Delay(TimeSpan.FromSeconds(4));
if (Worker?.IsConnected == true) return;
var exe = _workerLocator.Find();
if (exe is null) return;
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* logon task is the primary mechanism; this is a convenience */ }
}
```
Keep `RestartWorkerAsync` / `RestartWorkerService` (still used by the existing Restart button). `_workerLocator` stays in use (RestartWorkerService + Task 8).
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded (no remaining references to `EnsureWorkerRunningAsync`).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
git commit -m "refactor(ui): stop auto-spawning the worker on app start"
```
---
## Task 7: WorkerConnectionModal (VM + View)
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`
- Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs`
- [ ] **Step 1: Create the ViewModel**
`src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`:
```csharp
using System;
using System.Diagnostics;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
{
private readonly WorkerLocator _workerLocator;
private readonly InstallerLocator _installerLocator;
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
{
_workerLocator = workerLocator;
_installerLocator = installerLocator;
}
public Action? CloseAction { get; set; }
[RelayCommand] private void Close() => CloseAction?.Invoke();
[RelayCommand]
private void StartWorker()
{
var exe = _workerLocator.Find();
if (exe is null) return;
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); }
catch { /* nothing useful to show */ }
CloseAction?.Invoke();
}
[RelayCommand]
private void RerunInstaller()
{
var path = _installerLocator.Find();
if (path is null) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
Environment.Exit(0);
}
catch { /* nothing useful to show */ }
}
}
```
- [ ] **Step 2: Create the View (mirrors `AboutModalView` + `ModalShell`)**
`src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml`:
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Modals.WorkerConnectionModalView"
x:DataType="vm:WorkerConnectionModalViewModel"
Title="Worker not reachable"
Width="520" Height="240"
WindowDecorations="None"
ExtendClientAreaToDecorationsHint="True"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="WORKER NOT REACHABLE" CloseCommand="{Binding CloseCommand}">
<Grid RowDefinitions="*,Auto" Margin="20,16">
<TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap"
Text="ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists."/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,16,0,0">
<Button Classes="btn" Content="Dismiss" Command="{Binding CloseCommand}"/>
<Button Classes="btn" Content="Rerun Installer" Command="{Binding RerunInstallerCommand}"/>
<Button Classes="btn primary" Content="Start Worker" Command="{Binding StartWorkerCommand}"/>
</StackPanel>
</Grid>
</ctl:ModalShell>
</Window>
```
`src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs`:
```csharp
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace ClaudeDo.Ui.Views.Modals;
public partial class WorkerConnectionModalView : Window
{
public WorkerConnectionModalView()
{
InitializeComponent();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
}
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml src/ClaudeDo.Ui/Views/Modals/WorkerConnectionModalView.axaml.cs
git commit -m "feat(ui): add worker connection help modal"
```
---
## Task 8: Shell hook, command, grace timer + decision gate
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`
- [ ] **Step 1: Write the failing test for the decision gate**
`tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs`:
```csharp
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class ConnectionPromptGateTests
{
[Fact]
public void Shows_once_when_offline()
{
var vm = new IslandsShellViewModel();
Assert.True(vm.DecideShowConnectionPrompt(isOffline: true));
Assert.False(vm.DecideShowConnectionPrompt(isOffline: true)); // not a second time
}
[Fact]
public void Does_not_show_when_connected_before_grace()
{
var vm = new IslandsShellViewModel();
Assert.False(vm.DecideShowConnectionPrompt(isOffline: false));
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
Expected: FAIL — `DecideShowConnectionPrompt` does not exist.
- [ ] **Step 3: Add the hook, command, gate, and grace timer**
In `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`:
Add a hook property near the other `Show*Modal` hooks (after line 52):
```csharp
// Set by MainWindow to open the worker-connection help dialog.
public Func<Modals.WorkerConnectionModalViewModel, Task>? ShowWorkerConnectionModal { get; set; }
```
Add the gate field + method and the open command (place near `OpenAbout`, around line 271):
```csharp
private bool _connectionPromptShown;
internal bool DecideShowConnectionPrompt(bool isOffline)
{
if (!isOffline) return false;
if (_connectionPromptShown) return false;
_connectionPromptShown = true;
return true;
}
private async Task OpenWorkerConnectionHelpAsync()
{
var vm = new Modals.WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
if (ShowWorkerConnectionModal is not null) await ShowWorkerConnectionModal(vm);
}
[RelayCommand]
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
```
Add the grace timer field near `_clearTimer` (line 74):
```csharp
private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };
```
Wire and start it inside the **public** constructor (after the `_primeStatusTimer.Elapsed` wiring, near line 222 — NOT in the parameterless test constructor):
```csharp
_connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
{
if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
});
_connectTimer.Start();
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests --filter ConnectionPromptGateTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Build the app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs tests/ClaudeDo.Ui.Tests/ConnectionPromptGateTests.cs
git commit -m "feat(ui): prompt once on worker connection failure with grace timer"
```
---
## Task 9: Wire the modal in MainWindow + clickable status pill
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml`
- [ ] **Step 1: Wire the dialog hook**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`, inside `OnDataContextChanged`, after the existing `vm.ShowRepoImportModal = ...` block (line 70), add:
```csharp
vm.ShowWorkerConnectionModal = async (connVm) =>
{
var dlg = new WorkerConnectionModalView { DataContext = connVm };
connVm.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
```
(`ClaudeDo.Ui.Views.Modals` is already imported at line 10.)
- [ ] **Step 2: Make the status pill a button**
In `src/ClaudeDo.Ui/Views/MainWindow.axaml`, replace the left "connection pill" `StackPanel` (lines 190-202) with a `Button` wrapping the same content:
```xml
<!-- Left: connection pill (click to open worker help) -->
<Button DockPanel.Dock="Left"
Command="{Binding OpenWorkerConnectionHelpCommand}"
Background="Transparent" BorderThickness="0" Padding="0"
Cursor="Hand" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusRunningBrush}"
IsVisible="{Binding Worker.IsConnected}"/>
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusReviewBrush}"
IsVisible="{Binding Worker.IsReconnecting}"/>
<Ellipse Width="7" Height="7" Fill="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding IsOffline}"/>
<TextBlock Classes="eyebrow"
Text="{Binding ConnectionText, Converter={StaticResource UpperCase}}"
LetterSpacing="1.4"
VerticalAlignment="Center"/>
</StackPanel>
</Button>
```
- [ ] **Step 3: Build the app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj`
Expected: Build succeeded.
- [ ] **Step 4: Manual verification**
Start the worker (or leave it stopped) and run the App:
- Worker stopped → after ~12s the "WORKER NOT REACHABLE" dialog appears once. **Start Worker** launches it (footer pill turns ONLINE); **Rerun Installer** launches the installer and exits; **Dismiss** closes and does not reappear automatically.
- Click the footer status pill anytime → the dialog reopens.
- Worker running before launch → no dialog appears.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/MainWindow.axaml.cs src/ClaudeDo.Ui/Views/MainWindow.axaml
git commit -m "feat(ui): wire worker connection modal and make status pill clickable"
```
---
## Task 10: Full build + test sweep
- [ ] **Step 1: Build the touched projects**
Run:
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
dotnet build src/ClaudeDo.Installer/ClaudeDo.Installer.csproj
```
Expected: both Build succeeded.
- [ ] **Step 2: Run the affected test suites**
Run:
```bash
dotnet test tests/ClaudeDo.Installer.Tests
dotnet test tests/ClaudeDo.Ui.Tests
```
Expected: all pass; no references to the deleted `ScheduledTaskXml`.
- [ ] **Step 3: Final commit (if any stragglers)**
```bash
git add -A
git commit -m "chore: worker lifecycle redesign cleanup" || echo "nothing to commit"
```
@@ -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
@@ -37,7 +37,7 @@ Fields:
|---|---|---|
| `DefaultClaudeInstructions` | text | `""` |
| `DefaultModel` | string | `sonnet` |
| `DefaultMaxTurns` | int | `30` |
| `DefaultMaxTurns` | int | `100` |
| `DefaultPermissionMode` | string | `acceptEdits` |
| `WorktreeStrategy` | string | `sibling` |
| `CentralWorktreeRoot` | string? | `null` |
@@ -0,0 +1,197 @@
# Planning Merge-All & Subtask Visibility — Design
**Date:** 2026-04-24
**Status:** Approved design, ready for implementation planning
## Problem
Three concrete issues with the current Planning feature:
1. **Queued subtasks are not visible in the Queue List.** When a planning session finalizes, its subtasks transition to `Queued`, but the Queue List's hierarchy rules only show children when their Planning parent is expanded. A collapsed (or already-`Planned`) parent effectively hides the subtasks.
2. **Completed subtasks vanish from view.** Once a subtask becomes `Done`, the regroup logic moves it to the "Completed" bucket. Users expect subtasks to remain visible under their Planning parent until the Planning task itself is marked Done.
3. **No aggregated view or bulk merge.** Each subtask must be merged individually through its worktree. There is no way to see a combined diff of all changes produced by a Planning session, and no "merge everything" action.
## Goals
- Treat Planning subtasks as belonging to their Planning parent for visibility and lifecycle purposes.
- Provide a single aggregated diff view that shows all changes produced by a Planning session.
- Provide a single "Merge all" action that sequentially merges all subtasks, with a usable conflict-resolution flow.
- Auto-complete the Planning task when all merges succeed.
## Non-goals
- Building a full-featured in-app diff editor. Textual unified diff is acceptable for now; conflict *editing* happens in VS Code.
- Persisting Merge-all progress across worker restarts. Restart clears in-memory orchestration state; user re-starts Merge-all (already-merged subtasks are skipped because their worktrees are `Merged`).
- Modifying how individual subtasks are created, executed, or finalized.
## Design
### 1. Visibility model
Planning subtasks are exclusively children of their Planning parent until the Planning task transitions to `Done`. The Planning parent acts as a roll-up in the Queue List.
- Tasks with a non-null `ParentTaskId` are excluded from all virtual lists (`virtual:queued`, `virtual:running`, `CompletedItems`, etc.) as separate rows.
- A Planning/Planned task is included in `virtual:queued` if **any** child is `Queued`, and in `virtual:running` if any child is `Running`.
- Children are always attached under their parent in the task tree; expansion purely controls visual collapse.
- When Merge-all completes successfully, the Planning task is set to `Done` and the entire subtree moves to Completed together.
- Status badge on the Planning row summarizes children (e.g., `3/5 queued`, `2 running`, `1 failed`).
### 2. Planning detail panel
Extends the existing task detail view. New elements when the selected task is a Planning task:
- **Subtasks list.** Grouped by status badge (Queued / Running / Done / Failed). Each row preserves existing per-subtask actions (view logs, open worktree, individual merge).
- **Merge target dropdown.** Single target branch that applies to all subtasks in Merge-all. Defaults to the branch that was current when the Planning session started.
- **`[Review combined diff]` button.** Opens the Aggregated Diff Viewer. Enabled as soon as any subtask has produced a diff.
- **`[Merge all subtasks]` button.** Orchestrates sequential merge + auto-Done. Disabled until every subtask is `Done` and every worktree is `Active` or `Merged` (no `Discarded` / `Kept`). Tooltip explains why when disabled (e.g., "2 subtasks still running", "1 subtask failed — resolve first", "1 worktree was discarded").
- Existing per-subtask merge action remains available; Merge-all is additive.
### 3. Aggregated diff viewer
New Avalonia view `PlanningDiffView` + `PlanningDiffViewModel`, opened as a modal or dedicated tab.
**Default — grouped by subtask:**
- Left pane: subtask list in creation order with `title • +added deleted • N files`.
- Right pane: selected subtask's diff. Reuse any existing diff-rendering control; if none exists, render unified diff text with basic syntax coloring (monospace, minimal decoration).
- Summary stats come from `WorktreeEntity.DiffStat`. Raw diff comes from `git diff <base>..<head>` executed in each subtask's worktree via `GitService`. Cached in memory per subtask until the subtask's HEAD moves.
**Toggle — "Preview combined diff":**
- Calls `PlanningAggregator.BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)`:
1. Create/reset branch `planning/<slug>-integration` off the current merge target.
2. Merge each subtask's branch sequentially with `--no-ff`.
3. On conflict during preview: abort the merge, reset the integration branch, surface a warning identifying which two subtasks conflict. Grouped view remains available.
4. On success: compute `git diff <merge-target>..planning/<slug>-integration` and render as a single flat unified diff.
- Toggle flips back to grouped mode.
**Integration-branch lifecycle:** scratch artifact, rebuilt on every preview (deleted + recreated). Cleaned up when the Planning task is marked `Done` or when the Planning session is discarded.
### 4. Merge-all orchestration
**Happy path (`PlanningMergeOrchestrator.StartAsync`):**
1. Pre-flight checks — fail fast with a clear message on any:
- Every subtask is `Done`.
- Every subtask's worktree is `Active` or `Merged` (no `Discarded` / `Kept`). `Merged` worktrees are allowed so that an interrupted Merge-all can be restarted.
- Repo working tree is clean.
- No mid-merge in progress in the target repo.
2. For each subtask in creation order, skip if its worktree is already `Merged` (idempotent restart). Otherwise call `TaskMergeService.MergeAsync` with `removeWorktree: true` and `leaveConflictsInTree: true`. Each success flips the worktree to `Merged`.
3. After the last successful merge:
- Set Planning task `Status = Done`.
- Call `PlanningAggregator.CleanupIntegrationBranchAsync` if the integration branch exists.
- Emit `PlanningCompleted` so the UI removes the row from the Queue List.
**Conflict path:**
1. `MergeAsync` with `leaveConflictsInTree: true` reports a conflict, leaves the repo in a mid-merge state, and returns the conflicted file paths (`git diff --name-only --diff-filter=U`).
2. Orchestrator halts the loop, stores the in-progress state (remaining subtasks, target branch, current subtask id) in memory, and emits `PlanningMergeConflict(planningTaskId, subtaskId, conflictedFiles)`.
3. The UI opens the **Conflict Resolution dialog** — see §5.
4. On `ContinueAsync`: calls `TaskMergeService.ContinueMergeAsync(subtaskId)` which stages the recorded files and runs `git commit --no-edit`. Flips worktree to `Merged`. Loop resumes with remaining subtasks.
5. On `AbortAsync`: calls `TaskMergeService.AbortMergeAsync(subtaskId)` which runs `git merge --abort`. Planning stays in `Planned`. Already-merged earlier subtasks remain `Merged`. Orchestration state cleared.
**Idempotent restart:** if the worker restarts mid Merge-all, in-memory state is lost. A fresh `StartAsync` re-runs pre-flight; already-`Merged` worktrees are skipped by the loop (their status gates them out). User experience: "I clicked Merge all again and it continued from where it left off."
### 5. Conflict Resolution dialog
Avalonia modal (`ConflictResolutionView` + `ConflictResolutionViewModel`).
- **Header:** `Conflicts in subtask: <title> merging into <target-branch>`.
- **File list:** full absolute paths of conflicted files.
- **`[Open all in VS Code]`** — for each file, spawn `code <absolute-path>` via `Process.Start`. If `code` is not on PATH, show an inline error row with the file list so the user can copy paths manually. No popup-on-popup.
- **`[I've resolved — continue]`** — calls `ContinuePlanningMerge(planningTaskId)` hub method, closes dialog. The orchestration loop continues with the remaining subtasks.
- **`[Abort this merge]`** — calls `AbortPlanningMerge(planningTaskId)` hub method, closes dialog. Planning stays `Planned`.
### 6. Data model
**No schema changes.**
- Conflicted files are queried from git on demand (`git diff --name-only --diff-filter=U`) while the merge is in progress.
- Integration branch name is derived from the Planning task slug: `planning/<slug>-integration`.
- Planning completion uses existing `TaskStatus.Done`.
### 7. Services
**New:**
- **`PlanningAggregator`** (`src/ClaudeDo.Worker/Planning/PlanningAggregator.cs`)
- `GetAggregatedDiffAsync(planningTaskId, ct)` — returns per-subtask diff entries.
- `BuildIntegrationBranchAsync(planningTaskId, targetBranch, ct)` — creates/resets the integration branch, merges subtasks sequentially, returns `(success, combinedDiff)` or `(failure, firstConflictSubtaskId, conflictedFiles)`. Always leaves the integration branch in a consistent state (aborts + resets on failure).
- `CleanupIntegrationBranchAsync(planningTaskId, ct)` — deletes the integration branch.
- **`PlanningMergeOrchestrator`** (singleton, `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs`)
- Owns in-memory state per planning task: `{ remainingSubtasks, targetBranch, currentSubtaskId }`.
- `StartAsync(planningTaskId, targetBranch)`, `ContinueAsync(planningTaskId)`, `AbortAsync(planningTaskId)`.
- Emits SignalR events: `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`.
**Modified:**
- **`TaskMergeService`**
- `MergeAsync` gets a `leaveConflictsInTree: bool` parameter (default `false`). When `true`, on conflict records conflicted files on the returned result, does **not** call `git merge --abort`.
- New `ContinueMergeAsync(taskId, ct)` — stages the recorded conflicted files and runs `git commit --no-edit`, flips worktree to `Merged`.
- New `AbortMergeAsync(taskId, ct)` — runs `git merge --abort`, restores pre-merge state.
- Existing callers unaffected by the default.
- **`WorkerHub`** — new methods:
- `GetPlanningAggregate(planningTaskId)`
- `BuildPlanningIntegrationBranch(planningTaskId, targetBranch)`
- `MergeAllPlanning(planningTaskId, targetBranch)`
- `ContinuePlanningMerge(planningTaskId)`
- `AbortPlanningMerge(planningTaskId)`
- **`TasksIslandViewModel.Regroup`**
- Exclude tasks with `ParentTaskId != null` from virtual lists.
- Include Planning parents in `virtual:queued` / `virtual:running` based on children's statuses.
- Keep children attached to parent in the tree at all times until Planning is `Done`.
### 8. UI components (new)
- `PlanningDiffView` + `PlanningDiffViewModel` — aggregated diff viewer (§3).
- `ConflictResolutionView` + `ConflictResolutionViewModel` — conflict dialog (§5).
- Planning Detail section inside the existing task detail pane — subtask list + merge target dropdown + two buttons (§2).
## Error handling
- **Pre-flight failures** — surface as inline errors in the Planning detail panel. No merge work attempted.
- **Preview-build conflict** — keep grouped diff available; show a warning banner identifying the conflicting pair of subtasks.
- **Merge-all conflict** — Conflict Resolution dialog (§5). The failed subtask's worktree stays `Active`; prior successes stay `Merged`.
- **VS Code not on PATH** — inline error row in the Conflict dialog with copyable file paths.
- **Worker restart mid-merge** — in-memory state lost; restarting Merge-all is idempotent because merged worktrees are skipped by status gating.
## Testing
Convention: xUnit integration tests with real SQLite and real git (`tests/ClaudeDo.Worker.Tests`).
**`PlanningAggregatorTests`** — real git fixture
- `GetAggregatedDiffAsync` returns one entry per subtask with correct stats.
- `BuildIntegrationBranchAsync` with non-conflicting subtasks — success, branch contains all changes.
- `BuildIntegrationBranchAsync` with conflicting subtasks — failure, branch reset (not mid-merge), correct subtask id and file list reported.
- Rebuild overwrites a stale integration branch.
- `CleanupIntegrationBranchAsync` removes the branch.
**`PlanningMergeOrchestratorTests`** — real git + real DB
- Happy path: all subtasks merge → worktrees `Merged`, Planning `Done`, `PlanningCompleted` emitted.
- Conflict path: first subtask conflicts → repo left in conflict state, `PlanningMergeConflict` emitted with correct file list, worktree stays `Active`, Planning stays `Planned`.
- `ContinueAsync` after conflict: resolution commits, loop proceeds, final state `Done`.
- `AbortAsync` after conflict: `merge --abort` restores clean state, earlier merged subtasks remain `Merged`, Planning stays `Planned`.
- Pre-flight rejection: running subtask, failed subtask, dirty repo — each returns the expected error with no side effects.
- Idempotent restart: partial merge + fresh `StartAsync` — already-`Merged` worktrees skipped.
**`TaskMergeServiceConflictTests`** (extending existing tests)
- `MergeAsync(leaveConflictsInTree: true)` on conflict: no `merge --abort`, returns conflicted files, worktree state unchanged.
- `ContinueMergeAsync`: completes in-progress merge, flips worktree to `Merged`.
- `AbortMergeAsync`: runs `merge --abort`, restores clean state.
**`TasksIslandRegroupTests`** — ViewModel unit tests, no DB
- Queued subtask with a Planning parent is NOT in `virtual:queued` as its own row.
- Planning parent with any Queued child IS in `virtual:queued`.
- Done subtask stays nested under Planning parent until Planning is `Done`.
- After Planning is marked `Done`, parent + children move to Completed together.
**Manual smoke test** (documented in PR description):
- End-to-end planning session in the app: create plan, finalize, let subtasks run.
- Open aggregated diff, toggle Preview combined.
- Merge-all happy path.
- Merge-all conflict path with VS Code dialog open/continue.
- Merge-all conflict path abort.
## Open questions
None at this stage. All decisions from the brainstorming session are captured above.
@@ -0,0 +1,95 @@
# Planning UX Polish + Sequential Subtask Queue
**Status:** design
**Date:** 2026-04-24
**Scope:** three small UX changes + one feature — sequential execution of planning subtasks triggered from the context menu.
## Goals
1. Collapse the children of a finished planning-parent row in the task list by default.
2. Allow the user to collapse the Description section in the Details pane.
3. Halve the width of the GridSplitters between islands.
4. Let the user queue all subtasks of a planning parent so they run one after another, with a new `Waiting` status for pending siblings.
## 1. Auto-collapse done planning parents
**Rule for "done":** a planning parent is "done" when every one of its children has `Status == Done`.
**Changes:**
- `TaskRowViewModel`: add UI-only `[ObservableProperty] bool _areChildrenExpanded`. Default computed from status — `false` when the row is a done planning parent, else `true`. Not persisted.
- Add `[RelayCommand] void ToggleChildrenExpanded()`.
- `TasksIslandView.axaml` (or `TaskRowView.axaml`): chevron button on the planning-parent row, visible only when `IsPlanningParent && HasPlanningChildren`. Bound to the toggle command.
- `TasksIslandViewModel.Regroup()`: before adding child rows to `OpenItems`/`CompletedItems`, check each child's parent row in `Items`. If the parent's `AreChildrenExpanded == false`, skip the child.
- When a planning parent flips from "not done" → "done" in `OnWorkerTaskUpdated`, call `Regroup()` so the collapse takes effect.
No DB changes.
## 2. Collapsible description in Details pane
**Changes:**
- `DetailsIslandViewModel`: `[ObservableProperty] bool _isDescriptionExpanded = true` + `[RelayCommand] void ToggleDescriptionExpanded()`.
- `DetailsIslandView.axaml`: wrap the existing description `TextBox` in a `StackPanel`; add a thin header row with the label "Description" and a chevron button. Body's `IsVisible` binds to the flag.
- State is per ViewModel instance — reset to `true` whenever a different task is loaded.
No persistence.
## 3. Narrower GridSplitters
`MainWindow.axaml` lines 158 and 170: `Width="5"``Width="3"` on both `GridSplitter` elements.
That's the whole change.
## 4. Sequential subtask queue
### Data
- `ClaudeDo.Data/Models/TaskStatus.cs`: add a new enum value `Waiting` (lowercase serialized form `waiting`, matching existing convention).
- Verify status is stored as string (it should be based on existing patterns). If stored as int, ensure new value gets a stable numeric slot at the end of the enum to avoid breaking existing rows. **No EF migration** beyond what the enum emits automatically.
### Worker
- New SignalR hub method: `QueuePlanningSubtasksAsync(string parentTaskId) : Task`.
- Loads all children of the parent, ordered by `SortOrder`.
- Validates: parent must be a planning parent, children must currently all be in `Manual` or `Planned` (reject if any child is already Queued/Running/Done/Failed, surface a friendly error).
- First child → `Queued`. All other children → `Waiting`. Save.
- Emit `TaskUpdated` for each affected task.
- Chain progression — hook into the existing finish/complete path that already fires `TaskFinished`:
- On a child task finishing with status `Done` **and** its parent has waiting siblings: find the next sibling by `(ParentTaskId == parent.Id && Status == Waiting)` ordered by `SortOrder`, flip to `Queued`, emit `TaskUpdated`, and let the existing queue pickup loop pick it up.
- On `Failed`: do nothing. Remaining `Waiting` siblings stay waiting. (A toast for failed tasks will be added in a later spec.)
This logic lives in a new `PlanningChainCoordinator` service (or similar) in `ClaudeDo.Worker/Planning/`, registered as a singleton and wired into whatever already emits task-finished events.
### UI
- `TaskRowView` — add context menu entry **"Queue subtasks sequentially"**:
- `IsVisible` bound to `IsPlanningParent && HasPlanningChildren`.
- `IsEnabled` when all children are in `Manual` / `Planned` state (new property on `TaskRowViewModel`: `CanQueueSubtasksSequentially`).
- Calls `WorkerClient.QueuePlanningSubtasksAsync(Id)`.
- `TaskRowViewModel`:
- Add `IsWaiting => Status == TaskStatus.Waiting` and extend `StatusChipClass` switch to return a new class `"waiting"`.
- Add `CanQueueSubtasksSequentially` (computed; requires access to children).
- `StatusColorConverter` — add a muted color for `Waiting` (proposed: the existing `TextMuteBrush` or a faint cyan).
- Task list — planning parent continues to appear in virtual:queued because it has a `Queued` child (existing logic). **Extend** the virtual:queued match predicate in `TasksIslandViewModel.TaskMatchesList` so a task matches when `Status == Queued || Status == Waiting`. This ensures all sibling subtasks (the queued one + the waiting ones) render under the parent in that list.
### Client
- `IWorkerClient` / `WorkerClient`: add `QueuePlanningSubtasksAsync(string parentTaskId)` that calls the hub method.
## Out of scope
- Toast notifications on subtask failure (separate follow-up spec).
- Retrying a stopped chain from a failed task (user does it manually via existing actions).
- Persisting the collapse state of planning parents or the Description across sessions.
- Drag-to-reorder of waiting subtasks (execution order = `SortOrder` at the moment the chain starts).
## Validation plan
Manual:
- Plan a task with 3 subtasks. Context-menu → Queue subtasks sequentially. Confirm first = Queued, others = Waiting. Watch the first run to Done, confirm the second flips Queued → Running automatically.
- Force-fail subtask 2 (cancel or make it fail). Confirm subtask 3 stays Waiting; no further dispatch.
- Once all three are Done, confirm the planning parent auto-collapses in the list.
- Toggle the Description chevron in the Details pane on an arbitrary task.
- Eyeball the narrower GridSplitter — still resizable, still hittable.
Automated (minimal — only where cheap):
- Worker-level unit test for `PlanningChainCoordinator`: happy-path chain advance on Done; no advance on Failed; correct ordering by `SortOrder`.
@@ -0,0 +1,172 @@
# Planning Session MCP via Ephemeral Worktree
**Date:** 2026-04-24
**Status:** Design approved, pending implementation plan
**Scope:** `ClaudeDo.Worker` — planning session launch, MCP config delivery
## Problem
When a user starts a planning session, `claude` is spawned in the list's working directory via Windows Terminal and passed `--mcp-config <absolute-path>` pointing at a session-local `mcp.json`. In practice, the spawned `claude` session does **not** pick up the ClaudeDo MCP server: `mcp__claudedo__*` tools are not available, and no trust prompt is shown. The user has to fall back to the built-in `TaskCreate` tool, which writes nothing to ClaudeDo.
The `--mcp-config` flag is documented for headless (`-p`) invocations; in interactive TUI mode it appears to be either ignored or silently dropped on at least some CLI versions. The JSON payload itself is already correct (verified against Claude Code docs — `type: "http"` + `Authorization` header is the documented form).
The reliable path per Claude Code docs is project-root `.mcp.json` auto-discovery plus a one-time trust approval (or `enableAllProjectMcpServers: true`).
## Goal
Spawn planning sessions so that `mcp__claudedo__*` tools are available immediately, without modifying any file in the user's working directory and without requiring a trust prompt.
## Non-goals
- Installer-time MCP registration (rejected — loses per-session token isolation; pollutes every `claude` invocation on the machine).
- Changing how task execution (non-planning) spawns `claude`.
- Supporting planning on a working directory that is not a git repository.
## Approach: ephemeral planning worktree
Each planning session runs inside its own short-lived git worktree, created from `HEAD` of the list's working directory. The worktree is the isolated surface where we write `.mcp.json` and the settings override. The worktree is force-removed on `FinalizeAsync` / `DiscardAsync`.
### Files changed
- `src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`
- `src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs` (extend to carry worktree path + branch name)
- `src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs` (may drop `McpConfigPath` if no longer used)
- `src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs`
- `src/ClaudeDo.Worker/Runner/WorktreeMaintenanceService.cs` (optional — defensive startup prune)
- DI registration in `src/ClaudeDo.Worker/Program.cs` (inject `GitService`, `WorkerConfig`, `IDbContextFactory<ClaudeDoDbContext>` into `PlanningSessionManager`)
### Data flow on `StartAsync`
1. Resolve `list.WorkingDir`; hard-error if `null`, not a directory, or not a git repo (`GitService.IsGitRepoAsync`).
2. Resolve `HEAD` via `GitService.RevParseHeadAsync`.
3. Resolve worktree strategy from `AppSettingsRepository.GetAsync` (same resolution as `WorktreeManager.CreateAsync`):
- `sibling``<parent-of-WorkingDir>\.claudedo-worktrees\planning\<taskId>`
- `central``<CentralWorktreeRoot>\planning\<taskId>`
Normalize with `Path.GetFullPath`.
4. Branch name: `claudedo/planning/<taskId-stripped-of-dashes>`.
5. `GitService.WorktreeAddAsync(list.WorkingDir, branchName, worktreePath, baseCommit, ct)`. On `"already exists"` failure, run the same self-heal pattern as `WorktreeManager.CreateAsync` (list worktrees for branch → force-remove stale → prune → delete branch → retry once).
6. Write into the worktree:
- `<worktreePath>\.mcp.json` — JSON with env-var expansion for the token (see below).
- `<worktreePath>\.claude\settings.local.json``{ "enableAllProjectMcpServers": true }` (create `.claude` dir if missing).
7. Write session artifacts in the session directory (unchanged from today): `system-prompt.md`, `initial-prompt.txt`. The session-local `mcp.json` is no longer written — drop that write.
8. Return `PlanningSessionStartContext` with `WorkingDir = worktreePath` and a new `WorktreePath` field (redundant with `WorkingDir` for now, but explicit for cleanup). Also carry `BranchName` so finalize/discard can delete it.
### MCP JSON payload
```json
{
"mcpServers": {
"claudedo": {
"type": "http",
"url": "http://127.0.0.1:47821/mcp",
"headers": {
"Authorization": "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
}
}
}
}
```
The token never lives on disk in literal form — `${CLAUDEDO_PLANNING_TOKEN}` is expanded by Claude Code at load time from the spawned process's environment.
### `.claude/settings.local.json` payload
```json
{
"enableAllProjectMcpServers": true
}
```
Since the worktree is always empty of user customizations (fresh checkout), we write this file unconditionally. No merge / backup logic needed.
### Launcher changes (`WindowsTerminalPlanningLauncher`)
- `LaunchStartAsync`:
- Set `psi.Environment["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token` (new field on `PlanningSessionStartContext`).
- `-d` now points at the worktree path (already handled by `ctx.WorkingDir` change).
- **Remove** `--mcp-config` and its path argument.
- Keep `--allowedTools mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill``enableAllProjectMcpServers` only handles trust, not per-tool pre-approval.
- Keep `--append-system-prompt-file` as the "single-value flag buffer" before the positional prompt (the existing arg-order concern is unchanged).
- `LaunchResumeAsync`:
- Same env-var setup.
- Same `-d <worktreePath>`.
- **Remove** `--mcp-config` (the worktree's `.mcp.json` is discovered automatically).
- Keep `--resume <ClaudeSessionId>`.
### Finalize / Discard
`PlanningSessionManager.FinalizeAsync` and `DiscardAsync` gain:
1. Look up the worktree path + branch name (deterministic from `taskId` → reuse the same resolution code as `StartAsync`).
2. `GitService.WorktreeRemoveAsync(list.WorkingDir, worktreePath, force: true, ct)``--force` because claude may have created scratch files.
3. `GitService.BranchDeleteAsync(list.WorkingDir, branchName, force: true, ct)`.
4. Delete the session dir as today.
All three steps are best-effort in `DiscardAsync` (log warnings, don't throw — the user explicitly asked to discard). `FinalizeAsync` should propagate failures, since a failed cleanup leaves resources we care about.
### Resume
Resume already looks up `list.WorkingDir` from the list; the worktree path is deterministic from `taskId`. `ResumeAsync` must:
1. Verify the worktree directory exists; if not, hard-error ("planning session was discarded or lost — cannot resume").
2. Return `PlanningSessionResumeContext` with `WorkingDir = worktreePath` and the token (re-read from session state — see Token persistence below).
### Token persistence
The token today is generated in `StartAsync` and embedded in `mcp.json` at creation time — never read again. With env-var expansion, the token must be available on **resume**. Options:
- **A) Persist token to session dir** (`<sessionDir>\token`) with `FileOptions.WriteAllBytes`, restrict file ACL to current user. Read on resume.
- **B) Store token hash in DB, raw token in memory only** — breaks across Worker restarts → no resume possible.
**Chosen: A.** Token file sits inside the existing session directory (`<PlanningSessionManager._rootDirectory>\<taskId>\token`), restricted to the current user via Windows ACLs (`File.SetAccessControl` with an explicit DACL granting `FullControl` to `WindowsIdentity.GetCurrent()` only). Cleaned up in `DiscardAsync`/`FinalizeAsync` with the rest of the session dir.
### Defensive startup cleanup
`WorktreeMaintenanceService` already prunes worktrees tracked in the DB. Planning worktrees are **not** in the DB (they're purely filesystem-backed, keyed by `taskId` via path convention). Add a lightweight pass:
- Enumerate directories matching `<root>\.claudedo-worktrees\planning\*` (for each strategy / central root we know about).
- For each, check whether a corresponding session dir exists under `~/.todo-app/sessions/<taskId>`.
- If no session dir: `git worktree remove --force` + `git branch -D claudedo/planning/<taskId-stripped>`.
This is a small addition; if scoped too large, defer to a follow-up and accept that a crashed Worker leaves orphaned worktrees until manual cleanup.
## Edge cases
| Case | Behavior |
|------|----------|
| `list.WorkingDir` not a git repo | Hard-error on `StartAsync`. Surface message in UI. |
| Worktree branch already exists from a prior crashed session | Self-heal: force-remove matching worktrees, prune, delete branch, retry once. (Same pattern as `WorktreeManager.CreateAsync`.) |
| User closes Windows Terminal without clicking Finalize/Discard | Session dir + worktree remain. `ResumeAsync` works. Startup cleanup handles abandoned sessions whose session dir the user manually deletes. |
| Claude creates/edits files in the planning worktree | Discarded with the worktree. No impact on user's real working dir. |
| User deletes the session dir out from under the Worker | `ResumeAsync` hard-errors. Startup cleanup GCs the orphaned worktree. |
| Two simultaneous planning sessions on the same task | Already prevented by task status transition (`Planning` is exclusive). No new consideration. |
| `HEAD` is on a detached commit | `git worktree add` handles this fine — base commit is explicit. |
## Testing
Extend `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (or a new file) with integration tests using the real-SQLite + real-git pattern the project already uses:
- **Start happy path:** worktree dir exists after `StartAsync`, contains `.mcp.json` with `${CLAUDEDO_PLANNING_TOKEN}` literal, contains `.claude/settings.local.json` with `enableAllProjectMcpServers: true`.
- **Finalize cleanup:** worktree dir is gone, branch is gone, session dir is gone.
- **Discard cleanup:** same as finalize.
- **Self-heal:** pre-create a stale branch `claudedo/planning/<id>`, then `StartAsync` must succeed.
- **Non-git working dir:** `StartAsync` throws a specific error type.
- **Resume after Worker restart:** seed session dir + token file, recreate `PlanningSessionManager`, `ResumeAsync` returns context pointing at the still-existing worktree.
Mock `IPlanningTerminalLauncher` (already an interface) so tests don't actually spawn `wt.exe`.
## Trade-offs and alternatives considered
1. **Write `.mcp.json` into the user's working dir with backup/restore.** Rejected — clobber risk, file-noise on crash, user's `.gitignore` may not cover it, exposes token alongside source even with env-var expansion (because expansion is on claude's side, the raw `${VAR}` string still lives in the user's repo).
2. **User-scope registration via installer** (`claude mcp add --scope user`). Rejected — requires a static secret baked into the Worker, loses per-session isolation, every `claude` session on the machine sees claudedo tools.
3. **Keep `--mcp-config` and debug why it's not honored.** Rejected — even if it works on the maintainer's machine, the behavior is undocumented for interactive TUI mode, and we'd need a fallback anyway. Fixing to the documented path eliminates the uncertainty.
## Open questions resolved
- **WorkingDir must be a git repo?** Yes — hard-error.
- **Worktree path strategy?** Follow the same `sibling`/`central` setting as task execution.
- **HEAD snapshot vs WIP?** HEAD snapshot is fine — planning proposes subtasks, doesn't edit files.
## Implementation sequencing
A separate implementation plan (via `superpowers:writing-plans`) will break this into test-first steps.
@@ -0,0 +1,174 @@
# External MCP — CRUD Extensions
**Date:** 2026-04-25
**Status:** Approved
## Goal
Give a normal (non-planning) Claude CLI session full control over the ClaudeDo task inbox via the existing always-on `ExternalMcpService`. Primary use case: when a chat session produces scope-creep work, Claude can spin up a fully-formed task — title, description, tags (including the `agent` tag for auto-execution) — without leaving the session.
The work is purely additive: the `ExternalMcpService` endpoint is already wired, authenticated by the optional `X-ClaudeDo-Key` header, and exposes `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTaskStatus`, `RunTaskNow`, `CancelTask`. Missing for "full CRUD" are tag handling, content updates, deletion, and tag discovery.
## Scope
| Tool | Status | Notes |
|---|---|---|
| `ListTaskLists` | exists | unchanged |
| `ListTasks` | exists | unchanged |
| `GetTask` | exists | unchanged |
| `AddTask` | extend | add optional `tags` parameter |
| `UpdateTaskStatus` | exists | unchanged (Manual ↔ Queued) |
| `RunTaskNow` | exists | unchanged |
| `CancelTask` | exists | unchanged |
| `UpdateTask` | new | patch title/description/commitType/tags |
| `DeleteTask` | new | delete a task (cascades) |
| `SetTaskTags` | new | replace the full tag set on a task |
| `ListTags` | new | enumerate all known tag names |
Out of scope:
- List CRUD (creating/renaming/deleting lists) — out of scope for this iteration; UI remains the source of truth for list management.
- ListConfig / agent settings overrides — handled by the UI, not surfaced via MCP here.
- Tag CRUD beyond auto-creation during `AddTask` / `UpdateTask` / `SetTaskTags`. There is no `DeleteTag` tool; tag rows live as long as some task references them.
## Authentication
No change. The endpoint continues to be gated by `ExternalMcpAuthMiddleware` — if `WorkerConfig.ExternalMcpApiKey` is set, callers must include `X-ClaudeDo-Key`; otherwise the loopback-only worker is open to local processes.
## Tool specifications
### `AddTask` (extended)
```
AddTask(
listId: string,
title: string,
description: string?,
createdBy: string,
queueImmediately: bool,
tags: string[]?,
cancellationToken)
-> TaskDto
```
Behavior:
- Existing behavior preserved. New `tags` parameter, when non-null, attaches the named tags to the new task.
- Tag names are matched case-insensitively against existing rows; missing tag rows are auto-created (mirrors `TaskRepository.CreateChildAsync`).
- Empty/whitespace tag names are skipped; duplicates are deduplicated.
- `tags` is the LAST parameter before `CancellationToken` so existing positional callers are unaffected (CancellationToken is bound by name in MCP runtime; defensive — see Migration).
### `UpdateTask` (new)
```
UpdateTask(
taskId: string,
title: string?,
description: string?,
commitType: string?,
tags: string[]?,
cancellationToken)
-> TaskDto
```
Behavior:
- Loads the task; throws `InvalidOperationException` if not found.
- **Refuses if status is `Running`** — protects in-flight worktrees and the streaming log.
- Does NOT change status (use `UpdateTaskStatus`) and does NOT change `createdBy`, `listId`, or `parentTaskId` (audit + structural fields, immutable here).
- For each non-null parameter, applies the update. Null means "leave unchanged".
- `tags` semantics: full replacement of the tag set (same as `SetTaskTags`). Auto-creates missing tag rows.
- Broadcasts `TaskUpdated` on the SignalR hub on success.
### `DeleteTask` (new)
```
DeleteTask(taskId: string, cancellationToken) -> void
```
Behavior:
- Loads the task; throws if not found.
- **Refuses if status is `Running`** — caller must `CancelTask` first.
- Calls `TaskRepository.DeleteAsync` (FK cascades remove `task_tags`, `worktrees`, `task_runs`, `subtasks`).
- Broadcasts `TaskUpdated(taskId)` so UIs drop the row.
### `SetTaskTags` (new)
```
SetTaskTags(taskId: string, tags: string[], cancellationToken) -> TaskDto
```
Behavior:
- Convenience wrapper for "I just want to (re)set tags". Equivalent to `UpdateTask(taskId, null, null, null, tags)`.
- Same validation: refuses if `Running`.
- Returns the updated `TaskDto` (with status; tags are not included in `TaskDto` today — see Open Decisions).
### `ListTags` (new)
```
ListTags(cancellationToken) -> { Id: long, Name: string }[]
```
Behavior:
- Returns every row in the `tags` table. No filter, no pagination — the table is small (seed values + user-defined).
- Lets Claude discover existing tag names (`agent`, `manual`, plus any user-defined) before tagging, avoiding duplicates that differ only by case/whitespace.
## Repository changes
`src/ClaudeDo.Data/Repositories/TaskRepository.cs`:
- Add `public Task SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)` — replaces the tag set, auto-creates missing rows. Implementation pattern matches the tag block already inside `CreateChildAsync` and the new `UpdateChildAsync` from the planning-MCP work; consider extracting a private helper `ApplyTagsAsync(TaskEntity, IReadOnlyList<string>, CancellationToken)` shared by both.
`src/ClaudeDo.Data/Repositories/TagRepository.cs`:
- Add `public Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)` if it does not already exist. (Matches `ListRepository.GetAllAsync` style.)
No new tables, no migrations.
## Service changes
`src/ClaudeDo.Worker/External/ExternalMcpService.cs`:
- Add `TagRepository` to the constructor (DI registration is already in place since the planning service uses it).
- Extend `AddTask` signature with `IReadOnlyList<string>? tags` and apply via the repository.
- Add `UpdateTask`, `DeleteTask`, `SetTaskTags`, `ListTags` methods, each annotated `[McpServerTool, Description("…")]`.
- Each new mutating tool calls `_broadcaster.TaskUpdated(taskId)` on success (matches existing pattern in this file).
DI: `ExternalMcpService` is already registered. If `TagRepository` is not already registered (it is — used by `ListRepository`), no change. If a constructor parameter is added, `Program.cs` does not need changes because services are scoped/transient.
## Error handling
All errors raised as `InvalidOperationException` with a human-readable message — matches the existing pattern in `ExternalMcpService` and `PlanningMcpService`. The MCP SDK serializes these to the JSON-RPC error channel; Claude sees the message text directly.
Specific cases:
- Task not found → `"Task {id} not found."`
- Running-task guard → `"Cannot {update|delete} a running task. Cancel it first."`
- Unknown status (in `UpdateTaskStatus`, unchanged) → `"Unknown status '{x}'."`
## Testing
Add `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (or extend if it exists) with:
| Test | Asserts |
|---|---|
| `AddTask_WithTags_AttachesTags` | `tags` param creates and attaches tag rows |
| `AddTask_WithUnknownTag_AutoCreatesTagRow` | new tag name produces a row in `tags` table |
| `UpdateTask_PatchesNonNullFields` | only non-null fields change |
| `UpdateTask_OnRunning_Throws` | `InvalidOperationException` |
| `UpdateTask_BroadcastsTaskUpdated` | hub broadcast received |
| `UpdateTask_TagsReplaceFullSet` | passing tags=[…] replaces existing tags wholesale |
| `DeleteTask_RemovesTaskAndTagJoins` | task and `task_tags` rows gone |
| `DeleteTask_OnRunning_Throws` | `InvalidOperationException` |
| `SetTaskTags_ReplacesAndBroadcasts` | replacement semantics + broadcast |
| `ListTags_ReturnsSeedAndCustomTags` | `agent` + `manual` + any user-defined |
Existing test infrastructure (`DbFixture`, `FakeHubContext`) is reused. No new fakes required.
**Caveat:** the test assembly currently fails to compile on `main` because of pre-existing in-progress work on `PlanningChainCoordinator` (missing constructor argument in `WorkerHub`/`TaskRunner` test instantiations). Tests will pass only after that work lands; do not block this design on it.
## Open decisions (defaults chosen, easy to flip)
1. **`TaskDto` does not currently include tags.** For consistency, the spec keeps `TaskDto` as-is and ships a separate `ListTags` tool. If preferred, we could add `Tags: string[]` to `TaskDto` so every tool response includes them — small DB cost (one extra `SelectMany`), one struct field added. Default: leave `TaskDto` alone, defer.
2. **Per-tag `AddTaskTag` / `RemoveTaskTag` micro-tools.** Skipped — `SetTaskTags` covers the use case, and it's idempotent. Add later if granular ops are wanted.
3. **List CRUD via MCP.** Out of scope. UI owns lists.
## Migration / compatibility
`AddTask` gains an optional parameter. The MCP server SDK sends parameters by name in JSON-RPC `params`, so existing clients that omit `tags` continue to work without code changes. No version bump required.
@@ -0,0 +1,297 @@
# Worker State & Queue Consolidation — Design
**Date:** 2026-04-27
**Status:** Approved (brainstorming)
**Scope:** `ClaudeDo.Worker` + `ClaudeDo.Data` (TaskEntity, TaskRepository), EF migration
## Problem
The worker layer has accumulated structural problems that culminate in a concrete bug — the queue does not pick up tasks created by a planning session.
### Concrete bug
`TaskRepository.FinalizePlanningAsync(parentId, queueAgentTasks=true)` only flips a draft child to `Queued` if the child *or* its list carries the `agent` tag:
```csharp
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
```
When neither carries the tag, the child silently becomes `Manual` — the queue ignores it. There is no UI feedback. Users observe "queue never picks up planning tasks".
### Underlying design issues
1. **Status enum mixes orthogonal concerns.** Today's `TaskStatus` carries 10 values: lifecycle (`Manual, Queued, Running, Done, Failed`), planning hierarchy (`Planning, Planned`), chain ordering (`Waiting`), and an unclear `Draft`. Every consumer has to know which subset applies in which context.
2. **Status writes are scattered.** TaskRunner, StaleTaskRecovery, PlanningChainCoordinator, FinalizePlanningAsync, TaskResetService, ExternalMcpService, and PlanningMcpService all mutate `Status` directly. Some go through `TaskRepository.Mark*Async` helpers, some do `task.Status = …` straight on the DbContext (PlanningChainCoordinator).
3. **Guards are duplicated.** `if (Status == Running) throw …` appears in at least four places (delete, retag, merge, reset).
4. **Two competing planning flows.** `FinalizePlanningAsync` (parallel queueing in Repo) and `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (sequential chain) make incompatible assumptions about child status.
5. **`WakeQueue()` is manual.** Multiple callers must remember to invoke it after any DB mutation that creates a `Queued` task. `QueueSubtasksSequentiallyAsync` forgets to. The queue only picks up after a backstop tick.
6. **`Worker/Services/` is a grab-bag.** Queue, lifecycle, merge, worktree maintenance, agent files, and recovery sit side-by-side without domain boundaries.
## Goals
- One source of truth for status mutations: `TaskStateService`.
- Status enum reflects only lifecycle. Planning state and chain blocking are separate fields.
- Wake-queue side effects are automatic, not caller-driven.
- Planning finalization has exactly one path.
- `Worker/Services/` is split into domain folders.
## Non-Goals
- No change to UI status-rendering logic beyond adapting to renamed values.
- No change to SignalR/MCP wire formats beyond the necessary status-string updates.
- No change to git/worktree behavior.
## Design
### 1. Status model reform
Replace today's single `TaskStatus` with three orthogonal fields on `TaskEntity`.
#### `TaskStatus` (lifecycle only) — 6 values
| Value | Meaning |
|---|---|
| `Idle` | not in queue, not active. Replaces today's `Manual` and `Draft`. |
| `Queued` | waiting for queue pickup. |
| `Running` | currently executing. |
| `Done` | finished successfully. |
| `Failed` | finished with error. |
| `Cancelled` | aborted by user (today conflated with `Failed`). |
#### `PlanningPhase` (parent-only, new column) — 3 values
| Value | Meaning |
|---|---|
| `None` | no planning session. Default for all tasks. |
| `Active` | planning session is running. Replaces `Status=Planning`. |
| `Finalized` | plan is committed, children exist. Replaces `Status=Planned`. |
A parent task can now be `Status=Idle, PlanningPhase=Finalized` simultaneously, enabling re-runs of finalized plans without losing planning metadata.
#### `BlockedByTaskId` (nullable FK, new column) — replaces `Waiting`
- Today: `Status=Waiting` means "waiting on a predecessor in the chain".
- New: `Status=Queued` AND `BlockedByTaskId=<predecessor>`. Picker filters out any row with `BlockedByTaskId IS NOT NULL`.
- `ON DELETE SET NULL` — if predecessor is deleted, child becomes pickable.
### 2. `TaskStateService` (centralized state machine)
The only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All other code goes through it.
```csharp
public interface ITaskStateService
{
Task<TransitionResult> EnqueueAsync(string taskId, CancellationToken ct);
Task<TransitionResult> StartRunningAsync(string taskId, DateTime startedAt, CancellationToken ct);
Task<TransitionResult> CompleteAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> StartPlanningAsync(string parentId, CancellationToken ct);
Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct);
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct);
}
public sealed record TransitionResult(bool Ok, string? Reason);
```
#### Allowed transitions
```
Idle → Queued | Running (RunNow)
Queued → Running | Cancelled | Idle (ResetToIdle)
Running → Done | Failed | Cancelled
Done → Idle (ResetToIdle, for re-run)
Failed → Idle | Queued (re-queue)
Cancelled → Idle | Queued
```
Anything else returns `TransitionResult(false, "invalid transition X→Y")`. No exceptions for invalid transitions — Result pattern keeps callers tolerant.
#### Invariants
1. **Atomic.** Each transition is a single `ExecuteUpdate` (or short tx) using `WHERE Status = <expected>` to be TOCTOU-free.
2. **Validated.** Source status is verified at the SQL level, not in C#.
3. **Side effects (after successful DB write):**
- On any `→ Queued`: `IQueueWaker.Wake()`.
- On any successful transition: `HubBroadcaster.TaskUpdated(taskId)`.
- On `Done`/`Failed`/`Cancelled` for a child task: `IPlanningChainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` and `TryCompleteParent` if applicable.
4. **No caller responsibility for side effects.** A caller only needs to invoke one method.
#### Caller migration
| Today | New |
|---|---|
| `TaskRunner.MarkRunningAsync` | `_state.StartRunningAsync` |
| `TaskRunner.HandleSuccess` (Mark + chain + parent) | `_state.CompleteAsync` (handles all) |
| `TaskRunner.HandleFailure` | `_state.FailAsync` |
| `StaleTaskRecovery.FlipAllRunningToFailedAsync` | `_state.RecoverStaleRunningAsync("worker restart")` |
| `PlanningChainCoordinator.QueueSubtasksSequentiallyAsync` (direct DbContext) | iterates children, calls `_state.EnqueueAsync` for first, `_state.BlockOnAsync` for rest |
| `TaskRepository.FinalizePlanningAsync` | **removed**; `PlanningSessionManager` orchestrates via state-service |
| `TaskResetService` (direct DbContext) | `_state.ResetToIdleAsync` (service only owns worktree-cleanup) |
`Mark*Async` repo helpers stay but become `internal` — used only by `TaskStateService`.
### 3. Queue dispatch & wake mechanics
Three classes, clear responsibilities.
#### `IQueueWaker`
```csharp
public interface IQueueWaker { void Wake(); }
```
- Singleton. Backed by today's `SemaphoreSlim`.
- Called automatically by `TaskStateService` after any `→ Queued` transition.
- Manual `WakeQueue()` calls in app code are removed (Hub `WakeQueue` SignalR endpoint stays for diagnostics but maps directly to `IQueueWaker.Wake`).
#### `IQueuePicker`
```csharp
public interface IQueuePicker
{
Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct);
}
```
- The single place where queue selection happens.
- Filter (all required):
- `Status == Queued`
- `BlockedByTaskId IS NULL`
- `(ScheduledFor IS NULL OR ScheduledFor <= :now)`
- `EXISTS task_tags WHERE name='agent'` OR `EXISTS list_tags WHERE name='agent'`
- Order: `SortOrder ASC, CreatedAt ASC`.
- Atomic claim via `UPDATE … RETURNING` (matching today's pattern), flips `Queued → Running` and writes `StartedAt`.
- Picker is the sole caller of `Queued → Running` transition. `TaskStateService.StartRunningAsync` exists for the override slot path (RunNow / Continue).
#### `QueueService` (BackgroundService) — slimmer
- Wait on wake-signal or backstop timer.
- Call `_picker.ClaimNextAsync`.
- If task: occupy queue slot, run via `_runner.RunAsync`, in `ContinueWith` invoke `_waker.Wake()` for the next pickup.
- No DbContext. No status mutation. No DTO knowledge.
#### `OverrideSlotService` (new)
- Owns `RunNow` and `ContinueTask` (today both in `QueueService`).
- Holds the override slot state.
- Status mutations go through `TaskStateService.StartRunningAsync` (non-atomic claim — caller-driven, fine because override is user-initiated and serialized by slot lock).
### 4. Planning chain integration
Single flow, replaces both `FinalizePlanningAsync` (Repo) and `QueueSubtasksSequentiallyAsync` (Coordinator).
1. `PlanningSessionManager.StartAsync(parentId)``_state.StartPlanningAsync` → parent `PlanningPhase=Active`.
2. User edits children in MCP tool. Children are in `Status=Idle`.
3. `PlanningSessionManager.FinalizeAsync(parentId)`:
- `_state.FinalizePlanningAsync(parentId)` → parent `PlanningPhase=Finalized, Status=Idle`.
- `_chainCoordinator.SetupChainAsync(parentId)`:
- Attaches `agent` tag to all children (automatic — confirmed in brainstorming).
- `_state.EnqueueAsync(children[0])` → wake fires.
- `_state.BlockOnAsync(children[i], children[i-1])` for `i ≥ 1`.
4. When a child finishes, `TaskRunner.HandleSuccess` calls `_state.CompleteAsync(child)`. State-service internally invokes `_chainCoordinator.OnChildFinishedAsync`, which calls `_state.UnblockAsync(nextChild)` (wake fires). Predecessor block goes away because of `ON DELETE SET NULL`-style logic in `UnblockAsync`.
5. When all children are terminal: `_state` runs `TryCompleteParent` and sets parent `Done`/`Failed` based on aggregate.
`TaskRepository.FinalizePlanningAsync` is **deleted**. `QueueSubtasksSequentiallyAsync` is renamed to `SetupChainAsync` and made internal to the coordinator (called only from `PlanningSessionManager.FinalizeAsync`).
### 5. `Worker/Services/` reorganization
```
Worker/
State/
ITaskStateService.cs
TaskStateService.cs
TransitionResult.cs
Queue/
IQueueWaker.cs
IQueuePicker.cs
QueuePicker.cs
QueueService.cs (BackgroundService, slimmer)
OverrideSlotService.cs
QueueSlotState.cs
Lifecycle/
StaleTaskRecovery.cs
TaskResetService.cs
TaskMergeService.cs
Worktrees/
WorktreeMaintenanceService.cs
Agents/
AgentFileService.cs
DefaultAgentSeeder.cs
Runner/ (unchanged)
Planning/ (ChainCoordinator simplified)
External/ (unchanged)
Hub/ (unchanged)
```
`WorkerHub` calls fewer services — typically `_state.X` plus a domain service for non-status work (Merge, Worktree-Cleanup).
### 6. EF migration
```sql
ALTER TABLE tasks ADD COLUMN planning_phase INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tasks ADD COLUMN blocked_by_task_id TEXT NULL REFERENCES tasks(id) ON DELETE SET NULL;
CREATE INDEX ix_tasks_blocked_by ON tasks(blocked_by_task_id);
UPDATE tasks SET status='idle' WHERE status='manual';
UPDATE tasks SET status='idle' WHERE status='draft';
UPDATE tasks SET status='idle', planning_phase=1 WHERE status='planning';
UPDATE tasks SET status='idle', planning_phase=2 WHERE status='planned';
```
`Waiting` migration uses a CTE with `LAG()` to derive `BlockedByTaskId` from `(parent_task_id, sort_order)`:
```sql
WITH ordered AS (
SELECT id,
LAG(id) OVER (PARTITION BY parent_task_id ORDER BY sort_order, created_at) AS prev_id
FROM tasks WHERE status='waiting'
)
UPDATE tasks SET status='queued',
blocked_by_task_id=(SELECT prev_id FROM ordered WHERE ordered.id=tasks.id)
WHERE id IN (SELECT id FROM ordered);
```
Migration runs at worker startup via the existing `MigrateAsync` flow.
`Down()` is best-effort (local-only app). Reverse mapping is lossy: `Cancelled``Failed`, `BlockedByTaskId``Waiting`, planning fields → folded back into status.
### 7. Test strategy
New test fixtures (xUnit, real SQLite, real git where needed):
1. **`TaskStateServiceTests`** — happy path + reject for every transition; mock `IQueueWaker`, `HubBroadcaster`, `IPlanningChainCoordinator` and verify side-effect invocations; concurrency test (two parallel `StartRunningAsync` → exactly one wins).
2. **`QueuePickerTests`** — filter logic (blocked, missing tag, future schedule, wrong status) and ordering (`sort_order, created_at`); two parallel pickers → exactly one claims a row.
3. **`PlanningChainCoordinatorTests`** — `SetupChainAsync` produces correct (`Queued`, `BlockedBy`) layout; `OnChildFinishedAsync` unblocks the next child; child failure leaves remaining blocked, parent transitions to `Failed` after `TryCompleteParent`.
4. **`PlanningEndToEndTests`** — regression for the original bug. `Active` parent + 3 drafts → `Finalize` → assert first child reaches `Running` within 200 ms with no manual `Wake`.
5. **Existing tests** — anything seeding `task.Status = TaskStatus.Manual` or similar gets updated to new enum values or routed through `_state`.
Coverage target: state machine + queue picker at ≥90% branch coverage. Existing coverage levels preserved elsewhere.
### 8. Implementation slices
Each slice is one PR with green tests before the next starts.
1. **Slice 1 — Status model + migration.** New enum values, new columns, EF migration. Existing code mapped to new values mechanically (no behavior change).
2. **Slice 2 — `TaskStateService`.** Service + interface + tests. Migrate TaskRunner, StaleTaskRecovery, ExternalMcp/PlanningMcp guards, TaskResetService. Mark `Mark*Async` repo helpers `internal`.
3. **Slice 3 — `IQueueWaker` + `IQueuePicker`.** Extract from QueueService and Repo. Remove all manual `WakeQueue()` calls in app code.
4. **Slice 4 — Planning flow consolidation.** Delete `FinalizePlanningAsync` from repo. `PlanningSessionManager.FinalizeAsync` orchestrates via state-service + ChainCoordinator. Rename `QueueSubtasksSequentiallyAsync``SetupChainAsync` (internal). E2E test green.
5. **Slice 5 — `OverrideSlotService` + folder reorg.** Extract RunNow / ContinueTask. Move files to new folder structure. Update DI registration.
6. **Slice 6 — Cleanup & docs.** Update `Worker/CLAUDE.md`, `docs/plan.md`. Remove dead helpers.
## Risks & Mitigations
- **EF migration on existing DBs.** Tested via integration tests that load a pre-migration fixture DB. `MigrateAsync` is already in production use, low risk.
- **State-service becomes a god-object.** Mitigated by keeping it narrow: only status/phase/blocked-by writes, no business logic. Worktree, merge, and runner concerns stay in their own services.
- **Two paths to `Running` (picker atomic, state-service for override).** Confirmed acceptable in brainstorming. Picker remains the only atomic-claim path; override slot is serialized by slot lock so non-atomic is safe.
- **Waiting-migration CTE.** SQLite supports `LAG()` since 3.25. .NET 8's bundled SQLite is well above. Tested in migration unit tests.
## Open Questions
None at design time. All knackpunkte resolved during brainstorming.
@@ -0,0 +1,272 @@
# Tabbed Settings + Prime Claude — Design
**Date:** 2026-04-28
**Status:** Draft for review
## Goal
Two related UI changes:
1. Restructure the existing **Settings modal** from a single scrollable stack into a `TabControl` with focused tabs. Move the read-only "About" content out of Settings entirely, into a new modal accessible from the existing Help menu.
2. Add a new **Prime Claude** tab where the user defines date-bounded daily schedules. At each scheduled time, the worker fires a single non-interactive `claude -p "ping" --max-turns 1` call to start Claude's 5-hour usage window early — "priming" the day.
## Scope
### In scope
- Settings tabbed UI with 4 tabs: General, Worktrees, Files, Prime Claude.
- New About modal opened from `MainWindow` Help menu.
- New `PrimeSchedules` table, repository, EF migration.
- New `PrimeScheduler` background service (event-driven, no polling).
- New SignalR hub methods + client wiring.
- Footer notification on prime fire (success/failure) via `StatusBarView`.
- 30-minute catch-up window on app launch / wake.
- Tests: scheduler unit tests, tab VM tests.
### Out of scope
- Auto-start ClaudeDo at OS boot.
- Multiple pings per day per schedule.
- Per-schedule prompt customization (schema reserves the column for future use).
- Holiday / calendar integration.
- Toast notifications, sound, OS-level notifications.
## Settings tab layout
| Tab | Contents (existing sections, no field changes) |
|---|---|
| **General** | Claude Defaults: instructions, model, max turns, permission mode |
| **Worktrees** | Strategy, central root, auto-cleanup, Cleanup button, Force-remove confirm flow |
| **Files** | Agents (Restore default agents) + Prompts (System / Planning / Agent open-in-editor rows) |
| **Prime Claude** | New — schedule list + add button (see below) |
- Window stays 580×760, custom title bar preserved.
- Footer (Save / Cancel) preserved; Save iterates per-tab VMs.
- Status / validation strip stays above the footer.
- Tab strip uses the existing section-label style for headers (mono, 10pt, letter-spacing 1.4) so it visually matches the current aesthetic.
## About modal
New `AboutModalView` + `AboutModalViewModel`:
- Same 4 rows as today's About section: Version, Data folder, Logs folder, Worker config — each with an Open button.
- Compact dialog (~480×280), same chrome as `SettingsModalView`.
- Wired into `MainWindow` Help menu as a new `<MenuItem Header="About…">` next to "Check for updates".
- About content removed from `SettingsModalView` entirely (cleaner: not a setting).
## Prime Claude tab — UI
```
┌────────────────────────────────────────────────────────────────┐
│ Prime your Claude usage window each morning by firing a single │
│ non-interactive `ping` call at a chosen time. Only runs while │
│ ClaudeDo is open. If the app starts within 30 min of the target │
│ time, the ping fires immediately (catch-up window). │
├────────────────────────────────────────────────────────────────┤
│ ☑ May 5, 2026 → Jun 30, 2026 07:00 MonFri last: today ✕│
│ ☐ Jul 1, 2026 → Jul 7, 2026 09:30 All days — ✕│
├────────────────────────────────────────────────────────────────┤
│ [+ Add schedule] │
└────────────────────────────────────────────────────────────────┘
```
Per-row controls:
- Enabled checkbox (`Enabled`)
- Start date picker (`StartDate`)
- End date picker (`EndDate`)
- Time-of-day field (`TimeOfDay`, 24h, e.g. `07:00`)
- Workdays-only checkbox (`WorkdaysOnly`)
- Last run label (`{LastRunAt:g}` or `—` if null)
- Delete button (✕, with inline confirm bar matching the Worktrees pattern)
`+ Add schedule` appends a new row pre-filled with: today, today + 30 days, `07:00`, `WorkdaysOnly = true`, `Enabled = true`.
Validation per row:
- `StartDate <= EndDate`
- `TimeOfDay` parses as `HH:mm`
- `EndDate >= today` (else mark row disabled-looking + tooltip "expired")
Persistence: rows save with the rest of the modal on **Save**. On Save, `PrimeClaudeTabViewModel` diffs in-memory rows against the loaded snapshot and emits one hub call per change: `UpsertPrimeSchedule` for new/edited rows, `DeletePrimeSchedule` for removed rows. Cancel discards in-memory edits. No per-row autosave.
## Data model
New EF Core entity `PrimeScheduleEntity` in `ClaudeDo.Data/Models/`:
```csharp
public class PrimeScheduleEntity
{
public Guid Id { get; set; }
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
public TimeSpan TimeOfDay { get; set; } // local clock
public bool WorkdaysOnly { get; set; }
public bool Enabled { get; set; }
public DateTimeOffset? LastRunAt { get; set; }
public string? PromptOverride { get; set; } // reserved, always null today
public DateTimeOffset CreatedAt { get; set; }
}
```
- New `PrimeScheduleConfiguration : IEntityTypeConfiguration<PrimeScheduleEntity>` in `Configuration/`.
- New repository `PrimeScheduleRepository` matching the existing async + CancellationToken pattern. Methods: `ListAsync`, `GetAsync(id)`, `UpsertAsync(entity)`, `DeleteAsync(id)`, `UpdateLastRunAsync(id, when)`.
- EF migration `AddPrimeSchedules` (auto-named per existing migration history).
## Worker scheduler — `PrimeScheduler`
New folder `ClaudeDo.Worker/Prime/`. Class hierarchy:
- `PrimeScheduler : BackgroundService` — event-driven loop.
- `IPrimeRunner` / `PrimeRunner` — fires the actual `claude -p "ping" --max-turns 1` call. Injected so tests can fake it.
- `IPrimeClock` / `PrimeClock``DateTimeOffset Now { get; }`. Faked in tests.
- `PrimeSchedulerOptions``CatchUpWindow = TimeSpan.FromMinutes(30)`. Hardcoded today; typed for swappability.
### Loop
```text
while not cancelled:
next = ComputeNextDue(now) # null if no enabled schedules
if next is null:
await wait-on-signal # blocks until schedules change
continue
delay = max(0, next.At - now)
try:
await Task.Delay(delay, linkedToken) # cancellable by signal
catch OperationCanceledException:
continue # schedules changed → recompute
await Fire(next.Schedule)
```
`ComputeNextDue(now)`:
- For each enabled schedule:
- Determine the next eligible date `d >= today` within `[StartDate, EndDate]`, honoring `WorkdaysOnly`.
- Skip the day if `LastRunAt.LocalDate == today` (already fired today).
- Build `target = d.At(TimeOfDay)` in local time.
- Apply catch-up: if `target < now <= target + 30min` and not already fired today, target = `now` (fire immediately).
- If `target < now` (past catch-up window) and `d == today`, advance `d` to next eligible date.
- Return the schedule with the smallest `target`.
### Signal source
`IPrimeScheduleSignal` — a thin abstraction wrapping a `CancellationTokenSource` reset. The hub calls `Signal()` on:
- App start (initial recompute is implicit — service first-run computes immediately).
- After `UpsertPrimeSchedule` / `DeletePrimeSchedule`.
- After a successful fire (so the next-due is recomputed without polling).
### Fire
`PrimeRunner.FireAsync(schedule, ct)`:
1. Resolve `claude` executable via existing `ClaudeProcess` discovery.
2. Spawn with `cwd = Paths.AppDataRoot()`, args `["-p", "ping", "--max-turns", "1"]`. No worktree, no task entity, no list/tag side effects.
3. Capture stdout/stderr; success = exit 0 within a 60s timeout.
4. On finish: `await PrimeScheduleRepository.UpdateLastRunAsync(id, now)`, append a one-line summary to `~/.todo-app/logs/prime.log`, broadcast `PrimeFired(success, message, timestamp)` via `HubBroadcaster`.
Failure modes (network, auth, executable missing) → broadcast a failure message; `LastRunAt` still stamped so the day doesn't keep retrying.
## SignalR / IPC
### Hub methods (`WorkerHub`)
```csharp
Task<IReadOnlyList<PrimeScheduleDto>> ListPrimeSchedules();
Task<PrimeScheduleDto> UpsertPrimeSchedule(PrimeScheduleDto dto);
Task DeletePrimeSchedule(Guid id);
```
DTO mirrors entity minus `CreatedAt` (server-managed).
### Hub events (broadcast)
```csharp
event PrimeFired(Guid scheduleId, bool success, string message, DateTimeOffset firedAt);
```
The `scheduleId` lets an open Settings modal update the matching row's `LastRunAt` without a full reload. No separate `PrimeSchedulesChanged` event — Settings is the only writer, so the modal's own VM state is authoritative until Save.
`WorkerClient` adds matching async methods + the event handler.
## UI wiring
### ViewModel split
`SettingsModalViewModel` stops holding field properties directly and becomes a coordinator:
```csharp
public sealed partial class SettingsModalViewModel
{
public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; }
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; }
[RelayCommand] private async Task Save() { ... iterate tabs, call SaveAsync on each ... }
}
```
Each tab VM:
- Owns its observable properties.
- Has `Task LoadAsync()` and `Task SaveAsync()` (or returns a partial DTO the coordinator merges).
- Owns its own validation, surfaces `ValidationError`.
`PrimeClaudeTabViewModel`:
- `ObservableCollection<PrimeScheduleRowViewModel> Rows`
- `[RelayCommand] AddSchedule()` / `RemoveSchedule(id)`
- Subscribes to `WorkerClient.PrimeSchedulesChanged` / `PrimeFired` to keep rows fresh while modal is open.
### Footer notification
`StatusBarViewModel`:
- New `string? PrimeStatus` property.
- Subscribes to `WorkerClient.PrimeFired`.
- On event: set `PrimeStatus`, start a `DispatcherTimer` for 5s, clear on tick.
- `StatusBarView` gets a `TextBlock` bound to `PrimeStatus`, right-aligned, dim-foreground, only visible when non-empty.
Format: `"✓ Primed Claude at 07:01"` or `"⚠ Prime failed: <reason>"`.
### About wiring
- `MainWindowViewModel` adds `[RelayCommand] OpenAbout()` — opens `AboutModalView` via the existing dialog factory pattern.
- `MainWindow.axaml` Help menu gains `<MenuItem Header="About…" Command="{Binding OpenAboutCommand}"/>`.
## Tests
### `ClaudeDo.Worker.Tests/Prime/PrimeSchedulerTests.cs`
Real SQLite, fake `IPrimeClock`, fake `IPrimeRunner`. Cases:
- Fires once at exact target time.
- Fires immediately on startup if within catch-up window.
- Skips firing if past catch-up window (waits for next eligible day).
- Honors `WorkdaysOnly` (no fire on Sat/Sun).
- Honors date range (no fire before StartDate, none after EndDate).
- Idempotent: doesn't double-fire if `LastRunAt` is today.
- Recomputes on signal (upsert mid-wait).
- Disabling a schedule mid-wait recomputes.
### `ClaudeDo.Ui.Tests/ViewModels/PrimeClaudeTabViewModelTests.cs`
Cases:
- Add row appends with sensible defaults.
- Remove row removes from collection.
- Validation: StartDate > EndDate flags row as invalid.
- Save serializes all rows to repository in one batch.
- `PrimeFired` event updates the matching row's `LastRunAt`.
### `ClaudeDo.Ui.Tests/ViewModels/StatusBarViewModelTests.cs` (extend existing if present, else new)
- `PrimeFired` sets `PrimeStatus` and clears it after 5s (use a fake `IDispatcherTimer` or an injectable delay).
## Migration / rollout
- Single EF migration `AddPrimeSchedules`. Existing DBs upgrade on next launch via the existing migration runner (no manual step).
- No data backfill — table starts empty. Users add schedules manually via the new tab.
- Backwards compatibility for `AppSettingsEntity`: untouched.
## Risks & mitigations
| Risk | Mitigation |
|---|---|
| App is closed at scheduled time | 30 min catch-up on launch; explicit copy in tab explains the limitation. |
| Clock/timezone change while waiting | `Task.Delay` fires on monotonic time; recompute after each fire catches drift on next iteration. Acceptable for a 5h-window primer. |
| Claude CLI hangs | 60s timeout on the spawn; failure stamped + broadcast. |
| Multiple ClaudeDo instances on same machine | Out of scope (existing app already assumes single instance via fixed SignalR port). |
| User edits schedule while scheduler is mid-fire | Fire completes, then signal triggers recompute. No race — `UpdateLastRunAsync` is the last write. |
## Open questions
None at design time. Implementation may surface small details (e.g. exact Avalonia controls for date/time pickers — likely `CalendarDatePicker` + a `TextBox` masked to `HH:mm` since Avalonia 12 has no built-in TimePicker on all platforms).
@@ -0,0 +1,206 @@
# Worktree Overview Modal — Design
**Status:** Approved
**Date:** 2026-05-19
## Problem
Worktree management is becoming hard to oversee. The current UI only exposes per-task worktree actions (merge / keep / discard) from `TaskDetailView`, plus two global maintenance buttons (`CleanupFinishedWorktrees`, `ResetAllWorktrees`). There is no view that shows *all existing worktrees at a glance* with their state, age, branch, and diff stat. Stale or "phantom" worktrees (DB row but missing directory, or vice versa) have no targeted recovery path.
## Goals
- A modal that lists every worktree row from the DB, joined with task + list metadata.
- Two entry points: filtered to one list (List context menu), and global grouped by list (Help menu).
- Quick per-row actions hidden behind a right-click context menu.
- Targeted force-remove for stuck / phantom worktrees.
- Manual refresh only; no live SignalR subscription needed.
## Non-Goals
- No auto-refresh / live updates from SignalR events.
- No UI tests (the project has none for the Ui project).
- No changes to `WorktreeManager`, `TaskRunner`, or the existing per-worktree file-tree modal (`WorktreeModalView`) — it gets reused as the "Show diff" target.
## UI
### New view pair
`WorktreesOverviewModalView` + `WorktreesOverviewModalViewModel`, parallel to existing `WorktreeModalView` (which shows the *file tree inside one* worktree).
### Layout
```
┌─ Worktrees [List "Foo"] or Worktrees (all) ───────────────┐
│ [ Refresh ] [ Cleanup finished ] │
│ │
│ ▼ List Foo (global mode only) │
│ Title Branch State +/- Age │
│ Fix login bug claudedo/ab… Active +42-7 3h ago │
│ Add API … claudedo/cd… Merged +8 -0 1d ago │
│ ▼ List Bar │
│ … │
└──────────────────────────────────────────────────────────────┘
```
- `DataGrid` (or `ItemsControl` with Grid template) for rows.
- List-filtered mode: no group headers, just the table.
- Global mode: `Expander` per list with list name as header (default expanded).
- State as a colored badge — new `WorktreeStateColorConverter` analogous to `StatusColorConverter`:
- Active=Blue, Merged=Green, Discarded=Gray, Kept=Orange.
- Right-click on a row opens a `MenuFlyout` with all actions.
- Phantom rows (`PathExistsOnDisk == false`) get a small warning icon in the Path tooltip area.
### Default sort
State (Active first), then `CreatedAt` descending. Same inside each list group in global mode.
### Per-row context menu
| Item | Enabled when | Behavior |
|---|---|---|
| Show diff | always | Opens existing `WorktreeModalView` with `WorktreePath` set |
| Open in Explorer | `PathExistsOnDisk == true` | `Process.Start("explorer.exe", path)` |
| Jump to task | always | Closes modal, selects list + task in main window |
| Merge | `State == Active` | Calls existing `MergeTask` hub method |
| Discard | `State == Active` | `SetWorktreeState(taskId, Discarded)` |
| Keep | `State == Active` | `SetWorktreeState(taskId, Kept)` |
| Copy branch | always | Clipboard |
| Copy path | always | Clipboard |
| —————— | | (separator) |
| Force remove | `Task.Status != Running` | Confirmation dialog → `ForceRemoveWorktree(taskId)` (red label) |
### Bulk buttons (toolbar)
- **Refresh** — re-runs `GetWorktreesOverview`.
- **Cleanup finished** — `CleanupFinishedWorktrees(listId)`; in list-filtered mode acts on that list, in global mode on all.
### Entry points
- **List context menu** → "Worktrees anzeigen…" → opens modal in filtered mode (`listId` = the list).
- **Help menu** → "Worktrees" → opens modal in global mode (`listId = null`).
`MainWindowViewModel` gets `OpenWorktreesOverviewCommand(listId)` and `OpenWorktreesOverviewGlobalCommand()`, both using a DI `Func<WorktreesOverviewModalViewModel>` factory analogous to existing editor patterns.
## SignalR Contract
### New `WorkerHub` methods
```csharp
Task<IReadOnlyList<WorktreeOverviewDto>> GetWorktreesOverview(string? listId);
Task<bool> SetWorktreeState(string taskId, WorktreeState newState);
Task<ForceRemoveResultDto> ForceRemoveWorktree(string taskId);
```
`CleanupFinishedWorktrees` already exists — extend its signature to accept an optional `listId`:
```csharp
Task<CleanupResult> CleanupFinishedWorktrees(string? listId); // was: ()
```
`MergeTask` is reused unchanged.
### DTOs
```csharp
public sealed record WorktreeOverviewDto(
string TaskId,
string TaskTitle,
TaskStatus TaskStatus,
string ListId,
string ListName,
string Path,
string BranchName,
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
```
### Broadcasts
After successful `SetWorktreeState` and `ForceRemoveWorktree`, fire `HubBroadcaster.WorktreeUpdated(taskId)` so `TaskDetailView` (if open) refreshes. `CleanupFinishedWorktrees` already broadcasts; keep behavior, optionally batch.
### `WorkerClient` (UI)
Add wrapper methods for the four new/changed hub calls.
## Backend Changes
### `WorktreeMaintenanceService`
```csharp
public sealed record ForceRemoveResult(bool Removed, string? Reason);
public Task<IReadOnlyList<WorktreeOverviewRow>> GetOverviewAsync(string? listId, CancellationToken ct);
public Task<CleanupResult> CleanupFinishedAsync(string? listId, CancellationToken ct); // signature extended
public Task<ForceRemoveResult> ForceRemoveAsync(string taskId, CancellationToken ct);
```
- `GetOverviewAsync` — joins `worktrees × tasks × lists` (`AsNoTracking`), maps to DTO including `PathExistsOnDisk = Directory.Exists(path)`.
- `CleanupFinishedAsync(listId)` — same join as today but also filters `t.ListId == listId` when not null.
- `ForceRemoveAsync` — refactors existing `TryRemoveAsync(row, force: true, …)` into a single-row entry point shared with `ResetAllAsync`. Refuses when the task is currently `Running`, returning `ForceRemoveResult(false, "task is currently running")`. Otherwise removes the worktree directory, prunes, deletes the branch, deletes the DB row.
### `WorktreeRepository`
`SetStateAsync(string taskId, WorktreeState newState, CancellationToken ct)` already documented in CLAUDE.md. If absent, add it; if present, just expose it via the hub.
### Unchanged
`WorktreeManager`, `TaskRunner`, `WorktreeModalView`, all existing merge / cleanup flows.
## Data Flow
1. User opens modal → `WorkerClient.GetWorktreesOverviewAsync(listId)` → bind rows.
2. Refresh button → same call.
3. Per-row action → corresponding hub call → on success, update the affected row locally (no full reload).
4. Bulk Cleanup → hub call → full reload.
## Force-Remove Semantics
| Initial state | Result |
|---|---|
| Active, task not Running | Worktree dir removed, branch deleted, DB row deleted. Task remains in current status (Done/Failed/Idle). |
| Active, task Running | Refused with reason "task is currently running". |
| Merged / Discarded / Kept | Same removal path. |
| Phantom (dir missing) | DB row deleted, branch best-effort deleted. |
## Testing
New tests in `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs` (real SQLite, real git):
1. `GetOverviewAsync_returns_all_when_listId_null`
2. `GetOverviewAsync_filters_by_listId`
3. `GetOverviewAsync_flags_PathExistsOnDisk_false_for_phantom_row`
4. `CleanupFinishedAsync_filters_by_listId`
5. `ForceRemoveAsync_removes_active_worktree` (happy path incl. branch delete)
6. `ForceRemoveAsync_blocked_when_task_running`
7. `ForceRemoveAsync_removes_phantom_row`
UI verification (manual):
- Open from list context menu → only that list's rows.
- Open from Help menu → all lists grouped, default expanded.
- Force-remove an Active worktree → row vanishes, DB row gone, branch deleted.
- Force-remove while task Running → toast / dialog with reason, row unchanged.
- Cleanup finished in filtered mode → only finished rows of the selected list disappear.
- "Show diff" reuses existing `WorktreeModalView`.
## Files Touched
**New:**
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml.cs`
- `src/ClaudeDo.Ui/Converters/WorktreeStateColorConverter.cs`
- `src/ClaudeDo.Worker/Worktrees/WorktreeOverviewDto.cs` (or extend an existing DTOs file)
**Modified:**
- `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- `src/ClaudeDo.Ui/ViewModels/MainWindowViewModel.cs`
- `src/ClaudeDo.Ui/Views/MainWindow.axaml` (Help menu entry, list context menu entry)
- `src/ClaudeDo.App/Program.cs` (DI registration of new VM)
- `tests/ClaudeDo.Worker.Tests/Services/WorktreeMaintenanceServiceTests.cs`
@@ -0,0 +1,118 @@
# Planning: Draft → Planned → Queue gate
**Date:** 2026-05-29
**Status:** Approved (design)
## Problem
When a planning parent is finalized, `PlanningChainCoordinator.SetupChainAsync` immediately
enqueues the entire child chain (child[0] runs, successors wait blocked on their predecessor).
There is no review step: a user cannot hold finalized subtasks in a "ready but not running"
state, and the "DRAFT" label in the UI is only a derived side effect
(`TaskRowViewModel.IsDraft => IsChild && Status == Idle`) with no gate behind it — a draft
child already satisfies `CanSendToQueue` and can be queued directly.
We want an explicit lifecycle for planning children:
- **Draft** — child of a plan still being built (parent `PlanningPhase == Active`). Not queueable.
- **Planned** — child of a finalized plan (parent `PlanningPhase == Finalized`), still `Idle`. Queueable.
Finalizing a plan promotes its children Draft → Planned **without** queuing anything. The user
then explicitly sends the plan to the queue, which builds the sequential chain (today's behavior,
just user-triggered). The gate is enforced in both the UI and the server so no path (UI, MCP,
external agents) can queue or run a Draft child.
## Decisions
- **Q1 — Finalize semantics:** Finalize auto-marks children **Planned** (not Draft); nothing is
queued until the user explicitly sends to queue. Draft exists only while the plan is unfinalized.
- **Q2 — Queue granularity:** A single **parent-level** "Send plan to queue" action queues all
Planned children as a sequential chain (reuses `SetupChainAsync`). No per-child queueing.
- **Q3 — Enforcement:** UI **and** server. The gate is a server invariant in `TaskStateService`,
so MCP / external agents are bound by it too.
- **Data model — Approach 1 (derive, no schema change):** Draft/Planned is a pure function of the
parent's `PlanningPhase`. No new column, no migration, no parent/child drift.
## Core invariant
No schema change. A child task's stage is derived from its parent's `PlanningPhase`:
| Parent `PlanningPhase` | Child (`Status = Idle`) | Queueable? |
|---|---|---|
| `Active` (plan being built) | **DRAFT** | no |
| `Finalized` | **PLANNED** | yes |
**Server invariant:** a child task (`ParentTaskId != null`) may transition `Idle → Queued` or
`Idle → Running` **only if** its parent's `PlanningPhase == Finalized`. Standalone (non-child)
tasks are unaffected.
A failed/cancelled child returning to `Idle` while its parent is still `Finalized` is therefore
"Planned" again and re-queueable — desired.
## Components
### Worker / server
1. **`TaskStateService` transition guard** — the single enforcement point. When a child task is
about to enter `Queued` or `Running`, look up the parent's `PlanningPhase`; if it is not
`Finalized`, return a failed `TransitionResult` (no exception — consistent with the existing
no-throw transition pattern). This covers:
- UI single-task enqueue (`SetTaskStatus → Queued`)
- `RunNow` (`StartRunningAsync`, `Idle → Running`)
- the queue picker's `Queued → Running` claim (defense in depth; a Draft child can't reach
`Queued` in the first place)
- MCP `UpdateTaskStatus(Queued)` / `RunTaskNow`
2. **Finalize stops auto-queuing** — audit every `FinalizeAsync(taskId, queueAgentTasks, ct)`
call site and pass `queueAgentTasks: false`. Known callers to update: the UI finalize command
and the planning-MCP finalize tool. After this, `FinalizeAsync` only flips the parent to
`Finalized` (children become Planned); `SetupChainAsync` is no longer invoked from finalize.
3. **New queue action** — add `WorkerHub.QueuePlan(parentTaskId)`
`PlanningChainCoordinator.SetupChainAsync(parentTaskId)`. Guarded so it only runs when the
parent is `Finalized`; otherwise returns a failure the UI surfaces. This is the user-triggered
replacement for the auto-chain.
### UI
4. **`TaskRowViewModel`**
- Add `ParentFinalized` (`bool`), set by `TasksIslandViewModel`.
- `IsDraft => IsChild && Status == Idle && !ParentFinalized`
- `IsPlanned => IsChild && Status == Idle && ParentFinalized`
- `CanSendToQueue` gains `&& (!IsChild || ParentFinalized)`
- Child badge renders `DRAFT` / `PLANNED` (drive off `IsDraft` / `IsPlanned`).
- Raise `PropertyChanged` for the new derived members from the relevant `On*Changed` hooks
(`OnStatusChanged`, `OnParentTaskIdChanged`, and a new `OnParentFinalizedChanged`).
5. **`TasksIslandViewModel`** — when building/refreshing rows, resolve each child's parent
`PlanningPhase` from the loaded task set and set `ParentFinalized`. If the parent is not in the
loaded set, default to `false` (Draft — the safe, non-queueable default).
6. **`DetailsIslandViewModel`**
- `CanEnqueue` for a selected child additionally requires the parent to be `Finalized`.
- Add a parent-level **"Send plan to queue"** command, enabled when the selected task is a
`Finalized` planning parent with at least one Planned (`Idle`) child and nothing already
queued/running; calls `QueuePlanAsync(parentId)`.
7. **`IWorkerClient` / `WorkerClient`** — add `QueuePlanAsync(string parentId)`. Update the test
fakes (UI + Worker test projects) to implement the new member.
## Testing
- **Worker (`TaskStateService`):** child enqueue/run rejected when parent `Active`; allowed when
parent `Finalized`. Standalone task enqueue still allowed. Picker skips/ignores draft children.
- **Worker (finalize):** `FinalizeAsync(..., queueAgentTasks: false)` flips parent to `Finalized`
and queues nothing; children remain `Idle`.
- **Worker (`QueuePlan`):** on a `Finalized` parent, builds the sequential chain (child[0]
unblocked + queued, successors blocked on predecessor); on a non-`Finalized` parent, fails.
- **UI VM (`TaskRowViewModel`):** Draft vs Planned derivation and `CanSendToQueue` gating across
parent phases; badge text.
- **UI VM (`DetailsIslandViewModel`):** `CanEnqueue` gating for children; "Send plan to queue"
enablement.
## Out of scope
- Per-child manual promotion while a plan is still being built (Draft → Planned without
finalizing). Promotion happens only via finalize.
- Per-child independent queueing (Q2 = parent-level chain only).
- Any database schema / migration change.
@@ -0,0 +1,138 @@
# Repo Import List Helper — Design
**Date:** 2026-05-29
**Status:** Approved (pending spec review)
## Problem
Creating lists is one-at-a-time: click `+ New list`, then open List Settings to set the
working directory. Users with many repos under a few parent folders want to wire them all up
in one pass.
## Goal
A "list helper" that scans one or more parent folders for git repos, presents them as a
checklist, and bulk-creates a list (with `WorkingDir` pre-filled) for each ticked repo.
## Entry Points
1. **Help menu** — the title-bar dropdown in `MainWindow.axaml` that contains `About…`,
`Worktrees…`, etc. Add a new `MenuItem` `Add repos as lists…` wired to a command on
`MainWindowViewModel`.
2. **Lists island** — a small folder icon button beside the existing `+ New list` button in
`ListsIslandView.axaml`, wired to a command on `ListsIslandViewModel`.
Both open the same modal.
## Components
### `RepoScanner` (new, `ClaudeDo.Ui/Services` or `ClaudeDo.Data`)
Pure filesystem helper, no git library. Given a parent folder path, enumerates immediate
subdirectories and returns those that contain a `.git` entry (directory or file). Kept
separate from the VM so it is unit-testable.
```
IReadOnlyList<RepoCandidate> Scan(string parentFolder)
record RepoCandidate(string Name, string FullPath)
```
- Skips the parent itself; only immediate children are considered (non-recursive).
- `.git` may be a directory (normal repo) or a file (worktree/submodule) — both count.
- Returns empty on missing/unreadable folder rather than throwing.
### `RepoImportModalViewModel` (new, `ClaudeDo.Ui/ViewModels/Modals`)
Follows the existing modal-VM pattern (`CloseAction`, resolved from DI).
Dependencies:
- `IDbContextFactory<ClaudeDoDbContext>` — load existing lists' `WorkingDir` values (for the
"already added" check) and create new `ListEntity` rows. Same dependency
`ListsIslandViewModel` already uses.
State:
- `ObservableCollection<RepoImportItemViewModel> Repos` — the combined checklist.
- A set of parent folder paths already scanned (to de-dupe re-adds).
- `CreateCount` — computed count of ticked-and-new rows (drives the confirm button label).
Commands:
- `AddFolderAsync` — invokes the folder picker (via view code-behind callback, see below),
scans each chosen folder with `RepoScanner`, appends new candidates. De-dupes by full path
(case-insensitive) against rows already present.
- `CreateAsync` — for each ticked, non-existing row, create a `ListEntity` via
`ListRepository.AddAsync` (Name = folder name, WorkingDir = full path,
DefaultCommitType = `CommitTypeRegistry.DefaultType`, fresh `Guid` id, `CreatedAt` = now).
Then `CloseAction()`.
- `Cancel``CloseAction()`.
On load, fetch all existing lists once and capture their `WorkingDir`s into a case-insensitive
set; each appended candidate whose path is in that set is marked `AlreadyAdded`.
### `RepoImportItemViewModel` (new)
- `Name`, `FullPath` (display).
- `AlreadyAdded` (bool) — true if a list already points at this path.
- `IsChecked` ([ObservableProperty]) — defaults `true` for new repos. For already-added rows it
is forced `true` and the checkbox is disabled.
- `CanToggle` => `!AlreadyAdded` (binds to checkbox `IsEnabled`).
### `RepoImportModalView` (new, `ClaudeDo.Ui/Views/Modals`)
A `Window` styled like the other modals (header bar, body, footer), shown via
`ShowDialog(owner)`.
- **Header:** title `ADD REPOS AS LISTS` + close button.
- **Top of body:** `Add folder…` button.
- **Body:** scrollable `ItemsControl` over `Repos`. Each row = `CheckBox` (IsChecked two-way,
IsEnabled = `CanToggle`) + repo name + dim full path + `(already added)` label when
`AlreadyAdded`.
- **Footer:** `Create {CreateCount} lists` button (disabled when `CreateCount == 0`) + `Cancel`.
- Folder picker lives in the code-behind (mirrors `ListSettingsModalView.BrowseClicked`):
`OpenFolderPickerAsync` with `AllowMultiple = true`, results handed to the VM's
`AddFolderAsync`.
## Data Flow
1. User opens the modal from either entry point → modal loads existing lists' `WorkingDir`s.
2. User clicks `Add folder…` → picks one or more parent folders → `RepoScanner` finds repos →
rows appended (de-duped), already-added rows shown ticked+disabled.
3. User adjusts ticks → clicks `Create N lists`.
4. VM creates one `ListEntity` per ticked-new row via `ListRepository`.
5. Modal closes → the **caller reloads the Lists island** so new lists appear:
- Lists-island entry point: `ListsIslandViewModel.LoadAsync()`.
- Help-menu entry point: `MainWindowViewModel` reloads its `Lists` (the
`ListsIslandViewModel` instance) after the modal closes.
## DI / Wiring
- Register `RepoImportModalViewModel` (transient) alongside other modal VMs.
- Register `RepoScanner` if implemented as an injected service; a static helper needs no
registration.
- `ListsIslandViewModel` gains `Func<RepoImportModalViewModel, Task>? ShowRepoImportModal` and
an `OpenRepoImportCommand`, wired in `ListsIslandView.axaml.cs` (mirrors
`ShowListSettingsModal`).
- `MainWindowViewModel` gains the same `Func` + an `OpenRepoImportCommand`, wired in
`MainWindow.axaml.cs`.
## Error Handling
- Unreadable / missing folders: `RepoScanner` returns empty, no crash.
- Re-adding a folder already scanned: de-duped by path, no duplicate rows.
- Two ticked repos sharing a folder name: both created (list names are not unique) — acceptable.
- List creation failure (rare): best-effort per the existing pattern; do not block remaining
creations.
## Testing
- `RepoScanner` unit tests (the testable seam): a temp directory tree with a mix of git repos
(`.git` dir), a `.git`-file repo, plain folders, and an empty/missing parent. Assert only the
repo subfolders are returned and missing folders yield empty.
- VM-level "already added" logic and `CreateCount` can be exercised if a test seam is convenient,
but the filesystem scanner is the primary unit under test. UI wiring verified manually.
## Out of Scope (YAGNI)
- Recursive / deep scanning.
- Inline editing of the list name before creation.
- Setting model / system prompt / agent during import (tuned later per-list in List Settings).
- Picking repo folders directly (only parent-folder scan, per decision).
@@ -0,0 +1,165 @@
# Worker per-user autostart (drop Windows service)
Status: approved 2026-05-29
Author: brainstorm session (mika kuns + Claude)
## Problem
The worker runs as a Windows **service** registered under `LocalSystem`. The worker
shells out to the `claude` CLI, whose authentication is stored per-user
(`%USERPROFILE%\.claude`). Under `LocalSystem` the worker uses the system profile and
cannot see the user's Claude login, so task execution fails. The installer even exposes a
"Current User" service-account radio that the backend rejects (`RegisterServiceStep`
fails the install). Net effect: the only installable configuration cannot authenticate
Claude.
## Goal
Run the worker as the logged-in **user** so it inherits the user's Claude auth, starting
automatically at logon and staying alive in the background (independent of the desktop
app, so Prime/scheduled tasks fire when the UI is closed).
## Decisions (locked)
1. **Lifetime:** background from logon, always — independent of the UI.
2. **Mechanism:** per-user **logon Scheduled Task** (`schtasks`), run only when the user is
logged on (no stored password), hidden, with restart-on-failure.
3. **No console window:** worker becomes `WinExe`; add a **Serilog rolling file sink** so
worker diagnostics aren't lost.
4. **App ensures running:** "Restart Worker" becomes process-based; on app startup, if
SignalR doesn't connect within a few seconds, the app launches the worker.
5. **Auto-migrate:** the installer detects and removes the old `ClaudeDoWorker` service,
then registers the task. Uninstall removes the task + kills the worker process.
## Non-goals
- Cross-account elevation (admin elevates as a *different* account than the interactive
user). Single-user / user-is-admin is assumed; the task targets the interactive user.
- Running the worker when no user is logged on (that's the whole point — it must be a user
session for Claude auth).
---
## Component changes
### ClaudeDo.Worker
- **`ClaudeDo.Worker.csproj`**: `<OutputType>WinExe</OutputType>`. Add packages
`Serilog.AspNetCore` and `Serilog.Sinks.File`.
- **`Program.cs`**:
- Remove `builder.Host.UseWindowsService(...)`.
- Configure Serilog file sink: path `<LogRoot>/worker-.log`, `rollingInterval: Day`,
`retainedFileCountLimit: 7`, shared write. `LogRoot` comes from `WorkerConfig`
(expand `~`). Wire via `builder.Host.UseSerilog(...)`.
- **Single-instance guard:** at startup create `new Mutex(true, @"Local\ClaudeDoWorker",
out var createdNew)`. If `!createdNew`, log "another worker instance is already
running" and exit 0. Hold the mutex for process lifetime. `Local\` namespace = per
user session, which is what we want.
- CLI preflight (`ClaudeCliPreflight`) behavior unchanged.
### ClaudeDo.Installer
- **New `Steps/RegisterAutostartStep.cs`** (`IInstallStep`, "Register Autostart"):
- Build a Task Scheduler **definition XML** (UTF-16) and register via
`schtasks /Create /TN "ClaudeDoWorker" /XML "<tmpfile>" /F`.
- XML shape:
- `Principals/Principal`: `UserId` = current interactive user
(`WindowsIdentity.GetCurrent().Name`), `LogonType=InteractiveToken`,
`RunLevel=LeastPrivilege`.
- `Triggers/LogonTrigger` with the same `UserId`.
- `Settings`: `Hidden=true`, `MultipleInstancesPolicy=IgnoreNew`,
`StartWhenAvailable=true`, `ExecutionTimeLimit=PT0S`,
`DisallowStartIfOnBatteries=false`, `StopIfGoingOnBatteries=false`,
`RestartOnFailure` with `Interval` (>= `PT1M`; Task Scheduler's minimum granularity
is one minute) and `Count=3`.
- `Actions/Exec/Command`: quoted path to `<installDir>/worker/ClaudeDo.Worker.exe`.
- The XML builder is a **pure function** (string in → XML string out) so it is unit
testable without admin.
- **`MigrateServiceStep`** (or folded into `RegisterAutostartStep` as a first phase):
detect the old service via `sc query ClaudeDoWorker`; if present, `sc stop` then
`sc delete` (poll for clearance like the old `RegisterServiceStep` did). No-op when the
service doesn't exist (fresh installs).
- **Rename `StopServiceStep` → `StopWorkerStep`, `StartServiceStep` → `StartWorkerStep`**,
reworked to be process/task based:
- Stop: `schtasks /End /TN ClaudeDoWorker` (ignore errors) + kill any
`ClaudeDo.Worker` process whose `MainModule.FileName` is under the install dir;
wait for exit. This unlocks `worker/` binaries before extract.
- Start: `schtasks /Run /TN ClaudeDoWorker` (preferred — launches as the task principal).
Used by fresh install (so the worker runs immediately rather than waiting for next
logon) and by Settings "restart".
- **`Pages/ServicePage/ServicePageViewModel.cs`**: remove `IsLocalSystem`/`IsCurrentUser`
radios and `ServiceAccount` usage. Keep SignalR port, Claude CLI path, "Start at logon"
toggle (`AutoStart`), restart delay (maps to task `RestartOnFailure/Interval`, clamped
to >= 1 min). Update `ServicePageView.xaml` accordingly. Remove `ServiceAccount` from
`InstallContext`.
- **`RegisterServiceStep.cs`**: deleted (replaced by `RegisterAutostartStep`).
- **Pipelines (`InstallPageViewModel`)**:
- Fresh: DownloadAndExtract → WriteConfig → InitDatabase → **RegisterAutostart** (incl.
migration no-op) → CreateShortcuts → WriteUninstallRegistry → WriteInstallManifest →
**StartWorker**.
- Update: **StopWorker** → DownloadAndExtract → **RegisterAutostart** (migrates old
service) → **StartWorker** → WriteInstallManifest → WriteUninstallRegistry.
- **DI (`App.xaml.cs`)**: register the renamed/new steps (concrete + `IInstallStep` where
needed, following the existing double-registration pattern).
- **`Core/UninstallRunner.cs`**: replace `sc delete ClaudeDoWorker` with
`schtasks /Delete /TN ClaudeDoWorker /F` and kill the worker process; also `sc delete`
the legacy service best-effort (in case an old service still lingers).
### ClaudeDo.Ui / ClaudeDo.App
- **New `Services/WorkerLocator.cs`**: resolve `<installDir>/worker/ClaudeDo.Worker.exe`
by walking up for `install.json` then registry `InstallLocation` (mirrors
`InstallerLocator`).
- **`ViewModels/IslandsShellViewModel.cs`**:
- `RestartWorkerService`: drop `System.ServiceProcess.ServiceController`. Kill worker
process(es) under the install dir, then `Process.Start(workerExe)`.
- **Ensure-running:** on startup, if the `WorkerClient` connection isn't established
within ~4s, launch the worker via `WorkerLocator` + `Process.Start`. Guarded so it
runs at most once per app session.
- Remove the `System.ServiceProcess` package reference / usings if no longer used.
---
## Data flow
- **Logon:** Task Scheduler starts `ClaudeDo.Worker.exe` in the user session → mutex
acquired → Serilog file logging → SignalR hub on `127.0.0.1:47821` → app connects.
- **App start with worker down:** app waits ~4s for SignalR; if absent, `Process.Start`
worker → mutex acquired → hub up → app connects.
- **Duplicate launch (task + app race):** second instance fails the mutex → logs → exits 0.
- **Restart Worker button:** kill worker proc → relaunch → mutex reacquired.
## Error handling
- `schtasks`/`sc` calls go through the existing `ProcessRunner`; non-zero exits surface as
`StepResult.Fail` with the captured output (except best-effort cleanup which is ignored).
- Worker single-instance: losing the mutex is a normal, non-error exit (code 0).
- App ensure-running: `Process.Start` failures are swallowed (the logon task is the primary
mechanism; the app launch is a convenience).
## Testing
- **Unit (no admin required):**
- Task-definition XML builder: asserts UserId, LogonType, Hidden, RestartOnFailure
interval clamping, quoted command path.
- `WorkerLocator`: path resolution via temp `install.json`.
- Migration decision: given `sc query` output (exists / not-found), decide stop+delete vs
no-op — keep the decision pure, mock `ProcessRunner` output.
- Restart-delay → task interval clamping (`< 1 min``PT1M`).
- **Manual verification (post-build, on this machine):**
1. Update from installed `1.0.2-alpha`: old service is removed (`sc query ClaudeDoWorker`
→ not found), task exists (`schtasks /Query /TN ClaudeDoWorker`), worker process runs
as the user, app connects, no console window.
2. Worker log file appears at `~/.todo-app/logs/worker-<date>.log`.
3. Kill worker → click Restart Worker in app → reconnects.
4. Close app, confirm worker still running (Prime/queue alive); reopen app → connects.
5. Log off / log on → worker autostarts.
6. Uninstall → task gone, worker process gone, (data kept unless opted out).
## Risks
- **Task restart granularity is minutes**, not the old seconds-level service restart. The
worker's own long-running resilience + the app ensure-running cover short gaps; acceptable.
- **Elevated installer must target the interactive user.** Using
`WindowsIdentity.GetCurrent().Name` is correct when the user elevates themselves (the
assumed single-user case). Documented non-goal otherwise.
@@ -0,0 +1,125 @@
# External MCP — UI Parity for Start & Observe
**Date:** 2026-05-30
**Status:** Approved (design)
## Goal
Expand the always-on **External MCP server** (`ExternalMcpService`, exposed on
`cfg.ExternalMcpPort` under `/mcp`) so an external Claude session can **start and
observe** ClaudeDo work sessions end-to-end, reaching parity with the desktop UI
for those two concerns.
The server's purpose is deliberately scoped: **help the user start sessions and
observe them.** It is *not* a git/worktree console — branch merging, worktree
resets, and multi-turn continuation are things Claude does *inside* a task, so
they stay out of the tool surface.
## Scope
### In scope
**START — set up and launch a session**
- *(existing)* `AddTask`, `UpdateTask`, `UpdateTaskStatus` (Idle/Queued), `RunTaskNow`, `CancelTask`, `DeleteTask`
- **List management** — create / rename / delete lists; set working dir + default commit type
- **List & task config** — per-list defaults and per-task overrides for `model`, `system_prompt`, `agent_path`
- **Agents (read-only)** — list agent files and refresh, so Claude can choose a valid `agent_path`
- **Reset failed task** — discard the failed worktree and reset the row to Idle (the retry path)
**OBSERVE**
- *(existing)* `ListTaskLists`, `ListTasks`, `GetTask`
- **Run history** — read `task_runs` for a task (session id, tokens, turns, result, structured output, error)
- **Logs** — fetch a task's (or run's) log output
- **App settings (read-only)** — read worker app settings
### Out of scope (explicitly excluded)
- **Tags** — already removed from the system (migration `20260519044715_RemoveTags`); only the stale doc reference in `src/ClaudeDo.Worker/CLAUDE.md` needs deleting.
- **Multi-turn continue** (`--resume`) — Claude's own concern inside a task.
- **Worktree ops** — merge, merge targets, cleanup-finished, reset-all, force-remove, set-state.
- **Start planning session** — not needed via MCP.
- **App settings writes** — risky (e.g. flips permission mode); read-only only.
- **Agent file create/edit/delete** — not part of "starting a session".
## Approach (chosen: A)
**Reuse existing worker services; split the growing tool surface into focused
`[McpServerToolType]` classes.** No business logic is duplicated — each new tool
injects the same service the SignalR hub already uses, so MCP behavior stays
identical to the UI.
Adding ~12 tools to the single `ExternalMcpService` would push it past 600 lines
across eight unrelated jobs. Instead, organize tools by category, mirroring the
existing `External/` + `Planning/` layout:
| Class (new, in `External/`) | Tools | Backing service |
|---|---|---|
| `ExternalMcpService` *(existing, unchanged scope)* | task CRUD + run/cancel/status | `TaskRepository`, `QueueService`, `ITaskStateService` |
| `ListMcpTools` | `CreateList`, `RenameList`, `DeleteList`, `SetListWorkingDir` (name/dir/commitType) | `ListRepository` |
| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `SetTaskConfig` (model/system_prompt/agent_path) | `ListRepository`, `TaskRepository.UpdateAgentSettingsAsync` |
| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` | `TaskRunRepository`, log file read |
| `AgentMcpTools` | `ListAgents`, `RefreshAgents` | `AgentFileService.ScanAsync` |
| `LifecycleMcpTools` | `ResetFailedTask` | `TaskResetService.ResetAsync` |
| `AppSettingsMcpTools` | `GetAppSettings` | `AppSettingsRepository.GetAsync` |
(Exact class grouping may be tuned during planning, but each class stays small
and single-purpose.)
## Architecture & wiring
The external MCP server is a **separate `WebApplication`** built in
`Program.cs` (≈ lines 188217) with its own DI container, distinct from the main
SignalR app. Shared singletons (`HubBroadcaster`, `QueueService`,
`ITaskStateService`, db factory, `WorkerConfig`) are injected by instance so both
apps act on the same runtime state.
Each new tool class must be:
1. Registered in the **external** builder (`externalBuilder.Services.AddScoped<…>()`),
alongside any newly required services (`TaskRunRepository`, `AgentFileService`,
`TaskResetService` + their dependencies).
2. Registered as tools via additional `.WithTools<T>()` calls on the external
`AddMcpServer()` chain.
No change to auth: the existing `ExternalMcpAuthMiddleware` (optional
`X-ClaudeDo-Key`, loopback-only otherwise) covers all tools uniformly. No
per-tool gating — the surface is read/observe + start, with the one borderline
write (`ResetFailedTask`) being a normal retry affordance.
## Data flow
- **Start:** Claude calls e.g. `CreateList``SetListConfig``AddTask(queueImmediately: true)`. Writes go through `ListRepository` / `TaskStateService`, which wake the queue and broadcast `ListUpdated` / `TaskUpdated` so the UI reflects changes live.
- **Observe:** Claude calls `ListTasks` / `GetTask``ListRuns` / `GetRun``GetTaskLog`. Pure reads from `TaskRepository` / `TaskRunRepository` and the log file at `TaskRunEntity.LogPath`.
- **Mutations broadcast** the same SignalR events the hub raises, keeping the desktop UI in sync.
## DTOs
- `RunDto` — projection of `TaskRunEntity`: `Id`, `RunNumber`, `SessionId`, `IsRetry`, `ResultMarkdown`, `StructuredOutputJson`, `ErrorMarkdown`, `ExitCode`, `TurnCount`, `TokensIn`, `TokensOut`, `StartedAt`, `FinishedAt`.
- `AgentDto` — from `AgentInfo` (`Name`, `Description`, `Path`).
- `ListConfigDto``Model`, `SystemPrompt`, `AgentPath` (reuse the shape already used by the hub).
- App-settings read reuses the existing `AppSettingsDto` shape (read-only subset is fine).
- Log fetch returns the file contents as a string (with a size cap / tail option decided in planning).
## Error handling
Follow the existing `ExternalMcpService` convention: throw
`InvalidOperationException` with a clear message for not-found / invalid-input /
illegal-state (e.g. "List {id} not found", "Cannot reset a non-failed task").
Reuse the guard patterns already present (required-field checks, status checks).
`ResetFailedTask` must refuse non-`Failed` tasks.
## Testing
Extend `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (and add
sibling test files per new tool class) using the existing real-SQLite + real-git
integration pattern:
- List CRUD round-trips; rename/delete propagate; delete blocked/handled sensibly.
- List + task config set/get round-trips; clearing all three fields removes list config (matches hub behavior).
- Run history reads return correct projections; `GetTaskLog` returns file contents and errors cleanly when no log exists.
- `ResetFailedTask` succeeds on a Failed task and refuses other statuses.
- Agent listing reflects files on disk after refresh.
- App-settings read returns current values.
## Doc cleanup (part of this work)
- `src/ClaudeDo.Worker/CLAUDE.md` — remove the stale `SetTaskTags` / `ListTags` /
"AddTask (with tags)" claim; replace the External MCP tool inventory with the
new surface.
@@ -0,0 +1,96 @@
# UI Normalization & Single Source of Truth — Design
Date: 2026-05-30
Status: Approved
## Goal
Make working on the ClaudeDo UI simpler by establishing the design tokens as the single source of truth for **every** visual value, eliminating duplicated styles, and providing reusable helpers for the patterns that are currently copy-pasted across views. Accept minor visual shifts where current values don't match the token scale — consistency is the priority over pixel-preservation.
## Scope decisions (locked)
- **Lane C (full normalization)** — global defaults + shared helpers + tokenize every hardcoded font/spacing/radius/color.
- **Normalization strategy: B (snap to existing scale).** Stray values round to the nearest existing token; off-palette colors fold into the closest design brush. The token vocabulary stays small; the UI shifts slightly in places and is verified by human eyeball.
- Badge colors collapse to palette (option A): blue is dropped.
## 1. Global defaults — `src/ClaudeDo.App/App.axaml`
Add application-level default styles so unstyled controls inherit the intended look instead of falling back to FluentTheme's Segoe UI:
- Default `FontFamily` = `{DynamicResource SansFont}` (Inter Tight) for text-bearing controls (`TextBlock`, `TextBox`, `Button`, `ComboBox`, `CheckBox`, `NumericUpDown`, `TabItem`).
- Default `FontSize` baseline = `{StaticResource FontSizeBody}` (13) where a control has no more specific style.
- Controls that need mono (`MonoFont`) continue to opt in explicitly via their class/style.
This single change fixes the Settings modal font and every other bare-Segoe-UI label across the app.
## 2. Tokens = source of truth — `src/ClaudeDo.Ui/Design/Tokens.axaml`
### Fonts — snap to the existing scale
Existing tokens: Eyebrow=10, Mono=11, Micro=11, Body=13, TaskTitle=14, H3=18, H2=24, H1=32.
- `9 → 10` (FontSizeEyebrow)
- `12 → 13` (FontSizeBody)
- `16 → 18` (FontSizeH3)
- Every `FontSize="N"` literal across all views/styles becomes a `{StaticResource FontSize*}` reference. No new size tokens are added.
### Spacing / radius — snap to the existing scale
- Modal body padding `16` / `20 → 18` (SpaceXl); the vertical component `12` stays `SpaceMd`.
- Corner radius `4 → 6` (ButtonCornerRadius).
- Text inputs (TextBox) standardize on `InputCornerRadius` (8); the `6` currently on DetailsIslandView TextBoxes moves to 8.
### Colors — fold off-palette into the palette
Add semantic brushes where a recurring role genuinely needs one, but reuse existing palette brushes wherever possible:
- **Connection-status dots** (MainWindow): green `#4CAF50``StatusRunningBrush`; amber `#FFA726``StatusReviewBrush`; red `#EF5350``StatusErrorBrush`. Also applies to the `#EF5350` literals in WorktreesOverviewModal.
- **Planning/draft badges** (IslandStyles `DraftBadgeBrush`/`PlanningBadgeBrush`/`PlannedBadgeBrush`): re-point to palette — draft → `TextMuteBrush`, planning → `PeatBrush`, planned → `SageBrush`. Blue dropped.
- **Named-color literals:** `OrangeRed` / `Orange``BloodBrush`; `White``TextBrush` (or `DeepBrush` where it sits on an accent fill, e.g. primary button text).
- **Terminal background** `#FF080C0B` (terminal + task-live-tail) → `VoidBrush` (`#FF0A0E0C`).
- **Status alpha-tints:** the repeated `#1F<hue>` fills and `#4C<hue>` borders used by chips and agent-strips become named brushes defined once in Tokens (e.g. `RunningTintBrush` / `RunningTintBorderBrush`, and the same for review/error/queued), then referenced from IslandStyles. The `#26<hue>` worktree-badge tints and `#147C9166` agent-strip tints fold into the same named tint family (snap the alpha to one value per family).
- **Island hairline overlay** `#0DFFFFFF` → a named `HairlineOverlayBrush` token.
## 3. Shared helpers
### `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
Promote the styles currently copy-pasted into modals into the shared stylesheet, then delete the per-modal copies:
- `Button.primary` — standardize on **one** definition: `AccentDimBrush` background + `AccentBrush` border + `TextBrush` foreground (matching the existing `Button.btn.primary` variant). Resolves the AccentBrush-vs-AccentDimBrush divergence.
- `Button.danger``BloodBrush` background + `TextBrush` foreground.
- `TextBlock.field-label` — FontSize Micro (11), `TextDimBrush`, bottom margin 4.
- `TextBlock.section-label` already exists in IslandStyles; remove the duplicate local copies.
### New control: `ModalShell` (`src/ClaudeDo.Ui/Views/Controls/ModalShell.axaml`)
A reusable `TemplatedControl` / `UserControl` providing the chrome every modal re-implements:
- Title bar: mono uppercase title (FontSize Mono, LetterSpacing 1.4), draggable region, ✕ close button (`icon-btn`).
- Outer border (SurfaceBrush bg, LineBrush border, ModalCornerRadius).
- Content slot for the body.
- Optional footer slot for action buttons (right-aligned).
- Exposes: `Title` (string), `Body` content, `Footer` content, and a `CloseCommand`.
The 8 modal windows (Settings, ListSettings, Merge, About, UnfinishedPlanning, RepoImport, Diff, PlanningDiff, ConflictResolution) migrate to wrap their content in `ModalShell` instead of re-declaring titlebar/border/footer grids. Window-level concerns (Width/Height, KeyBindings, WindowDecorations) stay on the `Window`; only the inner chrome is replaced.
## 4. Bug fixes (folded into the migration)
- `TaskRowView.axaml` schedule flyout: `BorderBrush="{DynamicResource BorderBrush}"``{DynamicResource LineBrush}` (the `BorderBrush` key does not exist in Tokens; current runtime resource-not-found).
- `DiffModalView.axaml`, `PlanningDiffView.axaml`, `ConflictResolutionView.axaml`: convert all `{StaticResource <token>}` references to `{DynamicResource <token>}` to match the rest of the app and survive theme changes. (Style-internal `Setter` references that must stay `StaticResource` for Avalonia reasons are left as-is; only token lookups in element attributes are converted.)
## 5. Verification
- `dotnet build` per project (`.slnx` requires .NET 9 — build individual csproj):
- `src/ClaudeDo.App/ClaudeDo.App.csproj` (pulls in Ui + Data)
- `src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
- A clean build confirms XAML compiles and all resource keys resolve (compiled bindings + StaticResource keys are validated at build time).
- Human visual pass: launch the app and walk each view/modal against a per-view checklist (provided with the plan), since lane B intentionally shifts some values. The eyeball is the regression check.
## Sequencing
1. Tokens.axaml: add new named brushes (tints, status, hairline), re-point badge brushes. (No behavior change yet.)
2. App.axaml: global font/size defaults.
3. IslandStyles.axaml: promote shared styles (primary/danger/field-label), replace internal hardcoded values with token refs.
4. Per-view migration: replace every hardcoded FontSize/spacing/radius/color with token refs; snap stray values.
5. ModalShell control + migrate the 8 modals.
6. Bug fixes (BorderBrush key, Static→Dynamic in the three views).
7. Build all projects; produce visual-check checklist.
## Out of scope
- No layout/structure redesign — only values and shared chrome.
- No new features.
- No changes to ViewModels or behavior (ModalShell migration is markup-only; existing `CancelCommand` etc. bind through unchanged).
@@ -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,153 @@
# Worker Lifecycle Redesign
**Date:** 2026-06-01
**Status:** Approved (design)
## Problem
The worker process has multiple competing owners, which collide in development and
muddy production behavior:
- The App auto-spawns its own worker on startup (`EnsureWorkerRunningAsync`,
`IslandsShellViewModel.cs:310`, called at line 224) ~4s after launch if it isn't
yet connected. In the IDE "Start Everything" multilaunch — which already runs the
worker via the `http` launch profile (`dotnet run`) — this produces a *second*
worker that fails to bind to `127.0.0.1:47821` and dies, surfacing a stray console
with a "failed to bind to address" error.
- Production autostart uses a per-user logon **Scheduled Task** (`RegisterAutostartStep`
+ `ScheduledTaskXml`), which the user wants to replace with a simpler Startup-folder
shortcut.
- When the App can't reach the worker, the only feedback is a silent "Offline" pill in
the footer — no guidance to the user.
## Goal
Establish a single owner for the worker lifecycle and make connection failures
actionable:
1. The worker is owned **externally** — a per-user **Startup-folder shortcut** in
production (replacing the Scheduled Task), or the IDE in development.
2. The App **only connects**; it never auto-spawns a worker.
3. When the App can't connect, it shows a one-time prompt offering **Start Worker**,
**Rerun Installer**, or **Dismiss**, plus a clickable Offline pill to reopen it.
## Non-Goals
- No change to the IDE dev setup. The "Start Everything" multilaunch keeps running the
worker via the `http` profile (console with live logs); the duplicate/bind-error
worker disappears purely because the App no longer auto-spawns. Rider run configs live
in `.idea/.../workspace.xml` (per-user, gitignored) and are out of scope.
- No change to the SignalR hub URL, port, reconnect policy, or the worker's
single-instance mutex.
## Design
### Component 1 — Installer: Scheduled Task → Startup-folder shortcut
**`RegisterAutostartStep`** (`src/ClaudeDo.Installer/Steps/RegisterAutostartStep.cs`)
- Replace the task-XML build + `schtasks /Create` with creation of a `.lnk` in the
per-user Startup folder (`Environment.SpecialFolder.Startup`) targeting
`{InstallDirectory}\worker\ClaudeDo.Worker.exe`. The worker is `WinExe`, so it launches
with no console window.
- **Migration:** keep the existing legacy Windows-service removal, and **add** removal of
the old scheduled task: `schtasks.exe /Delete /TN "ClaudeDoWorker" /F` (best-effort),
so existing installs migrate cleanly to the shortcut model.
**`StartWorkerStep`** (`src/ClaudeDo.Installer/Steps/StartWorkerStep.cs`)
- Replace `schtasks /Run /TN ClaudeDoWorker` with a direct
`Process.Start(new ProcessStartInfo(workerExe) { UseShellExecute = true })`.
**`StopWorkerStep`** (`src/ClaudeDo.Installer/Steps/StopWorkerStep.cs`)
- Drop the `schtasks /End` call. Keep the existing install-dir-scoped process kill, which
is the real stop mechanism.
**`UninstallRunner`** (`src/ClaudeDo.Installer/Core/UninstallRunner.cs`)
- Keep the existing `schtasks /Delete` and `sc delete` (migration/legacy cleanup).
- **Add** deletion of the Startup-folder `.lnk` alongside the existing Start Menu /
Desktop shortcut removal.
**Shared shortcut helper**
- Extract the `IShellLink` COM interop currently embedded in `CreateShortcutsStep` into a
shared `src/ClaudeDo.Installer/Core/ShortcutFactory.cs` (`CreateShortcut(path, target,
workingDir, description)`). Both `CreateShortcutsStep` and `RegisterAutostartStep` use it.
**Cleanup**
- Delete `src/ClaudeDo.Installer/Core/ScheduledTaskXml.cs` once unreferenced.
The autostart shortcut name and location: `ClaudeDo Worker.lnk` in
`Environment.SpecialFolder.Startup`, working directory `{InstallDirectory}\worker`.
### Component 2 — App: stop auto-spawning the worker
**`IslandsShellViewModel`** (`src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`)
- Remove the `_ = EnsureWorkerRunningAsync();` call (line 224) and the
`EnsureWorkerRunningAsync` method + its `_ensureRunningAttempted` flag.
- Keep the worker-launch logic (`RestartWorkerService`, which finds the worker exe via
`WorkerLocator` and starts it) — it becomes the backing action for the prompt's
**Start Worker** button. The existing `RestartWorkerAsync` command stays.
### Component 3 — App: connection-failure prompt
**New dialog** `WorkerConnectionModalViewModel`
(`src/ClaudeDo.Ui/ViewModels/Modals/WorkerConnectionModalViewModel.cs`) +
`WorkerConnectionModalView` (`src/ClaudeDo.Ui/Views/Modals/`).
- Buttons: **Start Worker**, **Rerun Installer**, **Dismiss**.
- Uses the established dialog pattern: a `Func<WorkerConnectionModalViewModel, Task>`
hook on `IslandsShellViewModel` set by `MainWindow` (mirroring `ShowAboutModal`), and
the dialog resolves a `TaskCompletionSource` on button press.
- **Start Worker**`WorkerLocator.Find()` + `Process.Start` (reuse the
`RestartWorkerService` path). **Rerun Installer**`InstallerLocator.Find()` + launch
+ `Environment.Exit(0)` (same pattern as the existing `UpdateNow` command).
**Dismiss** → close.
**Trigger logic** (in `IslandsShellViewModel`)
- A one-shot grace timer (~12s) started on construction/startup. When it elapses, if the
worker is still offline (`IsOffline` — not connected and not reconnecting) and the
prompt hasn't been shown yet (`_connectionPromptShown`), show the dialog once and set
the flag.
- If the worker connects before the grace elapses, the prompt is never shown.
**Clickable Offline pill** (`src/ClaudeDo.Ui/Views/MainWindow.axaml`)
- Turn the footer status pill into a button bound to a command that opens the same dialog
on demand (independent of the one-shot flag), so the user can reopen guidance anytime
while offline.
### Component 4 — Dev
No code change (see Non-Goals).
## Data Flow
```
Startup (production):
Windows logon -> Startup-folder .lnk -> ClaudeDo.Worker.exe (WinExe, mutex-guarded)
App launches -> WorkerClient connects to 127.0.0.1:47821
connected within grace -> Online pill, no prompt
still offline after ~12s -> WorkerConnectionModal (once)
User clicks Offline pill (anytime offline) -> WorkerConnectionModal
Start Worker -> Process.Start(worker exe)
Rerun Installer -> Process.Start(installer), Environment.Exit(0)
Dismiss -> close
```
## Error Handling
- Worker exe / installer not found (`Locator.Find()` returns null): the corresponding
dialog button is a no-op (consistent with existing `UpdateNow` behavior); the dialog
stays open so the user can pick another action.
- Startup-shortcut creation failure in the installer: surfaced as a failed install step
(`StepResult.Fail`), same as the current task-registration failure path.
- Legacy scheduled-task deletion is best-effort and never fails the install.
## Testing
- **`Installer.Tests`**: `RegisterAutostartStep` creates the Startup `.lnk` at the
expected path with the correct target, and issues the legacy-task delete command.
`UninstallRunner` removes the Startup `.lnk`.
- **`Ui.Tests`**: prompt trigger logic — grace elapsed while offline shows the prompt
exactly once; a connection established before grace suppresses it; the clickable-pill
command opens the dialog regardless of the one-shot flag. (Abstract the dialog-show
hook so it can be asserted without real UI.)
- **Manual**: dialog buttons (Start Worker / Rerun Installer / Dismiss) and the clickable
Offline pill in a running App.
@@ -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.
```

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