Compare commits

...
396 Commits
Author SHA1 Message Date
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
462 changed files with 47873 additions and 5409 deletions
+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
+5 -4
View File
@@ -145,18 +145,19 @@ jobs:
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip" ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker ) ( 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) INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
if [ -z "$INSTALLER_EXE" ]; then if [ -z "$INSTALLER_EXE" ]; then
echo "::error::No .exe produced by installer publish" >&2 echo "::error::No .exe produced by installer publish" >&2
exit 1 exit 1
fi fi
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe" cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe"
# 3) Checksums (sha256, relative filenames) # 3) Checksums (sha256, relative filenames)
( cd assets && sha256sum \ ( cd assets && sha256sum \
"ClaudeDo-${VERSION}-win-x64.zip" \ "ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \ "ClaudeDo.Installer.exe" \
> checksums.txt ) > checksums.txt )
echo "--- assets ---" echo "--- assets ---"
@@ -200,7 +201,7 @@ jobs:
cd "$WORK/src/assets" cd "$WORK/src/assets"
for f in \ for f in \
"ClaudeDo-${VERSION}-win-x64.zip" \ "ClaudeDo-${VERSION}-win-x64.zip" \
"ClaudeDo.Installer-${VERSION}.exe" \ "ClaudeDo.Installer.exe" \
"checksums.txt" "checksums.txt"
do do
echo "Uploading: $f" echo "Uploading: $f"
+1
View File
@@ -1,5 +1,6 @@
# Local dev worktrees (created by using-git-worktrees skill) # Local dev worktrees (created by using-git-worktrees skill)
.worktrees/ .worktrees/
.claude/worktrees/
# Brainstorming visual companion artifacts # Brainstorming visual companion artifacts
.superpowers/ .superpowers/
+1254
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -10,7 +10,11 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm) - **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService - **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner - **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.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
Each project has its own `CLAUDE.md` — those are the living per-project docs.
## Tech Stack ## Tech Stack
@@ -35,7 +39,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data) - EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker) - `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder - Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (merges the worktree into the target branch, then Done; conflicts keep it in WaitingForReview), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled). Tasks with no active worktree (sandbox run / improvement parent) are approved straight to Done. - Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done. In the detail pane, **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, and 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.
- Worktree state flow: Active -> Merged | Discarded | Kept - Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed - 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) - Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
@@ -75,6 +79,9 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
## Docs ## Docs
- `docs/plan.md`full architecture and design spec - `docs/open.md`open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
- `docs/open.md`verification checklist and improvement backlog - `docs/plan.md`original design spec (historical; tag-queue/schema.sql parts are outdated)
- `docs/improvement-plan.md`prioritized improvement items - `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 from deep exploration (detail too fine for a CLAUDE.md). **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`.
+411 -88
View File
@@ -1,106 +1,429 @@
# ClaudeDo # 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 | ## Contents
|---|---|
| **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 |
``` - [Is this for you?](#is-this-for-you)
┌────────────────┐ SignalR ┌────────────────┐ - [Requirements](#requirements)
│ ClaudeDo.App │◄───────────►│ ClaudeDo.Worker │ - [Install](#install)
│ (Avalonia) │ 127.0.0.1 │ (ASP.NET Core) │ - [The window](#the-window)
│ │ :47821 │ │ - [The core loop](#the-core-loop)
│ ┌────────────┐│ │ ┌────────────┐ │ - [Lists and repositories](#lists-and-repositories)
│ │ Ui ││ │ │ TaskQueue │ │ - [Writing a task](#writing-a-task)
│ │(ViewModels)││ │ │ Claude CLI │ │ - [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)
│ ClaudeDo.Data │ - [Settings](#settings)
│ (SQLite) │ - [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 ## Is this for you?
- 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
## 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) It fits when you:
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
- 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 - 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 ```bash
# Build dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.App dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.Worker dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
# 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
``` ```
## License ## License
+38
View File
@@ -0,0 +1,38 @@
# 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.
## 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.
+124
View File
@@ -0,0 +1,124 @@
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `896d4b5` (2026-07-23).
> Drift check: `git log --oneline 896d4b5..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.
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
- `StartRunningAsync()` (only if not pre-claimed): atomic Queued → Running. Broadcasts TaskStarted.
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.
- **Failed** FailAsync (Running/Queued → Failed).
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
## 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.
- `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/RunCreated…).
- `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.
- **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) # 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. Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
--- ---
+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.
+104 -15
View File
@@ -1,30 +1,119 @@
# ClaudeDo — Offene Punkte # ClaudeDo — Offene Punkte
Stand: 2026-06-04. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code nicht hier. Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus der Verifikations-Session vom 2026-07-24. Die laufende Verifikations-Checkliste (Pass/Fail je Abschnitt, inkl. noch offener Abschnitte §7–§11 und Kanten) lebt in `docs/verification-handoff.md`. Erledigtes steht in den Commits/im Code, nicht hier.
--- ---
## Manuelle Verifikation (offen) ## Bugs (verifiziert 2026-07-24)
Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der Großteil der Pipeline ist laut User bereits in der Praxis getestet; hier das, was noch ein falsifizierbares Observable braucht. - **OUTCOME-Karte rendert rohes Structured-Output-JSON:** `TaskMonitorViewModel.ApplyOutcome` (ClaudeDo.Ui) setzt bei Tasks ohne Roadblock-Marker `SessionOutcome = result` wörtlich (Zeile ~261). Der Worker legt in `task.Result` das rohe `{"summary":…,"files_changed":[…]}` ab (die lesbare Fassung steht in `task_runs.resultMarkdown`), also zeigt die Detail-Insel OUTCOME als JSON-Blob statt als Text. Fix: (a) UI parst ein JSON-Result und zeigt `summary`, oder (b) Worker schreibt `summary`/`resultMarkdown` statt des JSON in `task.Result`. Verifiziert am Task `verif §1 diff matrix`. Deckt sich mit Memory `worker_testing_findings`.
- **Approve & Merge schluckt einen „blocked"-Merge still:** `DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `result.Status == "conflict"` (öffnet den Resolver); bei `"blocked"` (z.B. Ziel-Working-Tree hat uncommittete getrackte Änderungen) und anderen Nicht-`merged`/Nicht-`conflict`-Status passiert **nichts** — kein Footer-Fehler, kein Dialog (der `catch` greift nur bei Exceptions, „blocked" ist aber ein normaler Rückgabewert mit `ErrorMessage`). User sieht „Klick tut nichts". Doppelt verifiziert (Konflikt-Approve UND sauberer additiver Approve `verif §1b`, beide bei dirty `main`-Checkout still). Fix: bei `blocked`/unerwartetem Status `result.ErrorMessage` via `ShowErrorAsync`/`FlashFooterError` surfacen. Verstößt gegen `feedback_ui_error_surfacing`. (Der Konflikt-Resolver + 3-Pane-Editor funktionieren, sobald der Ziel-Tree sauber ist.)
- **„Waiting for Improvements" für Planning-Parents (Terminologie):** `taskStatus.waitingForChildren` = „Waiting for Improvements" (en.json:504), `agentStatus.children` dito (503), `childOutcomesLabel` = „IMPROVEMENTS" (191). Seit dem unified-parent-Modell gilt `WaitingForChildren` für Planning **und** Improvement-Parents — „Improvements" ist für einen Planning-Parent falsch. Auf neutrales „Waiting for Subtasks"/„Subtasks" umstellen (en **und** de — Localization.Tests-Parität). Verifiziert §3.
- **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live:** Parent-getriebene Änderungen propagieren nicht auf die Kind-`TaskRowViewModel`s ohne Listen-Reload. (a) Nach „Finalize planning" bleiben Kind-Rows auf „Draft" (`IsDraft`) statt „Planned" (`IsPlanned`) — der geänderte `ParentFinalized` kommt nicht über das Parent-`TaskUpdated`-Broadcast an. (b) Nach „Discard" bleiben die (in der DB gelöschten) Draft-Kind-Rows sichtbar, bis die Liste neu geladen wird. Gemeinsame Ursache: die Kinderliste/-zustände werden bei Parent-Transitionen nicht live neu aufgelöst. Doppelt verifiziert §3 (Finalize **und** Discard, 2026-07-24).
- **AskUser-Frage erscheint NUR in Mission Control, nicht in der Task-Detail-Insel:** Der `ask_user`-Banner (Frage + Antwort-Eingabe) lebt ausschließlich in `MonitorPaneView` (Mission Control). Die Detail-Insel (`DetailsIslandView`) streamt zwar den Live-Log eines laufenden Tasks, zeigt aber **kein** Frage-Banner — ein Nutzer, der nur die Detailansicht offen hat, sieht nicht, dass der Run auf eine Antwort wartet, und läuft nach 3 min in den Timeout-Fallback. Verifiziert §7 (User schaute Detailansicht, Frage war unsichtbar; erst in Mission Control sichtbar). Fix: Frage-Banner + Inline-Antwort auch in der Detail-Insel für den gebundenen laufenden Task surfacen (VM-Zustand liegt bereits in `TaskMonitorViewModel`, müsste für die Detail-Insel repliziert/geteilt werden). **Von Mika bei der §7-Verifikation ausdrücklich gewünscht** („vermisse diese Interaktion in der Detail-Ansicht"). §7.
- **„New session"-Button (Mission Control) unsichtbar — Icon.Plus ist Strich-Only:** `Icon.Plus` = `M12 5v14M5 12h14` (IslandStyles.axaml:61) ist reine Linien-Geometrie ohne Fläche; im `<PathIcon>` (MissionControlView.axaml:38, füllt Geometrie) rendert sie **unsichtbar** → der Ad-hoc-„New session"-Button erscheint leer und ist nicht auffindbar (Feature + VM `OpenAdHocConPtySessionAsync` + Tests funktionieren, nur das Icon fehlt sichtbar). Fix: Icon.Plus als gefüllte Geometrie authoren ODER als gestricheltes `Path` rendern (Icon-Gotcha in CLAUDE.md). **Andere `Icon.Plus`-Verwendungen mitprüfen.** Verifiziert §5.
- **Worktree-Pipeline:** ## UX / Nits (verifiziert 2026-07-24)
- Worktree-Happy-Path → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk.
- No-Changes-Run → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`.
- Kein Git-Repo (`working_dir=C:\Temp`) → `status='Failed'`, **keine** `worktrees`-Row, Git-Fehler im Log.
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
## Offene Code-Punkte - **„Resume planning session" ist grundsätzlich kaputt (Session-Id wird nie erfasst) + Fehler wird verschluckt:** Im UnfinishedPlanning-Modal macht **Resume** nichts. Ursache: `PlanningSessionManager.ResumeAsync` (PlanningSessionManager.cs:238) bricht mit `InvalidOperationException("No Claude session ID captured yet; cannot resume.")` ab, wenn `task.PlanningSessionId` leer ist — und der Setter `TaskRepository.UpdatePlanningSessionIdAsync` (TaskRepository.cs:322) hat **keinen einzigen Aufrufer** im Worker, d.h. `planning_session_id` wird nie befüllt (verifiziert an `verif §3 resume-discard`: `planning_session_id=NULL`, Token gesetzt, Drafts angelegt). Resume kann also **nie** erfolgreich sein. Zusätzlich verschluckt `TasksIslandViewModel.ResumePlanningSessionAsync` den ganzen Block in einem leeren `catch { }` (~Zeile 870) → der Button ist ein **stummer No-op**, kein Footer-Fehler, kein Dialog (verstößt gegen `feedback_ui_error_surfacing`). Hintergrund: die wt-Planning-Session ist interaktiv; ClaudeDo erfasst die claude-Session-Id dort nicht (analog zur bewusst nicht persistierten ConPTY-Session-Id, §5). Fix-Optionen: (a) beim Planning-Start/-Lauf die claude-Session-Id erfassen und via `UpdatePlanningSessionIdAsync` persistieren, damit Resume echt resumen kann; ODER (b) Resume entfernen/deaktivieren, wenn keine Session-Id vorliegt; in JEDEM Fall den Fehler statt des leeren `catch` surfacen. Verifiziert §3. §3.
- **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 (App-Neustart nötig, um die „erster-Drop"-Bedingung wiederherzustellen). 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 (die genaue Exception-Message fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt). Verifiziert §9 (einmalig beobachtet). §9.
- **Agent-Settings-Gear weicht vom übrigen Settings-Icon ab:** Der Agent-Settings-Flyout-Button (`TaskHeaderBar.axaml:68`) rendert ein Unicode-Glyph `⚙` (`<TextBlock Text="⚙">`), während die Listen-Nav (`ListsIslandView.axaml:56`) und die Listen-Settings (`TasksIslandView.axaml:47`) das gefüllte `Icon.Settings`-PathIcon (Gear-StreamGeometry, IslandStyles.axaml:110) nutzen → optisch ein anderes Zahnrad. Angleichen: den `⚙`-TextBlock durch `<PathIcon Data="{StaticResource Icon.Settings}" .../>` ersetzen. Verifiziert §8 (User-Sichtprüfung). §8.
- **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"). Verifiziert §8 (frischer Zustand vor ponytail-Install). Kleiner Empty-State-Text ergänzen.
- **Status-Bar Live-Update:** Prüfen, ob `RunNow`-Enable/Disable pro Task-Row bei Connection-Change sauber re-evaluiert. Connection-Status lebt in `IslandsShellViewModel` / `WorkerConnectionModalViewModel` (es gibt keinen `StatusBarViewModel` mehr). Erst messen, dann ggf. fixen. Klein. - **Diff-Viewer: reiner Rename schwach dargestellt:** Rename korrekt erkannt (`UnifiedDiffParser``DiffFileStatus.Renamed`, Badge `StatusCode="R"`), aber der File-Tree zeigt nur den neuen Namen + „+0 0" ohne „alt → neu"-Pfad; liest sich wie „keine Änderung". Optional: alten Pfad + „renamed"-Label anzeigen. §1.
- **Header-TurnsText zeigt `0/max` für abgeschlossene Runs:** `TurnsText => {Turns}/{EffectiveMaxTurns}``Turns` wird beim Laden eines terminalen Tasks nicht aus `task_runs.turnCount` restauriert (nur live gefüllt). Kosmetisch. §1.
- **Conflict-Resolver: Continue/Merge-Button klickbar trotz offener Konflikte:** Gate greift funktional (merged erst wenn alle Konflikte in allen Dateien gelöst), aber der Button ist nicht disabled/gegraut → früher Klick ist ein stummer No-op. Besser: disabled bis `AllResolved`, oder Hinweis „N Konflikte in M Dateien offen". §4.
- **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". §4.
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Status-Chip zeigt „Idle"; der `PlanningBadge` ersetzt das nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".) §3.
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt die Reihenfolge/Abhängigkeit nicht dar. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>"), nicht nur der „waiting"-Chip nach dem Queueen. §3.
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks`, `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind. §3.
- **„Open ConPTY session" erneut = Prompt wird neu gesendet, kein Resume:** Da die ConPTY-Session-Id bewusst nicht persistiert wird, startet ein erneutes „Open ConPTY session" auf demselben Task eine frische Session und sendet den Task-Prompt erneut (re-runt die Arbeit im selben Worktree) statt zu resumen. So designt, aber UX-Falle — evtl. „Resume"-Affordance oder Re-Open-Warnung. §5.
## Feature-Wünsche (aus der Session)
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
## Sichtprüfung offen (2026-07-27, Batch „Claude do"-Liste)
Alles gebaut + unit-getestet, aber **nicht visuell verifiziert** — Mika prüft:
- **Ctrl+K fokussiert die Suche, `#` ist wieder tippbar** (Binding war `OemQuestion` = `#` auf DE-Layout).
- **Titel-Edit in der Detail-Insel persistiert** (400 ms debounced, wie die Beschreibung); Row-Titel + Merge-Kontext ziehen mit.
- **Spinner beim ConPTY-Start:** Tile erscheint sofort mit „Sitzung wird gestartet…" und deckt Launch-Spec-Roundtrip + Spawn ab. **Nicht** abgedeckt: die Sekunden, die die claude-TUI danach zum ersten Frame braucht (dafür bräuchte es einen Hook auf `TerminalControl.DataReceived`). Bei Launch-Fehler bleibt das Tile jetzt mit Inline-Banner stehen statt zu verschwinden.
- **Spinner beim Refine** ersetzt den Refine-Button in der Row, solange der Run läuft.
- **Interactive-Chip** statt „Parked" auf Tasks mit offener ConPTY-Session; Klick öffnet Mission Control und fokussiert die Pane. Accent-Tint (bewusst dieselbe „live"-Familie wie Running, klar unterschieden vom slate-blauen Parked).
- **Diff-Viewer:** rechter Abstand der `+n n`-Zahlen im File-Tree.
- **Manual-Tasks:** MANUAL-Badge, Kontextmenü-Toggle, Listen-Checkbox „Manuelle Liste"; Queue/Refine/Planning ausgeblendet, ConPTY bleibt.
- **Settings → Allgemein:** Tabelle „Vorgaben pro Modell" (Effort + Max. Durchläufe je haiku/sonnet/opus/fable). Ersetzt das einzelne globale „Max. Durchläufe"-Feld.
Offene Entscheidungen dazu:
- 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`.
---
## Offene Verifikation (2026-07-27)
- **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).
## Offene Verifikation (2026-08-05)
- **List handler owns a task (2026-08-05)** — build + unit tests all green, but **not visually
verified**: start "Let Claude handle it" on a list, confirm exactly one new task appears in that
list (`Idle`, MANUAL badge, title "List handler: <list>"), the Mission Control tile is
task-based (Submit for review button present), Submit for review flips it to
`WaitingForReview`, and the detail pane's diff/merge card shows the full range of everything the
run merged to main (via the new `HandlerBaseCommit`/`HandlerHeadCommit` fallback — no
`WorktreeEntity` is created for this task, so the diff comes from `list.WorkingDir` directly).
- **Roadblock reply field (2026-08-05)** — build + unit tests all green, but **not visually
verified**: on a `Done` (or `WaitingForReview`/`Failed`/`Cancelled`) task with a reported
roadblock, the ROADBLOCK card shows a reply textbox + Send button under the roadblock text.
Check layout/spacing against the AskUser question card it's modeled on, Enter-to-send, the
disabled state + hint text when there's no session ID to resume, and that an override-slot-busy
error shows up in the footer log strip (not a modal) with the typed text still in the field.
Approve should go straight to `Done` with no merge attempt. Design choice: the review range
lives as two new nullable columns directly on `TaskEntity` (not a phantom `WorktreeEntity` row),
specifically so `list_worktrees`/the Worktrees overview never see it.
- **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.
## Offene Verifikation (2026-08-05, Usage Monitor)
- **Visueller Pass Usage-Pill** (Footer **und** Mission-Control-Header): Text/Tooltip,
Dot-Zustände normal/warn/stale/blocked, Dark/Light.
- **Visueller Pass Usage-Monitor-Modal**: Gauges (dynamisch aus `limits[]`), Modell-/Task-
Tabellen, Info-Bänder (stale/gate-blocked), 7d/30d-Presets + Custom-Range, Dark/Light.
- **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).
--- ---
## Bewusst verworfen (nicht erneut vorschlagen) ## Bewusst verworfen (nicht erneut vorschlagen)
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session. - **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 (siehe oben). Tests nutzen `FakeClaudeProcess`. - **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; ADRs lohnen solo nicht. - **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku.
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (Begründung in `mailbox-proposal.md`). - **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 (Tag-System entfernt, Notes/Autostart anders gelöst). - **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt.
+2
View File
@@ -1,5 +1,7 @@
# ToDo-App mit autonomem Agent-Worker — Design # 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 ## 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. 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.
@@ -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,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.
@@ -0,0 +1,80 @@
# Per-task model override via MCP + cheapest-model prompt guidance
Date: 2026-06-09
## Goal
Let Claude pick the model for each task it generates (planning subtasks,
improvement follow-ups, external task creation) directly at creation time via
MCP, and instruct Claude — in the relevant prompts — to choose the *cheapest*
model that can do the job well.
## Background
- `TaskEntity.Model` (nullable) already exists and is resolved
task → list-config → global default in `TaskRunner.ResolveConfigAsync`, then
passed to the CLI as `--model` by `ClaudeArgsBuilder`.
- Today the model can only be set *after* creation via `set_task_config`
(`ConfigMcpTools.SetTaskConfig`). The creation tools (`CreateChildTask`,
`SuggestImprovement`, `AddTask`) accept no model, so assigning one is a
two-call dance.
- `ModelRegistry.Aliases = ["sonnet","opus","haiku"]`; no cost ordering or
validation helper exists.
No schema change is required — only plumbing a `model` argument through the
creation paths plus prompt edits.
## Decisions
- **Validation:** strict alias-only. `model` must be one of haiku/sonnet/opus
(case-insensitive); blank/null means "inherit" (no override); anything else
throws an MCP error so Claude self-corrects immediately rather than the task
failing later at CLI runtime.
- **`AddSubtask` is out of scope:** it creates a `SubtaskEntity` (a checklist
step), which is never independently executed — a model there is a no-op.
- **Improvement-child prompt:** the child's model is fixed at filing time and
it cannot re-pick, so only a one-line "this is an intentionally small/cheap
unit — stay minimal" reminder is added. The real model-choice instruction
lives in the main system prompt's SuggestImprovement guidance.
## Cost ordering & heuristic (single source: `ModelRegistry.ByCostAscending`)
`haiku < sonnet < opus`
- **haiku** — trivial/mechanical: doc tweaks, simple renames, small localized edits.
- **sonnet** — normal coding work (default).
- **opus** — complex architecture, cross-cutting changes, hard debugging.
## Changes
1. **`ClaudeDo.Data/Models/ModelRegistry.cs`**
- `ByCostAscending = ["haiku","sonnet","opus"]`.
- `string? NormalizeAlias(string? model)` — trim; null/blank → null;
case-insensitive match → canonical lowercase alias; else throw
`ArgumentException` with the allowed list.
2. **`TaskRepository.CreateChildAsync`** — add optional `string? model = null`;
set `child.Model = ModelRegistry.NormalizeAlias(model)`. Single choke-point
for both child-creation MCP tools.
3. **MCP creation tools** (add `model` param, document in `[Description]`):
- `PlanningMcpService.CreateChildTask` → forward to `CreateChildAsync`.
- `TaskRunMcpService.SuggestImprovement` → forward to `CreateChildAsync`.
- `ExternalMcpService.AddTask``NormalizeAlias` then set `entity.Model`.
4. **Prompts (`PromptFiles.cs`)**
- `PlanningSystemDefault` — instruct the planner to pass each
`CreateChildTask` the cheapest capable model (with the ordering/heuristic).
- `SystemDefault` (Out-of-scope improvements) — when filing via
`SuggestImprovement`, pass the cheapest capable `model`.
- `ImprovementChildDefault` — one-line minimality reminder.
5. **Tests** (no real CLI):
- `NormalizeAlias`: valid aliases (any case), blank/null → null, unknown → throws.
- `CreateChildTask` / `SuggestImprovement` / `AddTask` persist the model;
invalid model is rejected.
## Out of scope
- No DB migration. No locale changes (prompts and MCP descriptions are not
localized). No UI changes (existing per-task model display already covers it).
@@ -0,0 +1,148 @@
# Unify the parent-task model (planning · improvement · normal)
**Date:** 2026-06-09
**Status:** Approved-pending-implementation
## Problem
ClaudeDo has three ways a task produces and waits on work, grown as separate
mechanisms that represent the *same shape* — "a task runs, may emit children,
and once it + its children are terminal it surfaces for review":
| | children authored | scheduling | parent flow today | merge of children |
|---|---|---|---|---|
| **Normal** | none | — | `Running → WaitingForReview → Done` | own worktree on approve |
| **Improvement** | autonomously *during* run (`suggest_improvement`) | parallel (no blockers) | `Running → WaitingForChildren → WaitingForReview → Done` | separate `MergeAllPlanning` |
| **Planning** | interactively *before* run (planning session) | sequential chain (`BlockedByTaskId`) | `Idle →(Active→Finalized)→ Done` (skips review) | separate `MergeAllPlanning` |
The incidental divergence we want to remove:
1. **Two "parent is waiting on children" representations** — improvement uses
`Status=WaitingForChildren`; planning uses `PlanningPhase=Finalized` with the
parent's `Status` jumping `Idle → Done`, never passing through the waiting/review
states at all.
2. **Two parent-advance methods** doing the same job —
`TaskRepository.TryCompleteParentAsync` (planning → `Done`, no review) vs
`TaskStateService.TryAdvanceImprovementParentAsync` (improvement → `WaitingForReview`).
3. **A separate merge action**`MergeAllPlanning` / `PlanningMergeOrchestrator`
merges children, decoupled from the parent's `approve`. Approving a parent and
merging its unit are two clicks.
What is **genuinely unique and kept**: `PlanningPhase.Active` — the interactive,
human-in-the-loop authoring gate where children are drafted and cannot run until
finalize. Improvement has no equivalent. The two *authoring* entry points
(`PlanningMcpService.CreateChildTask` vs `TaskRunMcpService.SuggestImprovement`)
also stay distinct — they already share `CreateChildAsync`; unifying the authoring
UX is explicitly out of scope.
## Decisions (locked)
- **All parents get review.** A planning parent now surfaces in `WaitingForReview`
after its children finish, instead of auto-completing to `Done`.
- **Approve merges the whole unit — full UX consolidation.** Approve is the single
entry for reviewing *and* merging any task. For a parent with children it drives the
existing `PlanningMergeOrchestrator` (unit merge + parent→`Done` + conflict
continue/abort, all already implemented); the standalone "Merge All" button is
removed and the orchestrator's conflict dialog + combined-diff preview are reused
in-place. Childless tasks keep `ApproveAndMergeAsync`.
- **Scope = state model + code paths.** Internal refactor; authoring UX and child
base-commit resolution are unchanged.
## Target model
**One parent-with-children lifecycle, used by every parent regardless of how its
children were authored:**
```
┌─ (no children) ──────────────┐
Idle → Queued → Running ──┤ ├→ WaitingForReview → Done
└─ (has/spawns children) ─┐ │ (approve =
│ │ merge unit)
WaitingForChildren ─┘ │
│ │
(all children terminal) ───────┘
```
Planning parent (never runs as an agent — it runs an interactive session):
```
Idle (PlanningPhase None)
→[StartPlanning] Idle (PlanningPhase Active) ← authoring gate (KEPT)
→[FinalizePlanning] WaitingForChildren (Finalized) ← children chain runs
→[all children terminal] WaitingForReview
→[approve] merge unit → Done
```
Children (planning **and** improvement) keep going straight to `Done` with no
individual review; they accumulate on their branches and merge as a unit when the
parent is approved.
### State machine after the change
- `WaitingForChildren` is the **single** "parent waiting on children" state, used by
both planning and improvement parents.
- `WaitingForReview` is reached by every parent before `Done`.
- `PlanningPhase`: `None | Active | Finalized` — unchanged; `Active` remains the
authoring gate, `Finalized` marks "was a planning parent" and is set together with
`Status=WaitingForChildren`.
## Code changes
1. **Single parent-advance path.** Rename
`TaskStateService.TryAdvanceImprovementParentAsync`
`TryAdvanceParentAsync`; it already only checks `Status==WaitingForChildren` +
"all children terminal" → `WaitingForReview` (with the failed/cancelled
annotation on `Result`). It becomes the only path for both systems.
- Handle **zero children**: a finalized planning parent with no children must go
straight to `WaitingForReview` (today `TryComplete`/`TryAdvance` both `return`
on `Count == 0`).
2. **Delete `TaskRepository.TryCompleteParentAsync`** (`TaskRepository.cs:477`) and
its invocation in `TaskStateService.OnChildTerminalAsync`. Planning parents now
advance via `TryAdvanceParentAsync` to `WaitingForReview` instead of `Done`.
- Keep `_chain.OnChildFinishedAsync` (inter-child unblock — planning-only effect).
3. **`FinalizePlanningAsync`** (`TaskStateService.cs:289`) sets the parent
`Status = WaitingForChildren` in the same update that sets
`PlanningPhase = Finalized`. This happens before `SetupChainAsync` enqueues
child[0], so the parent is in `WaitingForChildren` before any child can finish.
4. **Approve merges the unit.** `WorkerHub.ApproveReview` (and the MCP
`ReviewTask` approve path): when the approved task has children, run
`PlanningMergeOrchestrator` (parent worktree if `Active` + each `Done` child in
order), then transition the parent to `Done`. On a child merge conflict, the
parent stays in `WaitingForReview` (mirrors current single-task approve-conflict
behavior). Retire the `MergeAllPlanning` Hub method + UI button.
5. **Allow cancelling a `WaitingForChildren` parent.** Add `WaitingForChildren` to
the `CancelAsync` guard so a parent waiting on children can be cancelled (today it
cannot — minor gap).
6. **Docs.** Fix the `WaitingForChildren`-missing drift in
`src/ClaudeDo.Data/CLAUDE.md` and `src/ClaudeDo.Worker/CLAUDE.md`, and update the
transition diagram + the root `CLAUDE.md` status-flow line to the unified model.
## Out of scope (unchanged)
- Authoring UX: planning session vs `suggest_improvement` stay as two distinct
entry points (both already call `CreateChildAsync`).
- `WorktreeManager.ResolveBaseCommitAsync` base-commit divergence (planning children
branch from list HEAD; improvement children from parent head) — left as-is.
- Sequential-vs-parallel scheduling — already shared infrastructure
(`BlockedByTaskId`); planning chains, improvement doesn't. No change.
## Risks / edge cases
- **Ordering on finalize** — parent must be `WaitingForChildren` before the first
child can reach terminal. Guaranteed by setting it inside `FinalizePlanningAsync`,
which runs before `SetupChainAsync`.
- **Zero-children planning parent** — must advance to `WaitingForReview`, not stick
in `WaitingForChildren`. Explicit branch in `TryAdvanceParentAsync` /
`FinalizePlanningAsync`.
- **Failed/cancelled children** — parent still advances to `WaitingForReview` with
the existing `⚠ Children: N failed, M cancelled` annotation; no wedge.
- **Approve-merge conflict** — keep parent in `WaitingForReview`; surface the
conflicting child like the current merge-conflict path.
- **Existing rows** — planning parents currently sitting at `Idle`+`Finalized` with
live children: behavior change is forward-only (new finalizes use the new flow);
no migration needed since `Status`/`PlanningPhase` columns already exist.
@@ -0,0 +1,142 @@
# Online Inbox — desktop-side design
Date: 2026-06-10
Status: approved, implementing
Related: `docs/online-inbox-api-contract.md` (the API both ends share)
## Goal
Let the owner add task ideas and view their Idle backlog from a phone/browser. The desktop
ClaudeDo opts in to an online service, syncs its list catalog + Idle backlog up, and pulls
web-created tasks down as local `Idle` tasks. Execution stays 100% local.
This spec covers only the **desktop side** (this repo). The API + web client are built
VPS-side against the shared contract.
## Non-goals
- No remote execution; the Worker still runs everything locally.
- No syncing of any task state other than the `Idle` mirror.
- No multi-user. Single Zitadel user = the owner.
- Web client is create + read only.
## Opt-in & where things live
- **Off by default.** When disabled: zero network, zero auth — byte-for-byte today's
behaviour. Auth only matters once enabled.
- Sync runs in the **Worker** (it owns the DB and already hosts `BackgroundService`s). The
opt-in config and the stored refresh token live in `worker.config.json`-adjacent state.
- Interactive Zitadel login happens in the **UI** (browser flow), which hands the resulting
refresh token to the Worker over SignalR; the Worker persists it (DPAPI) and uses it for
headless token refresh during polling.
## Config (`WorkerConfig`, new `online_inbox` section)
```jsonc
"online_inbox": {
"enabled": false,
"api_base_url": "", // e.g. https://inbox.claudedo.kuns.dev
"poll_interval_seconds": 60,
"zitadel": {
"authority": "", // issuer URL (from VPS report)
"client_id": "",
"scopes": "openid offline_access" // offline_access → refresh token
}
}
```
The refresh token is NOT stored in this file. It lives encrypted via
`System.Security.Cryptography.ProtectedData` (DPAPI, CurrentUser) at
`~/.todo-app/online-inbox.token` and is read/written only by the Worker.
## Components (Worker, new `Online/` folder)
```
Worker/Online/
OnlineInboxConfig.cs — the config record (bound from WorkerConfig.OnlineInbox)
Dtos.cs — RemoteList, RemoteTask, MirrorTask DTOs (match the contract)
IOnlineInboxApi.cs — typed client surface (one method per endpoint)
OnlineInboxApiClient.cs — HttpClient impl; attaches bearer via IOnlineAuthProvider
Interfaces/IOnlineAuthProvider.cs — Task<string?> GetAccessTokenAsync(ct)
ZitadelAuthProvider.cs — concrete (PENDING: needs the Zitadel package + client config)
OnlineTokenStore.cs — DPAPI-backed refresh-token persistence
OnlineSyncService.cs — BackgroundService: the reconcile loop (§contract 5)
OnlineBacklog.cs — static helper: the Idle-backlog query/filter (§contract 2)
```
### `IOnlineInboxApi`
```
Task PutListsAsync(IReadOnlyList<RemoteList> lists, ct)
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(ct) // GET /tasks?imported=false
Task MarkImportedAsync(string id, ct) // POST /tasks/{id}/imported
Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, ct) // PUT /tasks/mirror
```
(The desktop never calls `POST /tasks`, `GET /lists`, or `GET /lists/{id}/tasks` — those are
web-only.)
### `IOnlineAuthProvider`
Single method `Task<string?> GetAccessTokenAsync(CancellationToken)` returning a bearer token
(refreshing transparently), or `null` if not logged in / refresh failed. Abstracting it lets
us:
- ship and test the sync engine now with a fake provider,
- wire the real `ZitadelAuthProvider` once the VPS reports authority/client-id and we add the
Zitadel package reference.
`ZitadelAuthProvider` reads the refresh token from `OnlineTokenStore`, exchanges it for an
access token, caches the access token until near expiry. **Marked with a
`// TODO(online-inbox)` until the flow is wired.**
> **Auth correction (2026-06-10):** the `KunsZitadel` nuget package is a *server-side*
> resource-server helper (`AddKunsZitadel` → `JwtBearer` token *validation*). It belongs on
> the VPS API, NOT the desktop. The desktop must *acquire* tokens, so `ZitadelAuthProvider`
> uses a client OIDC flow — `IdentityModel.OidcClient` (auth-code + PKCE, loopback redirect)
> or the device-authorization grant — against Zitadel's OIDC endpoints, then persists the
> refresh token via `OnlineTokenStore`.
### `OnlineSyncService` (the loop)
- Hosted only when `online_inbox.enabled == true` (guarded at registration).
- Every `poll_interval_seconds`: create a DI scope, resolve `TaskRepository` + `ListRepository`
(same pattern as the External MCP app), run the §5 reconcile loop.
- Skips a cycle (logs at debug) if `GetAccessTokenAsync` returns null (not logged in).
- All failures are caught per-cycle and logged; never crashes the Worker. Network errors back
off to the next interval.
- Import safety: a pulled task whose `listId` has no local list is skipped + logged (not
imported), and NOT marked imported, so it retries once the list exists. Imported tasks land
as `Status=Idle, CreatedBy="online"` — they never auto-run; the user queues them locally.
## UI (later increment, after VPS report)
- Settings modal → new "Online Inbox" section: enable toggle, API base URL, **Sign in /
Sign out** (Zitadel browser/device flow via the OIDC client lib), connection status.
- Login produces a refresh token; UI sends it to the Worker via a new hub method
`SetOnlineInboxAuth(refreshToken)` → Worker writes it through `OnlineTokenStore`.
- Config read/write via hub methods `GetOnlineInboxConfig` / `SetOnlineInboxConfig`
(mirrors the existing `GetAppSettings`/`UpdateAppSettings` pattern).
- Visual verification is a manual step (flagged — never claimed working without a run).
## Security
- Disabled → no network, no token read.
- Bearer attached only over HTTPS `api_base_url`; refuse `http://` non-loopback base URLs.
- Refresh token encrypted at rest (DPAPI CurrentUser). Never logged.
- Imported tasks are `Idle` only — no auto-execution path from the web.
## Testing
- `OnlineSyncService` reconcile logic tested against a **fake `IOnlineInboxApi`** + real
SQLite (Worker.Tests style): pull→import→flag, mirror set = Idle backlog, list catalog push,
unknown-list skip, disabled = no calls, not-logged-in = skipped cycle.
- `OnlineBacklog` filter tested directly (excludes children/planning/blocked/non-Idle).
- **No real network and no real Zitadel** in tests — fake the api + auth provider. (Consistent
with the no-real-Claude-in-tests rule.)
- DPAPI token store: round-trip test is Windows-only; guard or keep as a thin wrapper.
## Open items (need the VPS report)
- Exact Zitadel authority/issuer, client id, scopes, and **which grant the Zitadel app is
registered for** (auth-code+PKCE with which loopback redirect URI, or device-code). This
drives the desktop OIDC client implementation.
- Final API base URL.
- Desktop client OIDC library decision: `IdentityModel.OidcClient` (recommended) vs
hand-rolled device-code. (`KunsZitadel` is server-side only — see the auth correction
above; it's for the VPS API.)
@@ -0,0 +1,91 @@
# Feature unification — one component per feature
Date: 2026-06-19
## Goal
ClaudeDo grew organically; several features now exist as parallel implementations
or are reachable through many hand-wired entry points. This design maps the
duplication and defines a target where **each feature is one component**, reached
through one path, with dead code removed.
## Method
Mapped via five parallel exploration agents (merge/conflict, review→merge,
diff+worktree, task create/edit, UI entry-point inventory), then verified the
load-bearing claims by grep/read before writing this. Every file:line below was
confirmed against the working tree on 2026-06-19.
## Key finding: it is NOT three merge engines
There is **one** merge engine (`TaskMergeService`), wrapped **once** for multi-child
units (`PlanningMergeOrchestrator`), with **one** conflict resolver (the Rider
3-pane). `Worker/CLAUDE.md` already records "there is no separate 'Merge all' entry —
approve is the single review+merge action." What *looks* like 23 merge features is
**entry-point sprawl** in the UI plus **one dead hunks-API** left over from the
Layer-C rework. So unification is mostly UI plumbing + deletion, not re-architecting
the engine.
## Findings — three buckets
### Bucket A — genuine duplication (parallel implementations of one job)
| # | Feature | Duplicated components | Shared already |
|---|---|---|---|
| A1 | Diff viewing | `DiffModalViewModel` (worktree + commit-range), `WorktreeModalViewModel` (file-tree + per-file), `PlanningDiffViewModel` (per-subtask + integration) | `UnifiedDiffParser`, `DiffLinesView` (good) |
| A2 | Agent-config editing | `ListSettingsModalViewModel` (list scope), `AgentSettingsSectionViewModel` (task scope); global lives in `SettingsModalViewModel` | `InheritanceResolver`, `InheritedBadge` (good) |
| A3 | Worktree actions | `WorktreesOverviewModalViewModel` per-row cmds (Merge/Discard/Keep/ForceRemove/ShowDiff/Jump) vs `MergeSectionViewModel` (Merge/OpenDiff) | same `IWorkerClient` calls |
| A4 | Merge display | `AgentStripView` re-displays `MergeSectionViewModel` state | — |
### Bucket B — entry-point sprawl (one backend, many hand-wired doors)
| # | Feature | Doors | Evidence |
|---|---|---|---|
| B1 | Conflict-resolution seam | 5 copies of `Func<string,string,Task>? RequestConflictResolution` | `WorktreesOverviewModalViewModel.cs:83`, `DiffModalViewModel.cs:75`, `MergeModalViewModel.cs:33`, `MergeSectionViewModel.cs:51`, `DetailsIslandViewModel.cs:347` (delegates). Threaded through `MainWindow.axaml.cs:81`, `IslandsShellViewModel.cs:49/202`, `DiffModalViewModel.cs:103`, `MergeSectionViewModel.cs:159` |
| B2 | Diff (open) | 34 | MergeSection "Open Diff", TaskHeaderBar "Review Merged Diff", WorktreesOverview "Show Diff", Planning "Review Combined" |
| B3 | List Settings dialog | 3 | Lists context menu, Tasks header button, shell bridge `IslandsShellViewModel.cs:190-194` |
| B4 | Worktrees Overview | 23 | Repos menu (global), Lists context menu (per-list) |
| B5 | Repo Import | 2 | Repos menu, Lists footer button |
The conflict-resolution *target* is already single-point (`IslandsShellViewModel.RequestConflictResolutionAsync`, line 49). What is duplicated is the **seam plumbing**: five VMs each own the Func and it is threaded by hand.
### Bucket C — dead / leftover
| # | Item | Evidence |
|---|---|---|
| C1 | Dead hunks conflict API | `TaskMergeService.GetConflictsAsync` (`Lifecycle/TaskMergeService.cs:250`) ← `WorkerHub.GetMergeConflicts` (`Hub/WorkerHub.cs:378`) ← `WorkerClient` `"GetMergeConflicts"` (`Services/WorkerClient.cs:276`) ← `IWorkerClient`. Live resolver uses `GetMergeConflictDocuments` (`WorkerHub.cs:389`). Only `TaskMergeServiceTests.cs:672` still references the old one. |
| C2 | Two task-creation paths | UI quick-add `TasksIslandViewModel.AddAsync` writes EF directly (`db.Tasks.Add`); MCP `ExternalMcpService.AddTask` is the service path. They can drift. |
| C3 | Stale worktrees | `.claude/worktrees/feat+planning-sessions-ui/…` carries old copies of `DiffModalViewModel`/`ListSettingsModalViewModel`/`WorktreeModalViewModel`; layer-c resolver leftovers. Worktree hygiene, not main code. |
| C4 | Naming drift (deferred) | Hub `StartConflictMerge`/`ContinueConflictMerge`/`AbortConflictMerge` (`WorkerHub.cs:367/405/414`) vs service `MergeAsync`/`ContinueMergeAsync`/`AbortMergeAsync`. **Documented as intentional** at `Worker/CLAUDE.md:153`. |
## Targets — one component per feature
1. **MergeCoordinator (B1).** Replace the five `RequestConflictResolution` Func seams with one injected coordinator exposing `MergeAsync(taskId, targetBranch)` that owns the "merge → on-conflict open resolver" sequence. Every door (review Approve, Diff Merge button, WorktreesOverview single + batch, Details merge section) calls it. The single resolution point (`IslandsShellViewModel.RequestConflictResolutionAsync`) becomes the coordinator's body.
2. **DiffViewer (A1 + B2).** One `DiffViewerViewModel` + view with a `DiffSource` abstraction (`DirtyWorktree | BranchVsBase | CommitRange | PlanningAggregate | IntegrationBranch`) and an optional file-tree pane. Replaces `DiffModal` + `WorktreeModal` + `PlanningDiff` shells; keeps `UnifiedDiffParser`/`DiffLinesView`. All B2 doors open it with a different source.
3. **WorktreeActions (A3).** One `WorktreeActionsViewModel` for a single task's worktree (merge/diff/discard/keep/force-remove), reused by both the overview rows and the Details merge section instead of each owning copies.
4. **AgentConfigEditor (A2).** One editor component parameterized by scope (`Global | List | Task`) over `InheritanceResolver`, embedded in Settings, List Settings, and the Details panel. Collapses the duplicated property set + reset commands + badges.
5. **DialogService (B3B5).** Consolidate the per-modal `Show*` Func seams (`IslandsShellViewModel.cs:59-71`) into one `IDialogService` with typed open methods (`OpenListSettings(list)`, `OpenRepoImport()`, `OpenWorktreesOverview(listId?)`…). Menu, context menu, and footer all call the same method; duplicate command definitions across `ListsIsland`/shell collapse to one.
6. **Single task-creation path (C2).** Route UI quick-add through the same creation path MCP `AddTask` uses (repository/service), so both honor the same invariants.
Plus **C1** (delete dead hunks API + its test) and **C3** (prune stale worktrees) as groundwork. **C4** naming alignment is **deferred** — it is documented-intentional and would churn the hub + `WorkerClient` + every `IWorkerClient` fake (see the "fakes to sync" hazard) for cosmetic gain.
## Decisions
- **Phased, each phase ships green.** Six independently buildable/committable slices; cheapest and lowest-risk first (see the plan). No big-bang.
- **One plan file per slice.** Matching the 2026-06-05 layer-A/B/C convention, each slice gets its own `docs/superpowers/plans/2026-06-19-unify-<slice>.md` authored when it is picked up. This umbrella plan sequences them and details Phase 01.
- **DiffViewer (A1) is last.** Highest effort and most UX-sensitive (file-tree vs whole-unified are different layouts); deferring it lets the cheaper wins land first and de-risks the big one.
- **Keep the merge engine and the resolver seam contract.** `TaskMergeService`, `PlanningMergeOrchestrator`, `ConflictResolverViewModel` ctor/`OpenAsync`/`OpenForPlanningAsync`/`CloseRequested` are unchanged — unification is above them.
- **Naming alignment deferred, not done** (rationale above).
## Out of scope / deferred
- Hub/service merge-method renaming (C4).
- Subtask deletion in the UI (a missing feature surfaced during mapping, not a duplicate).
- Any DB migration, worker engine change, or push.
## Acceptance (per phase)
Each phase: `dotnet build -c Release` clean for touched projects; the relevant test
project green; locales in parity (Localization.Tests) where keys change; the feature
reachable through its single new path with the old doors removed or delegating. UI
phases (25) flag a visual-verification gap for Mika to confirm in the running app.
@@ -0,0 +1,132 @@
# Rider-style 3-pane merge editor (conflict resolver redesign)
Date: 2026-06-19
## Goal
Replace ClaudeDo's current conflict resolver (3 read-only columns Base|Ours|Theirs,
one conflict at a time, accept buttons + editable result below) with a JetBrains
Rider-style **3-pane merge editor**:
- LEFT = **Ours** (read-only) · current branch / merge target
- MIDDLE = **Result** (editable) · the merged file being assembled
- RIGHT = **Theirs** (read-only) · incoming task branch
Whole file per pane (not one conflict at a time), color-coded conflict blocks,
inline per-hunk accept controls (`` accept a side into the result, `✕` dismiss),
a `M conflicts · K resolved` readout, synced scrolling, Continue gated until every
conflict is resolved, Abort, and a binary-file guard. Visual reference: the
attached "Merge Revisions" screenshot.
## Background
- Avalonia 12 desktop app; the conflict editor already uses **AvaloniaEdit 12.0.0**
+ `AvaloniaEdit.TextMate` (theme `StyleInclude` in `src/ClaudeDo.App/App.axaml`).
- **Backend is kept unchanged.** `WorkerHub.GetMergeConflictDocuments(taskId)` returns
each conflicted file as ordered `MergeSegment`s: *stable* text (git's already
auto-merged content) interleaved with *conflict* blocks carrying `Ours/Base/Theirs`.
`StartConflictMerge` / `WriteConflictResolution` / `Continue[Planning]ConflictMerge` /
`Abort[Planning]ConflictMerge` and their `IWorkerClient` mirrors stay as-is.
`ConflictMarkerParser` (Data) already produces the segments. **ours = merge target
(current branch); theirs = incoming task branch.** Merges are LOCAL-only (no push).
- **Seam kept unchanged** so single-task AND planning conflict paths keep working:
`IslandsShellViewModel.ConflictResolverFactory` + `ShowConflictResolver`
(wired in `MainWindow.axaml.cs`), VM ctor `(IWorkerClient, taskId)`,
`OpenAsync(targetBranch)`, `OpenForPlanningAsync(parentId, subtaskId)`, `CloseRequested`.
The planning-path WIP currently uncommitted in the tree (`OpenForPlanningAsync`,
`_conflictTaskId`, `LoadDocumentsAsync`) is part of this seam and is preserved.
### Key insight: the segments already line the panes up
Because every conflicted file is split into *stable* (identical on both sides, git
auto-merged) and *conflict* (divergent) segments, reconstructing three documents —
- **Ours** = Σ over segments of (stable.Text | conflict.Ours)
- **Theirs** = Σ over segments of (stable.Text | conflict.Theirs)
- **Result** = Σ over segments of (stable.Text | conflict.Resolution ?? conflict.Ours)
— yields three documents that are byte-identical in their stable regions and differ
only inside conflict blocks. So the panes align line-for-line for free, and a real
client-side 3-way diff is **not** needed for the core feature.
## Decisions
- **Data source = segment-based (no backend change, no DiffPlex).** The worker already
applied git's auto-merge; only conflicts remain actionable. The screenshot's
"N changes" (non-conflicting hunks shown as separately flippable) are already merged
and have nothing to accept, so the readout is **`M conflicts · K resolved`**. True
"N changes" parity (raw `:1/:2/:3` blobs + DiffPlex 3-way) is an explicit later
add-on that does not touch the seam — see *Out of scope / fast-follow*.
- **One file at a time + file switcher.** Like Rider's title bar ("Merge Revisions for
…file"). When more than one file conflicts, a compact switcher selects the active
file; Continue still requires *all* files resolved. (Replaces today's cross-file
flattened one-at-a-time navigation as the primary model.)
- **Result-pane editing model.** The middle document is the merged file. Stable text is
read-only via `IReadOnlySectionProvider`; only conflict regions are editable. Each
conflict's result span is tracked in a `TextSegmentCollection` (anchors auto-adjust on
edit). Accepting ``(ours)/``(theirs) replaces that span; editing inside it or
accepting flips the block to **resolved**. Unresolved regions are seeded with the Ours
text and painted red until acted on.
- **Accept controls = overlay between panes** (not an AvaloniaEdit margin). A thin Canvas
overlay between Ours|Result and Result|Theirs hosts ``/`✕` (and ``) per conflict,
positioned at each block's visual Y (recomputed on scroll/resize). This matches the
screenshot's between-pane gutters and avoids the lack of a built-in right-side margin.
- **Synced scroll = proportional (Green).** Mirror each pane's vertical scroll offset to
the other two with a re-entrancy guard. Aligned/virtual-space scroll + bezier connector
curves are a deferred stretch.
- **Seam + existing VM tests preserved.** Keep `MergeConflictBlock` with its
`AcceptOurs/Theirs/Both/Base` commands and `MergeFile.Compose`; keep
`Current`/`CurrentIndex`/`Next`/`Previous` repurposed as the focused-conflict the top
arrows jump to. New state (active file, readout) is additive.
## Architecture
### ViewModel (`ConflictResolverViewModel`, `ConflictModels.cs`)
Unchanged seam: ctor, `OpenAsync`, `OpenForPlanningAsync`, `CloseRequested`,
`Continue`/`Abort` (incl. planning routing), `CanContinue` gating, binary guard.
Additive:
- `ActiveFile` (`MergeFile`) + the switcher list (`Files`) + `SelectFileCommand`.
- Per-active-file reconstruction exposed for the view and for tests:
`ActiveOursText`, `ActiveTheirsText`, `ActiveResultText` (result seeds unresolved =
Ours), plus an ordered list of conflict descriptors (the block + its segment index)
so the view can compute offsets/spans as it assembles each document.
- Readout `PositionText``"{M} conflicts · {K} resolved"` (active file and/or total);
`CanContinue` stays "all files resolved AND no binary".
- On switching files, block `Resolution` persists (state lives on `MergeConflictBlock`),
so progress survives navigation; the view rebuilds documents from the active file.
### View (`Views/Conflicts/ConflictResolverView.axaml` + `.cs`)
- AXAML: ModalShell host (kept), header (prev/next arrows, file switcher, readout),
`Grid` of three bordered panes with headers, two between-pane overlay Canvases,
footer (Continue/Abort), binary banner, `Escape`→Abort. Drop the Base column.
- Code-behind builds three `TextDocument`s from `ActiveFile`'s segments, recording each
conflict's line span per document; installs TextMate by file extension on all three;
rebuilds on file switch; pushes result-pane edits back into the active block's
`Resolution` and flips resolved.
- `IReadOnlySectionProvider` on the Result `TextArea` (stable = read-only, conflicts =
editable) backed by a `TextSegmentCollection` of the conflict result-spans.
- One `IBackgroundRenderer` per pane painting unresolved-conflict (red), resolved
(green/muted), and ours/theirs side tints, driven by the recorded spans + block state.
- Overlay accept controls positioned at each block's `TextView` visual top; click →
`block.AcceptOurs/AcceptTheirs` and the code-behind replaces the tracked result span.
- Proportional synced vertical scroll across the three panes.
### Localization / tokens
- New `conflictResolver.*` keys (pane headers, readout, accept tooltips) in
`en.json` + `de.json` (parity enforced by Localization.Tests).
- Block colors from `Tokens.axaml` (reuse Blood/Moss/Accent tints; add tokens only if a
needed shade is missing).
## Out of scope / fast-follow (not in this plan)
- **Raw 3-way diff "N changes" parity (Option B):** a new worker method returning raw
`:1/:2/:3` blobs per conflicted file + DiffPlex client-side 3-way diff so
non-conflicting changes also appear as accept-able hunks. Seam-preserving; later.
- **Intra-conflict word/line highlighting** (Rider's "Highlight words") via a line
transformer.
- **Bezier connector curves + aligned / virtual-space synced scroll** (Red stretch).
- No DB migration, no backend/seam changes, no push.
@@ -0,0 +1,49 @@
# Worker log → footer auto-route + Log Visualizer overlay
**Date:** 2026-06-23
**Status:** approved (design forks resolved with user)
## Goal
1. Auto-route **all Worker WARN/ERROR** Serilog events to the footer status strip (today only ~10 hand-curated business events reach it).
2. Make the footer log line **clickable** → opens a **Log Visualizer overlay** showing the **last 30 min** of logs at **all levels**, color-coded.
3. **Dedupe/rate-limit** the footer so repeating warnings (e.g. the current 60s OIDC-discovery failure) don't strobe.
## Decisions (locked)
- **Overlay source:** Worker-side **in-memory ring buffer** (30-min window, all levels), fetched via a hub call. No log-file parsing.
- **Levels:** overlay shows INF/WRN/ERR; footer flashes **WARN/ERROR only**.
- **Footer noise:** per-message dedupe within a rate-limit window (suppress the footer broadcast for an identical message seen recently; the event is still buffered for the overlay).
## Architecture
### Worker
- **`LogRingBuffer`** (singleton, `Logging/`): thread-safe, time-bounded (`TimeSpan` window, default 30 min) + hard cap (e.g. 5000) ring of `WorkerLogRecord(Message, Level, TimestampUtc)`. Evicts on append by age + cap. `Snapshot()` returns newest-last.
- **`BroadcastLogSink : Serilog.Core.ILogEventSink`** (`Logging/`): for every `LogEvent`
- map level: Verbose/Debug/Information→`Info`, Warning→`Warn`, Error/Fatal→`Error`;
- render `msg = evt.RenderMessage()` (+ `": {ex.GetType().Name}: {ex.Message}"` first-line if `evt.Exception != null`);
- append to `LogRingBuffer` (all levels);
- if `Warn|Error` **and** not rate-limited: fire-and-forget `HubBroadcaster.WorkerLog(msg, level, evt.Timestamp.UtcDateTime)`.
- **Loop guard:** wrap the broadcast in try/catch and swallow; skip broadcasting events whose `SourceContext` is SignalR/connections plumbing (still buffered). Broadcasting must never itself log.
- **Dedupe/rate-limit:** dict `message → lastBroadcastUtc`; suppress footer broadcast if `now - last < RateLimitWindow` (const, 120 s). Periodic prune of the dict.
- **DI wiring (chicken-egg):** `LogRingBuffer` + `BroadcastLogSink` are created as locals in `Program.cs` *before* `builder.Build()`, captured into `UseSerilog(... .WriteTo.Sink(broadcastSink))`, and registered as singletons. `HubBroadcaster` doesn't exist until post-build, so the sink starts detached; after `builder.Build()` we call `broadcastSink.Attach(app.Services.GetRequiredService<HubBroadcaster>())`. Buffering works from process start; broadcasting begins once attached.
- **Hub:** `WorkerHub.GetRecentLogs() -> IReadOnlyList<WorkerLogRecordDto>` reads `LogRingBuffer.Snapshot()`. (Read-only, no auth beyond existing hub.)
### UI
- **IWorkerClient / WorkerClient:** add `Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync(CancellationToken ct = default)`. ⚠ Update hand-rolled fakes in **both** test projects (StubWorkerClient + Worker.Tests UiVm fake).
- **Footer:** wrap the worker-log `TextBlock` so it's clickable (Button, transparent) → `IslandsShellViewModel.OpenLogVisualizerCommand`. Existing `OnWorkerLogReceived` already routes the (now more numerous) `WorkerLog` broadcasts to the strip — **no change needed** for footer routing itself.
- **`LogVisualizerViewModel`** (Modals/): on open, `GetRecentLogsAsync()``ObservableCollection<LogLineViewModel>` (msg, level→brush, HH:mm:ss). A level filter (All / Warn+Err) and a Refresh command. MVP = snapshot on open + Refresh; live-tail is a later nicety.
- **`LogVisualizerView`** (Modals/): `ModalShell`-based dialog (consistent with other modals), shown via `IDialogService.ShowLogVisualizerAsync(vm)`. Small, scrollable, monospaced, color-coded lines.
- **Localization:** new `vm.logVisualizer` (+ any view keys) in **en.json + de.json** (parity test enforces).
## Out of scope / follow-ups
- Live-tail while the overlay is open (snapshot + Refresh for MVP).
- The **OIDC-discovery-every-60s failure** is a *separate* bug (Online Inbox enabled, `auth.kuns.dev` SSL fails). Dedupe tames the footer symptom; the root cause is tracked separately.
## Tests
- Worker: `LogRingBufferTests` (age + cap eviction, snapshot order), `BroadcastLogSinkTests` (level mapping; all levels buffered; only Warn/Err broadcast; dedupe suppresses repeat broadcast within window but still buffers; exception rendering; loop-guard source filter).
- UI: `LogVisualizerViewModelTests` (loads from worker, populates, filter). Footer-click wiring smoke.
@@ -0,0 +1,102 @@
# Interactive "Answer Claude's Questions" — Design
**Date:** 2026-06-25
**Status:** Approved (brainstormed with Mika)
## Goal
Let the user answer a question Claude raises *mid-run* from inside Mission Control,
without leaving the autonomous-execution model. Not a chat panel, not a terminal, not
proactive steering — only: *Claude surfaces a question → the user types an answer → the
run continues with that answer in context.*
User decisions (brainstorm):
- Scope: "I mostly want to answer his questions if he surfaces any."
- Trigger: **any running task** may ask, with a **3-minute** answer window.
## Why not the alternatives
- **Embedded terminal / PTY** — would destroy the NDJSON contract the whole worker
pipeline depends on (StreamAnalyzer, token accounting, auto-commit, status flow) and
needs a terminal-emulator control Avalonia doesn't have. Rejected.
- **Streaming-stdin (`--input-format stream-json`)** — right tool for a free-form chat,
overkill here. Rejected for v1.
- **`--resume` per-turn** — already exists; not live (cold process per turn).
## Mechanism
The in-task MCP already blocks the `claude -p` process while a tool call is in flight.
That blocking *is* the pause. Add one in-task MCP tool, `AskUser(question)`:
1. The tool resolves the caller task id, registers a pending question + a
`TaskCompletionSource<string>` in a singleton `PendingQuestionRegistry`, and
broadcasts `TaskQuestionAsked(taskId, questionId, question)`.
2. Mission Control surfaces the question with an input box.
3. The user answers → `WorkerHub.AnswerTaskQuestion` resolves the TCS → the tool
returns the answer as its result → Claude continues.
4. No answer within **3 minutes** → the tool returns *"No response received within 3
minutes — proceed using your best judgment."* and the run carries on autonomously.
### Key facts that make this work
- **No persisted status change.** The task is still genuinely `Running` (process alive,
blocked mid-tool-call). "Waiting for input" is **ephemeral**: in-memory registry +
live SignalR events + a UI overlay. No `TaskStatus` enum value, no `TaskStateService`
transition, **no EF migration**. If the worker dies mid-wait, `StaleTaskRecovery`
flips the orphaned `Running` row to `Failed` like any interrupted run.
- **`MCP_TOOL_TIMEOUT` must be raised.** Claude Code caps HTTP MCP tool calls at **60 s**
by default. The `claudedo_run` MCP is HTTP, so `ClaudeProcess` must set
`MCP_TOOL_TIMEOUT=200000` (≈3 min + margin) on the spawned process or the 3-min window
is silently truncated to 60 s.
- **MCP wired for all runs.** Today `TaskRunner` only mints the run MCP for standalone
top-level tasks (for `SuggestImprovement`). To satisfy "any running task," move the
MCP-identity setup out of that gate so every `RunAsync` gets `claudedo_run`.
`AllowedTools` always includes `mcp__claudedo_run__AskUser`; `SuggestImprovement` stays
gated to improvement-eligible (standalone) runs.
## Surface changes
**Worker (mostly new files):**
- `Runner/PendingQuestionRegistry.cs` (new, singleton) — `Register`, `TryAnswer`, `Get`,
`Remove`; one pending question per task.
- `Runner/TaskRunMcpService.cs` (edit) — add `AskUser` `[McpServerTool]`; inject the
registry.
- `Runner/TaskRunner.cs` (edit) — wire MCP identity for all runs; add `AskUser` to
allowed tools.
- `Runner/ClaudeProcess.cs` (edit) — set `MCP_TOOL_TIMEOUT` env.
- `Hub/HubBroadcaster.cs` (edit) — `TaskQuestionAsked`, `TaskQuestionResolved`.
- `Hub/WorkerHub.cs` (edit) — `AnswerTaskQuestion`, `GetPendingQuestion` + DTO.
- `Program.cs` (edit) — register `PendingQuestionRegistry` singleton.
- System prompt (edit) — one line telling Claude the tool exists and to use it only when
a wrong guess would be costly/irreversible (otherwise proceed).
**UI:**
- `Services/IWorkerClient.cs` + `WorkerClient.cs` (edit) — `AnswerTaskQuestionAsync`,
`GetPendingQuestionAsync`, `TaskQuestionAskedEvent`, `TaskQuestionResolvedEvent`.
- `ViewModels/Islands/TaskMonitorViewModel.cs` (edit, **hot file**) — pending-question
state, `AnswerDraft`, `SubmitAnswerCommand`, clear on finish/resolve.
- `ViewModels/MissionControlViewModel.cs` (edit) — hydrate pending question on attach.
- `Views/MissionControl/MonitorPaneView.axaml` (edit, **hot file**) — additive
question/answer banner above the terminal.
- `Localization/locales/en.json` + `de.json``missionControl.question.*` keys.
**Tests:** `PendingQuestionRegistry` (answer/timeout/unknown/overwrite), `AskUser` tool
(answer + timeout fallback, fake broadcaster — no real Claude), `TaskMonitorViewModel`
(surface/submit/clear). Update IWorkerClient fakes in both test projects.
## Concurrency note
Two files (`TaskMonitorViewModel.cs`, `MonitorPaneView.axaml`) are also being touched by
a concurrent Mission Control drag-and-drop session on the shared main tree. Keep edits
additive, commit explicit paths only (never `git add -A`).
## Verification gaps (manual)
1. **Real-Claude smoke test** — confirm a blocking `AskUser` call survives ≥3 min with
`MCP_TOOL_TIMEOUT=200000` and that the model actually calls the tool when uncertain.
2. **Visual** — the question banner + input box in the pane (Mika does the visual pass).
## Non-goals
Free-form chat panel; proactive steering; tool-permission prompts (stays `auto`);
`ContinueAsync`/resumed runs gaining `AskUser` (deferred follow-up).
@@ -0,0 +1,144 @@
# Mission Control — multi-task live monitoring
Date: 2026-06-25
Status: approved (design); implementation not started
## Problem
The UI can observe only **one** running task at a time. `DetailsIslandViewModel` is hard 1:1
(single `Task`, single `_subscribedTaskId`); selecting another task in the middle pane *replaces*
what Details shows. Yet the worker runs several tasks concurrently (`MaxParallelExecutions`) and
already broadcasts every task's live output to all clients keyed by `taskId`. So the user cannot
watch multiple in-flight sessions, and monitoring blocks normal work (adding tasks, reviewing).
## Goal
Watch several running tasks at once **without** giving up the normal app. Requirements drawn from
the brainstorm:
- A **live console grid** — multiple full Claude output streams side by side.
- Each pane also shows **task details, blocking reasons**, and a **navigation helper** to open the
monitored task in the main app.
- Lives in a **separate, always-available window** so the main window stays fully usable (adding
tasks must never be blocked). Combines "full window" + "detachable".
## Non-goals
- No worker/SignalR changes. The broadcast layer is already N-capable (`TaskMessage(taskId,line)`,
`TaskStarted/Finished/Updated`, `GetActive()`). This is a UI/VM-only feature.
- No second SignalR connection. The new window shares the existing singleton `IWorkerClient`.
- No new merge/review engine. Review/merge stays in the main window's Details pane; Mission Control
is read-mostly (monitor + cancel + navigate).
## Hard constraint: no duplicated components or features
This feature is an **extract-and-reuse** exercise, not a rebuild. The single biggest risk is
forking a second live-streaming/parsing/status implementation. The reuse map below is binding.
### Reuse map (what already exists — use it, do not copy it)
| Concern | Existing asset | Location | How Mission Control uses it |
|---|---|---|---|
| Live console body (log list, LIVE/DONE/FAILED chip, auto-scroll) | `SessionTerminalView` (StyledProps `Entries`, `Label`, `IsRunning/IsDone/IsFailed`) | `Views/Islands/SessionTerminalView.axaml(.cs)` | Bind a pane's `Entries`→its `Log`, status flags + label. **No new console control.** |
| Log line model | `LogLineViewModel` + `LogKind` | `ViewModels/Islands/DetailsIslandViewModel.cs` (top) | Shared model — move to its own file so both consumers reference one type. |
| Live stream parse/replay | `OnTaskMessage` / `AppendStdoutLine` / `FlushClaudeBuffer` / `ReplayLogFileAsync` + `StreamLineFormatter` + `ExpandUserPath` | private in `DetailsIslandViewModel.cs` | **Extract to `TaskMonitorViewModel`** (Phase 1). One streaming engine, two consumers. |
| Status state machine | `AgentState` + `Is*` flags + `StatusToStateKey` / `FinishedStatusToStateKey` | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
| Outcome / roadblock split | `ApplyOutcome` + `RoadblockMarker` constant | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
| Status chip / terminal styling | `live-chip`, `terminal`, `log-*` style classes | `Design/IslandStyles.axaml` | Reuse the classes as-is. |
| Add a new task | `TasksIslandViewModel.AddAsync` (`NewTaskTitle`, user-list only, direct `TaskRepository`) | `TasksIslandViewModel.cs:406` | Optional quick-add reuses this path; **must not** introduce a second insert path. |
| Live task list | `IWorkerClient.GetActive()` + `TaskStarted/Finished` events | worker hub / `WorkerClient` | Populate the grid; add/remove panes. |
| DI / singletons | `IslandsShellViewModel`, `DetailsIslandViewModel`, `IWorkerClient` all singletons | `App/Program.cs` | Register `MissionControlViewModel` singleton; inject existing singletons. |
## Design
### TaskMonitorViewModel (the reusable core — new, but carved out of DetailsIslandViewModel)
One instance == one monitored task. Owns:
- `Log` (`ObservableCollection<LogLineViewModel>`), the filtered `TaskMessageEvent` subscription
(by `taskId`), stdout buffering, and NDJSON replay from disk on attach.
- `AgentState` + `Is*` flags; `SessionOutcome` / `Roadblocks` (the outcome split).
- Lightweight display: `Title`, `TaskIdBadge`, `Model`, `TurnsText`, `TokensFormatted`,
diff add/del, elapsed.
- `BlockingReason` (string/visible flag) derived from existing data: `BlockedByTaskId`
(planning/child chain), `WaitingForReview` / `WaitingForChildren` status, and roadblock markers.
- Commands: `OpenInApp`, `Detach`, `Cancel`.
- `IDisposable` — unsubscribes all worker events (mirror DetailsIslandViewModel.Dispose).
`DetailsIslandViewModel` is refactored to **own one `TaskMonitorViewModel` (`public Monitor`)** and
delegate streaming/status/outcome to it. Its heavy concerns (subtasks, attachments, editing, merge
cockpit, review verbs, child outcomes, notes/prep modes) stay put. **Phase 1 must be a no-behavior-
change refactor** — all existing Ui.Tests stay green.
> Binding-surface decision (Phase 1): repoint `WorkConsole.axaml`'s Output-tab bindings that
> reference streaming/status (`Log`, `IsRunning/IsDone/IsFailed`, `SessionOutcome`, `TurnsText`,
> diff text, `Model`) to `Monitor.*`. `x:DataType` stays `DetailsIslandViewModel`; compiled bindings
> handle the nested path. Review/merge/session bindings are untouched. Prefer repointing over adding
> ~15 forwarding properties (one source of truth, no boilerplate).
### MissionControlViewModel (new)
- `ObservableCollection<TaskMonitorViewModel> Monitors`, keyed by `taskId`.
- On open: seed from `GetActive()`. On `TaskStarted`: add a monitor. On `TaskFinished`: keep the
pane (so the final output stays readable) but flip its state; a "clear finished" action prunes them.
- Adaptive layout signal (column count) from `Monitors.Count`:
`1→1col, 2→2col, 34→2col(2 rows), 5+→fixed-width panes, horizontal scroll`. Least-active panes
beyond a threshold collapse to a compact card (title + last line + chip), click to expand — this is
the readability fallback so we never render N unreadable slivers.
- Optional `QuickAdd` (deferred within Phase 2): title + target user-list → the **same** creation
path as `TasksIslandViewModel.AddAsync` (shared method, not a copy).
- Disposes every monitor on window close.
### Windowing (new plumbing — thin)
- `MissionControlWindow` (Avalonia `Window`) hosting `MissionControlView`; DataContext =
the singleton `MissionControlViewModel`.
- No non-modal secondary-window precedent exists (all current dialogs use `ShowDialog(owner)`), so
this is genuinely new but small:
- Set `desktop.ShutdownMode = OnMainWindowClose` in `App.OnFrameworkInitializationCompleted` so
closing Mission Control never quits the app, and closing the main window does.
- Open via a **title-bar button in MainWindow** (toggle: show / focus-if-open). The window is
created lazily and hidden (not destroyed) on close so its monitors persist cheaply.
- Persist size/position (reuse the ui.config.json mechanism if present; otherwise defer).
### MonitorPaneView (new view, reuses SessionTerminalView)
```
┌─ #142 Refactor auth module ───────── ● running ─┐ header: title, live chip, tok/turn/elapsed
│ ⏱ 4m12s ◆ 18.3k tok ↻ turn 6 │
├───────────────────────────────────────────────────┤
│ ⚠ Blocked: waiting on #141 (planning parent) │ blocking banner (visible only when blocked)
├───────────────────────────────────────────────────┤
│ <SessionTerminalView Entries={Log} .../> │ the REUSED console
├───────────────────────────────────────────────────┤
│ [↗ Open in app] [⧉ Detach] [✕ Cancel] │ footer
└───────────────────────────────────────────────────┘
```
### Navigation helper "Open in app" (new shell method)
No select-by-id exists today. Add `IslandsShellViewModel.RevealTaskAsync(taskId)`:
1. resolve the task's list, set `Lists.SelectedList`; 2. await `Tasks.LoadForList`; 3. find the row in
`Tasks.Items` by id, set `Tasks.SelectedTask` (→ `Details.Bind`); 4. bring MainWindow to front.
`TaskMonitorViewModel.OpenInApp` calls this. Single navigation entry point — no duplicate selection logic.
### Detach (Phase 3)
`Detach` moves a `TaskMonitorViewModel` out of the grid into a small `TaskMonitorWindow`
(reuses `MonitorPaneView`), optionally always-on-top; closing it re-docks. Lowest priority.
## Risks / open items
- **Phase 1 binding repoint** is the main risk: a missed `WorkConsole` binding shows as a blank
field, not a build error. Mitigation: Ui.Tests + a manual visual pass on the Details pane.
- **Localization parity** (Localization.Tests): every new visible string needs en + de keys under a
`missionControl.*` namespace.
- **Quick-add coupling** across windows is the weakest part; kept optional/deferrable.
- Detached windows = most plumbing, least daily payoff → Phase 3, last.
## Verification
- Build `ClaudeDo.App` + run Ui.Tests / Localization.Tests after each phase.
- Manual visual pass (cannot be auto-verified): Details pane unchanged after Phase 1; grid populates
with 2+ concurrent tasks, blocking banner shows, Open-in-app surfaces the task, adding a task in the
main window works while Mission Control is open.
@@ -0,0 +1,147 @@
# In-App Interactive Sessions — Design
**Date:** 2026-06-26
**Status:** Proposed (awaiting approval)
## Goal
Replace the external Windows-Terminal "Run interactively" session with an **in-app
streaming chat**, rendered in the existing `SessionTerminalView` in **both task detail and
Mission Control**. Keep everything inside the app — no `wt.exe` pop-out. Autonomous task
execution is **untouched** (stays one-shot, non-interactive).
## Decisions (brainstorm)
1. **Engine: persistent streaming session.** One `claude` process kept alive with
`--input-format stream-json`; user messages pushed over stdin.
2. **Scope: interactive sessions only.** The autonomous `TaskRunner`/`ClaudeProcess` run
loop, review, queue, and worktree machinery are NOT changed.
3. **Placement: shared `SessionTerminalView`** — the in-app session + composer appear in the
task-detail session surface and in the Mission Control monitor pane.
4. **Full replace.** "Run interactively" now opens the in-app session; the
`WindowsTerminalLauncher.LaunchInteractiveAsync` path is removed. **Planning** sessions
keep using `wt` (untouched).
5. **Send semantics: interrupt + redirect** mid-turn (control protocol), with automatic
*queue-for-next-turn* fallback if interrupt is unavailable.
## What an interactive session is (unchanged semantics, new transport)
Today (`PlanningSessionManager.OpenInteractiveAsync` + `WindowsTerminalLauncher`):
`claude --model <PlanningAlias> --permission-mode auto "<task title+description>"` in the
**list's working dir**, env `MAX_THINKING_TOKENS=20000`, full default toolset, relies on the
globally-registered `claudedo` MCP. **Ephemeral** — no worktree, no `task_run` record, no
status change, no review.
We keep all of that. Only the transport changes: instead of a `wt` window, the same
`claude` invocation runs as a persistent stream-json process owned by the worker, its output
streamed into the app and its stdin fed from an in-app composer.
> Honest tradeoff: the `wt` terminal gave the full Claude Code TUI (slash-command UX,
> interactive prompts). An in-app stream-json chat is plainer — type messages, watch streamed
> output. `--permission-mode auto` means no blocking permission prompts (so headless works),
> but it is a simpler surface than the real TUI. Accepted per the "full replace" decision.
## The streaming engine
Flags: `--model <PlanningAlias> --permission-mode auto --input-format stream-json
--output-format stream-json --verbose --replay-user-messages` in the list working dir, env
`MAX_THINKING_TOKENS=20000`. No `--mcp-config`/`--allowedTools` (interactive uses the global
MCP + default tools, exactly as today).
- First stdin message = the seeded interactive prompt:
`{"type":"user","message":{"role":"user","content":[{"type":"text","text":"…"}]},"parent_tool_use_id":null}\n`
(stdin stays open).
- A stdout read task forwards each NDJSON line to a callback (→ broadcast + the session's log)
and detects `result` events (turn boundary; the process then idles for the next message).
- `SendUserMessageAsync(text)` writes a user-message JSON line; if a turn is in flight, also
`InterruptAsync()` (control-protocol interrupt) so Claude pivots immediately. If interrupt
is unavailable, the message lands when the current turn ends → automatic queue fallback.
- **Interrupt is verified working** (spike, 2026-06-26, CLI 2.1.191). Exact shape:
`{"type":"control_request","request_id":"<id>","request":{"subtype":"interrupt"}}` — no
`initialize` handshake needed; `control_response {"subtype":"success"}` confirms
synchronously; the same process then accepts the redirect and runs a fresh turn with
context intact.
- **Interrupt artifact:** the aborted turn emits a `result` with `is_error=true,
subtype="error_during_execution"`. The session must treat an interrupt-induced result as
*"turn aborted, continue"* (drain the queued redirect), **not** as a session failure.
Tolerate the incidental `system:init`/`system:status`/`rate_limit_event`/hook events that
also appear in the stream.
- `--replay-user-messages` echoes each sent message back on stdout as a `user` event, so it
rides the existing stream pipeline into the timeline (ordered + confirmed) with no extra
broadcast surface.
- The session ends only when the **user stops it** (kill the process tree) — an interactive
session has no auto-finalize and never enters review. No queue slot is involved (it is
launched directly, not via the autonomous picker).
## Surface changes
**Worker**
- `Runner/StreamingClaudeSession.cs` (new) — persistent process + send/interrupt/stop; reuse
the `ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT` from `ClaudeProcess`; streams via a line
callback; `IsTurnInFlight`. Cancellation kills the tree.
- `Runner/LiveSessionRegistry.cs` (new, singleton) — `taskId → StreamingClaudeSession`
(`Register`/`TryGet`/`Unregister`/`Stop`), mirrors `PendingQuestionRegistry`.
- `Planning/InteractiveSessionService.cs` (new) — owns interactive lifecycle: `StartAsync(
taskId)` resolves the list working dir + seeded prompt (reuse `OpenInteractiveAsync`'s
body), spawns the session, registers it, wires output to `HubBroadcaster.TaskMessage`,
broadcasts `InteractiveSessionStarted`; `SendAsync(taskId, text)`; `StopAsync(taskId)` →
`InteractiveSessionEnded`.
- `Planning/WindowsTerminalLauncher.cs` + `Planning/Interfaces/ITerminalLauncher.cs` — remove
`LaunchInteractiveAsync` (+ `InteractiveLaunchContext`). Planning start/resume stay.
- `Hub/WorkerHub.cs` — `OpenInteractiveTerminalAsync` re-pointed to
`InteractiveSessionService.StartAsync` (no terminal); add `SendInteractiveMessage(taskId,
text)`, `StopInteractiveSession(taskId)` (+ optional `InterruptInteractiveSession`).
- `Hub/HubBroadcaster.cs` — `InteractiveSessionStarted(taskId)`,
`InteractiveSessionEnded(taskId)`. Log lines reuse the existing `TaskMessage(taskId, line)`.
- `Program.cs` — register `LiveSessionRegistry` + `InteractiveSessionService`.
**UI**
- `Views/Islands/SessionTerminalView.axaml(.cs)` — add an optional composer (styled
properties: `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`).
Both hosts (task detail + Mission Control) get it by binding their VM's composer state.
- `StreamLineFormatter` — render `type:"user"` NDJSON events as a `LogKind.User` bubble.
- A small shared composer concept on `TaskMonitorViewModel` **and** `DetailsIslandViewModel`
(factor a helper to avoid duplication): `ComposerDraft`, `SubmitComposerCommand`,
`IsInteractiveLive` (set by `InteractiveSessionStarted/Ended`). Submit →
`SendInteractiveMessageAsync`; clear draft. (If a pending AskUser question exists, the same
composer answers it — keep the existing answer route.)
- `MissionControlViewModel` — `EnsureMonitor(taskId)` on `InteractiveSessionStarted` so the
session appears as a monitor; mark it interactive.
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs` — `SendInteractiveMessageAsync`,
`StopInteractiveSessionAsync` (+ optional interrupt); events
`InteractiveSessionStartedEvent`/`InteractiveSessionEndedEvent`. `OpenInteractiveTerminalAsync`
keeps its name/signature (now starts the in-app session). Update hand-rolled fakes in **both**
test projects (`iworkerclient_fakes_sync`).
- `TasksIslandViewModel.RunInteractivelyAsync` — unchanged call site; now opens/focuses the
in-app session surface instead of a terminal.
- Localization `interactive.*` / `missionControl.chat.*` (en/de, parity enforced).
**Tests**
- `StreamingClaudeSessionTests` (fake process stream, no real Claude): first message streams;
`result` idles; a sent message starts another turn; mid-turn send calls `InterruptAsync`
then delivers; interrupt-failure degrades to queue; stop kills.
- `LiveSessionRegistryTests` — register/get/unregister/stop.
- `InteractiveSessionServiceTests` — start resolves working dir + seeds prompt + registers +
broadcasts started; send routes to the session; stop broadcasts ended (fake session +
broadcaster).
- `TaskMonitorViewModelTests` / `DetailsIslandViewModelTests` — composer enabled while
interactive-live; submit invokes client + clears; `user` line renders; question route still
answers.
## Risks / open questions
- **Interrupt protocol shape — RESOLVED** (spike 2026-06-26, see "The streaming engine").
Mid-turn interrupt works on CLI 2.1.191 with the documented shape; the queue fallback is a
genuine fallback now, not the expected path. Re-verify if the CLI version changes.
- **Plainer than the TUI** — slash-command/interactive-prompt UX differs (accepted).
- **Auto-mode editing the list working dir directly** (no worktree) — this is the *existing*
interactive behavior, unchanged here.
- **No real-Claude tests** (project rule) — the live loop is covered only by the fake stream;
real interrupt/redirect is a **manual verification gap** to flag.
## Non-goals
- Changing autonomous task execution / review / queue / worktrees.
- Interactive sessions producing run records, worktrees, or review (stays ephemeral).
- Worktree isolation for interactive edits; image/attachment messages in the composer.
- Removing planning's `wt` terminal launch.
@@ -0,0 +1,169 @@
# Session Skills — Design
**Date:** 2026-07-03
**Status:** Approved (design), implementation not started
## Problem
Mika wants to give headless task agents a specific Claude skill (e.g.
[ponytail](https://github.com/DietrichGebert/ponytail)) **without** installing it
globally in `~/.claude/skills/`, where it would leak into every interactive session.
Skills should be a first-class, per-level session setting alongside `model`,
`max_turns`, and `system_prompt` — configurable **global / per-list / per-task** — and
sourced from a GitHub URL.
## Key facts that shape the design
- The Claude CLI discovers skills from two places: the **global** `~/.claude/skills/`
(every session — undesirable here) and the **working directory's** `.claude/skills/`
(plus plugins). ClaudeDo fully controls each spawned session's `WorkingDirectory`
(`ClaudeProcess.cs:30`), so a skill dropped into the session cwd is scoped to exactly
that headless run.
- **Auto-commit uses `git add -A`** (`WorktreeManager.CommitIfChangedAsync`
`_git.AddAllAsync`, `WorktreeManager.cs:142`). Anything seeded into a worktree's
`.claude/skills/` would be committed unless explicitly excluded → the seeder must add
the seeded paths to the worktree's `info/exclude`.
- A skill is not just prompt text: `SKILL.md` may reference scripts that run via Bash.
Headless agents run with `--permission-mode auto` (effectively unattended), so a skill
pulled from an arbitrary URL is **unattended third-party code execution**. This is why
install is a deliberate, pinned, reviewable step — not a live per-run URL fetch.
## Decisions (locked)
1. **Install-and-pin, not live fetch.** A dedicated registry screen installs a skill
once: clone the repo, pin to the current commit, store locally. Per-level config then
references installed skills **by name** (checkboxes), never a URL.
2. **Three levels, additive union.** Effective skill set = `global list task`.
(Unlike `model`/`prompt`, which override — skills add up. Trade-off accepted: an
inherited skill can't be switched off for a single task in the MVP.)
3. **A repo can contribute multiple skills.** Installer detects the layout:
- `skills/*/SKILL.md` (plugin bundle) → import **each** subskill flat. This is
ponytail: it ships 6 skills (`ponytail`, `-help`, `-review`, `-audit`, `-debt`,
`-gain`) under `skills/<name>/SKILL.md` plus `.claude-plugin/`, hooks, commands, an
MCP — none of which we consume; we take only the `skills/<name>/` dirs.
- root `SKILL.md` → single skill.
- neither → reject.
The CLI expects `.claude/skills/<name>/SKILL.md` **flat**, so subskills are flattened
on install. Selection is **per individual skill name** (enable just `ponytail` +
`ponytail-help` if you want, not all six).
4. **Public repos only** (plain `git clone` over HTTPS, no auth) for MVP.
> **Correction (2026-07-03, from the smoke test):** the original "one repo = one skill
> at root" MVP was wrong for the very target repo — ponytail is a multi-skill plugin.
> Decision 3 above replaces it.
## Architecture
### Storage & registry
- Each discovered skill is copied **flat** to `~/.todo-app/session-skills/<name>/` (its
own self-contained dir with `SKILL.md` at the root of that dir), so the seeder just
copies `<name>/` → cwd.
- New DB table `session_skills`, one row **per skill** (a multi-skill repo writes N rows
sharing `source_url` + `pinned_ref`): `name` (PK), `source_url`, `pinned_ref` (commit
SHA), `subpath` (dir within the repo the skill came from, e.g. `skills/ponytail` or
`.` for root), `description`, `added_at`. Repo-level ops act on all rows with the same
`source_url` (no separate sources table — keep it flat).
- New worker service `SessionSkillRegistry` (in a new `Skills/` area under the Worker):
- `InstallAsync(url)` — clone to temp → **detect layout** (`skills/*/SKILL.md` bundle
vs root `SKILL.md`) → for each discovered skill parse frontmatter (`name`,
`description`), resolve HEAD SHA as `pinned_ref`, copy its dir flat into place, upsert
a row. Returns the list of installed skill names. Name collision (a skill name from a
*different* source) → error surfaced to UI; reinstalling the same source updates.
Clone is injected (`IRepoCloner`) so tests use a local source dir — **no real network,
no real CLI**.
- `UpdateAsync(sourceUrl)` — re-clone, re-detect, refresh that source's skills +
`pinned_ref`.
- `RemoveAsync(sourceUrl)` — delete all its skill dirs + rows.
- `ListAsync()` — registry entries for the UI (grouped by source for display).
### Resolution
`TaskRunner.ResolveConfigAsync` (`TaskRunner.cs:488`) already merges
task → list → global for the other fields. Add:
```
SkillNames = Union(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills)
```
deduped, filtered to names that still exist in the registry (a removed skill is silently
dropped — logged). Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig`
(`ClaudeArgsBuilder.cs:5`). **No CLI flag is emitted** — skills are seeded on disk, not
passed as args. `ClaudeArgsBuilder.Build` is unchanged for skills.
Per-level storage: a nullable TEXT column `session_skills` (JSON array of names) on
`tasks`, `list_config`, and `app_settings`.
### Seeding
New service `SessionSkillSeeder`, called by `TaskRunner` after the working dir is
resolved and before `ClaudeProcess.RunAsync`:
- For each resolved skill name, copy `~/.todo-app/session-skills/<name>/`
`<cwd>/.claude/skills/<name>/` (overwrite → idempotent for resume/re-run).
- If the cwd is a git worktree, append `/.claude/skills/<name>/` to the worktree's
`info/exclude` (path via `git rev-parse --git-path info/exclude`, so it targets the
per-worktree exclude), only if not already present. **Only the seeded subdirs are
excluded** — never blanket-exclude `/.claude/`, in case the target project commits its
own `.claude/`.
- Sandbox runs (not a repo) skip the exclude step.
- No separate cleanup: seeded dirs vanish with the worktree/sandbox.
### allowedTools caveat
`--allowedTools` is only emitted when set (`ClaudeArgsBuilder.cs:80`); normal task runs
leave it null → all tools allowed → the `Skill` tool is available. If a future per-task
allowedTools restriction is added, it must include `Skill`. Noted, not handled in MVP.
## UI
Mirror the existing agent-file pattern.
- **Registry screen ("extra mask"):** a new **Skills** tab in the Settings modal
(`SettingsModalView.axaml`) with `SessionSkillsSettingsTabViewModel`. **Add** (URL text
box → install a repo, which may yield several skills); lists installed skills grouped by
source (name, description, source, short ref); **Update** / **Remove** act per source
(repo). Status/error line like `FilesSettingsTabViewModel`.
- **Global selector:** multi-select (checkbox list) of installed skills in the General
settings tab → `AppSettings.SessionSkills`.
- **List + Task selectors:** add a skills multi-select to the shared
`AgentConfigEditor` control (`AgentConfigEditor.axaml` /
`AgentConfigEditorViewModel.cs`), which is already reused by both List settings and the
per-task flyout — one addition covers both levels, with the existing inheritance-badge
pattern.
### Hub / client surface
New `WorkerHub` methods + `IWorkerClient` entries (update hand-rolled fakes in both test
projects — see memory `iworkerclient_fakes_sync`):
`GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(sourceUrl)`,
`RemoveSessionSkill(sourceUrl)`. Extend `AppSettingsDto`, `ListConfigDto`,
`UpdateListConfigDto`, `UpdateTaskAgentSettingsDto` with the selected skill-name lists.
New `SessionSkillDto`.
## Edge cases
- **Removed skill still referenced** by a level → dropped at resolve time, logged, no
failure.
- **Name collision on install** → reject with a clear message; offer Update instead.
- **Repo without root `SKILL.md`** → reject at install.
- **Target project already has `.claude/skills/`** → additive copy; exclude only our
subdirs.
- **Resume / re-run** reuses the worktree → re-seed overwrites, exclude append is
idempotent.
## Verification (must-check, can't be unit-tested)
- **Does `claude -p` actually load and invoke a skill placed in cwd `.claude/skills/`?**
**CONFIRMED 2026-07-03.** Mika ran, in a stable terminal, a `claude -p` invocation in a
scratch cwd holding `.claude/skills/ponytail*/SKILL.md`; the model invoked the
`ponytail-help` skill and returned its exact Lite/Full/Ultra table. Headless mode does
surface cwd skills → the whole approach holds; no `CLAUDE_CONFIG_DIR` fallback needed.
- Seeded skill is **not** committed by the auto-commit step (worktree run).
- Skill does not appear in a normal interactive session (no global leak).
## Out of scope (MVP)
Private-repo auth; consuming a plugin's *non-skill* parts (hooks, commands, MCP — we take
only `skills/<name>/`); auto-update & update notifications; per-task *disabling* of an
inherited skill; surfacing skill invocation in the run log.
@@ -0,0 +1,171 @@
# ConPTY Interactive Sessions — Design
Date: 2026-07-23
Status: Approved (design), implementation not started
## Problem
The current in-app interactive session path streams `stream-json` from a
`claude` process spawned **in the Worker** and renders it as a chat log with a
composer. It does not surface permission requests, AskUser questions, and other
TUI-native interactions well — the rendering is a partial reimplementation of
what the real Claude Code TUI already does. We want full fidelity for
interactive work without rebuilding the TUI.
## Decision
**Hybrid execution model:**
- **Autonomous queue tasks** (`Status=Queued`, picked by the queue): unchanged.
Headless `stream-json`, full orchestration (status flow, diff, review, merge).
- **Interactive sessions**: an **embedded ConPTY terminal** running the real
`claude` CLI, rendered in the **UI process**. Full TUI fidelity (permission
prompts, questions, colors, everything the standalone CLI does). Detached from
the review/merge/status machinery — these are a manual cockpit.
This is the third direction for interactive (external `wt` terminal → streaming
chat → embedded ConPTY). The streaming interactive stack is **removed**, not run
in parallel — accepted as discarded work in exchange for one interactive path
and full fidelity.
## Architecture
### Process location
ConPTY terminal controls render in-process and spawn their child (`claude`) as a
child of the host process. Therefore interactive sessions move **out of the
Worker and into the UI process**. They no longer flow over SignalR. This mirrors
the existing `ResumeTaskInTerminal` behavior (launch real `claude`), but embedded
instead of via external `wt.exe`.
### Worktree preparation (task-based sessions)
Interactive task sessions get the **same worktree preparation as autonomous
runs**: session-skills seeding, agent files, MCP config, environment. The Worker
performs the prep and returns a launch spec to the UI:
```
LaunchSpec {
cwd: string // worktree path
exe: string // resolved claude executable / shell
args: string[] // e.g. --resume <sessionId>
env: Dictionary<string,string>
}
```
The command construction reuses `WindowsTerminalLauncher.BuildResumeArgs`/`Resolve`.
Guards mirror `ResumeTaskInTerminal` for Running/Queued (rejected). Worktree
handling (FINAL):
- Existing Active/Kept worktree + persisted SessionId → `--resume <id>`.
- No usable worktree but the list has a WorkingDir (git repo) → create a worktree
**on demand** via `WorktreeManager.CreateAsync` (the same path autonomous runs
use), then a fresh-start spec. This lets never-run tasks be opened interactively.
- Fresh (non-resume) session → the task's prompt (title + description) is passed
as claude's positional prompt so the session starts on the task.
- No worktree and no WorkingDir → clear error.
Interactive sessions are detached: ClaudeDo does NOT record their claude session
id (ConPTY is opaque, no stream-json), so resuming a specific past interactive
conversation is only via claude's own `--continue`/`--resume` in the worktree dir.
### Free / ad-hoc sessions
In addition to task-based sessions, the user can open an ad-hoc terminal in a
chosen directory (no task). These also get MCP config + env set up so the
`claudedo` tools are available, but no per-level session-skills seeding tied to a
task.
### Terminal host control — RESOLVED by spike (2026-07-23)
**Library: `Iciclecreek.Avalonia.Terminal` 2.0.3** (namespace `Iciclecreek.Terminal`).
It needs Avalonia >= 12.0.2; the repo is on 12.0.4 → compatible, no bump.
`SvcSystems.UI.Terminal` (latest) needs Avalonia 12.1+ → rejected.
Visual pass (user, 2026-07-23): the real `claude` TUI renders correctly inside
the embedded control — Claude Code opened and was usable.
**Binding approach — use the library's own `LaunchProcess()` (FINAL).**
An initial attempt drove `Porta.Pty` ourselves (own read loop, key tunneling via
`GenerateKeyInput`/`GenerateCharInput`, manual resize) to bypass
`LaunchProcess()` and inject a custom `PtyOptions.Environment`. That was a
mistake: it rendered wrong, lagged, and dropped input. The spike had proven the
library's own `TerminalControl.LaunchProcess()` pipeline renders correctly, stays
responsive, and handles input/resize/focus. So `PtyTerminalSession` is a thin
wrapper (`src/ClaudeDo.Ui/Services/PtyTerminalSession.cs`):
- Set `control.Process = descriptor.Exe`, `control.Args = descriptor.Args`,
`control.StartingDirectory = descriptor.Cwd`, then `await control.LaunchProcess()`.
- Relay the control's own `ProcessExited` event and `Kill()`.
- `Process=""` stays on the AXAML `TerminalControl` to suppress the library's
auto-launch-on-load, so exactly one process starts (our manual launch).
**Environment:** `LaunchProcess()` gives no per-launch env dict, but
`Porta.Pty.SpawnAsync` inherits the *current process* environment. So apply
`descriptor.Env` entries via `Environment.SetEnvironmentVariable(key, value)`
(process scope) before `LaunchProcess()`. The only var needed is
`MCP_TOOL_TIMEOUT`; session-skills/agent-files/MCP-config are on disk / globally
registered, independent of env. (The earlier "child gets zero env vars" claim was
wrong — Porta.Pty seeds from the process env.)
**Sizing gotcha (critical):** the control derives Cols/Rows from
`arranged-size / character-cell-size`. Without an EXPLICIT monospace font the cell
metrics are wrong and the child TUI renders into the wrong area. Set
`FontFamily="Cascadia Mono,Consolas,monospace"`, `FontSize`, `BufferSize`, and
`HorizontalAlignment/VerticalAlignment=Stretch` on the `TerminalControl` (matching
the spike) — this is what made rendering correct in the pane.
**Lesson:** do not hand-roll pty/input/render around this control — use its
`LaunchProcess()` pipeline.
### Command Center layout
- `MonitorPaneView` keeps the streamed log for autonomous tasks.
- Interactive panes host the terminal control instead of the log+composer.
- Layout is **toggleable**: focus mode (tabs, one session large) ↔ overview mode
(grid, several sessions at once).
## Removals
Worker:
- `StreamingClaudeSession`, `InteractiveSessionService`
- `WorkerHub` interactive methods: `OpenInteractiveTerminal`,
`SendInteractiveMessage`, `RemoveQueuedInteractiveMessage`,
`StopInteractiveSession`, `InterruptInteractiveSession`
- Broadcast events: `InteractiveSessionStarted/Ended`, `InteractiveQueueChanged`,
`InteractiveMessageSent`
- `IdleSessionReaper` and `LiveSessionRegistry` **iff** unused elsewhere
(verify during implementation — do not delete blindly).
UI:
- Composer on `TaskMonitorViewModel`: `ComposerDraft`, `SubmitComposerCommand`,
`InterruptInteractiveCommand`, `StopInteractiveCommand`, `QueuedMessages`,
`IsInteractiveLive`.
- The composer + queued-messages portion of `SessionTerminalView` (the log
portion stays for autonomous panes).
- `IWorkerClient` interactive methods.
## Open items (implementation time)
- Whether `LiveSessionRegistry` is referenced outside the interactive path.
- Session-id availability for a never-run task (no `--resume` → start fresh).
Resolved: env approach (see Terminal host control — add custom vars on top of the
inherited process env via `PtyOptions.Environment`); library + binding seam.
## Non-goals
- No screen-scraping of terminal output back into task status/diff/review.
- No change to the autonomous queue execution path.
## Status (2026-07-23)
Implemented on main (not pushed) and visually verified by the user (rendering,
input, responsiveness correct after switching to `LaunchProcess()` + setting a
monospace font). Done: worker launch-spec (task + ad-hoc, on-demand worktree,
prompt seeding), UI terminal host, Command Center hosting (grid↔tabs), streaming
stack removed. Permission mode: left to claude's default (not forced), per user.
Deferred: **Avalonia 12.1 upgrade** — blocked, not adopted. 12.1's XAML source
generator needs Roslyn 4.14 (.NET 9.0.3xx SDK); the repo pins .NET 8 in
`global.json`, and bumping the SDK floor risks the Gitea Actions release build.
Staying on Avalonia 12.0.x (Iciclecreek 2.0.3 works there). Revisit only with a
deliberate SDK-floor decision.
@@ -0,0 +1,61 @@
# ConPTY Planning Sessions — Design (2026-07-24)
**Task:** `5d627df8` — interaktive Planning-Session über embedded ConPTY statt externem `wt`-Fenster.
## Problem
Planning-Sessions (`StartPlanningSession`/`ResumePlanningSession`) öffnen heute ein externes
Windows-Terminal (`WindowsTerminalLauncher``wt.exe`). Seit ConPTY existiert (embedded
claude-TUI im UI-Prozess, Command Center), soll die interaktive Planning-Session denselben
Weg nutzen: konsistente UX, keine externen Fenster.
## Approved decisions (Brainstorm 2026-07-24)
- **Env-Isolation:** Prozess-global akzeptiert (Sessions werden sequenziell per Klick geöffnet;
jedes claude-Child snapshottet Env beim Spawn). Keine Änderung an `PtyTerminalSession`.
- **wt-Launcher:** Code bleibt; nur das Routing für Planning wird auf ConPTY umgestellt
(kein Rip-out von `LaunchPlanningStart/ResumeAsync`).
- Planning-Kachel im Command Center (Mission Control), wie andere ConPTY-Sessions.
Finalize/Discard/Queue-Plan bleiben unverändert auf den bestehenden Buttons.
## Change map
### Worker
1. `WindowsTerminalLauncher`: Planning-Start-Args in einen bare Builder
`BuildPlanningStartArgs(PlanningSessionStartContext) -> IReadOnlyList<string>` herausziehen
(analog `BuildResumeArgs`); `BuildPlanningStartCommand` nutzt ihn weiter (wt unverändert).
Resume-Args `BuildPlanningResumeArgs(sessionId) = ["--permission-mode","plan","--resume",id]`
(bisher inline in `LaunchPlanningResumeAsync`).
2. `InteractiveLaunchSpecService`: `BuildPlanningStart(PlanningSessionStartContext) -> LaunchSpec`
und `BuildPlanningResume(PlanningSessionResumeContext) -> LaunchSpec` — resolve claude,
Args aus (1), Env `MAX_THINKING_TOKENS=20000` + `CLAUDEDO_PLANNING_TOKEN=<token>`
(+ `MCP_TOOL_TIMEOUT` wie interaktiv), Cwd = ctx.WorkingDir. Nimmt den Kontext (keine
Manager-Kopplung).
3. Hub: `GetPlanningStartLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.StartAsync`,
broadcastet `TaskUpdated`, baut Spec; bei Fehler discard+rethrow wie heute),
`GetPlanningResumeLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.ResumeAsync`).
### UI
4. `IWorkerClient`/`WorkerClient`: `GetPlanningStartLaunchSpecAsync`/`GetPlanningResumeLaunchSpecAsync`
(mirror `GetInteractiveLaunchSpecAsync`).
5. `MissionControlViewModel`: `OpenPlanningConPtySessionAsync(taskId, resume)` — dedupt nach
TaskId, holt die Planning-Spec, baut `TerminalLaunchDescriptor``ConPtyPaneViewModel`
(Titel „<task> (Planning)").
6. `TasksIslandViewModel`: `OpenPlanningSessionAsync`/`ResumePlanningSessionAsync` (Resume-Zweig
der `UnfinishedPlanningModal`) öffnen die Planning-ConPTY-Kachel via neuem Event
`OpenPlanningConPtyRequested(taskId, resume)`, statt `StartPlanningSessionAsync`/wt.
`IslandsShellViewModel` verdrahtet das Event → `OpenMissionControl()` +
`MissionControl.OpenPlanningConPtySessionAsync`.
### #12 (Nebenbefund)
MCP-Permission-Prompt trotz `--allowedTools "mcp__claudedo__*"`: im interaktiven ConPTY vom
User bestätigbar (kein Blocker wie headless). Beim Testen prüfen, ob der Glob den Prompt in
der aktuellen CLI unterdrückt; falls nicht, allowedTools/`--permission-mode`-Kombi nachziehen.
## Out of scope
- Entfernen des wt-Codes / `ResumeTaskInTerminal`.
- Per-Child-Env-Isolation in `PtyTerminalSession`.
- Änderungen an Finalize/Discard/Queue-Plan-Lifecycle.
## Verification
- Build Worker + App (`-c Release`), Worker.Tests + Ui.Tests grün.
- Visual (User): Planning-Start öffnet Command-Center-Kachel mit claude-TUI im Plan-Modus;
create_child_task erzeugt Draft-Kinder live; Resume greift die Session; kein wt-Fenster.
@@ -0,0 +1,182 @@
# Merge Helper ("Let Claude handle it") — Design
**Status:** Proposed — awaiting approval
**Date:** 2026-07-24
**Scope:** Feature — a per-list and global button that opens an **interactive ConPTY Claude session** pre-loaded with a set of user-selected tasks. The session (the "Merge Helper") drives each selected task to completion and merge autonomously via `mcp__claudedo__*` tools, asks the user interactively (in the ConPTY terminal) only when uncertain, resolves merge conflicts itself, and ends with a written summary of everything that changed.
---
## 1. Goal
Collapse the repetitive per-task review→merge clicking into a single "Let Claude handle it" action. The user picks the tasks; an embedded Claude session babysits them — running the ones that still need running, reviewing diffs, merging the clean ones, resolving conflicts, and reporting back — while remaining fully interactive so the user can answer questions mid-run.
This reuses the existing ConPTY infrastructure (UI-process embedded terminal) and the globally-registered `claudedo` MCP server. The only genuinely new worker capability is **MCP-driven conflict resolution** (§5), which today exists only in the UI hub.
---
## 2. Decisions (locked with user, 2026-07-24)
| Question | Decision |
|---|---|
| Which tasks does the helper handle? | **Free choice, any status.** User hand-picks; helper acts per-status. |
| How are tasks selected for a run? | **Checkbox dialog before launch** (candidates listed, user ticks). |
| Merge authority / review-gate | **Auto-merge; asks interactively on uncertainty.** The app's per-task diff-gate is intentionally bypassed for helper-driven merges. |
| Conflict handling | **Build MCP conflict tools** so the helper resolves conflicts in the working tree itself, asking only when unsure (Option B). The helper must handle **all** cases including parent/children unit merges; where the MCP path doesn't reach, **manual resolution by hand (Edit + git) is an accepted fallback** (user-confirmed 2026-07-24). |
---
## 3. UX Flow
1. **Entry points**
- **Per-list:** context-menu item **"Let Claude handle it"** on each user-list row in `ListsIslandView.axaml` (alongside Settings / Worktrees / Open in Explorer).
- **Global:** one entry (footer of the lists island) that spans *all* lists/repos.
2. Click opens the **Merge Helper selection dialog** (§4): a checkbox list of candidate tasks, grouped by list/repo, pre-filtered to tasks worth acting on but freely overridable.
3. User ticks tasks → **"Let Claude handle it"** confirm button.
4. UI asks the worker for a `MergeHelperLaunchSpec`, opens a **ConPTY tile in Mission Control** running the real `claude` TUI with the merge-helper prompt.
5. The session works through the tasks, printing progress and asking questions inline; the user answers directly in the terminal.
6. On completion Claude prints a **summary** (merged / skipped / conflicted / follow-ups). The tile stays open for review.
---
## 4. Selection Dialog
New modal `MergeHelperSelectionDialog` (View + VM), built with the existing `TaskCompletionSource<T>` dialog pattern used by other modals.
**Contents:**
- Title: *"Let Claude handle it"* + subtitle naming the scope ("List: <name>" or "All lists").
- A scrollable checkbox list of **candidate tasks**. Per row: checkbox, title, status badge, list/repo name (in global mode).
- Grouping: by list/repo in global mode; flat in per-list mode.
- Default selection: all **actionable** tasks pre-ticked — actionable = `WaitingForReview`, `Idle`, `Queued`, `Failed` (resettable). `Running` / `WaitingForChildren` shown but unticked (helper will poll them). Terminal `Done`/`Cancelled` excluded from the list entirely.
- Footer: **"Let Claude handle it"** (disabled when nothing ticked) + **Cancel**. A "select all / none" affordance.
**Candidate source:** `list_tasks` via the existing worker client (per list, or across all lists for global). No new query needed; the VM filters client-side by status.
**Output:** an ordered `IReadOnlyList<string>` of selected task IDs (+ their list/repo mapping), passed to the launch request.
---
## 5. New Worker Capability — MCP Conflict Resolution
Today (verified): `merge_task` / `review_task approve` call `TaskMergeService.MergeAsync(..., leaveConflictsInTree:false)` — on conflict they run `git merge --abort` (clean rollback, no markers) and return `mergeStatus="conflict"`; the task stays `WaitingForReview`. Continue/abort/write-resolution exist **only** on the SignalR hub (Rider merge editor). An MCP agent therefore cannot resolve conflicts. This section adds that.
### 5.1 Approach
Reuse the *exact* engine methods the UI already uses — `TaskMergeService.MergeAsync(leaveConflictsInTree:true)`, `ContinueMergeAsync`, `AbortMergeAsync` — and expose them over MCP. The helper resolves conflict markers on disk (it has filesystem access to the repo checkouts via `--add-dir`, see §6.3) and drives the merge state exclusively through MCP tools so the engine stays authoritative.
### 5.2 MCP surface changes (`ExternalMcpService.cs`)
1. **`review_task` / `merge_task` — new optional param `leaveConflictsInTree: bool = false`.**
When `true` and the merge conflicts: leave markers in the checkout instead of aborting, and return
`{ mergeStatus: "conflict_in_tree", conflicts: string[], repoPath: string }`
where `repoPath` is the checkout holding the markers. Task stays `WaitingForReview`, merge is in progress. Clean-merge behaviour is unchanged, so the helper can always pass `true`.
2. **New tool `continue_merge(taskId)`**`TaskMergeService.ContinueMergeAsync`.
Stages the resolved files and commits the merge; on success the task goes to `Done` and the worktree is marked merged, returning `{ merged: true, mergeCommit }`. If markers remain, returns `{ merged: false, conflicts: string[] }`.
3. **New tool `abort_merge(taskId)`**`TaskMergeService.AbortMergeAsync`.
Aborts the in-progress merge; task stays `WaitingForReview`. Returns `{ aborted: true }`.
**Implementation notes (verify against `TaskMergeService.cs` during the plan):**
- Confirm the exact signatures of `ContinueMergeAsync` / `AbortMergeAsync` and how in-progress-merge state is keyed. The hub tracks a single active conflict merge; the MCP variants must locate the merge from `taskId` (target branch + repo from the task/list), not shared hub state.
- Emit the existing `TaskUpdated` event after continue/abort so the UI list re-buckets live.
- Guard against a repo already mid-merge (`Blocked`) — surface it to the agent rather than clobbering.
### 5.3 Parent/children unit merges
`review_task approve` on a task **with children** drives `PlanningMergeOrchestrator` (a multi-step unit merge with its own continue/abort on the hub). The helper must handle these too. Two paths, tried in order:
1. **MCP (preferred):** `continue_merge` / `abort_merge` detect *which* kind of in-progress merge the task has (single-task `TaskMergeService` vs orchestrated `PlanningMergeOrchestrator`) and route to the matching engine continue/abort. This keeps the orchestrated path engine-mediated over MCP too. Implement if the orchestrator's continue/abort can be located from the task without shared hub UI state (verify in A1).
2. **Manual fallback (accepted):** where the MCP path genuinely can't reach an in-progress merge, the helper resolves the conflict markers on disk (Read/Edit) and completes the merge by hand (`git add <paths>` + `git commit`, or `git merge --continue`). The user has explicitly accepted hand-merging as a fallback. The system prompt still mandates: **prefer the MCP tools whenever they apply**; only drop to raw git for cases the MCP tools don't cover, and honour the shared-checkout rule (`git commit -- <paths>`, never a bare commit that sweeps peers' index).
---
## 6. Launch — Worker + Wiring
### 6.1 Prompt templates
Add `PromptKind.MergeHelper` (system) and `PromptKind.MergeHelperInitial` (brief) to `ClaudeDo.Data/PromptFiles.cs`, with built-in defaults and `{{token}}` rendering, mirroring `Planning` / `PlanningInitial`.
- **System prompt** (`merge-helper-system.md`): defines the role and the per-status algorithm (§7), the merge/conflict rules, the "ask on uncertainty" posture, and the required final summary format.
- **Initial brief** (`merge-helper-initial.md`): rendered with the selected tasks — a table of `{id, title, status, list, repo}` plus the scope label. Written to a session-brief file on disk; the positional prompt is a **single-line kickoff** pointing at that file via `--add-dir` (planning pattern — a multi-line positional prompt truncates at the first newline).
### 6.2 Session files
Path: `~/.todo-app/merge-helper-sessions/<sessionId>/` (a fresh GUID per run — these sessions are ephemeral and never resumed):
- `brief.md` — rendered task list + scope + instructions.
- No per-session MCP config: the session uses the **globally-registered `claudedo` MCP server** (same as task/ad-hoc sessions), so no token is needed.
Cleanup: prune session dirs older than N days on app start (best-effort; same posture as planning dirs).
### 6.3 Launch spec
New `InteractiveLaunchSpecService.BuildForMergeHelper(selectedTaskIds, scope, ct)` returning a `LaunchSpec`:
- **Cwd:** per-list → the list's repo working dir; global → the first selected task's repo (any valid repo; the agent works cross-repo via MCP).
- **`--add-dir`:** the session-brief dir **plus every distinct repo checkout** among the selected tasks (so the agent can read/resolve conflict markers in each repo). Computed from each task's list working dir.
- **Args:** `--permission-mode default`, `--allowedTools mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`, `--append-system-prompt-file <merge-helper-system.md>`, `--add-dir ...`, then the single-line kickoff prompt.
- `Edit` is required for conflict resolution; `Bash` is allowed for **read-only** git inspection (`git status`/`diff`) — the system prompt mandates that all merge *state changes* go through MCP tools, never raw `git merge/commit`, to keep the engine authoritative and honour the user's rejection of the "raw git" option.
- **Env:** `MCP_TOOL_TIMEOUT=200000` (as task/ad-hoc sessions set).
### 6.4 Hub + client + Mission Control
- **Hub:** `WorkerHub.GetMergeHelperLaunchSpec(string[] taskIds, string? listId)``_launchSpecService.BuildForMergeHelper(...)`. Sibling to `GetAdHocLaunchSpec` / `GetPlanningStartLaunchSpec`.
- **Client:** `IWorkerClient.GetMergeHelperLaunchSpecAsync(...)` + `WorkerClient` impl.
- **Mission Control:** `MissionControlViewModel.OpenMergeHelperConPtySessionAsync(spec)` — wraps the spec in a `TerminalLaunchDescriptor`, creates a `ConPtyPaneViewModel` (ad-hoc style, **never deduped** — each run is its own tile), adds it to `ConPtySessions`.
- **Event plumbing:** `ListsIslandViewModel` raises `LetClaudeHandleRequested(scope)`; `IslandsShellViewModel` forwards to Mission Control, which opens the selection dialog, then (on confirm) fetches the spec and opens the tile.
---
## 7. Helper Behaviour (encoded in the system prompt)
For each selected task, act by status:
- **Idle / Queued:** `run_task_now`; poll `get_task` until terminal or `WaitingForReview`.
- **Failed:** `reset_failed_task` then run, *or* ask the user — failures often need a human call; default to asking briefly.
- **Running / WaitingForChildren:** poll `get_task` until it surfaces for review.
- **WaitingForReview:** `get_task_diff` (stat first, then full if needed), sanity-check the change against the task's intent, then `review_task approve` with `leaveConflictsInTree:true`.
- **Clean →** merged, task Done.
- **Conflict (`conflict_in_tree`) →** open the conflicted files under `repoPath` (Read/Edit), resolve the markers guided by both sides' intent, then `continue_merge`. If the resolution is non-obvious or risky, **ask the user in the terminal** before continuing. `abort_merge` if the user declines or it's unsafe.
- **Parent with children:** clean unit merge proceeds; on conflict, resolve via the MCP tools if they reach the orchestrated merge, else hand-merge the markers and complete it (§5.3) — asking the user first when the resolution is non-obvious.
Cross-cutting rules (in the prompt):
- Ask the user interactively for anything ambiguous, risky, or destructive — that is the point of the ConPTY session.
- Never use raw `git merge/commit/reset`; drive all merge state through the MCP tools.
- Keep a running tally; at the end print a **summary**: per task — final status, merge commit (if any), conflicts resolved, anything skipped, and suggested follow-ups.
---
## 8. Testing
**Automated (`ClaudeDo.Worker.Tests`, real SQLite + real git):**
- `review_task`/`merge_task` with `leaveConflictsInTree:true`: clean merge → Done; conflicting merge → `conflict_in_tree`, markers present in the checkout, task stays `WaitingForReview`.
- `continue_merge`: after markers resolved on disk → commits, task Done, worktree merged; with markers still present → returns remaining conflicts.
- `abort_merge`: in-progress merge aborted, markers gone, task stays `WaitingForReview`.
- `continue_merge`/`abort_merge` on a task with no in-progress merge → clean MCP error, no clobber.
- `TaskUpdated` fired after continue/abort.
- `BuildForMergeHelper`: computes distinct repo `--add-dir` set, correct cwd per scope, brief file rendered with all selected tasks, allowed-tools string correct.
**No real-Claude tests** (per project convention) — the end-to-end ConPTY run is a manual smoke item.
**Manual (add to `docs/open.md`):**
- ConPTY tile launches with the brief; MCP tools reachable; a clean multi-task run merges all and prints a summary.
- A seeded conflict is resolved autonomously via `continue_merge`.
- Interactive question round-trip (helper asks, user answers in terminal).
- Global (multi-repo) run with `--add-dir` for each repo.
- Selection dialog: grouping, default ticks, select-all/none, per-list vs global scope.
---
## 9. Phasing
Delivered as one plan with three phases (see the plan doc). Phase A is independently useful and merges first.
- **Phase A — Worker MCP conflict tools** (§5): `leaveConflictsInTree` param + `continue_merge` + `abort_merge` + tests. No UI.
- **Phase B — Worker launch** (§6.16.4 worker side): prompt templates, `BuildForMergeHelper`, hub endpoint, session-file/brief generation, client method. Contract for C locked here.
- **Phase C — UI**: selection dialog (View+VM), per-list + global entries, event plumbing, `OpenMergeHelperConPtySessionAsync`.
---
## 10. Out of scope (v1)
- Resuming a merge-helper session (`--resume`); sessions are ephemeral.
- A non-interactive/headless merge-helper (this is deliberately a ConPTY interactive session).
- Cross-list *batching* semantics beyond "act on each selected task independently."
- Any change to the existing per-task Approve/diff-gate flow.
@@ -0,0 +1,191 @@
# "Let Claude handle it" — per-list handler with read / dedupe / enhance / run / merge
Date: 2026-07-27
Supersedes parts of: `2026-07-24-merge-helper-design.md` (global scope, run-then-merge-only prompt)
## 1. Problem
The merge helper shipped in v2.3.0 with two entry points — a per-list context-menu item and a
global footer Broom button — and a prompt that only runs and merges the selected tasks.
Two things are wrong with that:
- **The global scope is unwanted.** A run spanning several lists spans several repos, which
makes `cwd`, the `--add-dir` set and the merge order ambiguous for no benefit. The user
works one list (= one repo) at a time.
- **The helper starts too late.** It takes the task list as given: it never reads the tasks as
a set, so duplicates run twice and produce conflicting worktrees, and vague tasks go into an
autonomous run under-specified and come back wrong.
## 2. Goal
One entry point, on a user list. It opens an interactive ConPTY session that takes the selected
tasks through five phases: read them all, dedupe them, sharpen them for autonomous execution,
run them, then review and merge each worktree.
Non-goals: no change to the autonomous queue path, no change to the ConPTY tile plumbing,
no rename of the `MergeHelper*` identifiers (the user-facing label stays "Let Claude handle it").
## 3. Scope becomes list-only
`listId` becomes non-nullable across the whole chain:
| Layer | Change |
|---|---|
| `ListsIslandViewModel` | `MergeHelperRequest(string ListId, …)`; `LetClaudeHandleAllAsync` deleted |
| `IslandsShellViewModel:241` | unchanged (already forwards `req.ListId`) |
| `MissionControlViewModel:327` | `OpenMergeHelperConPtySessionAsync(string listId, …)` |
| `IWorkerClient:90` / `WorkerClient:525` | `GetMergeHelperLaunchSpecAsync(taskIds, string listId, ct)` |
| `WorkerHub:682` | `GetMergeHelperLaunchSpec(string[] taskIds, string listId)` |
| `IInteractiveLaunchSpecService:36` | `BuildForMergeHelperAsync(taskIds, string listId, ct)` |
Deleted UI surface:
- Broom button `ListsIslandView.axaml:205-210` and `LetClaudeHandleAllCommand`.
- `IsGlobal` on `MergeHelperSelectionModalViewModel` and the LIST column
(`MergeHelperSelectionModal.axaml:71`) — with a single list the column is constant.
- Localization keys `lists.letClaudeAllTip`, `modals.mergeHelper.scopeAll`,
`modals.mergeHelper.columnList` (en + de, parity test enforces both).
`Configure(string listId, string listName)` loses its nullable overload; `ScopeLabel` always
renders `modals.mergeHelper.scopeList`.
### 3.1 Single repo in the launch spec
`BuildForMergeHelperAsync` currently collects a distinct `repoDirs` set across the selected
tasks and picks `cwd` per scope. With a list scope every task shares the list's `WorkingDir`,
so this collapses to:
- Load the list; throw `InvalidOperationException` if it has no existing `WorkingDir`.
- `cwd` = that directory; `--add-dir` = the session dir + that one directory.
- The per-task brief line drops the now-constant `list:` and `repo:` fields.
The context-menu item is already hidden when `WorkingDir` is empty, so the throw is a guard,
not a normal path.
### 3.2 Entry point visibility
The item stays in the list row's context menu, next to "List settings", "Worktrees overview",
"Open in Explorer" and "Open in Terminal". That is the established place for list-scoped
actions; a second always-visible button in the row would break the pattern.
## 4. Worker: `Cancelled` becomes externally settable
`ExternalMcpService.UpdateTaskStatus` accepts only `Idle` and `Queued` and throws
`"Status '{target}' is not settable externally. Use run_task_now or cancel_task."` for the
rest — but neither escape hatch reaches an **Idle** task: `cancel_task` only cancels a
*running* task, and `review_task(decision="cancel")` requires WaitingForReview/Running/Queued.
The dedupe phase needs exactly that: retire an Idle duplicate without destroying it.
Add to the switch:
```csharp
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
```
`TaskStateService.CancelAsync` (`State/TaskStateService.cs:244`) already owns the transition
and its worktree/parent side effects. The existing error message in `UpdateTaskStatus` already
lists `Cancelled` as valid, so this also removes a lie. A cancelled task stays visible and can
be reset to Idle — nothing is lost, unlike `delete_task`.
## 5. The five-phase prompt
`PromptFiles.MergeHelperDefault` is rewritten. The session stays interactive and the helper is
told to ask whenever unsure — that is the point of a watched ConPTY session.
### Phase 0 — Read
`batch_get_tasks` over every id in the brief before touching anything: title, description,
status, parent/child links. The helper must hold the whole set in mind before acting on any
single task.
### Phase 1 — Dedupe
Compare the tasks pairwise for overlap. Emit a table of candidate pairs with the reason each
pair looks like a duplicate, then **ask per pair**:
- merge → fold the loser's unique content into the survivor via `update_task`, then
`update_task_status(loserId, "Cancelled")`;
- keep both → note why and move on.
Nothing is cancelled without an explicit answer.
### Phase 2 — Enhance
For each surviving task, sharpen title and description for autonomous execution:
- concrete acceptance criteria,
- the files/areas actually involved — grounded in the repo via Read/Grep/Glob, not guessed,
- explicit out-of-scope.
Write back with `update_task` (title / description / commitType are the settable fields; it
refuses while Running, which cannot happen this early). Rules: do not change intent, do not
invent requirements. A task too vague to sharpen safely gets a question, not a guess.
### Phase 3 — Run
`run_task_now` cannot be used for a batch: `OverrideSlotService.StartInSlot`
(`Queue/OverrideSlotService.cs:65-70`) holds a single slot and throws `"override slot busy"`
on the second concurrent call. The queue picker is the only parallel path.
So: read `get_app_settings`, tell the user how many parallel slots are configured
(`MaxParallelExecutions`, default 1 — `AppSettingsEntity.cs:14`), then
`update_task_status(id, "Queued")` for every surviving task, then poll `get_task` until each
has left Queued/Running — `WaitingForReview` on success, `Failed` on error. Announcing the
slot count up front stops the user wondering why "run them all" executes one at a time.
A task already `Running` or `WaitingForChildren` when the session starts is not re-queued, only
polled. A task already `WaitingForReview` skips straight to Phase 4.
### Phase 4 — Review and merge
Sequential, in list order. A task that came back `Failed` needs human judgement — ask whether
to `reset_failed_task` and re-queue it, or skip it. Otherwise, unchanged from the shipped
prompt: `get_task_diff` (stat first,
full diff when non-trivial), sanity-check against the task's intent, ask before merging
anything that looks wrong, then `review_task(taskId, decision="approve",
leaveConflictsInTree=true)` and the conflict loop (`continue_merge` / `abort_merge`, parent id
for unit merges, hand-resolution only where MCP cannot reach, always
`git commit -- <paths>` and never `git add -A` because the checkout is shared).
New in this phase: the prompt states that because Phase 3 branches all fork from the same
base, **conflicts are the normal case, not an exception** — resolve them rather than bailing
out of the run.
### Phase 5 — Summary
One line per task: title — dedupe action — enhanced? — final status — merge commit —
conflicts resolved. Then anything skipped and why, then follow-ups.
### Brief template
`MergeHelperInitialDefault` names the list and repo once in the header and drops the per-task
`list:`/`repo:` fields, leaving `- [{status}] {title} (id: {id})`. Descriptions stay out of the
brief; Phase 0 fetches them.
## 6. Testing
| Test | Change |
|---|---|
| `MergeHelperSelectionModalViewModelTests` | drop `IsGlobal`, list-scoped `Configure` |
| `InteractiveLaunchSpecServiceTests` | three `null`-listId cases → non-null; drop the two-repo global-scope test; add "list without WorkingDir throws" |
| `MissionControlViewModelTests` | four `OpenMergeHelperConPtySessionAsync(null, …)` calls |
| `StubWorkerClient`, `TasksIslandViewModelPlanningTests` fake | signature |
| `PromptFilesTests` | assert the five phase markers in the default prompt |
| `Localization.Tests` | parity after removing three keys |
| new: `ExternalMcpService` / worker test | `UpdateTaskStatus(id, "Cancelled")` cancels an Idle task; unknown status still throws |
No test spawns the real `claude` CLI — the prompt content is asserted as text, the session
itself is a manual smoke step.
## 7. Verification left to the user
- The list context menu shows "Let Claude handle it" only for lists with a working dir, and
the Broom button is gone from the footer row.
- The selection dialog has no LIST column and reads "List: <name>".
- A real ConPTY run: dedupe questions appear, enhancements land in the task descriptions,
queued tasks execute, merges complete or hand off to conflict resolution.
+196
View File
@@ -0,0 +1,196 @@
# Usage-Optimierung — Befunde und Messmethodik
Stand: 2026-08-05. Ausgangsfrage: Wie lassen sich autonome ClaudeDo-Agents
token-effizient betreiben? Abrechnung läuft über **Abo-Session-Limits, nicht über
die API** — relevant ist roher Token-Verbrauch gegen 5h-/7d-Fenster, nicht Geld.
Dieses Dokument ist bewusst kompakt gehalten. Was eine Session früh in den Prefix
lädt, wird mit jeder weiteren Nachricht multipliziert (siehe Befund 5) — ein
30k-Handover-Dokument würde das Problem reproduzieren, das es beschreibt.
---
## 1. Verbrauchsstruktur
Gemessen über alle Transcripts unter `C:\Users\mika.kuns\.claude\projects`.
| Posten | Roh-Token | Anteil |
|---|---:|---:|
| Cache-Read | 3.118.025.644 | 95,6 % |
| Cache-Write | 140.783.227 | 4,3 % |
| Output | 22.466.702 | 0,7 % |
| Input frisch | 141.496 | 0,0 % |
**Kontext-Resend = 99,3 % des Rohverbrauchs.** Auch unter API-Preisgewichtung
(read 0,1× / write 1,25× / out 5×) bleibt es bei 81,3 %. Die Rangfolge ist gegen
jede plausible Gewichtung robust.
Verstärkung: ~3,6 Mio einzigartiger Inhalt → 1,59 Mrd abgerechnete Prompt-Token
in ClaudeDo-Sessions. **Jeder Kontext-Token wird im Schnitt ~425× erneut
abgerechnet.**
## 2. Scope-Split — wer verbraucht
| Scope | Roh-Token | Anteil |
|---|---:|---:|
| INTERAKTIV (Mikas eigene Sessions) | 2.690.028.785 | **81,6 %** |
| AGENT (ClaudeDo-Runs) | 606.222.055 | **18,4 %** |
Interaktiv nach Modell: opus-4-8 33,0 % · opus-5 28,5 % · sonnet-5 14,0 % ·
fable-5 5,2 %. Agent-Runs fahren überwiegend sonnet-5 — der Default greift, die
Modelldisziplin auf Agent-Seite ist in Ordnung.
**Konsequenz:** ClaudeDo-Optimierung adressiert maximal 18 % des Limits. Der
größere Block sind die eigenen Opus-Sessions.
## 3. Session-Länge ist der Treiber
Verbrauch ≈ Nachrichten × Ø-Kontext, und der Kontext wächst mit den Nachrichten →
**quadratisch**. Eine Session in k Teile schneiden bringt grob 1/k.
Interaktiv: **Top 20 von 255 Sessions = 55,9 % des Verbrauchs.**
| Roh-Token | Msgs | Ø Kontext | Modell |
|---:|---:|---:|---|
| 199.516.097 | 716 | 278.653 | opus-4-8 |
| 138.020.247 | 440 | 313.682 | opus-5 |
| 123.586.522 | 463 | 266.925 | opus-4-8 |
## 4. Was den Kontext füllt
| Tool | Anteil (Agent) | Anteil (interaktiv) |
|---|---:|---:|
| Read | 59,8 % | 68,2 % |
| Bash | 16,3 % | 15,4 % |
| Grep | 8,3 % | 5,2 % |
Read ohne `offset`/`limit`: **57 % (Agent) / 62 % (interaktiv)**.
Re-Reads derselben Datei in derselben Session: 18 % der Read-Calls.
Subagent-Nutzung: nur 40 Calls bei 1.325 Reads (Agent-Seite) — die
Kontext-Firewall ist praktisch ungenutzt. Interaktiv sind Subagent-Sessions
dagegen 20 % des Verbrauchs.
Teuerste Read-Ziele sind God-Files: `TasksIslandViewModel.cs` (1121 Z.),
`ExternalMcpService.cs` (1002 Z.), `WorkerHub.cs` (979 Z.).
## 5. Prefix-Größe × Nachrichtenzahl schlägt alles
Fallstudie an der Analyse-Session selbst (136 Msgs, 43.492.835 Roh-Token):
```
msg 1 : 39.914
msg 3 : 273.822 (+233.908) <-- claude-api-Skill geladen
msg 136: 380.327
```
Der Skill in Nachricht 3 wurde danach 136× mitgelesen: **31,8 Mio Token = 73 %
der Session**. Zum Vergleich: **alle tool_results zusammen = 21.600 Token
(0,05 %)**.
Discovery ist praktisch gratis, wenn man aggregiert statt Dateien dumpt. Teuer ist
ausschließlich, was früh und groß in den Prefix wandert. Derselbe Block in
Nachricht 120 geladen hätte ein Zwanzigstel gekostet.
## 6. Amortisation von Planungssessions
| Posten | Token |
|---|---:|
| Analyse-Session gesamt | 43,5 Mio |
| davon vermeidbarer Prefix-Ballast | 31,8 Mio |
| echte Planungsleistung | ≈ 11,7 Mio |
Gegenrechnung: **10 von 37 Tasks brauchten einen Retry (27 %)**, 13 von 54 Runs
sind Wiederholungen. Ein Agent-Run liegt im Schnitt bei ~11 Mio Roh-Token — ein
vermiedener Retry spart also grob eine ganze Planungssession. Dazu ersparte
Rediscovery: ein Fund von ~8k Token, den ein Agent in Turn 10 eines 60-Turn-Runs
selbst machen müsste, wird danach ~50× mitgelesen = ~400k pro Fund.
**Fazit: gründliche Planung rechnet sich — aber nur bei schlankem Prefix.**
---
## Was NICHT hilft (geprüft und verworfen)
- **`--effort` senken.** Thinking ist 12.536 Token über alle interaktiven
Sessions = **0,00 %** des Verbrauchs. Effort zu senken spart nichts und kostet
Qualität. Endgültig erledigt.
- **Skill-Trigger entschärfen.** Der `claude-api`-Skill kostete in einer Session
73 % — feuerte aber nur **2× in 3 Wochen** (2026-07-14, 2026-08-05). Erwarteter
Nutzen vernachlässigbar, Risiko (Antworten aus veraltetem Prior) real. Er hat
sich in dieser Session sogar bezahlt gemacht: der Hinweis, dass `input_tokens`
nur der uncached Rest ist, hat den Accounting-Bug aufgedeckt.
- **God-Files splitten.** Eigenes Risiko; Read-Disziplin entschärft das Symptom
billiger.
## Offene Hebel — noch nicht untersucht
- Interaktive Session-Hygiene: ab wann lohnt `/clear`, messbar an Ø-Kontext?
- Modellrouting interaktiv (opus-4-8 + opus-5 = 61,5 % des Accounts).
- Subagent-Ökonomie: Firewall-Nutzen gegen Eigenverbrauch (20 % interaktiv).
- MCP-Tool-Definitionen im Prefix: Umfang bei ~200 deferred Tools nicht gemessen.
- Kalibrierung der tatsächlichen Limit-Gewichtung gegen die Rohtoken-Zahlen
(braucht `/usage`-Ausgabe; der OAuth-Endpoint ist für Claude nicht zugänglich,
weil `~/.claude/.credentials.json` hart blockiert ist).
---
## Messmethodik (reproduzierbar)
Datenquelle: `C:\Users\mika.kuns\.claude\projects\**\*.jsonl`, ein Record je
Message, Verbrauch in `message.usage`.
```python
raw = (u.get('input_tokens',0) + u.get('output_tokens',0)
+ u.get('cache_read_input_tokens',0) + u.get('cache_creation_input_tokens',0))
```
Kontextgröße einer Message = `input_tokens + cache_read + cache_write` (ohne
Output). Der Verlauf über die Session zeigt Prefix-Sprünge.
### Fallstricke
1. **Scope-Filter.** NICHT nach Pfadname `claudedo` filtern — das zieht
interaktive Sessions im ClaudeDo-Repo mit rein und verfälscht den Split massiv
(53 % statt korrekt 18,4 %). Agent-Runs erkennt man an `claudedo-worktrees`
oder `sandbox` im Projektordner, so wie es `TranscriptUsageReader` macht.
2. **`<synthetic>`-Messages** überspringen — keine echten API-Calls.
3. **`task_runs.tokens_in` ist unbrauchbar** — liest nur `input_tokens`, also den
uncached Rest. Lag bei einer 79-Mio-Token-Session bei 200 (Faktor ~400.000).
`tokens_out` zusätzlich 3,8× zu niedrig. Siehe Task `38394081`.
4. **In Bash absolute Pfade verwenden**`$HOME`/`~` zeigt auf dieser Maschine
auf das P:-Laufwerk, nicht auf `C:\Users\mika.kuns`.
5. **DB nie direkt lesen** während die App läuft — Kopie ziehen (`todo.db` +
`todo.db-wal`).
6. **Grundrate prüfen, bevor ein Hebel empfohlen wird.** Ein teurer Einzelfall ist
kein Muster (siehe Skill-Trigger oben).
---
## Abgeleitete Tasks (Liste „Claude do")
Empfohlene Reihenfolge:
| # | ID | Titel |
|---|---|---|
| 1 | `1b599d67` | Prompt-Dateien frieren Default ein — `SuggestImprovement`/`AskUser` tot |
| 2 | `38394081` | Limit-Verbrauch pro Run sichtbar machen (Cache-Token in `task_runs`) |
| 3 | `0d0aa8b0` | Read-Disziplin + Explorer-Subagent im System-Prompt |
| 4 | `2de2f008` | `max_turns` deckeln (Ceiling, Defaults, UI-Warnung) |
| 5 | `87105f5e` | UsageGate: Parallelität stufenweise drosseln |
(1) zuerst, weil es ein echter Funktionsbug ist und Voraussetzung dafür, dass (3)
den Nutzer überhaupt erreicht. (2) als Nächstes, weil ohne korrektes Accounting
nichts messbar ist.
### Ist-Zustand zum Zeitpunkt der Analyse
- `app_settings`: `default_model` = sonnet, `default_max_turns` = 100,
`max_parallel_executions` = 3, `model_presets` = **null** (fällt auf Defaults
zurück: haiku 20 / sonnet 30 / opus 40 / fable 25 — greifen faktisch nie).
- 15 Tasks überschreiben `max_turns` nach oben: 10× auf 100, 5× auf 200.
- UsageGate existiert bereits: 5h @ 80 %, 7d @ 90 %
(Migration `20260805074906_AddUsageGateAndRunModel`, dieselbe Migration hat auch
die `model`-Spalte auf `task_runs` gebracht).
- `~/.todo-app/prompts/system.md` stammt vom 2026-06-04 und weicht vom
`SystemDefault` in `PromptFiles.cs` ab; `agent.md` und `planning.md` dort sind
verwaiste Reste eines alten Namensschemas.
+113
View File
@@ -0,0 +1,113 @@
# Verifikations-Handoff (Stand 2026-07-24)
Manuelle Verifikation am laufenden System. **Mika bedient die UI, die Session protokolliert Pass/Fail** und bereitet Fixtures vor (Tasks via ClaudeDo-MCP, Git-Setups im Testrepo). Aktive Findings landen in `docs/open.md`; hier steht der Fortschritt je Abschnitt.
## Handoff für die nächste Session
**Erledigt (2026-07-24):** §1 Detail-Insel/Diff-Viewer (bis auf DiffModal-Fehler-State), §2 Worktree-Pipeline (alle 3), §3 Planning-Walkthrough (inkl. UnfinishedPlanning-Modal: dismiss/Finalize/Discard PASS, **Resume BUG**), §4 Merge-Editor (Single-Task **und** Planning-Unit-Konflikt **und** Abort), §6 Pick-up-Gating (Code), §7 AskUser (Happy-Path + Timeout + UI-Cleanup; Finding: Banner nur in Mission Control), §8 Session Skills (Install/Aktivierung/Seeding/Gegenprobe/Remove; Findings: fehlender Empty-State + abweichendes Agent-Gear-Icon), §9 Attachments (Drag&Drop-UI + MCP + ComposedPreview; Finding: intermittenter erster-Drop-Fehler), §12 RunNow (moot).
**Noch offen:**
- **§10 Daily Prep/Weekly** — **Bewusst zurückgestellt (2026-07-24)**: Mika will Daily Prep + Weekly Report ohnehin überarbeiten — Verifikation lohnt erst nach dem Rework.
- **§11 Self-Update/Autostart** — **Nicht formell gefahren, aber laut Mika „läuft bisher sehr gut" (2026-07-24)** → als OK behandelt; bei Bedarf später gezielt nachtesten.
- ~~**Kanten:** §1 DiffModal-Fehler-State, §3 UnfinishedPlanning-Modal, §4 Abort~~**alle erledigt (2026-07-24)**: §4 Abort PASS, §3 Modal (Finalize/Discard PASS, Resume BUG), §1 via Code-Analyse geklärt (defensiv/unerreichbar).
**Vorbedingungen:** Worker + App laufen (SignalR 37821, External MCP 47822 — lt. `~/.todo-app/worker.config.json`). Testliste **`ClaudeDoTests`** (`C:\TestRepos\ClaudeDoTests`, listId `e7992fee6035448394b646d069690e05`) für zerstörungsfreie Runs.
**Wichtige Gotchas / Lessons (diese Session):**
- **NIE `model:"haiku"` für schreibende Task-/Verifikations-Runs** — unter dem Default `--permission-mode auto` werden haiku-Writes denied (still no-op). Immer **sonnet**. (Memory `auto_permission_haiku_footgun`.)
- **Merge-Preflight blockt bei dirty Ziel-Working-Tree** (getrackte uncommittete Änderung im `main`-Checkout) — und **Approve schluckt das still** (Bug, open.md). Vor Merge-Checks `git -C C:\TestRepos\ClaudeDoTests status --short` prüfen; fremde WIP nur mit Rücksprache verwerfen (Memory `shared_worktree_partial_commits`: nur pfad-scoped committen).
- **Konflikt-Fixture-Rezept:** Task (sonnet) überschreibt eine getrackte Datei im Branch → danach dieselbe Datei auf `main` divergent ändern + `git commit -- <pfad>` → Approve konfliktet. Für Planning-Unit-Konflikt: main-Edit erst scharfschalten, wenn der Parent in WaitingForReview steht.
- **Datenwahrheit** notfalls direkt aus der DB: `sqlite3 -readonly ~/.todo-app/todo.db "SELECT substr(id,1,8),status,planning_phase,substr(blocked_by_task_id,1,8),title FROM tasks WHERE …"`.
**Testrepo-/Fixture-Zustand:** Aufgeräumt (2026-07-24, §7+§8-Session-Ende) — alle `verif §7/§8`-Tasks gelöscht, deren Worktrees + Branches entfernt, `ClaudeDoTests`-`main` unangetastet bei `ecad650` (Tree clean; die §7-Runs liefen isoliert in Worktrees, main nie berührt). ponytail-Skills nach dem Remove-Test wieder deinstalliert (`session_skills` leer, `~/.todo-app/session-skills/` leer). Keine `verif`-Tasks und keine claudedotests-Worktrees übrig. Die übrigen `claudedo/*`-Branches im Testrepo stammen aus früheren Sessions — nicht anfassen. **Die nächste Session baut Fixtures frisch** (Rezepte s. Gotchas oben).
**Erfasste Folge-Tasks** (Liste „Claude do", Idle, brauchen Brainstorm vor Umsetzung): `f9809a93` Approve erzwingt Diff/Review vor Merge (+ blocked-Merge-Silent-Fail-Fix), `5d627df8` Planning-Session über embedded ConPTY statt wt (+ Planning-Permission-Prompt).
**Fix-Session:** Alle fixbaren Findings sind für eine frische Session in **`docs/fix-plan-2026-07-24.md`** aufbereitet (gruppiert nach Fixbarkeit: A mechanisch, B error-surfacing, C entscheidungsbedürftig, D investigation, E nits). Volltext je Finding bleibt in `docs/open.md`.
**Nacharbeit:** Erledigtes aus `docs/open.md` austragen; dieses File löschen, sobald §10 (nach Rework) + evtl. §11-Nachtest erledigt sind.
---
## 1. Detail-Insel & Diff-Viewer (reines Durchklicken)
- [~] Detail-Insel komplett: Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides (InheritedBadge korrekt), Prep-Panel — nach dem VM-Split (`DetailsIslandViewModel` → Sektions-VMs) alles gebunden, keine leeren Panels. — **TEILWEISE (2026-07-24, User-Sichtprüfung an `verif §1 diff matrix`):** Output/Git-Tabs, Merge-Sektion, Agent-Badges vorhanden & gebunden, keine leeren Panels. Git-Tab: `+1 37`, „merges cleanly" korrekt. **BUG:** OUTCOME-Karte zeigt rohes JSON (s. open.md). Session-Tab fehlt — **erwartet** (nur bei Parent mit Kindern, `HasChildOutcomes`; wird in §4/§3 mit echtem Parent geprüft). Minor: TurnsText `0/max` bei terminalem Reload (open.md). Prep-Panel → §10.
- [~] Diff-Viewer: Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach einem Merge. — **Added/Deleted/Binary PASS**; **Renamed** erkannt + „R"-Badge, aber schwach dargestellt (kein alt→neu-Pfad, „+0 0" — nit, s. open.md); „Review Combined Diff" korrekt ausgegraut (kein Planning-Parent). **Commit-Range-Diff nach Merge: PASS (2026-07-24)** — Diff des gemergten Done-Tasks rendert über `base..head` trotz entferntem Worktree.
- [x] DiffModal-Fehler-State: Commit-Range ohne aufgezeichnete Commits → „Diff nicht mehr verfügbar" statt Crash/leer. — **GEKLÄRT via Code-Analyse (2026-07-24): der Fehler-State ist defensiv/unerreichbar.** `vm.diff.unavailable` (DiffViewerViewModel.cs:117-119) feuert nur bei `FromCommitRange && (BaseRef==null || HeadCommit==null)` oder `WorktreePath==null`. Alle Aufrufer sind gegated: `MergeSectionViewModel.OpenDiffAsync` ruft `ConfigureCommitRange` nur unter `CanDiffMergedRange` (base **und** head non-null) und `ConfigureWorktree` nur unter `hasLiveWorktree` (Pfad non-null + existiert); `WorktreesOverviewModalViewModel` übergibt `row.Path` (non-null). Damit können die Null-Checks über die UI nie wahr werden — der „Crash/leer"-Fall, den der Check absichern wollte, ist durch die CanExecute-/Modus-Gates ausgeschlossen. (Fehlende Commit-Objekte bei vorhandenem base+head → `GetCommitRangeDiffAsync` wirft → „loadFailed", nicht „unavailable".) Nicht manuell auslösbar; kein Bug.
- [x] „children need attention"-Band auf dem Session-Tab eines Parents mit failed/blocked Kind. — **PASS (2026-07-24, in §3)**: Band + OUTCOMES-Liste mit Roadblock-Hinweis auf dem Session-Tab des Planning-Parents (`verif §3`, Roadblock-Kind).
## 2. Worktree-Pipeline (3 falsifizierbare Fälle)
- [x] Happy-Path: Task mit WorkingDir → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id[:8]>` existiert auf Disk. — **PASS (2026-07-24)** via Task `e63eb2f5` (sonnet): Worktree active, `headCommit 8a3ae86` (ahead=1 vom Base), `diff_stat` non-empty (`review-cancel.txt`), Branch `claudedo/e63eb2f5…` auf Disk. (Mein erster Versuch mit `model=haiku` schlug fehl — das war ein haiku/auto-Footgun, keine Pipeline-Sache; s. open.md.)
- [x] No-Changes-Run: → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`. — **PASS (2026-07-24)**; Präzisierung: aktueller Flow endet in `WaitingForReview` (nicht `Done`, das ist erst nach Approve) — Doc-„Done" ist veraltet. Kein neuer Commit, leerer Diff bestätigt.
- [x] Kein Git-Repo (WorkingDir = `C:\Temp`): → `status='Failed'`, KEINE `worktrees`-Row, Git-Fehler im Log. — **PASS (2026-07-24)**; Fehler: „Worktree creation failed: working_dir is not a git repository".
## 3. Planning-Flow-Walkthrough
- [x] Draft → Finalize → Kette: Finalize queued NICHT automatisch (Kinder bleiben Idle); „Queue plan" setzt alle nicht-terminalen Kinder Queued, Kette läuft sequenziell durch (blocked-by löst sich je Vorgänger). — **PASS (2026-07-24, End-to-End mit `verif §3`)**: 3 Kinder gedraftet, Finalize → Parent WaitingForChildren+finalized, Kinder Idle+Kette (clean→conflict→roadblock), Queue plan → sequenzieller Durchlauf, alle Done. **Begleit-Findings (open.md):** „Waiting for Improvements"-Mislabel, Kind-Badge-Live-Refresh (Draft→Planned erst nach Listenwechsel), Kette nicht visualisiert, planning-aktiver Parent zeigt „Idle", Dequeue-X fehlt auf wartenden Kindern, Planning-Permission-Prompt, Planning nutzt wt statt ConPTY.
- [x] Parent landet nach letztem terminalen Kind in WaitingForReview; Approve merged die ganze Unit (Parent-Worktree falls Active + jedes Done-Kind in Reihenfolge). — **PASS (2026-07-24)**: Parent → WaitingForReview auch mit Roadblock-Kind; Approve → Unit-Merge (child-clean clean, child-conflict → Konflikt-Editor pro Subtask → aufgelöst → Continue, child-roadblock no-op) → Parent Done, Merge gelandet.
- [x] UnfinishedPlanning-Modal: Resume / FinalizeNow / Discard. — **GETESTET (2026-07-24, `verif §3 unfinished-planning` + `verif §3 resume-discard`)**: Modal wird via Rechtsklick→„Resume planning session" ausgelöst, zeigt Titel + „N draft task(s) waiting to be finalized" + Buttons Discard/Finalize/Resume + ×. **× (dismiss)**: No-op (State bleibt `active`). **Finalize**: Parent → `finalized`+`WaitingForChildren`, Kinder Idle+Blocked-by-Kette (= Planned) — PASS. **Discard**: Draft-Kinder gelöscht, Parent → `none`+`idle` — funktional PASS. **Resume: BUG — macht nichts** (`planning_session_id` nie erfasst → `ResumeAsync` wirft „No Claude session ID captured yet", vom UI im leeren `catch` verschluckt; s. open.md). Beide State-ändernden Aktionen reproduzieren das Kind-Rows-Live-Refresh-Finding (Finalize: Badges stale; Discard: gelöschte Rows bleiben bis Reload; open.md).
## 4. Merge-Editor (Rider-Style 3-Pane) mit echtem Konflikt
Konflikt provozieren: gleiche Datei auf main ändern, während der Task-Branch sie ändert. Beide Wege testen: **(a)** Single-Task-Approve mit Konflikt, **(b)** Planning-Unit-Merge mit Konflikt in einem Subtask (`PlanningMergeConflict` → Editor öffnet pro Subtask).
**Verifiziert 2026-07-24 (Single-Task-Approve mit Konflikt, 2 Dateien, `verif §4`): End-to-End PASS** — Approve→Konflikt→3-Pane-Resolver→beide Dateien auflösen→Continue→Merge→Task Done (Merge-Commit gelandet). Sauberer additiver Approve (`verif §1b`, kein Konflikt) → direkt gemergt → Done, kein Editor: **PASS (2026-07-24)**. ⚠ **Vorbedingung-Fund:** ein dirty Ziel-Working-Tree (getrackte uncommittete Änderung) blockt den Merge und Approve schluckt das **still** (s. open.md „Approve & Merge schluckt blocked"). Planning-Unit-Merge-Konflikt (Weg b): **PASS (2026-07-24, in §3)** — Approve eines Planning-Parents mit einem konfliktenden Subtask öffnet den 3-Pane-Editor pro Subtask (`PlanningMergeConflict`), Continue führt den Unit-Merge fort → Parent Done.
- [x] Drei Panes: MAIN read-only | Result editierbar | INCOMING read-only; Konfliktblöcke rot, aufgelöst grün, in allen Panes. — **PASS**.
- [x] Gutter-Toggle ``/``: Seite rein/raus, Klickreihenfolge = Reihenfolge im Result; main/incoming/beide/keine möglich. — **PASS**.
- [x] Nur Konfliktregionen im Result editierbar (Stable read-only); Edits fließen in den Block zurück. — **PASS**.
- [~] Synchrones vertikales Scrollen; File-Switcher bei mehreren Dateien; `M conflicts · K resolved`-Readout; Conflict-Ruler (Klick springt). — **PASS**, aber **Multi-File schlecht erkennbar** (2 Konfliktdateien schwer zu sehen — UX-Nit, open.md).
- [~] Continue erst aktiv, wenn ALLE Konflikte in ALLEN Dateien gelöst; Binär-Guard greift. — **funktional PASS** (merged erst nach Auflösung beider Dateien), aber **Button ist klickbar statt disabled** solange offen → stummer No-op (UX-Bug, open.md). Binär-Guard hier n/a.
- [x] Abort: Tree sauber, Task bleibt WaitingForReview. — **PASS (2026-07-24, `verif §4 abort`)**: Single-Task-Approve mit divergentem main→Konflikt→3-Pane-Editor→**Abort**: Editor schließt, Task bleibt `WaitingForReview`, `main`-Tree sauber (kein `MERGE_HEAD`, kein dirty), `main`-Inhalt + HEAD unverändert.
- [FEATURE-WUNSCH] farbliches Hervorheben der übernommenen/eingefügten Zeilen im Result-Pane (open.md).
- Bekannte Kanten (nur gegenprüfen, nicht als Fail werten): leere Ours-Seite → null-lange Result-Region (Accept geht, Handtippen fummelig); Gutter-Y-Ausrichtung bei sehr hohen Fenstern/großem Scroll; vertikaler Drift nachfolgender Blöcke nach Konflikt mit ungleicher Zeilenzahl.
## 5. Embedded ConPTY / Mission Control
- [x] **Kritisch:** Task-basiert (Kontextmenü „Open ConPTY session"): frischer Task → Worktree wird on-demand angelegt, `claude` startet — Task-Prompt wirklich GESENDET. — **PASS (2026-07-24)**: Agent antwortete mit dem Marker `CONPTY-PROMPT-RECEIVED-4Q7`, Worktree on-demand angelegt.
- [~] Ad-hoc: „New session"-Button → Ordnerwahl → freie Session im gewählten Verzeichnis. — **funktioniert, aber Button unsichtbar**: `Icon.Plus` ist Strich-Only → `PathIcon` rendert nichts (Bug, open.md). Über Tooltip/Klick auf die leere Header-Fläche erreichbar; Ordnerwahl + Ad-hoc-Session funktionieren.
- [x] Grid↔Tabs-Toggle; Close killt den Prozess + entfernt die Kachel; mehrere Sessions parallel; Pane-Resize reflowt das TUI. — **PASS (2026-07-24)**: Resize/Reflow ok; Close entfernt Kachel UND killt den Prozess (verifiziert: ConPTY-PID 50964, Kind von ClaudeDo.App, nach Close tot).
- [~] Resume: im Worktree-Dir per `claude --continue` möglich (ClaudeDo persistiert die ConPTY-Session-Id bewusst nicht). — **wie designt**: erneutes „Open ConPTY session" resumt NICHT, sondern startet frisch und **sendet den Prompt erneut** (UX-Falle, open.md). In-App-Resume gibt es nicht; nur claude-eigenes `--continue`/`--resume` im Worktree-Dir.
## 6. Pick up in terminal
- [x] Sichtbarkeit: Kontextmenü-Eintrag + Terminal-Button (ArrowOut) NUR bei WaitingForReview und Failed; bei Idle/Running/Queued/Done nicht. — **PASS (2026-07-24, Code+Test)**: `CanPickUpInTerminal => Status is WaitingForReview or Failed` (TaskRowViewModel.cs:69, DetailsIslandViewModel.cs:898), gebunden in TaskRowView.axaml:56 + TaskHeaderBar.axaml:34, Notify bei Statuswechsel, Unit-Test `CanPickUpInTerminal_OnlyForReviewOrFailed`. (Klick→Terminal + Fehler-Surfacing bleiben manueller UI/CLI-Check.)
- [ ] Klick → neues Windows-Terminal im Worktree-Verzeichnis, `claude --resume <id>` nimmt die Session mit Kontext wieder auf.
- [ ] Fehlerfälle surfacen sauber (Footer-Strip bzw. Fehlerdialog): laufende/gequeuete Task, keine persistierte Session-Id, kein aktiver Worktree.
- Bekannte Kante: parked-Idle (reject-park) hat oft Session+Worktree, zeigt die Aktion aber bewusst NICHT (Idle nicht unterscheidbar). Nervt das in der Praxis → `CanPickUpInTerminal` erweitern.
## 7. AskUser (Frage aus laufendem Task)
Runtime-Toolname ist `mcp__claudedo_run__ask_user` (snake_case, nicht `AskUser`). Fixture: sonnet-Task mit Prompt „zwei sich widersprechende, irreversible Optionen — du MUSST `ask_user` fragen, nicht raten" erzwingt den Call zuverlässig.
- [x] Task, dessen Prompt eine Rückfrage erzwingt → Frage erscheint im Task-Monitor (Mission Control), Prozess wartet. — **PASS (2026-07-24, `verif §7 askuser 2`)**: Banner in Mission Control, Run blockiert im `ask_user`-Tool-Call. **BUG:** in der Task-Detail-Insel erscheint KEIN Banner (nur Mission Control) → s. open.md.
- [x] Antwort inline absenden → Run läuft mit der Antwort weiter. — **PASS (2026-07-24)**: Antwort „A" inline gesendet → Agent bekam „option A", benannte `merge-playground.txt``renamed-by-agent.txt` um, schrieb `askuser-result.txt` = „USER CHOSE: A", Task → WaitingForReview.
- [x] Timeout-/Cleanup-Verhalten der PendingQuestionRegistry (Frage unbeantwortet lassen): Task schlägt kontrolliert fehl, UI räumt die Frage auf (MCP_TOOL_TIMEOUT-Gotcha). — **PASS (2026-07-24)**: Backend (`verif §7 askuser`) nach exakt 3 min Fallback zurück, Agent hielt sich an „MUST NOT guess", meldete `CLAUDEDO_BLOCKED`, Repo untouched, Registry im `finally` aufgeräumt, Run endete `success` → WaitingForReview. **UI-Cleanup visuell bestätigt** (`verif §7 timeout-ui`, MC-Kachel offen/subscribt bis zum Timeout): Banner verschwindet beim Timeout automatisch (`TaskQuestionResolved``ClearPendingQuestion`).
## 8. Session Skills (E2E + UI)
- [x] Settings → Skills: `https://github.com/DietrichGebert/ponytail` installieren → 6 Skills erscheinen (ponytail, -help, -review, -audit, -debt, -gain), auf Commit gepinnt, Dateien unter `~/.todo-app/session-skills/<name>/`. — **PASS (2026-07-24)**: 6 Rows in `session_skills`, alle auf Commit `16f29800` gepinnt, `subpath=skills/<name>`, Dateien inkl. `SKILL.md` am erwarteten Ort. UI-Install nahm die URL an. **Nit:** Skills-Tab hat keinen Empty-State (s. open.md).
- [x] Skill per-Task (Agent-Settings-Flyout) oder global aktivieren → Task laufen lassen → im Worktree liegt `.claude/skills/<name>/`, Agent kann ihn nutzen, `git status` im Worktree bleibt sauber (info/exclude greift). — **PASS (2026-07-24, per-Task, `verif §8 skill-activate`)**: `ponytail-help` per Flyout aktiviert (`task.SessionSkills=["ponytail-help"]`), Run → `.claude/skills/ponytail-help/SKILL.md` im Worktree, `info/exclude` bekam `/.claude/skills/ponytail-help/`, `git status` clean, Auto-Commit enthält NUR `skill-proof.txt` (Skill NICHT im Tree). Agent hat den Skill nachweislich genutzt (`skill-proof.txt` = ponytail-help-Referenzkarte verbatim). Global-Aktivierung (AppSettings.SessionSkills, General-Tab) nutzt denselben Seeding-Union-Pfad — funktional äquivalent, nicht separat gefahren.
- [x] Gegenprobe: nicht-aktivierte/andere interaktive Session sieht den Skill NICHT (kein Leak nach `~/.claude`). — **PASS (2026-07-24)**: `~/.claude/skills/` enthält nur die vorbestehenden globalen Skills, KEIN ponytail. Seeder schreibt per Konstruktion nur nach `<workingDir>/.claude/skills` (SessionSkillSeeder), nie nach `~/.claude`.
- [~] UI: Skills-Tab (Install-Zeile, Karten mit Update/Remove), Checkbox-Listen im General-Tab + AgentConfigEditor (Flyout-Höhe!), leerer Zustand (0 Skills — Empty-State fehlt evtl., dann entscheiden), lange Namen/URLs (Trimming). — **TEILWEISE (2026-07-24)**: Install-Zeile nimmt URL an (PASS); AgentConfigEditor-Flyout-Checkbox-Liste zeigt alle 6 Skills, Höhe ok, Trimming ok (User: „funktioniert wie erhofft"). **Empty-State fehlt** (nackte Fläche — open.md). **Agent-Settings-Gear ⚙ weicht vom Listen-Gear ab** (open.md). Skills-Tab-**Karten** (Name/Beschreibung/Pinned-Commit + Update/Remove) und **General-Tab-Checkbox-Liste** (6 Skills) sichtgeprüft (User: „sieht alles gut aus"). **Remove PASS (2026-07-24)**: ein Remove-Klick auf eine ponytail-Karte entfernte alle 6 Skills per Quell-URL (`session_skills` leer, alle Verzeichnisse unter `~/.todo-app/session-skills/` weg). **Update** nicht separat gefahren (Code-Pfad `SessionSkillRegistry.UpdateAsync`).
## 9. Attachments (Drag & Drop + MCP)
- [x] Drop aufs Detail-Pane: „Drop to attach"-Overlay, Datei erscheint in der Liste, landet unter `~/.todo-app/attachments/<taskId>/`; „Add file…"-Picker; Remove-Button. — **PASS (2026-07-24, `verif §9 attachments-ui`)**: Overlay/Highlight beim Drüberziehen, Drop-Round-Trip (`drop-me.txt` in Liste + Disk + DB, 29 B), „Add file…"-Picker (`pick-me.md`), Remove (Datei aus Liste + Disk + DB) — alle bestätigt. **Finding:** der ALLERERSTE Drop der Session schlug einmalig mit inline „An error occurred" fehl (nichts persistiert), danach fehlerfrei — intermittierend, nicht reproduzierbar (s. open.md).
- [x] `ComposedPreview` enthält die Attachment-Pfade („## Reference files"). — **PASS (2026-07-24, Code+Test)**: TaskPromptComposer.cs:31 emittiert „## Reference files", ComposedPreview reicht Pfade durch, TaskRunner.cs:132 injiziert zur Laufzeit; TaskPromptComposerTests decken es ab.
- [x] MCP: `add_task_attachment` / `list_task_attachments` / `remove_task_attachment`; Running-Task verweigert add/remove. — **PASS (2026-07-24)** für add/list/remove-Round-Trip inkl. Datei unter `~/.todo-app/attachments/<taskId>/` (71 B, korrekt, nach Remove weg). Running-Task-Verweigerung ist code-guarded (AttachmentMcpTools), aber ohne dauerhaft laufende Task nicht live geprüft → manueller Rest.
## 10. Daily Prep (Prime) & Weekly Report
- [ ] Prime-Trigger: Schedule feuert bzw. „Plan day" manuell → Prep-Log streamt live, `daily-prep.log` enthält letzten Run, MyDay-Auswahl respektiert `DailyPrepMaxTasks`.
- [ ] Weekly Report: Range-Default „seit letztem Standup-Wochentag → heute", Markdown rendert, Cache pro Range.
## 11. Self-Update & Autostart (am Gerät)
- [ ] Update-Banner → Update durchführen → danach „up to date".
- [ ] Autostart: Logoff/Logon startet den Worker (Startup-`.lnk`); Update-Pfad erhält den Autostart; Uninstall entfernt die `.lnk`.
## 12. Status-Bar / RunNow (Mini-Codecheck, erst messen)
- [x] Worker trennen/verbinden → prüfen, ob RunNow-Enable pro Task-Row sauber re-evaluiert (Connection-State lebt in `IslandsShellViewModel`). Nur fixen, wenn tatsächlich kaputt. — **PASS/moot (2026-07-24, Code)**: Es gibt kein per-Row-RunNow-Control in der UI; `RunNowAsync` (IWorkerClient/WorkerClient) hat keinen VM/View-Aufrufer (RunNow nur via MCP `run_task_now`). Die realen Detail-Pane-Aktionen (Enqueue/Dequeue/Continue/ResetAndRetry) re-evaluieren korrekt bei Connection-Change (DetailsIslandViewModel.cs:317-323). Nebenbefund: `RunNowAsync` in der UI ist toter Code.
+2
View File
@@ -21,6 +21,7 @@
<converters:DotBrushConverter x:Key="DotBrush"/> <converters:DotBrushConverter x:Key="DotBrush"/>
<converters:BoolToItalicConverter x:Key="BoolToItalic"/> <converters:BoolToItalicConverter x:Key="BoolToItalic"/>
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/> <converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>
@@ -31,6 +32,7 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" /> <StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
<!-- Global defaults: every Window inherits Inter Tight + body size. <!-- Global defaults: every Window inherits Inter Tight + body size.
Controls that need mono opt in via their own class/style. --> Controls that need mono opt in via their own class/style. -->
+8
View File
@@ -1,7 +1,9 @@
using System; using System;
using Avalonia; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views; using ClaudeDo.Ui.Views;
@@ -21,6 +23,8 @@ public partial class App : Application
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
if (_services?.GetService<AppSettings>() is { } settings)
AccentPresetService.Apply(AccentPresets.Find(settings.AccentPreset));
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
@@ -32,6 +36,10 @@ public partial class App : Application
FocusClearing.Install(); FocusClearing.Install();
// The main window is authoritative — closing it shuts the app down even if the
// modeless Mission Control window is still open.
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow
{ {
DataContext = services.GetRequiredService<IslandsShellViewModel>(), DataContext = services.GetRequiredService<IslandsShellViewModel>(),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 55 KiB

+2 -2
View File
@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
## DI Registration Pattern ## DI Registration Pattern
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext) - **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation - **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `DiffViewerViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation (`Func<DiffViewerViewModel>` for the diff viewer); `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
## Notes ## Notes
+6 -4
View File
@@ -14,10 +14,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" /> <PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" /> <PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" /> <PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0"> <PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
+33 -7
View File
@@ -116,13 +116,19 @@ sealed class Program
return new UpdateCheckService(releases, version); return new UpdateCheckService(releases, version);
}); });
// Conflict-merge coordinator: single seam the shell wires to its resolver entry.
sc.AddSingleton<MergeCoordinator>();
sc.AddSingleton<IMergeCoordinator>(sp => sp.GetRequiredService<MergeCoordinator>());
// ViewModels // ViewModels
sc.AddTransient<WorktreeModalViewModel>(); sc.AddTransient<DiffViewerViewModel>();
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>()); sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>(); sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>()); sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddTransient<MergeHelperSelectionModalViewModel>();
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>(); sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddSingleton<INotesApi, WorkerNotesApi>(); sc.AddSingleton<INotesApi, WorkerNotesApi>();
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
sc.AddTransient<PrimeClaudeTabViewModel>(); sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>(); sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>(); sc.AddTransient<MergeModalViewModel>();
@@ -132,24 +138,44 @@ sealed class Program
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>()); sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
sc.AddTransient<WeeklyReportModalViewModel>(); sc.AddTransient<WeeklyReportModalViewModel>();
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>()); sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddTransient<UsageMonitorModalViewModel>();
sc.AddTransient<Func<UsageMonitorModalViewModel>>(sp => () => sp.GetRequiredService<UsageMonitorModalViewModel>());
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<IWorkerClient>(), taskId));
// Islands shell VMs // Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp => sc.AddSingleton<ListsIslandViewModel>(sp =>
new ListsIslandViewModel( new ListsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp, sp,
sp.GetRequiredService<WorkerClient>())); sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<TasksIslandViewModel>(sp => sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel( new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>())); sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel( new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>(), sp.GetRequiredService<IWorkerClient>(),
sp, sp,
sp.GetRequiredService<INotesApi>())); sp.GetRequiredService<INotesApi>(),
sc.AddSingleton<IslandsShellViewModel>(); sp.GetRequiredService<IMergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<MissionControlViewModel>(sp =>
new MissionControlViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<UsagePillViewModel>()));
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
shell.ConflictResolverFactory =
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
sp.GetRequiredService<MergeCoordinator>().Handler = shell.RequestConflictResolutionAsync;
return shell;
});
return sc.BuildServiceProvider(); return sc.BuildServiceProvider();
} }
+85
View File
@@ -0,0 +1,85 @@
namespace ClaudeDo.Data;
public sealed class AttachmentStore
{
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB
private readonly string _root;
public AttachmentStore(string? root = null)
=> _root = root ?? Paths.Expand("~/.todo-app/attachments");
public string Root => _root;
public IReadOnlyList<string> EnumerateTaskIds()
{
if (!Directory.Exists(_root)) return Array.Empty<string>();
return Directory.GetDirectories(_root)
.Select(Path.GetFileName)
.Where(n => n is not null)
.Select(n => n!)
.ToList();
}
public string TaskDir(string taskId)
=> Path.Combine(_root, taskId);
public async Task<long> SaveAsync(string taskId, string fileName, Stream content, CancellationToken ct = default)
{
if (Path.GetFileName(fileName) != fileName)
throw new ArgumentException("fileName must not contain path separators or '..'.", nameof(fileName));
var dir = TaskDir(taskId);
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
// Containment guard: resolved path must stay inside TaskDir
var resolvedDir = Path.GetFullPath(dir);
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
throw new ArgumentException("fileName resolves outside the task directory.", nameof(fileName));
Directory.CreateDirectory(dir);
// Buffer up to MaxBytes + 1 to detect oversize without reading fully
await using var fs = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
var buffer = new byte[81920];
long total = 0;
int read;
while ((read = await content.ReadAsync(buffer, ct)) > 0)
{
total += read;
if (total > MaxBytes)
{
fs.Close();
try { File.Delete(resolvedPath); } catch { }
throw new InvalidOperationException($"Attachment exceeds the 5 MB size limit.");
}
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
}
return total;
}
public void DeleteFile(string taskId, string fileName)
{
if (Path.GetFileName(fileName) != fileName)
return; // traversal attempt — ignore silently
var dir = TaskDir(taskId);
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
var resolvedDir = Path.GetFullPath(dir);
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
return; // containment violation — ignore silently
try { File.Delete(resolvedPath); } catch (DirectoryNotFoundException) { } catch (FileNotFoundException) { }
}
public void DeleteTaskDir(string taskId)
{
var dir = TaskDir(taskId);
try { Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } catch (IOException) { }
}
}
+16 -10
View File
@@ -4,27 +4,32 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration. - **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit (nullable; review range for a worktree-less "list handler" host task — Mission Control's "Let Claude handle it" — which commits straight into the list's working dir instead of a per-task worktree: `HandlerBaseCommit` is stamped to the list repo's HEAD when the host task is created, `HandlerHeadCommit` when it's submitted for review; the Worker's `SubmitTaskForReview`/`GetTaskDiff` and the Ui's `DetailsIslandViewModel`/`MergeSectionViewModel` fall back to this pair whenever `Worktree` is null). Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable) - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate (see `ClaudeDo.Worker/CLAUDE.md` → TaskMergeService): null/blank = today's behavior, no gate.
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) - **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable; SHA of the merge commit this worktree's branch produced on the target branch, stamped by `TaskMergeService` the moment a merge/continue-merge succeeds — the only thing that makes `revert_merge` possible without heuristically searching `git log`; null for any worktree merged before this field existed), State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path) - **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path, nullable `Model` — the model the run actually executed with, column `model`)
- **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. - **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.
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes` - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date) - **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) - **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) , `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`), and `UsageGateFiveHourPct` / `UsageGateSevenDayPct` (int, defaults 80/90, columns `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` — percentage of the 5h/7d Claude usage window at which the autonomous queue pauses; `0` = gate off; `AppSettingsRepository.UpdateAsync` clamps both to 0..100)
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1200) and never throw — a malformed settings row must not stop a run. `For(presets, model, fallbackMaxTurns = 30)` always returns a usable row: `model` is resolved through `ModelRegistry.TryNormalizeAlias` first, so a full CLI model id (e.g. `claude-sonnet-4-6`) still hits its alias's preset row instead of missing every lookup and falling through; only a model that normalizes to nothing recognized falls back to a synthesized row (`EffortRegistry.DefaultLevel` + `fallbackMaxTurns` — callers pass `AppSettings.DefaultMaxTurns` here so that setting has a real effect instead of a hardcoded number). Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
- **ModelRegistry.TryNormalizeAlias** — non-throwing counterpart to `NormalizeAlias` for the run path: exact alias match, then substring match against a full model id, else `null`. Never throws, unlike `NormalizeAlias` (which stays the strict, throwing validator for `add_task`/planning model input).
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files - **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
## Repositories ## Repositories
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here. All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them. - **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config` - **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync` - **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged and stamps MergeCommit in one update — the only writer of MergeCommit)
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** - **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
- **DailyNoteRepository**`ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync` - **DailyNoteRepository**`ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
- **WeekReportRepository**`GetByRangeAsync`, `UpsertAsync` - **WeekReportRepository**`GetByRangeAsync`, `UpsertAsync`
- **TaskAttachmentRepository**`AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
## Infrastructure ## Infrastructure
@@ -32,14 +37,15 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service) - **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app` - **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl) - **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
- **AttachmentStore** — dependency-free file store; default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and path-traversal/containment guard. Also exposes `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, and `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
## Git ## Git
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync` - **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), revert (`RevertMergeCommitAsync``git revert --no-edit -m 1 <sha>`, reverts a merge commit against its first parent; `RevertAbortAsync`; `IsMidRevertAsync``REVERT_HEAD` presence, mirrors `IsMidMergeAsync`'s `MERGE_HEAD`), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo. Revert never resets/rewrites — it always produces a new commit, because the working directory it operates on is shared with other concurrent sessions.
## Schema ## Schema
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. Migration `AddHandlerCommitRange` added the nullable `handler_base_commit`/`handler_head_commit` columns to `tasks`. Migration `AddUsageGateAndRunModel` added `app_settings.usage_gate_five_hour_pct`/`usage_gate_seven_day_pct` (defaults 80/90) and the nullable `task_runs.model` column. Migration `AddWorktreeMergeCommit` added the nullable `worktrees.merge_commit` column. Migration `AddVerifyCommand` added the nullable `list_config.verify_command` column. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
## Conventions ## Conventions
+31
View File
@@ -1,6 +1,8 @@
using System.Data.Common;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Seeding; using ClaudeDo.Data.Seeding;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -9,18 +11,47 @@ namespace ClaudeDo.Data;
public class ClaudeDoDbContext : DbContext public class ClaudeDoDbContext : DbContext
{ {
// Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK
// enforcement is active for all IDbContextFactory-created contexts, not
// just the single context used in MigrateAndConfigure.
private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor
{
internal static readonly SqliteForeignKeyInterceptor Instance = new();
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
=> Apply(connection);
public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
{
Apply(connection);
return Task.CompletedTask;
}
private static void Apply(DbConnection connection)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA foreign_keys=ON;";
cmd.ExecuteNonQuery();
}
}
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { } public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
public DbSet<TaskEntity> Tasks => Set<TaskEntity>(); public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>(); public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>(); public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>(); public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>(); public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>(); public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>(); public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>(); public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>(); public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>(); public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
private static readonly ValueConverter<DateTime, DateTime> UtcConverter = private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
@@ -44,6 +44,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.DailyPrepMaxTasks) builder.Property(s => s.DailyPrepMaxTasks)
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5); .HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
builder.Property(s => s.ModelPresets).HasColumnName("model_presets");
builder.Property(s => s.UsageGateFiveHourPct)
.HasColumnName("usage_gate_five_hour_pct").IsRequired().HasDefaultValue(80);
builder.Property(s => s.UsageGateSevenDayPct)
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
} }
} }
@@ -16,5 +16,7 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt"); builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
builder.Property(c => c.AgentPath).HasColumnName("agent_path"); builder.Property(c => c.AgentPath).HasColumnName("agent_path");
builder.Property(c => c.MaxTurns).HasColumnName("max_turns"); builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
} }
} }
@@ -17,6 +17,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort"); builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
@@ -0,0 +1,21 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class SessionSkillEntityConfiguration : IEntityTypeConfiguration<SessionSkillEntity>
{
public void Configure(EntityTypeBuilder<SessionSkillEntity> builder)
{
builder.ToTable("session_skills");
builder.HasKey(s => s.Name);
builder.Property(s => s.Name).HasColumnName("name");
builder.Property(s => s.SourceUrl).HasColumnName("source_url").IsRequired();
builder.Property(s => s.PinnedRef).HasColumnName("pinned_ref").IsRequired();
builder.Property(s => s.Subpath).HasColumnName("subpath").IsRequired();
builder.Property(s => s.Description).HasColumnName("description").IsRequired();
builder.Property(s => s.AddedAt).HasColumnName("added_at").IsRequired();
}
}
@@ -0,0 +1,27 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TaskAttachmentEntityConfiguration : IEntityTypeConfiguration<TaskAttachmentEntity>
{
public void Configure(EntityTypeBuilder<TaskAttachmentEntity> builder)
{
builder.ToTable("task_attachments");
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnName("id");
builder.Property(a => a.TaskId).HasColumnName("task_id").IsRequired();
builder.Property(a => a.FileName).HasColumnName("file_name").IsRequired();
builder.Property(a => a.ByteSize).HasColumnName("byte_size").IsRequired();
builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasOne(a => a.Task)
.WithMany()
.HasForeignKey(a => a.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(a => a.TaskId).HasDatabaseName("idx_task_attachments_task_id");
}
}
@@ -89,8 +89,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.MaxTurns).HasColumnName("max_turns"); builder.Property(t => t.MaxTurns).HasColumnName("max_turns");
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false); builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false);
builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id"); builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id"); builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
@@ -27,6 +27,7 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity
builder.Property(r => r.LogPath).HasColumnName("log_path"); builder.Property(r => r.LogPath).HasColumnName("log_path");
builder.Property(r => r.StartedAt).HasColumnName("started_at"); builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.FinishedAt).HasColumnName("finished_at"); builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
builder.Property(r => r.Model).HasColumnName("model");
builder.HasOne(r => r.Task) builder.HasOne(r => r.Task)
.WithMany(t => t.Runs) .WithMany(t => t.Runs)
@@ -35,6 +35,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti
builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired(); builder.Property(w => w.BaseCommit).HasColumnName("base_commit").IsRequired();
builder.Property(w => w.HeadCommit).HasColumnName("head_commit"); builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
builder.Property(w => w.DiffStat).HasColumnName("diff_stat"); builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
builder.Property(w => w.MergeCommit).HasColumnName("merge_commit");
builder.Property(w => w.State).HasColumnName("state").IsRequired() builder.Property(w => w.State).HasColumnName("state").IsRequired()
.HasDefaultValue(WorktreeState.Active) .HasDefaultValue(WorktreeState.Active)
.HasConversion(StateConverter); .HasConversion(StateConverter);
@@ -7,8 +7,7 @@ public sealed class ReviewFilter : ITaskListFilter
{ {
public string Id => "virtual:review"; public string Id => "virtual:review";
public bool Matches(TaskEntity t) => public bool Matches(TaskEntity t) =>
t.Status == TaskStatus.Done && t.Status == TaskStatus.WaitingForReview;
t.Worktree is { State: WorktreeState.Active };
public bool ShouldCount(TaskEntity t) => Matches(t); public bool ShouldCount(TaskEntity t) => Matches(t);
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false; public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
} }
@@ -0,0 +1,134 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace ClaudeDo.Data.Git;
/// <summary>
/// One piece of a conflicted file: either common ("stable") text both sides agree on,
/// or a conflict region holding the two — or, with diff3 markers, three — competing versions.
/// </summary>
public sealed record MergeSegment
{
public bool IsConflict { get; init; }
/// <summary>Stable text (verbatim, line endings preserved) when <see cref="IsConflict"/> is false.</summary>
public string Text { get; init; } = "";
/// <summary>"Ours" side (the target branch) when <see cref="IsConflict"/> is true.</summary>
public string Ours { get; init; } = "";
/// <summary>Merge base, present only when the merge used diff3 conflict style; null otherwise.</summary>
public string? Base { get; init; }
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
public string Theirs { get; init; } = "";
public static MergeSegment Stable(string text) => new() { Text = text };
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
}
/// <summary>
/// Parses a conflicted file's text into ordered stable / conflict segments and reassembles it.
/// Reads git conflict markers verbatim, so a file with no markers yields a single stable
/// segment, and reassembling the stable text plus one chosen resolution per conflict
/// round-trips the file exactly (line endings included).
/// </summary>
public static class ConflictMarkerParser
{
private const string OursMarker = "<<<<<<<";
private const string BaseMarker = "|||||||";
private const string SepMarker = "=======";
private const string TheirsMarker = ">>>>>>>";
public static IReadOnlyList<MergeSegment> Parse(string fileText)
{
var segments = new List<MergeSegment>();
var lines = SplitKeepLineEndings(fileText);
var stable = new StringBuilder();
var i = 0;
while (i < lines.Count)
{
if (!IsMarker(lines[i], OursMarker))
{
stable.Append(lines[i++]);
continue;
}
if (stable.Length > 0)
{
segments.Add(MergeSegment.Stable(stable.ToString()));
stable.Clear();
}
i++; // consume "<<<<<<<"
var ours = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
ours.Append(lines[i++]);
string? @base = null;
if (i < lines.Count && IsMarker(lines[i], BaseMarker))
{
i++; // consume "|||||||"
var baseText = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], SepMarker))
baseText.Append(lines[i++]);
@base = baseText.ToString();
}
if (i < lines.Count && IsMarker(lines[i], SepMarker)) i++; // consume "======="
var theirs = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], TheirsMarker))
theirs.Append(lines[i++]);
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
}
if (stable.Length > 0)
segments.Add(MergeSegment.Stable(stable.ToString()));
return segments;
}
/// <summary>True when the file still contains an opening conflict marker.</summary>
public static bool HasConflicts(string fileText) =>
SplitKeepLineEndings(fileText).Any(l => IsMarker(l, OursMarker));
/// <summary>
/// Reassembles a file from its segments. Stable segments emit their text verbatim;
/// each conflict segment emits whatever <paramref name="resolveConflict"/> returns for it.
/// </summary>
public static string Compose(
IEnumerable<MergeSegment> segments, Func<MergeSegment, string> resolveConflict) =>
string.Concat(segments.Select(s => s.IsConflict ? resolveConflict(s) : s.Text));
// A marker line starts with exactly the 7-char marker, then end-of-line or whitespace/label.
private static bool IsMarker(string line, string marker)
{
if (!line.StartsWith(marker, StringComparison.Ordinal)) return false;
if (line.Length == marker.Length) return true;
return line[marker.Length] is ' ' or '\t' or '\r' or '\n';
}
// Splits into physical lines, each retaining its trailing "\n" (and "\r" if present).
private static List<string> SplitKeepLineEndings(string s)
{
var lines = new List<string>();
var i = 0;
while (i < s.Length)
{
var nl = s.IndexOf('\n', i);
if (nl < 0) { lines.Add(s[i..]); break; }
lines.Add(s[i..(nl + 1)]);
i = nl + 1;
}
return lines;
}
}
+101 -16
View File
@@ -7,6 +7,11 @@ public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<stri
public sealed class GitService public sealed class GitService
{ {
// git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds
// race and fail with "failed to read .git/worktrees/<other>/commondir". Serialize them
// process-wide so parallel task starts don't collide.
private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default) public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
{ {
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct); var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
@@ -23,10 +28,30 @@ public sealed class GitService
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{ {
var (exitCode, _, stderr) = await RunGitAsync(repoDir, await WorktreeAddGate.WaitAsync(ct);
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct); try
if (exitCode != 0) {
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}"); const int maxAttempts = 3;
for (var attempt = 1; ; attempt++)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
if (exitCode == 0)
return;
// Transient races leave a half-written worktree metadata dir; retry briefly.
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
if (!transient || attempt >= maxAttempts)
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
await Task.Delay(150 * attempt, ct);
}
}
finally
{
WorktreeAddGate.Release();
}
} }
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default) public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
@@ -46,9 +71,22 @@ public sealed class GitService
return stdout; return stdout;
} }
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) public Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) =>
HasChangesAsync(worktreePath, includeUntracked: true, ct);
/// <summary>
/// Uncommitted-changes check. <paramref name="includeUntracked"/>=false ignores untracked
/// files — use this for merge preflights on a shared target working dir, where stray
/// untracked files (e.g. from a concurrent session) shouldn't block a merge. Auto-commit
/// and data-loss-guard callers keep the default (true): a new file a task created, or an
/// untracked file about to be discarded, is a real uncommitted change.
/// </summary>
public async Task<bool> HasChangesAsync(string worktreePath, bool includeUntracked, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct); string[] args = includeUntracked
? ["status", "--porcelain"]
: ["status", "--porcelain", "--untracked-files=no"];
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
if (exitCode != 0) if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return !string.IsNullOrWhiteSpace(stdout); return !string.IsNullOrWhiteSpace(stdout);
@@ -99,6 +137,20 @@ public sealed class GitService
return await GetDiffAsync(worktreePath, ct); return await GetDiffAsync(worktreePath, ct);
} }
/// <summary>
/// Diff between two commits, run in any repo that can reach them. Used to view a
/// task's changes after its worktree has been merged away (the commits survive on
/// the target branch even though the worktree directory and branch ref are gone).
/// </summary>
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["diff", $"{baseCommit}..{headCommit}"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default) public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
@@ -213,8 +265,11 @@ public sealed class GitService
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync( public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
string repoDir, string sourceBranch, string message, CancellationToken ct = default) string repoDir, string sourceBranch, string message, CancellationToken ct = default)
{ {
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
// clean merges are unaffected.
var (exitCode, _, stderr) = await RunGitAsync(repoDir, var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["merge", "--no-ff", "-m", message, sourceBranch], ct); ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
return (exitCode, stderr); return (exitCode, stderr);
} }
@@ -225,6 +280,36 @@ public sealed class GitService
throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git merge --abort failed (exit {exitCode}): {stderr}");
} }
public async Task<bool> IsMidRevertAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
if (exitCode != 0) return false;
var gitDir = stdout.Trim();
if (!Path.IsPathRooted(gitDir))
gitDir = Path.Combine(repoDir, gitDir);
return File.Exists(Path.Combine(gitDir, "REVERT_HEAD"));
}
/// <summary>
/// Reverts a single commit with `-m 1` (diff against its first parent) — the form needed to
/// revert a merge commit. On success this creates a new commit with the inverse changes;
/// the original commit and all history stay intact (no rewrite, no reset).
/// </summary>
public async Task<(int ExitCode, string Stderr)> RevertMergeCommitAsync(
string repoDir, string mergeCommitSha, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["-c", "merge.conflictStyle=diff3", "revert", "--no-edit", "-m", "1", mergeCommitSha], ct);
return (exitCode, stderr);
}
public async Task RevertAbortAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["revert", "--abort"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}");
}
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default) public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
@@ -238,6 +323,13 @@ public sealed class GitService
.ToList(); .ToList();
} }
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}");
}
/// <summary> /// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only /// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched. /// loose objects — the working tree, index, and refs are left untouched.
@@ -281,15 +373,8 @@ public sealed class GitService
.Count(s => s.Length > 0); .Count(s => s.Length > 0);
} }
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
if (exitCode != 0)
throw new InvalidOperationException($"Fast-forward merge of '{branchName}' failed. Manual merge required. git stderr: {stderr}");
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync( private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null) string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
{ {
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
@@ -338,6 +423,6 @@ public sealed class GitService
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd()); return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
} }
} }
@@ -0,0 +1,696 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260609000000_UniqueListName")]
partial class UniqueListName
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class UniqueListName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Remove duplicate list rows that have no tasks — keep the oldest rowid.
// This handles the startup-race case where both App and Worker seeded
// the same default list names concurrently.
migrationBuilder.Sql("""
DELETE FROM lists
WHERE (SELECT COUNT(*) FROM tasks WHERE list_id = lists.id) = 0
AND rowid NOT IN (
SELECT MIN(l2.rowid) FROM lists l2 WHERE l2.name = lists.name
)
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -0,0 +1,739 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260622150934_AddTaskAttachments")]
partial class AddTaskAttachments
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddTaskAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "task_attachments",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: false),
file_name = table.Column<string>(type: "TEXT", nullable: false),
byte_size = table.Column<long>(type: "INTEGER", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_task_attachments", x => x.id);
table.ForeignKey(
name: "FK_task_attachments_tasks_task_id",
column: x => x.task_id,
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "idx_task_attachments_task_id",
table: "task_attachments",
column: "task_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "task_attachments");
}
}
}
@@ -0,0 +1,786 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260703072917_AddSessionSkills")]
partial class AddSessionSkills
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,75 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddSessionSkills : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "session_skills",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "session_skills",
table: "list_config",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "session_skills",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "session_skills",
columns: table => new
{
name = table.Column<string>(type: "TEXT", nullable: false),
source_url = table.Column<string>(type: "TEXT", nullable: false),
pinned_ref = table.Column<string>(type: "TEXT", nullable: false),
subpath = table.Column<string>(type: "TEXT", nullable: false),
description = table.Column<string>(type: "TEXT", nullable: false),
added_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_session_skills", x => x.name);
});
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "session_skills",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "session_skills");
migrationBuilder.DropColumn(
name: "session_skills",
table: "tasks");
migrationBuilder.DropColumn(
name: "session_skills",
table: "list_config");
migrationBuilder.DropColumn(
name: "session_skills",
table: "app_settings");
}
}
}
@@ -0,0 +1,802 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260727114206_AddModelPresetsAndManualFlag")]
partial class AddModelPresetsAndManualFlag
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddModelPresetsAndManualFlag : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_manual",
table: "tasks",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_manual",
table: "lists",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "model_presets",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "model_presets",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_manual",
table: "tasks");
migrationBuilder.DropColumn(
name: "is_manual",
table: "lists");
migrationBuilder.DropColumn(
name: "model_presets",
table: "app_settings");
}
}
}
@@ -0,0 +1,810 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805065217_AddHandlerCommitRange")]
partial class AddHandlerCommitRange
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddHandlerCommitRange : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "handler_base_commit",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "handler_head_commit",
table: "tasks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "handler_base_commit",
table: "tasks");
migrationBuilder.DropColumn(
name: "handler_head_commit",
table: "tasks");
}
}
}
@@ -0,0 +1,828 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805074906_AddUsageGateAndRunModel")]
partial class AddUsageGateAndRunModel
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddUsageGateAndRunModel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "model",
table: "task_runs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "usage_gate_five_hour_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 80);
migrationBuilder.AddColumn<int>(
name: "usage_gate_seven_day_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 90);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
columns: new[] { "usage_gate_five_hour_pct", "usage_gate_seven_day_pct" },
values: new object[] { 80, 90 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "model",
table: "task_runs");
migrationBuilder.DropColumn(
name: "usage_gate_five_hour_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_gate_seven_day_pct",
table: "app_settings");
}
}
}
@@ -0,0 +1,832 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805090016_AddVerifyCommand")]
partial class AddVerifyCommand
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddVerifyCommand : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "verify_command",
table: "list_config",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "verify_command",
table: "list_config");
}
}
}
@@ -0,0 +1,832 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260805091910_AddWorktreeMergeCommit")]
partial class AddWorktreeMergeCommit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddWorktreeMergeCommit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "merge_commit",
table: "worktrees",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "merge_commit",
table: "worktrees");
}
}
}
@@ -66,6 +66,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(1) .HasDefaultValue(1)
.HasColumnName("max_parallel_executions"); .HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders") b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("repo_import_folders"); .HasColumnName("repo_import_folders");
@@ -74,12 +78,28 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("report_excluded_paths"); .HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday") b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(3) .HasDefaultValue(3)
.HasColumnName("standup_weekday"); .HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("WorktreeAutoCleanupDays") b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -114,6 +134,8 @@ namespace ClaudeDo.Data.Migrations
DefaultPermissionMode = "auto", DefaultPermissionMode = "auto",
MaxParallelExecutions = 1, MaxParallelExecutions = 1,
StandupWeekday = 3, StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
WorktreeAutoCleanupDays = 7, WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false, WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling" WorktreeStrategy = "sibling"
@@ -168,10 +190,18 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("model"); .HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt") b.Property<string>("SystemPrompt")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("system_prompt"); .HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId"); b.HasKey("ListId");
b.ToTable("list_config", (string)null); b.ToTable("list_config", (string)null);
@@ -194,6 +224,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("chore") .HasDefaultValue("chore")
.HasColumnName("default_commit_type"); .HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("TEXT") .HasColumnType("TEXT")
@@ -256,6 +292,41 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("prime_schedules", (string)null); b.ToTable("prime_schedules", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -294,6 +365,38 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("subtasks", (string)null); b.ToTable("subtasks", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -331,6 +434,20 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("finished_at"); .HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay") b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -405,6 +522,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("scheduled_for"); .HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -477,6 +598,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("log_path"); .HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt") b.Property<string>("Prompt")
.IsRequired() .IsRequired()
.HasColumnType("TEXT") .HasColumnType("TEXT")
@@ -586,6 +711,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("head_commit"); .HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path") b.Property<string>("Path")
.IsRequired() .IsRequired()
.HasColumnType("TEXT") .HasColumnType("TEXT")
@@ -625,6 +754,17 @@ namespace ClaudeDo.Data.Migrations
b.Navigation("Task"); b.Navigation("Task");
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
@@ -27,4 +27,15 @@ public sealed class AppSettingsEntity
// Max number of open tasks the daily prep ("Prime Claude") may place in MyDay. // Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
public int DailyPrepMaxTasks { get; set; } = 5; public int DailyPrepMaxTasks { get; set; } = 5;
// JSON array of session skill names applied by default to new tasks.
public string? SessionSkills { get; set; }
// JSON array of ModelPreset rows (model → effort + max turns). Supplies the global effort and
// turn defaults per model; list-/task-level max-turns overrides still win. Null = ship defaults.
public string? ModelPresets { get; set; }
// Percentage of the 5h/7d Claude usage window at which the autonomous queue pauses. 0 = gate off.
public int UsageGateFiveHourPct { get; set; } = 80;
public int UsageGateSevenDayPct { get; set; } = 90;
} }
@@ -0,0 +1,25 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// The reasoning-effort levels the claude CLI accepts for <c>--effort</c>.
/// </summary>
public static class EffortRegistry
{
public static readonly IReadOnlyList<string> Levels = new[] { "low", "medium", "high", "xhigh", "max" };
public const string DefaultLevel = "high";
/// <summary>
/// Validate an effort level from external input. Null/blank → null (don't pass --effort).
/// Returns the canonical lowercase level; throws on an unknown value.
/// </summary>
public static string? NormalizeLevel(string? effort)
{
var e = effort?.Trim();
if (string.IsNullOrEmpty(e)) return null;
foreach (var level in Levels)
if (string.Equals(level, e, StringComparison.OrdinalIgnoreCase))
return level;
throw new ArgumentException($"Unknown effort '{effort}'. Allowed: {string.Join(", ", Levels)}.");
}
}
@@ -7,6 +7,8 @@ public sealed class ListConfigEntity
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
public string? AgentPath { get; set; } public string? AgentPath { get; set; }
public int? MaxTurns { get; set; } public int? MaxTurns { get; set; }
public string? SessionSkills { get; set; }
public string? VerifyCommand { get; set; }
// Navigation property // Navigation property
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;
+3
View File
@@ -8,6 +8,9 @@ public sealed class ListEntity
public string? WorkingDir { get; set; } public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType; public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
public int SortOrder { get; set; } public int SortOrder { get; set; }
// A manual list holds reminders rather than Claude work: new tasks created here start out
// manual (see TaskEntity.IsManual).
public bool IsManual { get; set; }
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }
+97
View File
@@ -0,0 +1,97 @@
using System.Text.Json;
namespace ClaudeDo.Data.Models;
/// <summary>Per-model run defaults: which reasoning effort and turn budget a session gets when
/// it runs under <paramref name="Model"/> and nothing more specific overrides it.</summary>
public sealed record ModelPreset(string Model, string Effort, int MaxTurns);
/// <summary>
/// Serialization + lookup for <see cref="AppSettingsEntity.ModelPresets"/> (a JSON array on the
/// singleton settings row). One row per <see cref="ModelRegistry.Aliases"/> entry; the row supplies
/// the *global* effort and max-turns defaults, which list- and task-level overrides still beat.
/// </summary>
public static class ModelPresets
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
/// <summary>Shipping defaults — one row per known model alias.</summary>
public static IReadOnlyList<ModelPreset> Defaults { get; } = new[]
{
new ModelPreset("haiku", "medium", 20),
new ModelPreset("sonnet", "high", 30),
new ModelPreset("opus", "high", 40),
new ModelPreset("fable", "high", 25),
};
public static string SerializeDefaults() => Serialize(Defaults);
public static string Serialize(IEnumerable<ModelPreset> presets)
=> JsonSerializer.Serialize(Normalize(presets), Json);
/// <summary>Parses the stored JSON, falling back to <see cref="Defaults"/> for anything absent
/// or malformed — a bad settings row must never stop a run from starting.</summary>
public static IReadOnlyList<ModelPreset> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Defaults;
try
{
var parsed = JsonSerializer.Deserialize<List<ModelPreset>>(json, Json);
return parsed is { Count: > 0 } ? Normalize(parsed) : Defaults;
}
catch (JsonException)
{
return Defaults;
}
}
/// <summary>The preset for <paramref name="model"/>, or the default row for it. Never null so
/// callers don't have to special-case an unconfigured or unknown model. <paramref name="model"/>
/// is resolved through <see cref="ModelRegistry.TryNormalizeAlias"/> first, so a full CLI model
/// id (e.g. "claude-sonnet-4-6") still hits its alias's preset row instead of falling through.
/// If <paramref name="model"/> normalizes to nothing recognized, <paramref name="fallbackMaxTurns"/>
/// (typically <c>AppSettings.DefaultMaxTurns</c>) is used instead of a hardcoded number, so a
/// truly unknown model is no harsher than the configured global default.</summary>
public static ModelPreset For(IReadOnlyList<ModelPreset> presets, string? model, int fallbackMaxTurns = 30)
{
var alias = ModelRegistry.TryNormalizeAlias(model) ?? (model ?? ModelRegistry.DefaultAlias).Trim();
foreach (var p in presets)
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
return p;
foreach (var p in Defaults)
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
return p;
return new ModelPreset(alias, EffortRegistry.DefaultLevel, fallbackMaxTurns);
}
public static ModelPreset For(string? json, string? model, int fallbackMaxTurns = 30)
=> For(Parse(json), model, fallbackMaxTurns);
// One row per known alias, canonical order, clamped values — so an edited or partial list
// still round-trips into something every consumer can rely on.
private static IReadOnlyList<ModelPreset> Normalize(IEnumerable<ModelPreset> presets)
{
var byModel = new Dictionary<string, ModelPreset>(StringComparer.OrdinalIgnoreCase);
foreach (var p in presets)
{
if (p is null || string.IsNullOrWhiteSpace(p.Model)) continue;
byModel[p.Model.Trim()] = p;
}
var result = new List<ModelPreset>();
foreach (var fallback in Defaults)
{
var source = byModel.TryGetValue(fallback.Model, out var found) ? found : fallback;
var effort = TryNormalizeEffort(source.Effort) ?? fallback.Effort;
var turns = source.MaxTurns is >= 1 and <= 200 ? source.MaxTurns : fallback.MaxTurns;
result.Add(new ModelPreset(fallback.Model, effort, turns));
}
return result;
}
private static string? TryNormalizeEffort(string? effort)
{
try { return EffortRegistry.NormalizeLevel(effort); }
catch (ArgumentException) { return null; }
}
}
+36 -1
View File
@@ -2,11 +2,46 @@ namespace ClaudeDo.Data.Models;
public static class ModelRegistry public static class ModelRegistry
{ {
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" }; public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
public const string DefaultAlias = "sonnet"; public const string DefaultAlias = "sonnet";
public const string PlanningAlias = "opus"; public const string PlanningAlias = "opus";
public const string ListDefaultSentinel = "(default)"; public const string ListDefaultSentinel = "(default)";
public const string TaskInheritSentinel = "(inherit)"; public const string TaskInheritSentinel = "(inherit)";
/// <summary>
/// Validate a model alias from external input. Null/blank → null (inherit).
/// Returns the canonical lowercase alias; throws on an unknown value.
/// </summary>
public static string? NormalizeAlias(string? model)
{
var m = model?.Trim();
if (string.IsNullOrEmpty(m)) return null;
foreach (var alias in Aliases)
if (string.Equals(alias, m, StringComparison.OrdinalIgnoreCase))
return alias;
throw new ArgumentException($"Unknown model '{model}'. Allowed: {string.Join(", ", Aliases)}.");
}
/// <summary>
/// Best-effort alias lookup for run-path model resolution, where a full CLI model id
/// (e.g. "claude-sonnet-4-6") is just as likely as a bare alias and a run must never be
/// blocked by an unrecognized one. Null/blank/unrecognized → null; never throws.
/// </summary>
public static string? TryNormalizeAlias(string? model)
{
var m = model?.Trim();
if (string.IsNullOrEmpty(m)) return null;
foreach (var alias in Aliases)
if (string.Equals(alias, m, StringComparison.OrdinalIgnoreCase))
return alias;
foreach (var alias in Aliases)
if (m.Contains(alias, StringComparison.OrdinalIgnoreCase))
return alias;
return null;
}
} }
@@ -0,0 +1,11 @@
namespace ClaudeDo.Data.Models;
public sealed class SessionSkillEntity
{
public required string Name { get; init; }
public required string SourceUrl { get; set; }
public required string PinnedRef { get; set; }
public required string Subpath { get; set; }
public required string Description { get; set; }
public required DateTimeOffset AddedAt { get; set; }
}
@@ -0,0 +1,13 @@
namespace ClaudeDo.Data.Models;
public sealed class TaskAttachmentEntity
{
public required string Id { get; init; }
public required string TaskId { get; init; }
public required string FileName { get; set; }
public long ByteSize { get; set; }
public required DateTime CreatedAt { get; init; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
}
+14
View File
@@ -43,8 +43,22 @@ public sealed class TaskEntity
public int? MaxTurns { get; set; } public int? MaxTurns { get; set; }
public bool IsStarred { get; set; } public bool IsStarred { get; set; }
public bool IsMyDay { get; set; } public bool IsMyDay { get; set; }
// Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep,
// list handler) and the Claude affordances are hidden; a hand-driven ConPTY session is still
// allowed. New tasks in a manual list default to true.
public bool IsManual { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public string? SessionSkills { get; set; }
// Review range for a worktree-less task hosting an interactive "list handler" run
// (Mission Control's "Let Claude handle it"): the handler commits its own changes
// straight to the list's working dir, so there is no per-task worktree to diff. These
// capture the repo's HEAD at session start / submit-for-review instead, so the normal
// diff/get_task_diff paths can show `HandlerBaseCommit..HandlerHeadCommit` over the
// list's working dir exactly like a merged task's commit-range diff.
public string? HandlerBaseCommit { get; set; }
public string? HandlerHeadCommit { get; set; }
public string? ParentTaskId { get; set; } public string? ParentTaskId { get; set; }
public string? PlanningSessionId { get; set; } public string? PlanningSessionId { get; set; }
@@ -18,6 +18,7 @@ public sealed class TaskRunEntity
public string? LogPath { get; set; } public string? LogPath { get; set; }
public DateTime? StartedAt { get; set; } public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; } public DateTime? FinishedAt { get; set; }
public string? Model { get; set; }
// Navigation property // Navigation property
public TaskEntity Task { get; set; } = null!; public TaskEntity Task { get; set; } = null!;
@@ -16,6 +16,7 @@ public sealed class WorktreeEntity
public required string BaseCommit { get; set; } public required string BaseCommit { get; set; }
public string? HeadCommit { get; set; } public string? HeadCommit { get; set; }
public string? DiffStat { get; set; } public string? DiffStat { get; set; }
public string? MergeCommit { get; set; }
public WorktreeState State { get; set; } = WorktreeState.Active; public WorktreeState State { get; set; } = WorktreeState.Active;
public required DateTime CreatedAt { get; init; } public required DateTime CreatedAt { get; init; }
+124 -7
View File
@@ -2,7 +2,7 @@ using System.Text;
namespace ClaudeDo.Data; namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine } public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
public static class PromptFiles public static class PromptFiles
{ {
@@ -18,6 +18,8 @@ public static class PromptFiles
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"), PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"), PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
PromptKind.Refine => Path.Combine(Root, "refine.md"), PromptKind.Refine => Path.Combine(Root, "refine.md"),
PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind)) _ => throw new ArgumentOutOfRangeException(nameof(kind))
}; };
@@ -63,6 +65,8 @@ public static class PromptFiles
PromptKind.WeeklyReport => WeeklyReportDefault, PromptKind.WeeklyReport => WeeklyReportDefault,
PromptKind.ImprovementChild => ImprovementChildDefault, PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault, PromptKind.Refine => RefineDefault,
PromptKind.MergeHelper => MergeHelperDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
_ => "" _ => ""
}; };
@@ -82,7 +86,10 @@ public static class PromptFiles
## Out-of-scope improvements ## Out-of-scope improvements
If you notice worthwhile work that is genuinely outside this task's scope If you notice worthwhile work that is genuinely outside this task's scope
(a refactor, a follow-up, tech debt), do NOT do it here. File it with (a refactor, a follow-up, tech debt), do NOT do it here. File it with
SuggestImprovement(title, description) and stay focused on the task at hand. SuggestImprovement(title, description, model) and stay focused on the task at hand.
Set `model` to the cheapest model that can do the follow-up well 'haiku' for
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
complex work (cheapest to most capable: haiku < sonnet < opus).
## Working in the repo ## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase - Read a file before editing it. Match the conventions already in this codebase
@@ -102,9 +109,12 @@ public static class PromptFiles
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials. - Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
## You are running unattended ## You are running unattended
You run autonomously with no human watching. There is no one to answer mid-task You run autonomously, usually with no one watching. Default to making the most
questions, so never stop to ask make the most reasonable decision, note the reasonable decision yourself, noting the assumption, and continuing do not stop
assumption, and continue. for routine choices. The one exception: at a genuine fork where a wrong guess
would be costly or hard to undo (an irreversible action, contradictory
requirements), you may call AskUser(question) to ask the user and wait briefly for
an answer. If no one responds in time, proceed on your best judgment.
## When you are blocked ## When you are blocked
If something genuinely prevents you from completing part of the task (missing If something genuinely prevents you from completing part of the task (missing
@@ -122,8 +132,8 @@ public static class PromptFiles
# Out-of-scope follow-up # Out-of-scope follow-up
You are an improvement follow-up that another task filed via SuggestImprovement. You are an improvement follow-up that another task filed via SuggestImprovement.
It was deliberately scoped narrow. Do EXACTLY what this task's title and It was deliberately scoped narrow, and is intentionally a small, cheap unit of
description ask nothing more. work. Do EXACTLY what this task's title and description ask nothing more.
- Make the smallest change that satisfies the task. No opportunistic refactors, - Make the smallest change that satisfies the task. No opportunistic refactors,
renames, reformatting, or "while I'm here" cleanup beyond what is asked. renames, reformatting, or "while I'm here" cleanup beyond what is asked.
@@ -150,6 +160,20 @@ public static class PromptFiles
Once the design is approved, create the child tasks with CreateChildTask, then Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear call Finalize. Keep each subtask concrete and self-contained with a clear
done-state, ordered so dependencies come first. done-state, ordered so dependencies come first.
For each subtask, pass CreateChildTask's `model` argument set to the CHEAPEST
model that can do that subtask well. Models, cheapest to most capable:
haiku < sonnet < opus.
- haiku trivial/mechanical work: doc tweaks, simple renames, small localized edits.
- sonnet normal coding work; the sensible default when unsure.
- opus only for genuinely complex, cross-cutting, or hard-to-debug work.
Do not default everything to opus most subtasks are haiku or sonnet.
Only for a subtask you can tell will need noticeably more turns than the
default budget (a large or sprawling piece of work), also pass CreateChildTask's
`maxTurns` argument with a generous turn count, so the run doesn't die mid-work
at the turn limit. Leave `maxTurns` null for everything else it inherits the
list/global default.
"""; """;
private const string PlanningInitialDefault = """ private const string PlanningInitialDefault = """
@@ -210,6 +234,99 @@ public static class PromptFiles
task, stop. task, stop.
"""; """;
private const string MergeHelperDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: merging a duplicate task, a task whose intent is too unclear to act on safely, a diff that looks wrong or risky, or a conflict resolution you cannot resolve with confidence. Everything else, decide and keep moving.
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
The brief is the primary source: it already lists every task's title, id, status and full description. Read it in full before acting. Only call batch_get_tasks if you need something the brief does not carry for a specific task, e.g. 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.
If no pair looks like a duplicate, say so in one sentence and move straight to Phase 2 do not ask the user to confirm the absence of duplicates.
If at least one pair looks like a duplicate, 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.
## 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.
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for the "do not invent requirements" rule still applies.
## 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.
For each surviving task, resolve the effective max-turns it will run with task.MaxTurns, else the list's get_list_config MaxTurns, else the model's preset/global default and report it. If a task looks substantial (several files, or one you just split off above) but its effective turns look low, say so and ask before queuing it; set_task_config/get_task_config let you raise it per task.
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.
Call wait_for_task_change with the ids of every task still Queued or Running (timeoutSeconds up to 170) instead of sleeping and polling get_task yourself. It returns as soon as any of them leaves Queued/Running WaitingForReview on success, Failed on error or reports timedOut if none did. Report progress as tasks land, then call it again with whatever ids are still Queued/Running until none remain.
## Phase 4 Review and merge
Before merging anything, call preview_merge_set with every surviving task's id (the same targetBranch you are about to merge into). It tells you, per task, whether a clean merge-tree preview is even possible (status/conflictFiles/changedFileCount/behind) and which files more than one of the tasks changed (overlaps). Read the overlaps: a file two tasks both touch is where a same-branch collision could happen. This is a HINT, not proof it only catches same-file overlap, not a cross-file break (e.g. one task deletes a symbol another task's file still references), and a clean preview never guarantees the result compiles or passes tests. Use it to decide merge order and to know which pairs to look at extra carefully in step 1 below; it does not replace reading the diffs.
One task at a time. Default to the order the brief lists them. If the overlap check above (or the file lists you gathered in Phase 2) shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) deviate from brief order only with that stated reason.
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. If preview_merge_set flagged this task in an overlap, also skim the diff of the other task(s) sharing that file.
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.
After each merge, main has moved a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging 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.
""";
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.
""";
private const string WeeklyReportDefault = """ private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer, You are generating a concise weekly standup report for a software developer,
covering {start} to {end}. covering {start} to {end}.
@@ -18,8 +18,18 @@ public sealed class AppSettingsRepository
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }; row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row); _context.AppSettings.Add(row);
await _context.SaveChangesAsync(ct); try
_context.Entry(row).State = EntityState.Detached; {
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
return row; return row;
} }
@@ -54,6 +64,9 @@ public sealed class AppSettingsRepository
? null : updated.ReportExcludedPaths; ? null : updated.ReportExcludedPaths;
row.StandupWeekday = updated.StandupWeekday; row.StandupWeekday = updated.StandupWeekday;
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks; row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -6,8 +6,13 @@ namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository public sealed class ListRepository
{ {
private readonly ClaudeDoDbContext _context; private readonly ClaudeDoDbContext _context;
private readonly AttachmentStore _attachments;
public ListRepository(ClaudeDoDbContext context) => _context = context; public ListRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null)
{
_context = context;
_attachments = attachments ?? new AttachmentStore();
}
public async Task AddAsync(ListEntity entity, CancellationToken ct = default) public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{ {
@@ -23,7 +28,13 @@ public sealed class ListRepository
public async Task DeleteAsync(string listId, CancellationToken ct = default) public async Task DeleteAsync(string listId, CancellationToken ct = default)
{ {
var taskIds = await _context.Tasks
.Where(t => t.ListId == listId)
.Select(t => t.Id)
.ToListAsync(ct);
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct); await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
foreach (var id in taskIds)
_attachments.DeleteTaskDir(id);
} }
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default) public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
@@ -66,6 +77,8 @@ public sealed class ListRepository
existing.SystemPrompt = config.SystemPrompt; existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath; existing.AgentPath = config.AgentPath;
existing.MaxTurns = config.MaxTurns; existing.MaxTurns = config.MaxTurns;
existing.SessionSkills = config.SessionSkills;
existing.VerifyCommand = config.VerifyCommand;
} }
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -0,0 +1,61 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
public sealed class SessionSkillRepository
{
private readonly ClaudeDoDbContext _context;
public SessionSkillRepository(ClaudeDoDbContext context) => _context = context;
public async Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct = default)
{
return await _context.SessionSkills.AsNoTracking()
.OrderBy(s => s.Name)
.ToListAsync(ct);
}
public async Task<SessionSkillEntity?> GetAsync(string name, CancellationToken ct = default) =>
await _context.SessionSkills.AsNoTracking().FirstOrDefaultAsync(s => s.Name == name, ct);
public async Task UpsertAsync(SessionSkillEntity entity, CancellationToken ct = default)
{
var existing = await _context.SessionSkills.FirstOrDefaultAsync(s => s.Name == entity.Name, ct);
if (existing is null)
{
_context.SessionSkills.Add(entity);
}
else
{
existing.SourceUrl = entity.SourceUrl;
existing.PinnedRef = entity.PinnedRef;
existing.Subpath = entity.Subpath;
existing.Description = entity.Description;
existing.AddedAt = entity.AddedAt;
}
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string name, CancellationToken ct = default)
{
await _context.SessionSkills
.Where(s => s.Name == name)
.ExecuteDeleteAsync(ct);
}
public async Task DeleteBySourceAsync(string sourceUrl, CancellationToken ct = default)
{
await _context.SessionSkills
.Where(s => s.SourceUrl == sourceUrl)
.ExecuteDeleteAsync(ct);
}
public async Task<IReadOnlyList<SessionSkillEntity>> ListBySourceAsync(string sourceUrl, CancellationToken ct = default)
{
return await _context.SessionSkills.AsNoTracking()
.Where(s => s.SourceUrl == sourceUrl)
.OrderBy(s => s.Name)
.ToListAsync(ct);
}
}

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